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

Ruby 내부 들여다보기: 파일 "Slurping"과 스트리밍

Ruby Magic 시리즈의 이번 편에서는 Ruby에서 파일을 스트리밍하는 방법을 알아봅니다. IO 클래스가 어떻게 파일 전체를 메모리에 로드하지 않고도 파일을 읽을 수 있는지, 그리고 읽어 들인 바이트를 버퍼링하면서 어떻게 한 줄씩 파일을 처리하는지 함께 살펴보겠습니다. 바로 시작해 볼까요?

"Slurping(통째로 읽기)"과 파일 스트리밍

Ruby의 File.read 메서드는 파일을 열어 전체 내용을 읽은 뒤 하나의 문자열로 반환합니다.

irb> content = File.read("log/production.log")
=> "I, [2018-06-27T16:45:02.843719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" for 127.0.0.1 at 2018-06-27 16:45:02 +0200\nI, [2018-06-27T16:45:02.846719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Processing by ArticlesController#index as HTML\nI, [2018-06-27T16:45:02.848212 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendering articles/index.html.erb within layouts/application\nD, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Article Load (0.3ms)  SELECT \"articles\".* FROM \"articles\"\nI, [2018-06-27T16:45:02.850901 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendered articles/index.html.erb within layouts/application (1.7ms)\nI, [2018-06-27T16:45:02.851633 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Completed 200 OK in 5ms (Views: 3.4ms | ActiveRecord: 0.3ms)\n"

내부적으로 이 메서드는 파일을 열고, 내용을 모두 읽은 다음, 파일을 닫고, 그 결과를 하나의 문자열로 돌려줍니다. 파일 내용을 한꺼번에 "slurp(통째로 들이켜듯 읽기)"하기 때문에, 이 문자열은 Ruby의 가비지 컬렉터가 회수할 때까지 메모리에 계속 머물게 됩니다.

간단한 예제: 파일 전체를 대문자로 변환하기

예를 들어, 파일의 모든 문자를 대문자로 바꿔 다른 파일에 저장하고 싶다고 해봅시다. File.read로 내용을 가져온 뒤, 결과 문자열에 String#upcase를 호출하고, 대문자로 변환된 문자열을 File.write에 넘겨주면 됩니다.

irb> upcased = File.read("log/production.log").upcase
=> "I, [2018-06-27T16:45:02.843719 #9098]  INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] STARTED GET \"/ARTICLES\" FOR 127.0.0.1 AT 2018-06-27 16:45:02 +0200\n...\n"
irb> File.write("log/upcased.log", upcased)
=> 896

파일 크기가 작을 때는 이 방식으로도 충분히 잘 동작합니다. 하지만 파일 전체를 메모리에 올리는 방식은 대용량 파일을 다룰 때 문제가 될 수 있습니다. 예컨대 14GB짜리 로그 파일을 파싱한다면, 파일 전체를 한 번에 읽어 들이는 것은 매우 비용이 큰 작업입니다. 파일 내용이 통째로 메모리에 유지되기 때문에 애플리케이션의 메모리 사용량이 크게 증가하고, 결국 메모리 스와핑이 발생하거나 운영체제가 프로세스를 강제 종료(OOM Kill)하는 상황까지 이어질 수 있습니다.

줄 단위 읽기: File.foreach

다행히 Ruby는 File.foreach를 사용해 파일을 한 줄씩 읽을 수 있는 방법을 제공합니다. 이 메서드는 파일 전체를 한 번에 읽는 대신, 각 줄마다 전달된 블록을 실행합니다.

반환 결과가 Enumerable이기 때문에, 각 줄마다 블록을 yield하거나, 블록이 없으면 Enumerator 객체를 반환합니다. 덕분에 파일 전체를 메모리에 한 번에 올리지 않고도 큰 파일을 읽어 처리할 수 있습니다.

irb> File.foreach("log/production.log") { |line| p line }
"I, [2018-06-27T16:45:02.843719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" for 127.0.0.1 at 2018-06-27 16:45:02 +0200\n"
"I, [2018-06-27T16:45:02.846719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Processing by ArticlesController#index as HTML\n"
"I, [2018-06-27T16:45:02.848212 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendering articles/index.html.erb within layouts/application\n"
"D, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Article Load (0.3ms)  SELECT \"articles\".* FROM \"articles\"\n"
"I, [2018-06-27T16:45:02.850901 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendered articles/index.html.erb within layouts/application (1.7ms)\n"
"I, [2018-06-27T16:45:02.851633 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Completed 200 OK in 5ms (Views: 3.4ms | ActiveRecord: 0.3ms)\n"

파일 전체를 대문자로 변환하려면 입력 파일에서 한 줄씩 읽고, 각 줄을 대문자로 바꾼 후 출력 파일에 이어 붙이면 됩니다.

irb> File.open("upcased.log", "a") do |output|
irb*   File.foreach("production.log") { |line| output.write(line.upcase) }
irb> end
=> nil

그렇다면 파일 전체를 먼저 읽지 않고도 한 줄씩 읽는 것이 어떻게 가능할까요? 이를 이해하려면 파일을 읽는 과정의 겉면을 조금씩 벗겨 내야 합니다. Ruby의 IO 클래스를 자세히 살펴보겠습니다.

I/O와 Ruby의 IO 클래스

File.readFile.foreach가 존재함에도 불구하고, File 클래스 문서에는 이들이 나열되어 있지 않습니다. 실제로 파일을 읽거나 쓰는 메서드는 File 클래스 문서 어디에서도 찾아볼 수 없는데, 그 이유는 이 메서드들이 부모 클래스인 IO 클래스로부터 상속받은 것이기 때문입니다.

I/O란 무엇인가

I/O 장치(I/O device)란 키보드, 디스플레이, 하드 디스크처럼 데이터를 컴퓨터로 주고받는 장치를 의미합니다. 이런 장치들은 데이터 스트림을 읽거나 생성하는 방식으로 입력/출력(Input/Output), 즉 I/O를 수행합니다.

하드 드라이브에서 파일을 읽고 쓰는 작업은 가장 흔하게 접하는 I/O입니다. 그 외에도 소켓 통신, 터미널로 로그를 출력하는 것, 키보드 입력 등 다양한 종류의 I/O가 있습니다.

Ruby의 IO 클래스는 파일 읽기/쓰기를 포함한 모든 입출력을 담당합니다. 파일을 읽는 것은 다른 I/O 스트림을 읽는 것과 본질적으로 다르지 않기 때문에, File 클래스는 IO.read, IO.foreach 같은 메서드를 그대로 상속받습니다.

irb> IO.foreach("log/production.log") { |line| p line }
"I, [2018-06-27T16:45:02.843719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" for 127.0.0.1 at 2018-06-27 16:45:02 +0200\n"
"I, [2018-06-27T16:45:02.846719 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Processing by ArticlesController#index as HTML\n"
"I, [2018-06-27T16:45:02.848212 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendering articles/index.html.erb within layouts/application\n"
"D, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Article Load (0.3ms)  SELECT \"articles\".* FROM \"articles\"\n"
"I, [2018-06-27T16:45:02.850901 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22]   Rendered articles/index.html.erb within layouts/application (1.7ms)\n"
"I, [2018-06-27T16:45:02.851633 #9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Completed 200 OK in 5ms (Views: 3.4ms | ActiveRecord: 0.3ms)\n"

File.foreachIO.foreach와 완전히 동일하므로, 앞서 살펴본 것과 같은 결과를 얻으려면 IO 클래스 버전을 사용해도 됩니다.

커널을 통한 I/O 스트림 읽기

내부적으로 Ruby의 IO 클래스가 가진 읽기·쓰기 기능은 커널 시스템 콜을 감싼 추상화 위에 구축되어 있습니다. 운영체제의 커널이 I/O 장치로부터의 읽기와 쓰기를 실제로 처리합니다.

파일 열기

IO.sysopen은 커널에 요청해 파일 테이블에 해당 파일에 대한 참조를 등록하고, 프로세스의 파일 디스크립터 테이블에 파일 디스크립터를 생성함으로써 파일을 엽니다.

파일 디스크립터와 파일 테이블

파일을 열면 파일 디스크립터(file descriptor)가 반환됩니다. 파일 디스크립터란 I/O 리소스에 접근하기 위해 사용되는 정수 값입니다.

각 프로세스는 자신만의 파일 디스크립터 테이블을 메모리에 유지하며, 각 디스크립터는 시스템 전역에서 공유되는 파일 테이블(file table)의 항목을 가리킵니다.

I/O 리소스를 읽거나 쓰려면 프로세스가 시스템 콜을 통해 파일 디스크립터를 커널에 전달합니다. 프로세스는 파일 테이블에 직접 접근할 수 없기 때문에, 커널이 프로세스를 대신하여 파일에 접근하는 구조입니다.

파일을 연다고 해서 그 내용이 메모리에 유지되는 것은 아니지만, 파일 디스크립터 테이블은 가득 찰 수 있으므로 파일을 연 후에는 반드시 닫아주는 것이 좋은 습관입니다. File.open을 감싸는 File.read 같은 메서드나 블록을 받는 메서드들은 파일을 자동으로 닫아 줍니다.

이번 예제에서는 한 단계 더 나아가 IO.sysopen 메서드를 직접 호출해 보겠습니다. 파일 이름을 인자로 넘기면, 이 메서드는 나중에 열린 파일을 참조할 때 사용할 수 있는 파일 디스크립터를 만들어 줍니다.

irb> IO.sysopen("log/production.log")
=> 9

Ruby가 읽고 쓸 수 있는 IO 인스턴스를 만들려면, 파일 디스크립터를 IO.new에 넘겨주면 됩니다.

irb> file_descriptor = IO.sysopen("log/production.log")
=> 9
irb> io = IO.new(file_descriptor)
=> #<IO:fd 9>

I/O 스트림을 닫고 파일 테이블에서 파일 참조를 제거하려면, IO 인스턴스에 대해 IO#close를 호출합니다.

irb> io.close
=> nil

바이트 읽기와 커서 이동

IO#sysreadIO 객체에서 지정한 바이트 수만큼 읽어 들입니다.

irb> io.sysread(64)
=> " [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" "

이 예제는 앞서 파일 디스크립터 정수를 IO.new에 넘겨 만든 IO 인스턴스를 사용합니다. 인자로 64를 넘겨 IO#sysread를 호출하면 파일에서 처음 64바이트를 읽어 반환합니다.

irb> io.sysread(64)
=> "for 127.0.0.1 at 2018-06-27 16:45:02 +0200\nI, [2018-06-27T16:45:"

처음 파일에서 바이트를 요청했을 때 커서가 자동으로 이동했기 때문에, 같은 인스턴스에 다시 IO#sysread를 호출하면 그다음 64바이트를 얻게 됩니다.

커서 직접 이동하기

IO.sysseek를 사용하면 커서를 파일 내 특정 위치로 직접 옮길 수 있습니다.

irb> io.sysseek(32)
=> 32
irb> io.sysread(64)
=> "9098]  INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started "
irb> io.sysseek(0)
=> 0
irb> io.sysread(64)
=> " [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" "

이 예제에서는 먼저 위치 32로 커서를 옮긴 뒤 IO#sysread로 64바이트를 읽습니다. 이어서 IO.sysseek를 0과 함께 다시 호출하면 파일 맨 앞으로 되돌아가므로, 첫 64바이트를 다시 읽을 수 있습니다.

한 줄씩 파일 읽기

이제 IO 클래스의 편의 메서드들이 어떻게 I/O 스트림을 열고, 바이트를 읽고, 커서 위치를 이동시키는지 알게 되었습니다.

IO.foreachIO#gets 같은 메서드는 바이트 수 단위가 아니라 줄 단위로 요청할 수 있습니다. 하지만 미리 다음 개행 문자의 위치를 확인하고 그 지점까지의 바이트를 모두 가져오는 성능 좋은 방법은 없습니다. 따라서 Ruby가 직접 파일 내용을 분할(splitting)해서 처리해야 합니다.

class MyIO
  def initialize(filename)
    fd = IO.sysopen(filename)
    @io = IO.new(fd)
  end
 
  def each(&block)
    line = ""
 
    while (c = @io.sysread(1)) != $/
      line << c
    end
 
    block.call(line)
    each(&block)
  rescue EOFError
    @io.close
  end
end

이 예제 구현에서 #each 메서드는 IO#sysread를 사용해 파일에서 바이트를 한 번에 하나씩 가져오다가, 해당 바이트가 개행 문자를 의미하는 $/일 때 멈춥니다. 개행 문자를 발견하면 바이트를 읽는 것을 중단하고, 그동안 모은 줄을 인자로 전달받은 블록을 호출합니다.

이 방식은 동작하지만, 파일의 모든 바이트마다 IO.sysread를 호출하기 때문에 매우 비효율적입니다.

파일 내용 버퍼링

Ruby는 파일 내용에 대한 내부 버퍼를 유지하는 더 영리한 방식으로 이 문제를 해결합니다. 파일을 한 바이트씩 읽는 대신 한 번에 512바이트씩 읽어 들이고, 반환된 바이트 안에 개행 문자가 있는지 검사합니다. 개행 문자가 있다면 개행 앞부분을 반환하고 나머지는 메모리에 버퍼로 보관합니다. 버퍼에 개행 문자가 없다면 개행을 찾을 때까지 512바이트를 추가로 더 읽어 들입니다.

class MyIO
  def initialize(filename)
    fd = IO.sysopen(filename)
    @io = IO.new(fd)
    @buffer = ""
  end
 
  def each(&block)
    @buffer << @io.sysread(512) until @buffer.include?($/)
 
    line, @buffer = @buffer.split($/, 2)
 
    block.call(line)
    each(&block)
  rescue EOFError
    @io.close
  end
end

이 예제에서 #each 메서드는 @buffer 변수에 개행 문자가 포함될 때까지 512바이트씩 내부 버퍼에 추가합니다. 개행 문자가 포함되는 순간 버퍼를 첫 번째 개행 문자 기준으로 분할합니다. 분할된 첫 번째 부분이 line이고, 두 번째 부분이 새로운 버퍼가 됩니다.

그런 다음 전달받은 블록이 해당 줄과 함께 호출되며, 남은 @buffer는 다음 반복에서 사용하기 위해 그대로 유지됩니다.

이렇게 파일 내용을 버퍼링하면 I/O 호출 횟수를 줄이면서도 파일을 논리적인 단위(줄)로 나누어 처리할 수 있습니다.

스트리밍 정리

요약하자면, 파일 스트리밍은 운영체제 커널에 파일 열기를 요청한 뒤, 파일로부터 바이트를 조금씩 읽어 오는 방식으로 동작합니다. Ruby에서 파일을 줄 단위로 읽을 때는 파일에서 한 번에 512바이트씩 데이터를 가져온 후, 그 데이터를 "줄" 단위로 분할하는 것입니다.

지금까지 Ruby의 I/O와 파일 스트리밍에 대한 개요였습니다. 이 글에 대한 생각이나 궁금한 점이 있다면 언제든 알려주세요. 저희는 항상 탐구하고 설명할 주제를 찾고 있으니, Ruby의 마법 같은 부분 중 읽어보고 싶은 주제가 있다면 @AppSignal로 주저 없이 알려주시기 바랍니다!