Ruby에는 스크립트가 자기 자신의 소스 파일을 데이터 소스로 활용할 수 있는 방법이 있다는 사실을 알고 계셨나요? 일회성 스크립트나 프로토타입(개념 증명) 코드를 작성할 때 시간을 크게 아껴주는 유용한 기법입니다. 지금부터 하나씩 살펴보겠습니다!
DATA와 __END__
아래 예제에서는 __END__라는 재미있는 키워드를 사용합니다. Ruby 인터프리터는 __END__ 아래에 있는 모든 내용을 무시합니다. 더 흥미로운 점은, Ruby가 DATA라는 IO 객체를 제공한다는 것입니다. 이 객체를 사용하면 마치 일반 파일을 읽듯이 __END__ 아래의 모든 내용을 읽어올 수 있습니다.
다음 예제에서는 각 줄을 순회하면서 출력해 보겠습니다.
DATA.each_line do |line|
puts line
end
__END__
Doom
Quake
Diablo
이 기법의 가장 실용적인 활용 예는 DATA에 ERB 템플릿을 담아두는 것입니다. YAML이나 CSV 같은 다른 형식과도 함께 사용할 수 있습니다.
require 'erb'
time = Time.now
renderer = ERB.new(DATA.read)
puts renderer.result()
__END__
The current time is <%= time %>.
사실 DATA를 사용하면 __END__ 위쪽의 내용도 읽을 수 있습니다. 그 이유는 DATA가 실제로는 전체 소스 파일을 가리키는 포인터이되, __END__ 키워드 위치까지 미리 이동해 있는 상태이기 때문입니다. IO 객체를 되감기(rewind)한 뒤 출력하면 이를 직접 확인할 수 있습니다. 아래 예제는 소스 파일 전체를 출력합니다.
DATA.rewind
puts DATA.read # prints the entire source file
__END__
meh
여러 파일에서 발생하는 문제
이 기법의 큰 단점 중 하나는, 스크립트가 단일 소스 파일 안에 모두 들어가고 해당 파일을 직접 실행할 때만 제대로 동작한다는 점입니다. 다른 파일에서 require로 불러오는 경우에는 기대대로 작동하지 않습니다.
아래 예제에는 두 개의 파일이 있으며, 각각 자체적인 __END__ 섹션을 가지고 있습니다. 하지만 DATA 전역 객체는 프로그램 전체에서 하나만 존재할 수 있습니다. 따라서 두 번째 파일의 __END__ 섹션에는 접근할 수 없습니다.
# first.rb
require "./second"
puts "First file\n----------------------"
puts DATA.read
print_second_data()
__END__
First end clause
# second.rb
def print_second_data
puts "Second file\n----------------------"
puts DATA.read # Won't output anything, since first.rb read the entire file
end
__END__
Second end clause
snhorne ~/tmp $ ruby first.rb
First file
----------------------
First end clause
Second file
----------------------
여러 파일을 위한 우회 방법
Sinatra에는 꽤 멋진 기능이 있습니다. __END__ 구문 뒤에 여러 개의 인라인 템플릿을 추가할 수 있게 해주는 것이죠. 다음과 같이 사용합니다.
# This code is from the Sinatra docs at https://www.sinatrarb.com/intro.html
require 'sinatra'
get '/' do
haml :index
end
__END__
@@ layout
%html
= yield
@@ index
%div.title Hello world.
그런데 Sinatra는 대체 어떻게 이런 일을 해낼까요? 앱은 아마 rack에 의해 로드될 텐데, 프로덕션 환경에서 ruby myapp.rb로 직접 실행하지 않을 테니까요! Sinatra는 여러 파일과 함께 DATA를 사용하는 별도의 방법을 찾아낸 것처럼 보입니다.
하지만 Sinatra 소스 코드를 조금 들여다보면 사실 약간의 편법을 쓰고 있다는 것을 알 수 있습니다. 실제로는 DATA를 전혀 사용하지 않고, 대신 아래 코드와 비슷한 방식으로 처리합니다.
# I'm paraphrasing. See the original at https://github.com/sinatra/sinatra/blob/master/lib/sinatra/base.rb#L1284
app, data = File.read(__FILE__).split(/^__END__$/, 2)
실제 구현은 조금 더 복잡합니다. __FILE__을 그대로 읽으면 sinatra/base.rb 파일 자체가 되어버리기 때문입니다. Sinatra가 원하는 것은 특정 함수를 호출한 파일의 내용입니다. 이 정보는 caller의 결과를 파싱해서 얻습니다.
caller 함수는 현재 실행 중인 메서드가 어디에서 호출되었는지 알려줍니다. 간단한 예제는 다음과 같습니다.
def some_method
puts caller
end
some_method # => caller.rb:5:in `<main>'
이제 여기서 파일 이름만 추출하면, 해당 파일에 대해 DATA와 동일한 역할을 하는 값을 뽑아내는 일은 아주 간단합니다.
def get_caller_data
puts File.read(caller.first.split(":").first).split("__END__", 2).last
end
좋은 용도로 활용하세요
이런 종류의 트릭이 매일 사용할 만한 것은 아니라는 점은 분명합니다. 깔끔하고 유지보수하기 좋은 대규모 코드베이스를 만들기에는 적합하지 않습니다.
하지만 가끔은 일회성 유틸리티 스크립트나 개념 증명을 위해 빠르고 간단한 방법이 필요할 때가 있습니다. 바로 그럴 때 DATA와 __END__가 상당히 유용한 도구가 되어줍니다.