본문 바로가기
카테고리 없음

Enumerator 파헤치기7

by 경자꿈사 2025. 2. 18.

지난 글에서 봤던 예제의 결과를 다시 한번 살펴보자.


결과를 보면 next 메서드를 호출할 때마다 블록의 내용이 실행되면서 next 메서드로 값을 전달해 주고 next 메서드는 그 값을 그대로 반환해 준다.
그런데 어떻게 값을 전달한 후 블록의 실행이 멈췄다가 다시 next 메서드를 호출하면 멈췄던 부분부터 실행을 다시 시작할 수 있는 걸까?
컴퓨터 프로그래밍에 코루틴(Coroutine)이라는 개념이 있는데 이것은 일반 함수와 달리 실행을 중단하고 중단한 지점부터 다시 실행을 재개할 수 있는 특징이 있다.
이러한 코루틴의 개념을 실제 프로그래밍에서 사용할 수 있도록 루비에서는 Fiber라는 이름으로 구현하여 제공하고 있다.

간단히 Fiber를 테스트해 보기 위해 아래 코드를 D:/blog/ruby/enumerator 폴더 아래 test_fiber.rb 파일에 저장한 후 cmd 창을 열어 실행해 보자.

fiber = Fiber.new do
  puts "fiber start"
  Fiber.yield(1)
  puts "fiber restart"
  Fiber.yield(2)
  puts "fiber stop"
  3
end

puts "main start"
p fiber.resume
p fiber.resume
p fiber.alive?
p fiber.resume
p fiber.alive?
fiber.resume

아래 실행 결과를 보면 블록을 전달하여 Enumerator 객체를 생성한 후 next 메서드를 호출하는 예제와 실행 흐름이 거의 비슷함을 알 수 있다.
Fiber 객체의 resume 메서드를 호출하면 Fiber 객체 생성 시 전달한 블록을 이전 중단점 또는 처음부터 실행하고, yield 클래스 메서드를 호출하면 실행을 멈추고 resume 메서드를 호출한 스레드로 제어권을 넘기게 된다.
이때 yield 클래스 메서드에 전달한 인수를 resume 메서드의 결괏값으로 받을 수 있다.
그리고 resume 메서드 호출 시 블록 끝까지 실행했다면 블록의 결괏값을 반환하면서 해당 Fiber 객체는 종료되는데, 이미 종료된 Fiber 객체에 대해 다시 resume 메서드를 호출하면 FiberError 예외가 발생한다.
특정 Fiber 객체의 종료 여부는 alive? 메서드를 호출해 보면 알 수 있다.

아래 Fiber를 사용하여 Enumerator 클래스와 유사한 기능을 제공하는 MyEnumeratorUsingFiber 클래스를 작성해 보았다.
Fiber 객체를 생성해 돌려주는 make_fiber 메서드를 보면 Fiber 객체 생성 시 전달하는 블록 안에서 MyEnumeratorUsingFiber 객체를 생성할 때 전달받은 블록을 호출하고 있다.
이때 Yielder 객체를 하나 생성해서 블록 파라미터로 넘기고 있는데, Yielder 클래스의 << 연산자 메서드를 보면 Fiber 클래스의 yield 클래스 메서드를 호출하는 걸 볼 수 있다.
next 메서드는 @fiber 객체에 대해 resume 메서드를 호출하는데, 결국 Yielder 객체의 << 연산자 메서드 호출을 통해 전달한 값을 받게 된다.
rewind 메서드는 Fiber 객체를 새로 생성하여 블록을 처음부터 다시 실행하도록 만든다.

class MyEnumeratorUsingFiber
  def initialize(&block)
    raise ArgumentError, "Block is required" if block.nil?
    @block = block
    @fiber = make_fiber
  end
  
  def next
    raise StopIteration, "iteration reached an end" if !@fiber.alive?
    value = @fiber.resume
    raise StopIteration, "iteration reached an end" if !@fiber.alive?
    value
  end
  
  def rewind
    @fiber = make_fiber
    self
  end
  
  def make_fiber
    Fiber.new do
      @block.call(Yielder.new)
    end
  end
  
  private :make_fiber
  
  class Yielder
    def <<(value)
      Fiber.yield(value)
      self
    end
  end
end

이제 MyEnumeratorUsingFiber 클래스의 코드를 D:/blog/ruby/enumerator 폴더 아래 my_enumerator_using_fiber.rb 파일에 저장한 후 다음 그림처럼 irb를 실행하여 테스트를 진행해 보자.
결과를 보면 Enumerator 클래스를 사용했을 때와 동일하게 동작하는 것을 볼 수 있다.

아래 그림을 보면 배열로부터 Enumerator 객체를 생성해서 next 메서드를 테스트해 본 건데, 이렇게 동작할 수 있도록 MyEnumeratorUsingFiber 클래스를 수정해 보자.

아래 코드를 보면 initialize 메서드만 수정했는데, 블록을 전달받지 못할 경우 가변 인수를 통해 컬렉션 객체와 메서드 이름을 필수로 받고 해당 메서드에 전달할 인수가 있다면 해당 인수까지 추가로 받도록 수정하였다.
그리고 Fiber 객체 생성 시 필요한 블록을 위해 Proc.new에 블록을 전달하여 Proc 객체를 직접 생성하였다.
Proc.new에 전달한 블록을 보면 Yielder 객체를 받을 수 있도록 파라미터 y를 하나 선언했고, 블록 안에서는 send 메서드를 사용하여 인수로 받은 컬렉션 객체의 메서드를 호출하도록 했다.
그리고 send 메서드를 호출할 때도 블록을 전달해 줘야 하는데, 실제 send 메서드를 통해 호출되는 메서드가 블록 파라미터를 통해 요소(또는 요소와 인덱스 등)를 넘겨주며 블록을 실행시켜 주는 메서드이기 때문이다.
결국 send 메서드에 전달하는 블록 안에서 파라미터를 통해 받은 값을 Yielder 객체의 << 연산자 메서드를 사용하여 전달하면 된다.

class MyEnumeratorUsingFiber
  def initialize(*args, &block)
    if block
      @block = block
    else
      raise ArgumentError, "number of arguments must be 2+" if args.size < 2
      @object = args[0]
      @method = args[1]
      @method_args = args[2..]
      @block = Proc.new do |y|
        @object.send(@method, *@method_args) do |*e|
          if e.size > 1
            y << e
          else
            y << e[0]
          end
        end
      end
    end
    @fiber = make_fiber
  end  
  
  ...생략
end

수정한 MyEnumeratorUsingFiber 클래스의 코드를 my_enumerator_using_fiber.rb 파일에 저장한 후 irb를 실행하여 아래 그림처럼 테스트를 진행해 보자.
배열나 범위에 대해 each, each_with_index, each_with_object, each_slice 등의 메서드를 호출했을 때 블록 파라미터를 통해 받게 되는 값을 MyEnumeratorUsingFiber 객체의 next 메서드를 사용하여 받을 수 있는 것을 볼 수 있다.

실행을 중단하고 재개할 수 있는 코루틴의 특징 덕분에 필요할 때 값을 생성해 내는 로직을 쉽게 만들 수 있었다.

See you again~~