Ruby에는 반복 작업을 수행하는 다양한 방법이 있습니다—루프, 블록, 그리고 열거자(Enumerator)입니다. 대부분의 Ruby 개발자는 루프와 블록은 능숙하게 다루지만, Enumerator와 Fiber는 상대적으로 낯설게 느껴지곤 합니다. 이번 Ruby Magic 에디션에서는 게스트 저자 Julik과 함께 Enumerable과 Fiber를 자세히 살펴보며, 흐름을 제어하는 열거형 객체와 '블록을 뒤집어서' 사용하는 기법을 설명합니다.
블록 일시 중단과 연쇄 반복
Ruby Magic의 이전 에디션에서 Enumerator를 다룬 적이 있습니다. 그때는 자신만의 #each 메서드에서 Enumerator를 반환하는 방법과 그 활용처를 소개했습니다. 하지만 Enumerator와 Fiber의 더 폭넓은 활용 사례는 바로 "블록을 도중에 일시 중단"할 수 있다는 점입니다. #each에 넘긴 블록이나 #each 호출 전체만이 아니라, 어떤 블록이든 말입니다!
이는 매우 강력한 구조로, 블록을 매개로 동작하는 메서드를 블록 대신 순차적인 호출을 기대하는 호출자에게 연결해주는 어댑터(shim)를 구현할 때 유용하게 쓰일 수 있습니다. 예를 들어, 데이터베이스 핸들을 열어 조회된 각 행(row)을 읽어오는 상황을 상상해 보겠습니다:
db.with_each_row_of_result(sql_stmt) do |row|
yield row
end블록 API는 훌륭합니다. 블록이 종료될 때 다양한 정리(cleanup) 작업을 대신 처리해 줄 수 있기 때문입니다. 하지만 어떤 사용자는 아래와 같은 방식으로 데이터베이스를 다루고 싶어 할 수 있습니다:
@cursor = cursor
# 나중에:
row = @cursor.next_row
send_row_to_event_stream(row)즉, 블록의 실행을 "당분간" 멈춰 두었다가 나중에 다시 이어서 진행하고 싶은 것입니다. 그러면 제어권이 피호출자(블록을 실행하는 메서드)가 아니라 호출자의 손으로 넘어갑니다.
이터레이터 체이닝
이 패턴의 가장 흔한 활용 사례 중 하나는 여러 이터레이터를 연결(chaining)하는 것입니다. 이렇게 하면 평소 반복에 쓰던 메서드(#each 등)가 Enumerator 객체를 반환하고, 이 객체를 통해 블록이 yield 문으로 건네주는 값들을 "받아올" 수 있습니다:
range = 1..8
each_enum = range.each # => <Enumerator...>Enumerator는 연결할 수 있어서, "인덱스를 곁들인 임의의 반복" 같은 연산을 수행할 수 있습니다. 아래 예제에서는 범위(range)에 #map을 호출해 Enumerable 객체를 얻고, 여기에 #with_index를 체이닝하여 인덱스와 함께 순회합니다:
(1..3).map.with_index {|element_n, index| [element_n, index] }
#=> [[1, 0], [2, 1], [3, 2]]특히 시스템에서 이벤트를 사용한다면 이 기법이 매우 유용합니다. Ruby는 모든 메서드를 Enumerator 생성기로 감싸는 내장 메서드를 제공하며, 이를 통해 원하는 동작을 그대로 구현할 수 있습니다. with_each_row_of_result가 우리에게 값을 yield 해주는 대신, 우리가 행을 하나씩 "당겨 오는(pull)" 방식으로 바꿔 보겠습니다.
@cursor = db.to_enum(:with_each_row_of_result, sql_stmt)
schedule_for_later do
begin
row = @cursor.next
send_row_to_event_stream(row)
rescue StopIteration # 블록이 끝나고 커서가 비었으며, 정리 작업도 완료됨
end
end직접 구현한다면 아마 이런 형태가 될 것입니다:
cursor = Enumerator.new do |yielder|
db.with_each_row_of_result(sql_stmt) do |row|
yielder.yield row
end
end블록을 뒤집어 생각하기
Rails는 응답 본문(response body)으로 Enumerator를 할당하는 것도 허용합니다. Rails는 응답 본문으로 지정된 Enumerator에 대해 next를 호출하며, 반환값이 문자열이기를 기대합니다—그 문자열은 Rack 응답에 기록됩니다. 예를 들어 Range의 #each 메서드 호출 결과를 Rails 응답 본문으로 반환할 수 있습니다:
class MyController < ApplicationController
def index
response.body = ('a'..'z').each
end
end제가 이를 "블록을 뒤집는다"고 부르는 이유입니다. 본질적으로 이는 블록(또는 Ruby에서 사실상 블록인 루프)의 도중에 "시간을 얼려 두는(freeze time)" 제어 흐름 헬퍼입니다.
다만 Enumerator에는 활용도를 조금 떨어뜨리는 제약이 있습니다. 다음과 같은 코드를 작성하고 싶다고 상상해 봅시다:
File.open('output.tmp', 'wb') do |f|
# 파일을 계속해서 쓰기 위해 yield
loop { yield(f) }
end이를 Enumerator로 감싸서 데이터를 써 보겠습니다:
writer_enum = File.to_enum(:open, 'output.tmp', 'wb')
file = en.next
file << data
file << more_data모두 잘 동작합니다. 그런데 한 가지 문제가 있습니다—쓰기가 끝났음을 Enumerator에게 알려 블록을 "마무리"하고, 파일을 닫고 종료하려면 어떻게 해야 할까요? 이 과정에서는 중요한 단계들이 수행되는데, 예컨대 리소스 정리(파일 닫기)나 버퍼에 남은 쓰기 내용을 디스크로 플러시하는 작업 등이 그렇습니다. 물론 File 객체에 접근할 수 있으니 직접 닫을 수도 있지만, 가능하면 Enumerator가 닫는 일까지 관리해 주길 바랍니다. 그러려면 Enumerator가 블록을 지나쳐 마저 진행하도록 만들어야 합니다.
또 다른 장애물은, 일시 중단된 블록 내부에서 무슨 일이 일어나는지 인수(argument)를 전달하고 싶을 때입니다. 다음과 같은 의미론을 가진 블록 수용 메서드가 있다고 가정해 봅시다:
write_file_through_encryptor(file_name) do |writable|
writable << "Some data"
writable << "Some more data"
writable << "Even more data"
end하지만 호출부 코드에서는 이렇게 사용하고 싶습니다:
writable = write_file_through_encryptor(file_name)
writable << "Some data"
# ...잠시 후
writable << "Some more data"
writable.finish이상적으로는 메서드 호출을 어떤 구조로 감싸서 다음과 같은 트릭이 가능하게 하고 싶습니다:
write_file_through_encryptor(file_name) do |writable|
loop do
yield_and_wait_for_next_call(writable)
# 그리고 어떻게든 이 루프를 빠져나가 블록이 완료되도록 한다
end
end쓰기를 이렇게 감싸면 어떨까요?
deferred_writable = write_file_through_encryptor(file_name)
deferred_writable.next("Some data")
deferred_writable.next("Some more data")
deferred_writable.next("Even more data")
deferred_writable.next(:terminate)여기서는 :terminate를 특별한 값(magic value)으로 사용해, 메서드가 블록을 마치고 반환해도 된다는 신호로 삼습니다. 바로 이 지점에서 Enumerator는 더 이상 도움이 되지 못합니다. Enumerator#next에는 인수를 전달할 수 없기 때문입니다. 만약 전달할 수 있다면 다음처럼 할 수 있을 텐데요:
deferred_writable = write_file_through_encryptor(file_name)
deferred_writable.next("Some data")
...
deferred_writable.next(:terminate)Ruby Fiber의 등장
바로 이것이 Fiber가 허용하는 기능입니다. Fiber는 재진입할 때마다 인수를 받을 수 있으므로, 앞서 원했던 래퍼를 다음과 같이 구현할 수 있습니다:
deferred_writable = Fiber.new do |data_to_write_or_termination|
write_file_through_encryptor(filename) do |f|
# 여기서 fiber의 블록 컨텍스트에 진입하며, 재진입 시 이 블록의 시작점부터 이어짐
loop do
# Fiber.yield를 호출하면 fiber가 일시 중단됩니다. fiber가 resume 되기 전에는
# "data_to_write_or_termination = " 대입문에 도달하지 않습니다.
data_to_write_or_termination = Fiber.yield
end
end
end동작 방식은 다음과 같습니다. 처음 .resume을 호출하면 fiber에 진입하여 첫 번째 Fiber.yield 문 또는 가장 바깥쪽 Fiber 블록의 끝, 둘 중 먼저 오는 지점까지 실행됩니다. Fiber.yield가 호출되면 제어권이 호출자에게 돌아옵니다. Enumerator를 기억하시나요? 블록은 일시 중단되며, 다음번에 .resume을 호출하면 resume에 전달한 인수가 새로운 data_to_write가 됩니다.
deferred_writes = Fiber.new do |data_to_write|
loop do
$stderr.puts "Received #{data_to_write} to work with"
data_to_write = Fiber.yield
end
end
# => #<Fiber:0x007f9f531783e8>
deferred_writes.resume("Hello") #=> Received Hello to work with
deferred_writes.resume("Goodbye") #=> Received Goodbye to work with정리하자면, Fiber 내부의 코드 흐름은 첫 Fiber#resume 호출에서 시작되고, 첫 Fiber.yield 호출에서 일시 중단된 뒤, 이후의 Fiber#resume 호출마다 계속 이어집니다. 이때 Fiber.yield의 반환값은 resume에 전달한 인수입니다. 코드는 마지막으로 Fiber.yield가 호출된 지점부터 다시 실행됩니다.
여기에 Fiber만의 독특한 특성이 하나 있습니다. fiber에 처음 전달한 인수들은 Fiber.yield의 반환값이 아니라 블록 인수로 전달된다는 점입니다.
이를 염두에 두면, resume에 특별한 인수를 전달함으로써 Fiber 내부에서 중단할지 계속할지를 결정할 수 있다는 것을 알 수 있습니다. 실제로 해볼까요?
deferred_writes = Fiber.new do |data_to_write|
loop do
$stderr.puts "Received #{data_to_write} to work with"
break if data_to_write == :terminate # 루프를 빠져나가거나...
write_to_output(data_to_write) # ...출력에 기록합니다
data_to_write = Fiber.yield # 스스로를 일시 중단하고 다음 `resume`을 기다립니다
end
# 위 루프를 break로 빠져나오면 여기에 도착합니다. 어디에도 Fiber.yield가 없으므로
# Fiber는 종료되고 "dead" 상태가 됩니다.
end
deferred_writes.resume("Hello") #=> Received Hello to work with
deferred_writes.resume("Goodbye") #=> Received Goodbye to work with
deferred_writes.resume(:terminate)
deferred_writes.resume("Some more data after close") # FiberError: dead fiber called이런 기능들이 빛을 발하는 상황은 여럿 있습니다. Fiber는 수동으로 재개(resume)할 수 있는 일시 중단된 코드 블록을 담고 있기 때문에, 이벤트 리액터(event reactor)를 구현하거나 단일 스레드 안에서 동시성 작업을 처리하는 데 활용할 수 있습니다. Fiber는 가볍기 때문에 클라이언트 하나에 Fiber 하나를 배정하고 필요에 따라 Fiber 객체들 사이를 전환하는 방식으로 서버를 구현할 수도 있습니다.
client_fiber = Fiber.new do |socket|
loop do
received_from_client = socket.read_nonblock(10)
sent_to_client = socket.write_nonblock("OK")
Fiber.yield # 제어권을 호출자에게 돌려주고, 호출자가 'resume'을 불러줄 때까지 대기
end
end
client_fibers << client_fiber
# 그리고 웹서버 메인 루프에서
client_fibers.each do |client_fiber|
client_fiber.resume # 클라이언트로부터 데이터가 있으면 수신하고 OK를 응답
end또한 Ruby에는 fiber라는 추가 표준 라이브러리가 있어, 한 fiber에서 다른 fiber로 명시적으로 제어권을 넘길 수 있습니다. 위와 같은 용도에서 유용한 부가 기능입니다.
데이터 출력 속도 제어하기
Fiber와 Enumerator의 또 다른 훌륭한 활용처는 Ruby 블록이 데이터를 내보내는 속도(rate)를 제어하고 싶을 때입니다. 예를 들어 zip_tricks 라이브러리에서는 아래와 같은 블록 사용 방식을 기본 API로 지원합니다:
ZipTricks::Streamer.open(output_io) do |z|
z.write_deflated_file("big.csv") do |destination|
columns.each do |col|
destination << column
end
end
end이 방식에서는 ZIP 아카이브를 생성하는 코드 쪽에 "푸시(push)" 제어권이 있으므로, 얼마나 많은 데이터를 얼마나 자주 출력하는지 통제할 수 없습니다. 만약 ZIP을 예컨대 5MB 단위 청크로 나눠 쓰고 싶다면—AWS S3 객체 스토리지의 제약 조건이 될 수 있는 크기입니다—세그먼트를 S3 멀티파트 파트로 분리해야 하는 시점에 << 메서드 호출을 거절하는 커스텀 output_io 객체를 만들어야 합니다. 하지만 우리는 제어를 반전시켜 "풀(pull)" 방식으로 만들 수 있습니다. 여전히 같은 블록으로 CSV 파일을 작성하되, 블록이 내놓는 출력에 따라 블록을 재개하거나 멈추는 것입니다. 그러면 다음과 같은 사용이 가능해집니다:
output_enum = ZipTricks::Streamer.output_enum do |z|
z.write_deflated_file("big.csv") do |destination|
columns.each do |col|
destination << column
end
end
end
# 이 시점에는 아직 아무것도 생성되거나 기록되지 않았습니다
enum = output_enum.each # Enumerator 생성
bin_str = enum.next # 블록이 이진 데이터를 일부 생성한 뒤 일시 중단됩니다
output.write(bin_str) # 블록은 일시 중단된 상태로 다음 `next` 호출을 기다립니다이를 통해 ZIP 파일 생성기가 데이터를 내보내는 속도를 우리가 통제할 수 있게 됩니다.
결국 Enumerator와 Fiber는 "푸시" 방식의 블록을, 메서드 호출을 받아들이는 "풀" 방식의 객체로 변환해주는 제어 흐름 메커니즘입니다.
Fiber와 Enumerator에는 딱 하나의 함정이 있습니다—블록 안에 ensure가 있거나 블록 완료 후 수행해야 할 작업이 있다면, 이제 그 블록을 충분한 횟수만큼 호출해 주는 책임은 호출자에게 넘어간다는 점입니다. 어느 면에서는 JavaScript에서 Promise를 사용할 때의 제약과 비슷하다고 볼 수 있습니다.
맺음말
지금까지 Ruby의 흐름 제어형 열거형(flow-controlled enumerables)을 살펴보았습니다. 이 과정에서 Julik은 Enumerable 클래스와 Fiber 클래스의 공통점과 차이점을 조명하고, 호출자가 데이터 흐름을 결정하는 다양한 예제를 깊이 있게 다루었습니다. 또한 블록이 재진입될 때마다 인수를 전달할 수 있게 해주는 Fiber만의 추가적인 마법도 배웠습니다. 즐거운 흐름 제어 되세요!
매달 마법 같은 Ruby 지식을 꾸준히 받아보고 싶다면 Ruby Magic을 구독하세요. 월간 호를 바로 여러분의 inbox로 보내드립니다.