커피 위에 스트룹와펠을 올려 따뜻하게 녹여 두셨나요? 오늘은 스트룹와펠의 두 반쪽을 붙여 주는 그 끈적한 스트룹(시럽)처럼, 지금까지 만든 조각들을 하나로 합쳐 보려 합니다. 이 시리즈의 앞선 두 편에서는 Lexer와 Parser를 구웠습니다. 이제 마지막 재료인 Interpreter를 더하고, 그 위에 스트룹을 부어 전체를 하나로 연결해 보겠습니다.
재료 준비하기
자, 베이킹을 위해 주방을 정리하고 재료를 테이블에 꺼내 봅시다. 인터프리터가 제 역할을 하려면 두 가지 재료, 즉 두 가지 정보가 필요합니다. 바로 앞서 생성한 추상 구문 트리(AST)와 템플릿에 삽입하려는 데이터입니다. 이 데이터를 environment라고 부르겠습니다.
AST를 순회하기 위해서는 비지터 패턴(visitor pattern)을 활용해 인터프리터를 구현하는 것이 좋습니다. 비지터, 즉 우리의 인터프리터는 노드를 매개변수로 받아 이를 처리하고, 현재 노드의 성격에 따라 자식 노드 중 일부 또는 전부를 대상으로 visit 메서드를 다시 호출하는 범용 visit 메서드를 구현합니다.
module Magicbars
class Interpreter
attr_reader :root, :environment
def self.render(root, environment = {})
new(root, environment).render
end
def initialize(root, environment = {})
@root = root
@environment = environment
end
def render
visit(root)
end
def visit(node)
# Process node
end
end
end본격적으로 들어가기 전에, 템플릿과 환경 데이터를 받아 렌더링된 결과를 출력하는 간단한 Magicbars.render 메서드도 함께 만들어 두겠습니다.
module Magicbars
def self.render(template, environment = {})
tokens = Lexer.tokenize(template)
ast = Parser.parse(tokens)
Interpreter.render(ast, environment)
end
end이렇게 해 두면 AST를 손으로 일일이 만들지 않고도 인터프리터를 테스트할 수 있습니다.
Magicbars.render('Welcome to {{name}}', name: 'Ruby Magic')
# => nil예상대로 아직은 아무것도 반환되지 않습니다. 그럼 visit 메서드 구현을 시작해 보겠습니다. 참고로 이 템플릿에 해당하는 AST는 다음과 같은 모습입니다.
이 템플릿을 처리하려면 네 가지 노드 타입, 즉 Template, Content, Expression, Identifier를 다뤄야 합니다. 물론 visit 메서드 안에 거대한 case 문을 넣는 방법도 있지만, 코드가 금방 읽기 어려워질 것입니다. 대신 루비의 메타프로그래밍 기능을 활용해 코드를 더 깔끔하고 읽기 좋게 유지해 보겠습니다.
module Magicbars
class Interpreter
# ...
def visit(node)
short_name = node.class.to_s.split('::').last
send("visit_#{short_name}", node)
end
end
end이 메서드는 노드를 받아 클래스 이름을 확인하고, 모듈 이름 부분을 제거합니다(문자열을 다듬는 다양한 방법이 궁금하다면 관련 글을 참고하세요). 이후 send를 사용해 해당 노드 타입을 처리하는 메서드를 호출합니다. 각 타입의 메서드 이름은 모듈 이름이 제거된 클래스 이름 앞에 visit_ 접두사를 붙인 형태입니다. 메서드 이름에 대문자가 들어가는 것이 다소 낯설 수 있지만, 메서드의 의도가 무엇인지 아주 명확하게 드러난다는 장점이 있습니다.
module Magicbars
class Interpreter
# ...
def visit_Template(node)
# Process template nodes
end
def visit_Content(node)
# Process content nodes
end
def visit_Expression(node)
# Process expression nodes
end
def visit_Identifier(node)
# Process identifier nodes
end
end
end각 노드 타입 구현하기
먼저 visit_Template 메서드부터 구현해 보겠습니다. 이 메서드는 노드의 모든 statements를 처리한 뒤 결과를 하나로 합치기만 하면 됩니다.
def visit_Template(node)
node.statements.map { |statement| visit(statement) }.join
end다음은 visit_Content 메서드입니다. 콘텐츠 노드는 단순히 문자열을 감싸고 있을 뿐이므로, 메서드도 그만큼 단순합니다.
def visit_Content(node)
node.content
end이제 플레이스홀더를 실제 값으로 치환하는 작업이 일어나는 visit_Expression 메서드로 넘어가 보겠습니다.
def visit_Expression(node)
key = visit(node.identifier)
environment.fetch(key, '')
end마지막으로, visit_Expression 메서드가 환경에서 어떤 키를 찾아야 할지 알 수 있도록 visit_Identifier 메서드를 구현합니다.
def visit_Identifier(node)
node.value
end이 네 가지 메서드가 모두 갖춰지면, 템플릿을 다시 렌더링했을 때 원하는 결과를 얻을 수 있습니다.
Magicbars.render('Welcome to {{name}}', name: 'Ruby Magic')
# => Welcome to Ruby Magic블록 표현식 해석하기
사실 지금까지 작성한 코드는 간단한 gsub 한 번으로도 처리할 수 있는 수준입니다. 그러니 좀 더 복잡한 예제로 넘어가 보겠습니다.
Welcome to {{name}}!
{{#if subscribed}}
Thank you for subscribing to our mailing list.
{{else}}
Please sign up for our mailing list to be notified about new articles!
{{/if}}
Your friends at {{company_name}}참고로 이 템플릿에 해당하는 AST는 다음과 같습니다.
아직 처리하지 않은 노드 타입은 딱 하나입니다. 바로 BlockExpression 노드입니다. 이 노드는 어느 면에서 Expression 노드와 비슷하지만, 값에 따라 BlockExpression 노드의 statements를 계속 처리할지, 아니면 inverse_statements를 처리할지가 달라집니다.
def visit_BlockExpression(node)
key = visit(node.identifier)
if environment[key]
node.statements.map { |statement| visit(statement) }.join
else
node.inverse_statements.map { |statement| visit(statement) }.join
end
end메서드를 살펴보면 두 분기가 매우 비슷하고, visit_Template 메서드와도 닮았다는 것을 알 수 있습니다. 셋 모두 배열에 담긴 노드들을 순회하며 방문하는 동일한 작업을 하므로, 코드를 정리하기 위해 visit_Array 메서드를 추출해 보겠습니다.
def visit_Array(nodes)
nodes.map { |node| visit(node) }
end새 메서드를 활용하면 visit_Template과 visit_BlockExpression에서 중복 코드를 제거할 수 있습니다.
def visit_Template(node)
visit(node.statements).join
end
def visit_BlockExpression(node)
key = visit(node.identifier)
if environment[key]
visit(node.statements).join
else
visit(node.inverse_statements).join
end
end이제 인터프리터가 모든 노드 타입을 처리할 수 있게 되었으니, 복잡한 템플릿을 렌더링해 봅시다.
Magicbars.render(template, { name: 'Ruby Magic', subscribed: true, company_name: 'AppSignal' })
# => Welcome to Ruby Magic!
#
#
# Please sign up for our mailing list to be notified about new articles!
#
#
# Your friends at AppSignal거의 완벽해 보이지만, 자세히 보면 이상한 점이 있습니다. 환경에 subscribed: true를 분명히 넣어 주었는데도 메일링 리스트 가입을 권유하는 메시지가 출력되었습니다. 뭔가 잘못된 것 같군요…
헬퍼 메서드 지원 추가하기
템플릿을 다시 살펴보면 블록 표현식 안에 if가 들어 있음을 알 수 있습니다. 그런데 visit_BlockExpression은 환경에서 subscribed의 값을 찾는 대신 if의 값을 찾고 있습니다. 환경에 if라는 키는 없으므로 조회 결과는 nil, 즉 거짓이 되는 것입니다.
여기서 멈추고 "우리는 Handlebars가 아니라 Mustache를 흉내 내는 것이 목표였다"며 템플릿에서 if를 제거해도 원하는 결과를 얻을 수 있습니다.
Welcome to {{name}}!
{{#subscribed}}
Thank you for subscribing to our mailing list.
{{else}}
Please sign up for our mailing list to be notified about new articles!
{{/subscribed}}
Your friends at {{company_name}}하지만 재미있는 상황에서 여기서 멈출 필요는 없겠죠? 한 걸음 더 나아가 헬퍼 메서드를 구현해 보겠습니다. 헬퍼 메서드는 다른 용도로도 요긴하게 쓰일 수 있습니다.
먼저 단순 표현식에 헬퍼 메서드 지원을 추가해 보겠습니다. 전달받은 문자열을 뒤집는 reverse 헬퍼를 추가하고, 더불어 주어진 값의 클래스 이름을 알려주는 debug 메서드도 만들어 보겠습니다.
def helpers
@helpers ||= {
reverse: ->(value) { value.to_s.reverse },
debug: ->(value) { value.class }
}
end헬퍼는 간단한 람다로 구현하고, 이름으로 조회할 수 있도록 해시에 저장합니다.
다음으로 visit_Expression을 수정해, 환경에서 값을 찾기 전에 먼저 헬퍼를 조회하도록 바꿉니다.
def visit_Expression(node)
key = visit(node.identifier)
if helper = helpers[key]
arguments = visit(node.arguments).map { |k| environment[k] }
return helper.call(*arguments)
end
environment[key]
end주어진 식별자와 일치하는 헬퍼가 있다면, 메서드는 모든 인수를 방문한 뒤 해당 값들을 찾아냅니다. 이후 헬퍼를 호출하면서 찾은 값들을 인수로 전달합니다.
Magicbars.render('Welcome to {{reverse name}}', name: 'Ruby Magic')
# => Welcome to cigaM ybuR
Magicbars.render('Welcome to {{debug name}}', name: 'Ruby Magic')
# => Welcome to String준비가 되었으니, 드디어 if와 unless 헬퍼를 구현해 보겠습니다. 이 헬퍼들은 인수 외에 두 개의 람다를 추가로 전달받아, 노드의 statements를 계속 해석할지 아니면 inverse_statements를 해석할지 스스로 판단할 수 있습니다.
def helpers
@helpers ||= {
if: ->(value, block:, inverse_block:) { value ? block.call : inverse_block.call },
unless: ->(value, block:, inverse_block:) { value ? inverse_block.call : block.call },
# ...
}
end
visit_BlockExpression에 필요한 변경 사항도 visit_Expression에서 했던 것과 비슷합니다. 다만 이번에는 두 개의 람다를 함께 전달한다는 점이 다릅니다.
def visit_BlockExpression(node)
key = visit(node.identifier)
if helper = helpers[key]
arguments = visit(node.arguments).map { |k| environment[k] }
return helper.call(
*arguments,
block: -> { visit(node.statements).join },
inverse_block: -> { visit(node.inverse_statements).join }
)
end
if environment[key]
visit(node.statements).join
else
visit(node.inverse_statements).join
end
end이것으로 베이킹이 완성되었습니다! 이제 이 여정의 시작점이었던 복잡한 템플릿을 렌더링할 수 있습니다. Lexer, Parser, Interpreter의 세계로 안내해 준 바로 그 템플릿 말입니다.
Magicbars.render(template, { name: 'Ruby Magic', subscribed: true, company_name: 'AppSignal' })
# => Welcome to Ruby Magic!
#
#
# Thank you for subscribing to our mailing list.
#
#
# Your friends at AppSignal이제 겨우 시작에 불과합니다
이 세 편의 시리즈에서 우리는 템플레이팅 언어를 만드는 기본기를 다뤘습니다. 이 개념들은 루비 같은 인터프리티드 프로그래밍 언어를 만들 때에도 그대로 활용할 수 있습니다. 물론 몇 가지 부분(올바른 에러 처리 🙀 등)은 가볍게 넘어갔고, 오늘날 프로그래밍 언어의 근간을 이루는 내용 중 빙산의 일각만 살짝 건드렸을 뿐입니다.
시리즈를 즐겁게 읽으셨기를 바랍니다. 더 많은 내용을 원하신다면 Ruby Magic 메일링 리스트를 구독해 주세요. 그리고 스트룹와펠이 당기신다면 저희에게 연락 주세요. 그쪽 배달도 가능할지 모르니까요!