Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby의 지연 열거자(Lazy Enumerator)로 대용량 파일 효율적으로 다루기

열거자(Enumerator)는 Ruby가 강력하고 동적인 언어로 자리 잡은 데 핵심적인 역할을 하는 요소입니다. 그리고 지연 열거자(Lazy Enumerator)는 여기서 한 걸음 더 나아가, 매우 방대한 컬렉션도 효율적으로 처리할 수 있게 해줍니다.

파일은 사실 수많은 줄(line)이나 문자(character)로 이루어진 거대한 컬렉션에 불과합니다. 따라서 지연 열거자를 활용하면 파일로 흥미롭고 강력한 작업을 손쉽게 수행할 수 있습니다.

열거자(Enumerator)란 무엇일까?

each 같은 메서드를 사용할 때마다 사실상 열거자를 생성하게 됩니다. 바로 이 덕분에 [1,2,3].map { ... }.reduce { ... }처럼 메서드를 체인 형태로 연결할 수 있는 것입니다. 아래 예시를 보면 each 호출이 열거자를 반환하며, 이를 활용해 다른 반복 연산을 수행할 수 있음을 알 수 있습니다.

# Ruby 공식 문서(https://ruby-doc.org/core-2.2.0/Enumerator.html)에서 가져온 코드입니다

enumerator = %w(one two three).each
puts enumerator.class # => Enumerator

enumerator.each_with_object("foo") do |item, obj|
  puts "#{obj}: #{item}"
end

# foo: one
# foo: two
# foo: three

지연 열거자는 대용량 컬렉션을 위한 기능

일반적인 열거자는 대용량 컬렉션에서 문제가 발생합니다. 호출하는 각 메서드가 컬렉션 전체를 순회하려 하기 때문입니다. 다음 코드를 직접 실행해 보면 확인할 수 있습니다:

# 이 코드는 "멈추므로" ctrl-c로 종료해야 합니다
(1..Float::INFINITY).reject { |i| i.odd? }.map { |i| i*i }.first(5)

reject 메서드는 무한 컬렉션의 순회를 끝낼 수 없기 때문에 영원히 실행됩니다.

하지만 아주 사소한 추가만으로 코드가 완벽하게 동작합니다. 단순히 lazy 메서드를 호출하면, Ruby는 똑똑하게 계산에 필요한 만큼만 반복을 수행합니다. 이 경우 딱 10번의 반복이면 충분하니, 무한대보다는 훨씬 적은 양이죠.

(1..Float::INFINITY).lazy.reject { |i| i.odd? }.map { |i| i*i }.first(5)
#=> [4, 16, 36, 64, 100]

모비 딕 6,000부

이러한 파일 처리 기법을 제대로 시험해 보려면 큰 파일이 필요합니다. "지연 처리하지 않았을 때"의 문제점이 뚜렷하게 드러날 만큼 큰 파일 말이죠.

저는 프로젝트 구텐베르크(Project Gutenberg)에서 모비 딕(Moby Dick)을 내려받아 100부 분량의 텍스트 파일을 만들었습니다. 하지만 그것으로는 부족했습니다. 결국 약 6,000부까지 늘렸습니다. 지금 전 세계에서 모비 딕 6,000부가 담긴 텍스트 파일을 가진 사람은 저 하나뿐일지도 모릅니다. 뭔가 겸손해지는 기분이네요. 하지만 본론으로 돌아가겠습니다.

Ruby의 지연 열거자(Lazy Enumerator)로 대용량 파일 효율적으로 다루기 모비 딕을 내려받아 수천 번 복제해 실험용 대용량 파일을 만들었습니다. 참고로 이 구문은 bash가 아니라 fish 셸입니다. 아직 fish를 쓰는 사람은 저뿐인 것 같습니다.

파일의 열거자 얻는 방법

여기 어쩌면 이미 무심코 사용해 왔을 재미있는 Ruby 트릭이 있습니다. Ruby에서 컬렉션을 순회하는 거의 모든 메서드는 블록 없이 호출하면 Enumerator 객체를 반환합니다. 무슨 의미일까요?

다음 예시를 살펴보겠습니다. 파일을 열고 각 줄을 출력할 수 있습니다. 하지만 블록 없이 호출하면 열거자를 얻게 됩니다. 주목할 메서드는 each_line, each_char, each_codepoint입니다.

File.open("moby.txt") do |f|
  # 파일의 각 줄 출력하기
  f.each_line do |l|
    puts l
  end

  # 역시 파일의 각 줄을 출력합니다. 다만 `each_line`이 반환한
  # Enumerator에 `each`를 호출하는 방식으로 동작합니다
  f.each_line.each do |l|
    puts l
  end
end

두 예시는 거의 똑같아 보이지만, 두 번째 예시가 놀라운 힘을 여는 열쇠입니다.

파일의 열거자 활용하기

파일의 모든 줄을 "담고 있는" 열거자를 확보하면, 일반 Ruby 배열을 다루듯 그 줄들을 잘라내고 조작할 수 있습니다. 몇 가지 예시를 소개합니다.

file.each_line.each_with_index.map { |line, i| "Line #{ i }: #{ line }" }[3, 10]
file.each_line.select { |line| line.size == 9 }.first(10)
file.each_line.reject { |line| line.match /whale/i }

정말 멋진 기능이지만, 이 예시들에는 한 가지 큰 문제가 있습니다. 모두 순회를 시작하기 전에 파일 전체를 메모리에 로드한다는 점입니다. 모비 딕 6,000부 분량의 파일이라면 그 지연은 눈에 띄게 느껴질 정도입니다.

파일 줄의 지연 로딩(Lazy Loading)

대용량 텍스트 파일에서 "whale"이라는 단어가 처음 등장하는 10개 사례를 찾는다면, 10번째를 찾은 후에는 더 이상 검색을 계속할 필요가 없습니다. 다행히 Ruby의 열거자에게 이렇게 동작하라고 지시하는 것은 아주 간단합니다. 그저 "lazy" 키워드를 사용하면 됩니다.

아래 예시에서는 지연 로딩을 활용해 꽤 정교한 작업을 수행합니다.

File.open("moby.txt") do |f|

  # "whale"이 포함된 첫 3개 줄 가져오기
  f.each_line.lazy.select { |line| line.match(/whale/i) }.first(3)

  # 파일의 처음으로 되돌아가기
  f.rewind

  # 첫 세 줄에 줄 번호 붙이기
  f.each_line.lazy.each_with_index.map do |line, i|
    "LINE #{ i }: #{ line }"
  end.first(3)

  f.rewind

  # "whale"이 포함된 첫 세 줄과 해당 줄 번호 함께 가져오기
  f.each_line.lazy.each_with_index.map { |line, i| "LINE #{ i }: #{ line }" }.select { |line| line.match(/whale/i) }.first(3)

end

파일만 되는 건 아닙니다

소켓, 파이프, 시리얼 포트 역시 Ruby에서 IO 클래스로 표현됩니다. 즉, 이들 모두 each_line, each_char, each_codepoint 메서드를 갖추고 있다는 뜻입니다. 따라서 이 트릭을 모든 IO 객체에 동일하게 활용할 수 있습니다. 꽤 유용하죠!

마법은 아닙니다

안타깝게도 지연 열거자는 수행하려는 작업이 파일 전체를 읽을 필요가 없을 때만 속도를 높여줍니다. 예컨대 찾으려는 단어가 책 마지막 페이지에만 등장한다면, 그 단어를 찾으려면 결국 책 전체를 읽어야 합니다. 하지만 그런 경우에도 이 방식이 열거자를 쓰지 않는 방식보다 느려지지는 않습니다.