Elixir 1.8과 EEx

Posted on Monday, 18 Feb 2019 elixir macro serum 

지난 달에 Elixir의 새로운 마이너 업데이트인 v1.8 버전이 출시되었습니다. 여러가지 새로운 기능과 개선 사항들이 있지만, 이번 포스트에서 살펴 볼 내용은 Elixir의 템플릿 엔진인 EEx의 개선 사항에 대한 것입니다.

공식 배포된 Changelog에는 이에 대한 내용이 간략하게만 기술되어 있습니다.

Optimize the default template engine to compile and execute more efficiently

기본 템플릿 엔진을 최적화하여 더욱 효율적으로 컴파일하고 실행합니다

사실 대부분의 사용자라면 이 한 줄의 설명으로도 충분할 것입니다. 평소에 쓰던 EEx 템플릿을 그대로 쓰면서 Elixir v1.8이 가져온 성능 향상을 누리기만 하면 되는 것이죠. 저도 그렇게 조용히 지나가면 될 줄 알았습니다. 왜 그러지 못하고 이런 포스트를 쓰게 됐는지는 뒤에서 자세히 설명하겠습니다.

우선 무엇이 어떻게 바뀌었다는 건지 살펴볼까요? 다음과 같은 EEx 템플릿을 한번 컴파일해 봅시다.

template = """
<% a = 1 + 2 %>
1 + 2 = <%= a %>
hello: <%= :hello %>
"""

아래는 Elixir 1.6.5에서 컴파일된 템플릿입니다.

iex> template |> EEx.compile_string() |> Macro.to_string() |> IO.puts()
(
  tmp1 = (
    tmp1 = (
      tmp2 = ""
      a = 1 + 2
      tmp2
    ) <> "\n1 + 2 = "
    tmp1 <> String.Chars.to_string(a)
  ) <> "\nhello: "
  tmp1 <> String.Chars.to_string(:hello)
) <> "\n"
:ok

이번에는 Elixir 1.8.1에서 같은 템플릿을 컴파일해 보겠습니다.

iex> template |> EEx.compile_string() |> Macro.to_string() |> IO.puts()
(
  a = 1 + 2
  arg0 = String.Chars.to_string(a)
  arg1 = String.Chars.to_string(:hello)
  <<"\n1 + 2 = ", arg0::binary, "\nhello: ", arg1::binary, "\n">>
)
:ok

확실히 개선된 EEx 엔진이 훨씬 더 간결한 코드를 생성하는 것을 볼 수 있습니다.

구체적으로 말하자면, EEx 템플릿은 “동적인 부분”과 “정적인 부분”으로 분리할 수 있습니다. “동적인 부분”이란, <%=%> 사이에 있어서 템플릿을 적용하는 시점에 동적으로 평가되어 삽입되는 부분이고, “정적인 부분”은 그 구분자의 바깥에 있는 다른 모든 텍스트를 가리킵니다.

기존의 EEx 엔진은 템플릿의 동적인 부분과 정적인 부분이 번갈아가며 나타날 때마다 이미 만들어진 바이너리에 해당 부분을 연결한 새로운 바이너리를 만들어내도록 코드를 생성합니다. 반면에 새로운 EEx 엔진은 각각의 동적인 부분에 대한 변수를 미리 만들어 두고, 마지막에 동적인 부분과 정적인 부분으로 이루어진 바이너리를 한 번에 만들어내는 코드를 생성합니다. 생성된 코드의 가독성은 물론, 바이너리의 생성 횟수를 한 번으로 줄이면서 효율성까지 향상시킨 좋은 변화라고 볼 수 있겠죠.

Elixir 1.8과 Serum

이번에는 제가 오래 전부터 지금까지 진행해 오고 있는 프로젝트 Serum에 대한 이야기를 하려고 합니다. Serum은 Elixir로 만든 정적 웹 사이트 생성기 프로젝트로, Markdown 문서와 EEx 템플릿을 조합해 정적인 웹 사이트를 만들어 줍니다. 지금 여러분이 보고 계신 이 페이지도 Serum에 의해 생성된 페이지입니다.

Serum이라는 프로젝트는 대략 3년 전쯤, 제가 Elixir를 처음 배우고 시작해서, 지금까지 학교 일 때문에 바쁘다, 개발 의욕이 떨어졌다 등등 여러가지 이유로 질질 끌어오다가 최근에 들어서야 Hex에 패키지를 등록하고 다시 활발히 개발이 진행중인 프로젝트입니다. 어느 날, 신나게 버그를 고치고 새 버전을 Hex를 통해 릴리즈한 후, 저는 새로운 버전의 Serum을 Elixir v1.8이 설치된 컴퓨터에서 시험해 보게 됩니다. 평소처럼 Serum의 개발용 서버를 띄우고, 웹 브라우저를 켜서 생성된 페이지를 보는데, 페이지의 상단에 원인을 알 수 없는 이상한 컨텐츠가 추가되어 있는 것이었습니다.

페이지 상단에 이상한 컨텐츠가 추가된 페이지의 스크린 샷

순간 식은 땀이 흘렀습니다. 어떤 패키지의 새 버전을 릴리즈하고 곧바로 눈치 채지 못했던 문제가 발견되어 재빠르게 올렸던 패키지를 내리고 다시 릴리즈 했던 것이 한 두 번이 아닌지라, 이번만큼은 그러고 싶지 않았거든요. 그래서 구 버전의 Elixir가 설치된 다른 컴퓨터에 연결해서 같은 패키지로 시험을 해 봤는데, 거기에서는 아무런 문제가 발견되지 않았습니다.

저는 이것이 <head> 태그 안에서 <meta> 태그를 만들어내는 EEx 코드와 관련된 문제라 생각하여 몇 가지 실험을 해 보았는데, 이해하지 못할 일이 일어났습니다.

이해할 수 없는 현상

템플릿의 맨 처음 두 개의 <%= ... %>는 그 안에 어떤 식이 들어있든지 상관 없이, 앞에서 본 것과 같은 내용으로 대체되는 것이었습니다. 그런데, 삽입되는 컨텐츠가 페이지의 메인 내비게이션 영역과 관련된 것이라는 점, 메인 내비게이션 영역은 Serum에서 제공하는 include/1 매크로에 의해 다른 템플릿에 삽입된다는 점을 고려하여 문제의 원인을 확정할 수 있었습니다.

include/1 매크로는 defmacro같은 것을 사용해서 구현된 것이 아닙니다. Serum이 템플릿을 컴파일 할 때, 우선 EEx.compile_string/1을 사용하여 템플릿에 대한 AST(추상 구문 트리)를 만들고, 그 트리를 순회하면서 include/1을 호출하는 식에 대한 AST를 다른 템플릿의 AST로 대체합니다. 따라서 템플릿을 작성할 때는 include/1을 매크로처럼 사용할 수 있는 것이죠. 이 동작을 단순화해서 나타내면 다음과 같습니다.

{:ok, ast} = EEx.compile_string(source)

Macro.postwalk(ast, fn
  {:include, _, [arg]} -> Template.get(arg)[:ast]
  anything_else -> anything_else
end)

지금까지 잘만 동작하던 코드가 어째서 한 글자도 바뀌지 않았는데 Elixir v1.8에서 오동작하게 된 걸까요? 이 원인을 간단한 예제를 들어서 설명해 보겠습니다.

다음과 같은 템플릿 a와 템플릿 b가 있습니다. 템플릿 a에는 템플릿 b를 삽입하도록 하는 코드가 있습니다.

a:

hello: <%= :hello %>
world: <%= :world %>
bye: <%= include("b") %>

b:

<%= :bye %>

이 두 템플릿은 Elixir v1.8 이전의 버전에서 다음과 같이 컴파일됩니다.

# 템플릿 a를 컴파일한 결과:
(
  tmp1 = (
    tmp1 = (
      tmp1 = "" <> "hello: "
      tmp1 <> String.Chars.to_string(:hello)
    ) <> "\nworld: "
    tmp1 <> String.Chars.to_string(:world)
  ) <> "\nbye: "
  tmp1 <> String.Chars.to_string(include("b"))
) <> "\n"

# 템플릿 b를 컴파일한 결과:
(
  tmp1 = ""
  tmp1 <> String.Chars.to_string(:bye)
) <> "\n"

앞에서 설명한 대로 include("b")를 처리하면 다음과 같은 최종 코드가 만들어집니다.

(
  tmp1 = (
    tmp1 = (
      tmp1 = "" <> "hello: "
      tmp1 <> String.Chars.to_string(:hello)
    ) <> "\nworld: "
    tmp1 <> String.Chars.to_string(:world)
  ) <> "\nbye: "
  tmp1 <> String.Chars.to_string((
    tmp1 = ""
    tmp1 <> String.Chars.to_string(:bye)
  ) <> "\n")
) <> "\n"

코드가 따라가기 힘들게 생겼습니다만, 자세히 살펴보면 tmp1이라는 변수에 다른 값이 여러번 대입되는데도 블록 바깥에 있는 tmp1의 값에 영향을 미치지 않는 것을 알 수 있을 것입니다. 실제로 이 코드를 평가해 보면 의도한 결과물이 나옵니다.

hello: hello
world: world
bye: bye

이번에는 같은 과정을 Elixir v1.8에서 진행해 볼까요? 먼저 두 템플릿을 컴파일하고, include/1 매크로를 확장합니다.

# 템플릿 a를 컴파일한 결과:
(
  arg0 = String.Chars.to_string(:hello)
  arg1 = String.Chars.to_string(:world)
  arg2 = String.Chars.to_string(bye())
  <<"hello: ", arg0::binary,
    "\nworld: ", arg1::binary,
    "\nbye: ", arg2::binary, "\n">>
)

# 템플릿 b를 컴파일한 결과:
(
  arg0 = String.Chars.to_string(:bye)
  <<arg0::binary, "\n">>
)
(
  arg0 = String.Chars.to_string(:hello)
  arg1 = String.Chars.to_string(:world)
  arg2 = String.Chars.to_string((
    arg0 = String.Chars.to_string(:bye)
    <<arg0::binary, "\n">>
  ))
  <<"hello: ", arg0::binary,
    "\nworld: ", arg1::binary,
    "\nbye: ", arg2::binary, "\n">>
)

이제 어떤 게 문제였는지 보이시나요? 다섯 번째 줄에서 arg0에 다른 값이 대입되어서 두 번째 줄에서 arg0에 대입된 값을 덮어 씌우고 있습니다. 변수의 스코프 문제 때문에 단순하게 AST를 있는 그대로 대체시키는 방법이 더 이상은 통하지 않게 된 것입니다.

hello: bye
world: world
bye: bye

이 문제를 해결하기 위한 방법 중 가장 직관적이고 바로 떠올릴 수 있는 방법은 삽입되는 코드를 별도의 스코프를 갖는 블록으로 감싸주는 것입니다. 이 문서에 따르면, Elixir에서는 다음과 같은 구조가 새로운 스코프를 만듭니다.

  • 모듈과 관련된 구조 (defmodule, defprotocol, defimpl)
  • 함수 (fn, def, defp)
  • for comprehension
  • try 블록

그래서 저는 다른 템플릿을 포함시킬 때, 포함될 템플릿의 AST를 익명 함수 호출로 감싸기로 했습니다.

Macro.postwalk(ast, fn
  {:include, _, [arg]} ->
    quote do
      (fn -> unquote(Template.get(arg)[:ast]) end).()
    end

  anything_else ->
    anything_else
end)

이렇게 하면 위의 예제 코드는 다음과 같이 바뀌고, 우리가 의도했던 결과물을 돌려주게 됩니다.

(
  arg0 = String.Chars.to_string(:hello)
  arg1 = String.Chars.to_string(:world)
  arg2 = String.Chars.to_string((fn -> (
    arg0 = String.Chars.to_string(:bye)
    <<arg0::binary, "\n">>
  ) end).())
  <<"hello: ", arg0::binary,
    "\nworld: ", arg1::binary,
    "\nbye: ", arg2::binary, "\n">>
)

마치며

이번 포스트에서는 Elixir v1.8에서 EEx의 기본 템플릿 엔진이 어떻게 개선되었는지, 그리고 이러한 변경 사항이 제가 진행하고 있던 프로젝트에 어떤 영향을 미쳤고 이를 어떻게 해결했는지에 대해 이야기 해 보았습니다. 그리고 이번 문제를 해결하면서, 앞으로는 프로젝트의 새로운 버전을 배포하기 전에 Elixir의 주요 버전에 따라 여러 테스트 환경을 구성하여 테스트를 진행하는 습관을 들여야겠다고 생각했습니다.

마지막으로, 쉽고 재미있는 함수형 프로그래밍 언어 Elixir와 제가 하고 있는 여러가지 프로젝트에 많은 관심 부탁드립니다. 😊