Elixir 매크로 이해하기, Part 1: 기본
Posted on Monday, 14 Aug 2017
이 글은 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의 get
과 match
를 호출하면 함수가 생성되고, 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.txt
와 SpecialCasing.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
라는 식을 나타내는 항을 생성한 것입니다. 최종적인 코드는 아직 발생되지 않았으므로 오류는 없습니다.
만약 우리가 이것을 a
와 b
가 유효한 식별자로서 존재하는 AST의 어느 부분에 삽입한다면, 이 코드는 올바른 코드가 될 것입니다.
이것을 한 번 확인해 봅시다. 먼저, 덧셈 식을 quote 해보겠습니다.
iex(4)> sum_expr = quote do a + b end
그 다음에 quote된 바인딩 식을 만듭니다.
iex(5)> bind_expr = quote do
a=1
b=2
end
다시 한 번, 이것들은 그저 quote된 식일 뿐이라는 것을 명심하세요. 이것들은 단순히 코드를 설명하는 데이터이며 아직 아무것도 평가되지 않았습니다. 특히, 변수 a
와 b
는 현재 셸 세션에 존재하지 않습니다.
이 조각들을 작동하게 하기 위해서는 반드시 연결을 해주어야 합니다.
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
)과 변수 a
와 b
가 각각 1
과 2
라는 값으로 바인딩된 것을 확인할 수 있는 바인딩 리스트로 이루어져 있습니다.
이것이 Elixir에서의 메타-프로그래밍 접근법에 대한 핵심 사항입니다. 메타-프로그래밍을 할 때 우리는 기본적으로 여러개의 AST 조각들을 구성해서 우리가 만들고자 하는 코드를 표현하는 대체 AST를 생성해냅니다. 이렇게 하는 동안에 우리는 보통 입력으로 주어지는 AST들(우리가 조합할 것들)의 정확한 내용이나 구조에 관심을 가지지 않습니다. 그 대신, 우리는 quote
를 사용해서 입력된 조각들을 조합하고 데코레이션된 코드를 생성합니다.
Unquote하기
이제 unquote
가 활약할 차례입니다. quote
블록 안에 있는 것들은 뭐든지 quote
되어 AST 조각으로 바뀐다는 것에 주목하세요. 즉 우리는 일반적으로 quote 바깥에 있는 변수의 내용을 인젝션할 수 없습니다. 위에 있는 예제와 달리 이 코드는 동작하지 않습니다.
quote do
bind_expr
sum_expr
end
이 코드 조각에서 quote
는 단순히 bind_expr
과 sum_expr
변수에 대한 quote된 참조를 생성할 뿐이며, 이 두 변수는 이 AST가 해석는 순간의 컨텍스트 내에 존재해야 합니다. 하지만 이건 우리가 바라는 경우가 아니죠. 우리는 우리가 만들고자 하는 AST 조각의 해당하는 위치에 bind_expr
과 sum_expr
의 내용을 직접 인젝션하는 방법을 알아야 합니다.
이것이 바로 unquote(...)
의 용도입니다. 괄호 안의 식은 곧바로 평가되어 unquote
를 호출한 위치에 삽입됩니다. 즉, unquote
의 결과 또한 유효한 AST 조각이어야 합니다.
unquote
를 이해하는 또 다른 방법은 이를 문자열 보간(#{}
)과 유사하게 보는 것입니다. 문자열을 가지고는 이런 걸 할 수 있죠.
"... #{some_expression} ... "
이와 비슷하게, quote를 할 때는 이렇게 할 수 있습니다.
quote do
...
unquote(some_expression)
...
end
위의 두 경우 모두, 여러분은 현재 컨텍스트에서 유효한 식을 평가하고, 그 결과를 여러분이 만들고 있는 식(문자열이든 AST 조각이든)에 인젝션하는 것입니다.
이것을 이해하는 것은 중요합니다. 왜냐하면 unquote
는 quote
의 반대 연산이 아니기 때문에요. 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_ast
에 quote(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]}]}
출력이 살짝 무섭게 생겼지만 여러분은 이해할 필요는 없습니다. 하지만 가까이 들여다보면 이 구조의 어딘가에 Tracer
와 trace
가 언급되는 것을 볼 수 있습니다. 즉 이는 이 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
)
이것의 포인트는 여러분의 매크로 호출이 정말로 다른 무언가로 확장된다는 것을 보여주는 것이었습니다. 매크로는 이런 식으로 동작합니다. 비록 셸 안에서만 실행해봤지만, 우리가 mix
나 elixirc
를 사용해서 프로젝트를 빌드할 때도 똑같은 일이 일어납니다.
첫 번째 시간은 여기까지로 충분할 것 같군요. 여러분은 컴파일러의 과정과 AST에 대해 대략적으로 배우고 매크로의 간단한 예제를 보셨습니다. 다음 장에서는 여기서 더 나아가 매크로의 기계적인 측면에 대해 이야기해 봅니다.
주:
제 2장은 아직 번역되지 않았습니다. 원문을 보시려면 이 링크를 따라가세요.