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

Ruby로 템플릿 렉서(Lexer) 직접 만들기: 문자열 보간부터 상태 머신까지

스쿠버 다이빙 장비를 점검하고 스텐실을 챙기세요. 오늘은 템플릿(Template)의 세계로 함께 다이빙해 보겠습니다!

웹 페이지를 렌더링하거나 이메일을 생성하는 대부분의 소프트웨어는 템플릿 기법을 사용해 가변 데이터를 텍스트 문서에 삽입합니다. 문서의 전체 구조는 데이터가 들어갈 자리에 플레이스홀더(placeholder)를 남겨 둔 정적 템플릿으로 구성되고, 사용자 이름이나 웹 페이지 콘텐츠 같은 가변 데이터는 렌더링 시점에 플레이스홀더를 대체하면서 들어갑니다.

이번 글에서는 다양한 프로그래밍 언어에서 사용할 수 있는 템플릿 언어인 Mustache의 일부 기능을 직접 구현해 보겠습니다. 문자열 연결이라는 간단한 방법에서 출발해, 점점 더 복잡한 템플릿을 처리할 수 있도록 우리만의 렉서(lexer)를 작성하는 것까지 단계적으로 살펴봅니다.

내장 문자열 보간(String Interpolation) 활용하기

가장 단순한 예제부터 시작해 보죠. 애플리케이션에 프로젝트 이름이 포함된 환영 메시지가 필요하다고 가정합니다. 가장 빠른 방법은 Ruby의 내장 문자열 보간 기능을 사용하는 것입니다.

name = "Ruby Magic"
template = "Welcome to #{name}"
# => Welcome to Ruby Magic

간단하게 해결됐습니다! 하지만 이 템플릿을 여러 상황에서 재사용하거나, 사용자가 템플릿 내용을 직접 수정할 수 있게 하려면 어떻게 해야 할까요?

문제는 문자열 보간이 즉시 평가된다는 점입니다. 그래서 템플릿을 재사용할 수 없고(루프 안에서 매번 재정의하는 경우가 아니라면), 잠재적으로 위험한 eval 함수를 사용하지 않는 한 Welcome to #{name} 템플릿을 데이터베이스에 저장해 두었다가 나중에 채워 넣을 수도 없습니다.

다행히 Ruby에는 문자열을 보간하는 또 다른 방법이 있습니다. 바로 Kernel#sprintfString#%입니다. 이 메서드들을 사용하면 템플릿 자체는 그대로 둔 채 보간된 문자열을 얻을 수 있으므로, 같은 템플릿을 여러 번 재사용할 수 있습니다. 게다가 임의의 Ruby 코드 실행도 허용하지 않습니다. 직접 사용해 보겠습니다.

name = "Ruby Magic"
template = "Welcome to %{name}"
 
sprintf(template, name: name)
# => "Welcome to Ruby Magic"
 
template % { name: name }
# => "Welcome to Ruby Magic"

정규표현식(Regexp)을 활용한 템플릿 처리

위 방법도 동작은 하지만 완벽하지 않으며, 우리가 원하는 것보다 많은 기능을 외부로 노출합니다. 다음 예제를 살펴보겠습니다.

name = "Ruby Magic"
template = "Welcome to %d"
 
sprintf(template, name: name)
# => TypeError (can't convert Hash into Integer)

Kernel#sprintfString#%는 서로 다른 타입의 데이터를 처리하기 위한 특수 문법을 지원합니다. 하지만 이 문법들이 우리가 전달하는 데이터와 항상 호환되는 것은 아닙니다. 위 예제에서는 템플릿이 숫자 포맷팅을 기대하는데 Hash가 전달되어 TypeError가 발생했습니다.

다행히 더 강력한 도구가 준비되어 있습니다. 정규표현식을 사용해 나만의 보간 방식을 구현하는 것이죠. 정규표현식을 활용하면 Mustache나 Handlebars에서 영감을 받은 커스텀 문법을 정의할 수 있습니다.

name = "Ruby Magic"
template = "Welcome to {{name}}"
assigns = { "name" => name }
 
template.gsub(/{{(\w+)}}/) { assigns[$1] }
# => Welcome to Ruby Magic

String#gsub를 사용해 모든 플레이스홀더(이중 중괄호로 감싸진 단어)를 assigns 해시의 값으로 치환합니다. 대응하는 값이 없으면 아무것도 삽입하지 않고 플레이스홀더를 제거합니다.

이렇게 문자열의 플레이스홀더를 치환하는 방식은 플레이스홀더가 몇 개 되지 않을 때는 충분히 viable한 해법입니다. 하지만 조금만 복잡해져도 금방 한계에 부딪힙니다.

예를 들어 템플릿 안에 조건문이 필요하다고 해보죠. 변수 값에 따라 결과가 달라져야 하는 상황입니다.

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}}

정규표현식으로는 이런 요구 사항을 깔끔하게 처리할 수 없습니다. 억지로 꾸역꾸역 만들어 낼 수는 있겠지만, 이 지점부터는 제대로 된 템플릿 언어를 만드는 편이 좋습니다.

템플릿 언어 만들기

템플릿 언어를 구현하는 일은 다른 프로그래밍 언어를 구현하는 것과 비슷합니다. 스크립팅 언어와 마찬가지로 템플릿 언어도 세 가지 구성 요소, 즉 렉서(lexer), 파서(parser), 인터프리터(interpreter)가 필요합니다. 하나씩 차례대로 살펴보겠습니다.

렉서(Lexer)

먼저 해결해야 할 작업은 토큰화(tokenization), 즉 어휘 분석(lexical analysis)입니다. 이 과정은 자연어에서 단어의 품사를 식별하는 것과 매우 유사합니다.

Ruby is a lovely language라는 문장을 예로 들어 보겠습니다. 이 문장은 서로 다른 범주에 속하는 다섯 개의 단어로 이루어져 있습니다. 각 단어가 어떤 범주인지 파악하려면 사전을 펼쳐 단어마다 품사를 찾아봐야 할 겁니다. 그러면 명사, 동사, 관사, 형용사, 명사 같은 목록이 만들어질 것입니다. 자연어 처리에서는 이를 '품사(Parts of Speech)'라고 부르며, 프로그래밍 언어 같은 형식 언어(formal language)에서는 토큰(token)이라고 부릅니다.

렉서는 템플릿을 읽으면서 텍스트 스트림을 미리 정의된 순서대로 각 범주별 정규표현식과 매칭하는 방식으로 동작합니다. 가장 먼저 매칭되는 정규표현식이 해당 토큰의 범주를 결정하고, 관련 데이터를 토큰에 붙여 줍니다.

간단한 이론은 여기까지 하고, 이제 템플릿 언어용 렉서를 실제로 구현해 보겠습니다. 작업을 조금 더 쉽게 만들기 위해 Ruby 표준 라이브러리의 strscan을 require해서 StringScanner를 사용하겠습니다. (참고로 이전 호에서 StringScanner에 대한 훌륭한 소개 글을 다룬 적이 있습니다.) 첫 단계로, 모든 것을 CONTENT로 식별하는 최소한의 버전을 만들어 보겠습니다.

새로운 StringScanner 인스턴스를 생성하고, 스캐너가 문자열 끝에 도달할 때까지 반복하는 until 루프로 작업을 수행합니다.

일단은 모든 문자(.*)를 여러 줄에 걸쳐(m修정자) 매칭하고, 그 전체를 하나의 CONTENT 토큰으로 반환하도록 합니다. 토큰은 배열로 표현하며, 첫 번째 요소는 토큰 이름, 두 번째 요소는 데이터입니다. 아주 기본적인 렉서는 다음과 같습니다.

require 'strscan'
 
module Magicbars
  class Lexer
    def self.tokenize(code)
      new.tokenize(code)
    end
 
    def tokenize(code)
      scanner = StringScanner.new(code)
      tokens = []
 
      until scanner.eos?
        tokens << [:CONTENT, scanner.scan(/.*?/m)]
      end
 
      tokens
    end
  end
end

이 코드를 Welcome to {{name}}으로 실행하면 전체 코드가 하나 붙어 있는 딱 한 개의 CONTENT 토큰 리스트가 반환됩니다.

Magicbars::Lexer.tokenize("Welcome to {{name}}")
=> [[:CONTENT, "Welcome to {{name}}"]]

이번에는 표현식(expression)을 감지해 보겠습니다. 루프 안의 코드를 수정해 {{}}를 각각 OPEN_EXPRESSIONCLOSE로 매칭하면 됩니다.

조건문을 추가해 각 경우를 구분해서 처리하도록 합니다.

until scanner.eos?
  if scanner.scan(/{{/)
    tokens << [:OPEN_EXPRESSION]
  elsif scanner.scan(/}}/)
    tokens << [:CLOSE]
  elsif scanner.scan(/.*?/m)
    tokens << [:CONTENT, scanner.matched]
  end
end

중괄호를 OPEN_EXPRESSIONCLOSE 토큰에 붙여 두는 것은 별 의미가 없으므로 버립니다. 또한 scan 호출이 이제 조건문의 일부가 되었기 때문에, 마지막 매칭 결과를 CONTENT 토큰에 붙이려면 scanner.matched를 사용해야 합니다.

하지만 아쉽게도 렉서를 다시 실행해도 여전히 이전처럼 CONTENT 토큰 하나만 반환됩니다. 마지막 표현식이 열린 표현식 직전까지만 매칭하도록 수정해야 합니다. 이중 중괄호에 대한 positive lookahead 앵커와 함께 scan_until을 사용하면 스캐너가 중괄호 바로 앞에서 멈추도록 만들 수 있습니다. 루프 안의 코드는 이제 다음과 같습니다.

until scanner.eos?
  if scanner.scan(/{{/)
    tokens << [:OPEN_EXPRESSION]
  elsif scanner.scan(/}}/)
    tokens << [:CLOSE]
  elsif scanner.scan_until(/.*?(?={{|}})/m)
    tokens << [:CONTENT, scanner.matched]
  end
end

렉서를 다시 실행하면 이번에는 네 개의 토큰이 생성됩니다.

Magicbars::Lexer.tokenize("Welcome to {{name}}")
=> [[:CONTENT, "Welcome to "], [:OPEN_EXPRESSION], [:CONTENT, "name"], [:CLOSE]]

결과가 거의 원하는 형태에 가까워졌습니다. 하지만 name은 일반 콘텐츠가 아니라 식별자(identifier)입니다! 이중 중괄호 안의 문자열은 밖에 있는 문자열과 다르게 취급되어야 합니다.

상태 머신(State Machine) 도입하기

이를 해결하기 위해 렉서를 서로 다른 두 상태를 가진 상태 머신으로 바꿔 보겠습니다. 렉서는 default 상태에서 시작하고, OPEN_EXPRESSION 토큰을 만나면 expression 상태로 전환된 뒤, CLOSE 토큰을 만나 다시 default 상태로 돌아올 때까지 그 상태를 유지합니다.

상태 머신은 배열을 사용해 현재 상태를 관리하는 몇 가지 메서드를 추가하여 구현합니다.

def stack
  @stack ||= []
end
 
def state
  stack.last || :default
end
 
def push_state(state)
  stack.push(state)
end
 
def pop_state
  stack.pop
end

state 메서드는 현재 상태를 반환하거나, 스택이 비어 있으면 default를 반환합니다. push_state는 새 상태를 스택에 추가해 렉서를 새 상태로 전환하고, pop_state는 렉서를 이전 상태로 되돌립니다.

다음으로 루프 안의 조건문을 분리하고, 현재 상태를 검사하는 조건문으로 감싸 줍니다. default 상태에서는 OPEN_EXPRESSIONCONTENT 토큰을 모두 처리합니다. 덕분에 CONTENT용 정규표현식에서는 더 이상 }} lookahead가 필요 없으므로 제거합니다. expression 상태에서는 CLOSE 토큰을 처리하고, IDENTIFIER를 위한 새로운 정규표현식을 추가합니다. 물론 OPEN_EXPRESSIONpush_state 호출을, CLOSEpop_state 호출을 추가해 상태 전환도 구현합니다.

if state == :default
  if scanner.scan(/{{/)
    tokens << [:OPEN_EXPRESSION]
    push_state :expression
  elsif scanner.scan_until(/.*?(?={{)/m)
    tokens << [:CONTENT, scanner.matched]
  end
elsif state == :expression
  if scanner.scan(/}}/)
    tokens << [:CLOSE]
    pop_state
  elsif scanner.scan(/[\w\-]+/)
    tokens << [:IDENTIFIER, scanner.matched]
  end
end

이 변경 사항을 적용하면 렉서가 드디어 예제를 올바르게 토큰화합니다.

Magicbars::Lexer.tokenize("Welcome to {{name}}")
# => [[:CONTENT, "Welcome to "], [:OPEN_EXPRESSION], [:IDENTIFIER, "name"], [:CLOSE]]

난이도를 한 단계 올려 보기

이제 좀 더 고급 예제로 넘어가 보겠습니다. 이번에는 여러 개의 표현식과 블록(block)이 함께 사용됩니다.

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}}

예상대로 현재 렉서로는 이 예제를 파싱할 수 없습니다. 동작하게 만들려면 누락된 토큰들을 추가하고, 마지막 표현식 뒤의 콘텐츠도 처리할 수 있게 해야 합니다. 루프 안의 코드는 대략 다음과 같습니다.

if state == :default
  if scanner.scan(/{{#/)
    tokens << [:OPEN_BLOCK]
    push_state :expression
  elsif scanner.scan(/{{\//)
    tokens << [:OPEN_END_BLOCK]
    push_state :expression
  elsif scanner.scan(/{{else/)
    tokens << [:OPEN_INVERSE]
    push_state :expression
  elsif scanner.scan(/{{/)
    tokens << [:OPEN_EXPRESSION]
    push_state :expression
  elsif scanner.scan_until(/.*?(?={{)/m)
    tokens << [:CONTENT, scanner.matched]
  else
    tokens << [:CONTENT, scanner.rest]
    scanner.terminate
  end
elsif state == :expression
  if scanner.scan(/\s+/)
    # 공백은 무시
  elsif scanner.scan(/}}/)
    tokens << [:CLOSE]
    pop_state
  elsif scanner.scan(/[\w\-]+/)
    tokens << [:IDENTIFIER, scanner.matched]
  else
    scanner.terminate
  end
end

조건문의 순서가 어느 정도 중요하다는 점을 기억하세요. 가장 먼저 매칭되는 정규표현식이 적용되므로, 더 구체적인 표현식이 항상 더 일반적인 표현식보다 앞에 와야 합니다. 대표적인 예가 바로 블록을 위한 특수 오픈 토큰들입니다.

최종 버전의 렉서를 사용하면 예제가 다음과 같이 토큰화됩니다.

[
  [:CONTENT, "Welcome to "],
  [:OPEN_EXPRESSION],
  [:IDENTIFIER, "name"],
  [:CLOSE],
  [:CONTENT, "!\n\n"],
  [:OPEN_BLOCK],
  [:IDENTIFIER, "if"],
  [:IDENTIFIER, "subscribed"],
  [:CLOSE],
  [:CONTENT, "\n  Thank you for subscribing to our mailing list.\n"],
  [:OPEN_INVERSE],
  [:CLOSE],
  [:CONTENT, "\n  Please sign up for our mailing list to be notified about new articles!\n"],
  [:OPEN_END_BLOCK],
  [:IDENTIFIER, "if"],
  [:CLOSE],
  [:CONTENT, "\n\nYour friends at "],
  [:OPEN_EXPRESSION],
  [:IDENTIFIER, "company_name"],
  [:CLOSE],
  [:CONTENT, "\n"]
]

이제 완성했으니, 최종적으로 일곱 가지 서로 다른 토큰 타입을 식별하게 되었습니다.

토큰예제
OPEN_BLOCK{{#
OPEN_END_BLOCK{{/
OPEN_INVERSE{{else
OPEN_EXPRESSION{{
CONTENT표현식 밖의 모든 내용 (일반 HTML 또는 텍스트)
CLOSE}}
IDENTIFIER단어 문자, 숫자, _, -로 구성된 식별자

다음 단계는 토큰 스트림의 구조를 파악해 추상 구문 트리(AST)로 변환하는 파서를 구현하는 것이지만, 이야기는 다음 기회에 이어가겠습니다.

앞으로의 여정

우리는 문자열 보간을 활용한 기본적인 템플릿 시스템 구현 방법을 살펴보면서 나만의 템플릿 언어를 향한 여정을 시작했습니다. 초기 접근 방식의 한계에 부딪히자, 이후에는 제대로 된 템플릿 시스템을 구현하기 시작했습니다.

지금까지 템플릿을 분석해 다양한 타입의 토큰을 식별하는 렉서를 구현했습니다. 다음 Ruby Magic 에디션에서는 파서와 인터프리터를 구현해 최종적으로 보간된 문자열을 생성하는 과정까지 여정을 계속 이어가겠습니다.