Elixir 매크로 이해하기, Part 1: 기본

Posted on Monday, 14 Aug 2017 elixir macro series study translation 

이 글은 The Erlangelist에 게시된 “Understanding Elixir Macros, Part 1 - Basics”을 한국어로 번역한 것입니다. 원문은 여기에서 읽어보실 수 있습니다.

개인적으로 공부하면서 번역한 글입니다. 부디 가벼운 마음으로 읽어 주시고, 정말 어색한 부분이나 이해가 잘 되지 않는 부분이 있으시면 댓글이나 이메일로 알려주시기 바랍니다.

Copyright 2014, Saša Jurić. 이 글은 크리에이티브 커먼즈 저작자표시-비영리 4.0 국제 라이센스에 따라 이용하실 수 있습니다.


이 글은 매크로를 다루는 미니시리즈 중 첫번째 글입니다. 저는 원래 이 주제를 곧 출간할 Elixir in Action 책에서 다루려고 계획했습니다만 그러지 않기로 결정했습니다. 이 주제는 아래에 깔려있는 VM과 OTP의 중요한 부분에 더 초점을 둔 이 책의 메인 테마와 맞지 않았기 때문이었어요.

그 대신에 여기에서 매크로에 대해 다루기로 했습니다. 개인적으로는 매크로에 대한 주제가 매우 흥미롭다고 생각하며, 이 미리시리즈에서 매크로가 어떻게 동작하는지를 기본적인 기법과 매크로를 어떻게 작성해야 하는지에 대한 조언과 함께 설명하려고 합니다. 저는 매크로를 작성하는 것이 그렇게 어렵지 않다고 확신하고 있지만, 그래도 일반적인 Elixir 코드를 작성할 때보다 더 높은 집중력을 요구한다는 것은 확실합니다. 따라서 저는 Elixir 컴파일러에 관한 몇 가지 세부사항을 이해하고 있는것이 많은 도움이 된다고 생각합니다. 내부적으로 어떤 일들이 일어나는지를 알고 있으면 메타-프로그래밍 코드에 대해 생각하는 것이 더욱 쉬워집니다.

이 글은 중간 난이도의 글이 될 것입니다. 만약 여러분이 Elixir와 Erlang에 익숙하지만 여전히 매크로에 대해 헷갈리는 것이 있다면 잘 찾아 오신 겁니다. 만약 여러분이 Elixir와 Erlang을 갓 배우기 시작하셨다면 Getting started guide나 시판되는 책 등의 다른 것을 먼저 읽고 오시는 것이 더 좋을 것입니다.

메타-프로그래밍

아마도 여러분은 이미 Elixir의 메타-프로그래밍에 대해 익숙할 것입니다. 기본적인 아이디어는 어떤 입력에 기반하여 코드를 생성하는 코드를 만든다는 것입니다.

매크로 덕분에 우리는 Plug를 사용하여 아래와 같은 구조를 작성할 수 있습니다.

get "/hello" do
  send_resp(conn, 200, "world")
end

match _ do
  send_resp(conn, 404, "oops")
end

아니면 ExActor를 사용해서 이런 것도 할 수 있죠.

defmodule SumServer do
  use ExActor.GenServer

  defcall sum(x, y), do: reply(x+y)
end

위의 두 경우에서, 우리는 컴파일 시간에 원본 코드를 다른 무언가로 바꿔주는 매크로를 실행하고 있습니다. Plug의 getmatch를 호출하면 함수가 생성되고, ExActor의 defcall을 호출하면 두 개의 함수, 그리고 인자를 클라이언트 프로세스로부터 서버로 적절하게 전파시키는 코드가 생성됩니다.

Elixir 자체만 해도 매크로의 도움을 많이 받고 있습니다. 많은 구조들, 이를테면 defmodule, def, if, unless, 심지어는 defmacro조차도 실제로는 매크로입니다. 이렇게 함으로써 언어의 핵심 부분을 최소로 유지하고, 언어의 추가적인 확장을 간편화할 수 있습니다.

이와 관련되어 있지만 별로 알려지지 않은 것은 그 자리에서 함수를 생성할 수 있다는 점입니다.

defmodule Fsm do
  fsm = [
    running: {:pause, :paused},
    running: {:stop, :stopped},
    paused: {:resume, :running}
  ]

  for {state, {action, next_state}} <- fsm do
    def unquote(action)(unquote(state)), do: unquote(next_state)
  end
  def initial, do: :running
end

Fsm.initial
# :running

Fsm.initial |> Fsm.pause
# :paused

Fsm.initial |> Fsm.pause |> Fsm.pause
# ** (FunctionClauseError) no function clause matching in Fsm.pause/1

위 코드는 FSM의 선언적인 명세인데, 이는 컴파일 시간에 이에 해당하는 여러 절의 함수로 변화합니다.

이와 비슷한 기술은 Elixir에서 String.Unicode 모듈을 생성하는 데에도 사용하고 있습니다. 본질적으로, 이 모듈은 코드 포인트가 적혀 있는 UnicodeData.txtSpecialCasing.txt 파일을 읽으면서 생성됩니다.

매크로나 그 자리에서 코드를 생성하는 것 이 두 경우에서, 우리는 컴파일 도중에 추상 구문 트리의 구조에 변화를 가합니다. 이것이 어떻게 동작하는지 이해하기 위해서는 컴파일 과정과 AST에 대해 잠깐 알고 넘어가야 합니다.

컴파일 과정

간단히 말해서, Elixir 코드의 컴파일은 세 단계에 걸쳐 진행됩니다.

컴파일 과정

입력 소스 코드가 분석되고, 이에 해당하는 추상 구문 트리(AST)가 만들어집니다. AST는 여러분의 코드를 중첩된 Elixir 항의 형식으로 표현합니다. 그 다음에 확장 단계로 이동합니다. 이 단계에서 바로 여러가지 내장 또는 사용자 매크로들이 호출되어 입력된 AST를 최종 버전으로 변화시킵니다. 이러한 변환이 완료되고 나면 Elixir가 여러분의 소스 프로그램의 이진 표현인 바이트코드를 만들어 낼 수 있습니다.

이는 실제 컴파일 과정을 근사하여 표현한 것입니다. 예를 들면, Elixir 컴파일러는 실제로는 Erlang의 AST를 생성하고 이를 바이트코드로 변환하기 위해 Erlang의 기능에 의존하는데, 이렇게 자세한 사항까지 아는 것은 중요하지 않습니다. 하지만 저에게는 메타-프로그래밍 코드에 관해 생각할 때 이런 대략적인 그림이 도움이 되었습니다.

여러분이 이해해야 할 주안점은 메타-프로그래밍의 마법이 확장 단계에서 일어난다는 점입니다. 컴파일러는 처음에 여러분의 원래 소스 코드와 아주 비슷한 AST에서 시작해서, 최종 버전으로 확장해냅니다.

이 도표에서 볼 수 있는 또 한가지 중요한 점은, Elixir에서는 이진 데이터가 만들어진 후에 메타-프로그래밍이 멈춘다는 것입니다. 코드 업그레이드나 동적 코드 로딩같은 꼼수(이 글의 범위에서 벗어난 내용)를 제외하고서는, 여러분의 코드가 재정의되지 않는다고 확신할 수 있습니다. 메타-프로그래밍은 언제나 코드에 보이지 않는 (또는 그다지 명확하지 않은) 레이어를 덧씌웁니다만, Elixir에서는 그나마 이 과정이 오직 컴파일 시간에만 진행되고, 따라서 프로그램의 여러가지 실행 경로로부터 독립적입니다.

코드의 변경이 컴파일 시간에만 일어나므로, 최종 결과물에 대해 생각해 보는 것이 비교적 쉽고, 메타-프로그래밍은 dialyzer같은 정적 분석 도구와 간섭하지 않습니다. 컴파일 시간 메타-프로그래밍은 성능 상의 불이익이 없다는 것을 의미하기도 합니다. 코드가 실행되고 있을 때는 코드는 이미 모양이 갖추어져 있는 상태이고, 어떠한 메타-프로그래밍 동작도 실행되지 않습니다.

AST 조각 만들기

그래서 Elixir AST가 무엇일까요. 이는 Elixir 항으로서, 문법적으로 올바른 Elixir 코드를 나타내는 깊게 중첩된 계층입니다. 명확하게 하기 위해서 몇 가지 예를 살펴봅시다. 코드로부터 AST를 생성하기 위해서는 quote special form을 사용할 수 있습니다.

iex(1)> quoted = quote do 1 + 2 end
{:+, [context: Elixir, import: Kernel], [1, 2]}

Quote는 임의의 복잡도를 갖는 Elixir 식을 받아서 입력된 코드를 표현하는 AST 조각을 반환합니다.

위 코드의 경우, 결과물은 간단한 덧셈 연산 (1+2)를 나타내는 AST 조각입니다. 이는 보통 _quote된 식_이라 불립니다.

대부분의 시간 동안 여러분은 quote된 구조의 구체적인 사항에 대해 정확하게 알 필요는 없지만, 그래도 이 간단한 예제를 한 번 들여다 봅시다. 위의 경우 우리의 AST 조각은 아래의 세 개로 이루어져 있습니다.

  • 수행될 연산을 식별하는 atom (:+)
  • 식의 컨텍스트 (즉, import와 alias). 보통 여러분은 이 데이터를 이해할 필요가 없습니다
  • 연산의 인자 (피연산자)

여기서의 주안점은 quote된 식은 코드를 표현하는 Elixir 항이라는 것입니다. 컴파일러는 이것을 사용해서 마지막에 최종 바이트코드를 생성합니다.

그렇게 흔한 방법은 아니지만, quote된 식을 평가하는 것도 가능합니다.

iex(2)> Code.eval_quoted(quoted)
{3, []}

결과로 나온 튜플에는 식의 결과값과 그 식에서 만들어진 변수 바인딩의 목록이 들어있습니다.

하지만, AST가 어떻게든 평가되기 전에는 (보통 컴파일러에 의해 처리됨) 이 quote된 식은 의미론적으로 확인되지 않습니다. 예를 들면, 우리가 다음과 같은 식을 쓸 때,

iex(3)> a + b
** (RuntimeError) undefined function: a/0

a라는 변수 (또는 함수)가 없으므로 오류가 발생합니다.

이와는 반대로, 위의 식을 quote한다면

iex(3)> quote do a + b end
{:+, [context: Elixir, import: Kernel], [{:a, [], Elixir}, {:b, [], Elixir}]}

오류가 발생하지 않고 a+b의 quote된 표현을 돌려받았습니다. 달리 말해서 우리는 변수의 존재 유무와 관계 없이 a+b라는 식을 나타내는 항을 생성한 것입니다. 최종적인 코드는 아직 발생되지 않았으므로 오류는 없습니다.

만약 우리가 이것을 ab가 유효한 식별자로서 존재하는 AST의 어느 부분에 삽입한다면, 이 코드는 올바른 코드가 될 것입니다.

이것을 한 번 확인해 봅시다. 먼저, 덧셈 식을 quote 해보겠습니다.

iex(4)> sum_expr = quote do a + b end

그 다음에 quote된 바인딩 식을 만듭니다.

iex(5)> bind_expr = quote do
          a=1
          b=2
        end

다시 한 번, 이것들은 그저 quote된 식일 뿐이라는 것을 명심하세요. 이것들은 단순히 코드를 설명하는 데이터이며 아직 아무것도 평가되지 않았습니다. 특히, 변수 ab는 현재 셸 세션에 존재하지 않습니다.

이 조각들을 작동하게 하기 위해서는 반드시 연결을 해주어야 합니다.

iex(6)> final_expr = quote do
          unquote(bind_expr)
          unquote(sum_expr)
        end

여기서 우리는 bind_expr 안에 들어있는 것과 sum_expr 안에 들어있는 것으로 구성된 또다른 quote된 식을 만듭니다. 본질적으로, 우리는 두 개의 식을 합치는 새로운 AST 조각을 만든 것입니다. unquote 부분에 대해서는 걱정하지 마세요. 잠시 후에 설명해 드릴게요.

그 동안에 우리는 최종 AST 조각을 평가해 볼 수 있습니다.

iex(7)> Code.eval_quoted(final_expr)
{3, [{{:a, Elixir}, 1}, {{:b, Elixir}, 2}]}

또다시, 결과물은 식의 결과값(3)과 변수 ab가 각각 12라는 값으로 바인딩된 것을 확인할 수 있는 바인딩 리스트로 이루어져 있습니다.

이것이 Elixir에서의 메타-프로그래밍 접근법에 대한 핵심 사항입니다. 메타-프로그래밍을 할 때 우리는 기본적으로 여러개의 AST 조각들을 구성해서 우리가 만들고자 하는 코드를 표현하는 대체 AST를 생성해냅니다. 이렇게 하는 동안에 우리는 보통 입력으로 주어지는 AST들(우리가 조합할 것들)의 정확한 내용이나 구조에 관심을 가지지 않습니다. 그 대신, 우리는 quote를 사용해서 입력된 조각들을 조합하고 데코레이션된 코드를 생성합니다.

Unquote하기

이제 unquote가 활약할 차례입니다. quote 블록 안에 있는 것들은 뭐든지 quote되어 AST 조각으로 바뀐다는 것에 주목하세요. 즉 우리는 일반적으로 quote 바깥에 있는 변수의 내용을 인젝션할 수 없습니다. 위에 있는 예제와 달리 이 코드는 동작하지 않습니다.

quote do
  bind_expr
  sum_expr
end

이 코드 조각에서 quote는 단순히 bind_exprsum_expr 변수에 대한 quote된 참조를 생성할 뿐이며, 이 두 변수는 이 AST가 해석는 순간의 컨텍스트 내에 존재해야 합니다. 하지만 이건 우리가 바라는 경우가 아니죠. 우리는 우리가 만들고자 하는 AST 조각의 해당하는 위치에 bind_exprsum_expr의 내용을 직접 인젝션하는 방법을 알아야 합니다.

이것이 바로 unquote(...)의 용도입니다. 괄호 안의 식은 곧바로 평가되어 unquote를 호출한 위치에 삽입됩니다. 즉, unquote의 결과 또한 유효한 AST 조각이어야 합니다.

unquote를 이해하는 또 다른 방법은 이를 문자열 보간(#{})과 유사하게 보는 것입니다. 문자열을 가지고는 이런 걸 할 수 있죠.

"... #{some_expression} ... "

이와 비슷하게, quote를 할 때는 이렇게 할 수 있습니다.

quote do
  ...
  unquote(some_expression)
  ...
end

위의 두 경우 모두, 여러분은 현재 컨텍스트에서 유효한 식을 평가하고, 그 결과를 여러분이 만들고 있는 식(문자열이든 AST 조각이든)에 인젝션하는 것입니다.

이것을 이해하는 것은 중요합니다. 왜냐하면 unquotequote의 반대 연산이 아니기 때문에요. quote가 코드 조각을 받아서 quote된 식으로 바꿔주는 반면에, unquote는 그 반대의 동작을 하지 않습니다. 만약 quote된 식을 문자열로 바꾸고 싶다면, Macro.to_string/1을 사용해야 합니다.

예제: 식 추적하기

이 이론을 하나의 간단한 예제로 종합해 봅시다. 여기서 우리는 코드를 디버깅하는 데 도움을 주는 매크로를 작성할 것입니다. 이 매크로는 이렇게 사용할 수 있습니다.

iex(1)> Tracer.trace(1 + 2)
Result of 1 + 2: 3
3

Tracer.trace는 주어진 식을 받아서 그 결과를 화면에 출력합니다. 그 다음 식의 결과가 반환됩니다.

중요한 점은 이것이 매크로라는 것을 깨닫는 것입니다. 입력된 식(1 + 2)은 결과를 출력하고 반환하는 좀 더 자세한 코드로 변환되는데, 이러한 변환은 확장 단계에서 수행되고 그 결과 발생되는 바이트코드에는 입력된 코드의 데코레이션된 버전이 들어갑니다.

매크로 구현을 보기 전에 최종 결과물을 상상해 보는 것이 도움이 될지도 모릅니다. Tracer.trace(1+2)를 호출하면 그 결과로 나오는 바이트코드는 아래와 같은 코드에 해당될 것입니다.

mangled_result = 1+2
Tracer.print("1+2", mangled_result)
mangled_result

mangled_result라는 이름은 Elixir 컴파일러가 우리가 매크로 내에서 사용하는 모든 임시 변수의 이름을 변형시킨다는 것을 나타냅니다. 이는 청결한 매크로(macro hygiens)라고도 알려져 있으며, 이에 대해서는 이 시리즈의 나중에 알아보기로 합시다.

위의 템플릿을 고려하여 이런 식으로 매크로를 구현할 수 있습니다.

defmodule Tracer do
  defmacro trace(expression_ast) do
    string_representation = Macro.to_string(expression_ast)

    quote do
      result = unquote(expression_ast)
      Tracer.print(unquote(string_representation), result)
      result
    end
  end

  def print(string_representation, result) do
    IO.puts "Result of #{string_representation}: #{inspect result}"
  end
end

이 코드를 한 단계씩 분석해 봅시다.

먼저, defmacro를 사용해서 매크로를 정의합니다. 매크로는 기본적으로 특수한 종류의 함수입니다. 이 함수의 이름은 변형되고, 이 함수는 오직 확장 단계에서만 호출되어야 합니다 (이론적으로는 실행 중에도 호출할 수 있기는 합니다).

우리의 매크로는 quote된 식을 넘겨받습니다. 이것은 반드시 명심하고 있어야 합니다. 여러분이 매크로에 어떤 인자를 넘기든간에, 그 인자들은 이미 quote된 채로 넘어갑니다. 따라서 Tracer.trace(1+2)를 호출하면 우리의 매크로(즉 함수)는 3을 받지 않습니다. 그 대신, expression_astquote(do: 1+2)의 결과가 들어갑니다.

세번째 줄에서는 Macro.to_string/1을 사용하여 넘겨받은 AST 조각의 문자열 표현을 계산합니다. 이것은 여러분이 실행 중에 호출되는 평범한 함수를 가지고는 할 수 없는 것들의 한 종류입니다. 실행 중에 Macro.to_string/1을 호출할 수는 있지만 더이상 AST에 접근할 수는 없으므로 우리는 어떤 식의 문자열 표현이 무엇인지 알 수 없습니다.

이제 우리는 문자열 표현을 가지고 있으므로 결과물인 AST를 생성하고 반환할 수 있으며, 이는 quote do ... end 구조 안에서 끝납니다. 이 매크로의 결과는 quote된 식이고, 이 식이 원래의 Tracer.trace(...) 호출을 대체하게 됩니다.

이 부분을 더 자세히 들여다 봅시다.

quote do
  result = unquote(expression_ast)
  Tracer.print(unquote(string_representation), result)
  result
end

만약 unquote에 대한 설명을 잘 이해했다면 이것은 나름 쉽습니다. 기본적으로 우리는 expression_ast(quote된 1+2)를 우리가 만들고자 하는 조각에 인젝션하고, 연산의 결과를 result 변수에 넣습니다. 그 다음 이 결과를 문자열로 변한 식(Macro.to_string/1을 통해 얻은)과 함께 출력하고, 마지막으로 결과값을 반환합니다.

AST 확장하기

셸 내에서 이것들이 어떻게 연결되는지 쉽게 관찰할 수 있습니다. iex 셸을 시작하고 위에 있는 Tracer 모듈의 정의를 복사해서 붙여넣으세요.

iex(1)> defmodule Tracer do
          ...
        end

그리고 반드시 Tracer 모듈을 require해야 합니다.

iex(2)> require Tracer

다음으로, trace 매크로에 대한 호출을 quote해 봅시다.

iex(3)> quoted = quote do Tracer.trace(1+2) end
{{:., [], [{:__aliases__, [alias: false], [:Tracer]}, :trace]}, [],
 [{:+, [context: Elixir, import: Kernel], [1, 2]}]}

출력이 살짝 무섭게 생겼지만 여러분은 이해할 필요는 없습니다. 하지만 가까이 들여다보면 이 구조의 어딘가에 Tracertrace가 언급되는 것을 볼 수 있습니다. 즉 이는 이 AST 조각이 우리의 원래 코드에 해당하며, 아직 확장되지 않았음을 증명합니다.

이제 Macro.expand/2로 이 AST를 확장된 버전으로 만들 수 있습니다.

iex(4)> expanded = Macro.expand(quoted, __ENV__)
{:__block__, [],
 [{:=, [],
   [{:result, [counter: 5], Tracer},
    {:+, [context: Elixir, import: Kernel], [1, 2]}]},
  {{:., [], [{:__aliases__, [alias: false, counter: 5], [:Tracer]}, :print]},
   [], ["1 + 2", {:result, [counter: 5], Tracer}]},
  {:result, [counter: 5], Tracer}]}

이것이 우리 코드의 완전히 확장된 버전이며, 이 안의 어딘가에 result(매크로에 의해 도입된 임시 변수)와 Tracer.print/2의 호출이 언급되는 것을 볼 수 있습니다. 심지어는 이 식을 문자열로 바꿀 수도 있습니다.

iex(5)> Macro.to_string(expanded) |> IO.puts
(
  result = 1 + 2
  Tracer.print("1 + 2", result)
  result
)

이것의 포인트는 여러분의 매크로 호출이 정말로 다른 무언가로 확장된다는 것을 보여주는 것이었습니다. 매크로는 이런 식으로 동작합니다. 비록 셸 안에서만 실행해봤지만, 우리가 mixelixirc를 사용해서 프로젝트를 빌드할 때도 똑같은 일이 일어납니다.

첫 번째 시간은 여기까지로 충분할 것 같군요. 여러분은 컴파일러의 과정과 AST에 대해 대략적으로 배우고 매크로의 간단한 예제를 보셨습니다. 다음 장에서는 여기서 더 나아가 매크로의 기계적인 측면에 대해 이야기해 봅니다.

주:

제 2장은 아직 번역되지 않았습니다. 원문을 보시려면 이 링크를 따라가세요.