매크로

매크로 (Macros)

매크로는 Elixir에서 가장 강력하면서도, 가장 신중하게 다뤄야 할 도구예요. 이 가이드에서는 매크로가 무엇인지, 어떻게 동작하는지, 그리고 언제 써야 하는지를 차근차근 살펴볼게요.

출처: Macros

본문

Elixir가 매크로를 위한 안전한 환경을 최대한 제공하긴 하지만, 매크로로 깨끗한 코드를 쓰는 책임의 대부분은 개발자에게 있어요. 매크로는 일반 Elixir 함수보다 쓰기 어렵고, 꼭 필요하지 않은데 쓰는 건 나쁜 스타일로 여겨져요. 매크로는 책임감 있게 쓰세요.

Elixir는 이미 데이터 구조와 함수로 일상 코드를 간단하고 읽기 좋게 쓸 수 있는 장치를 제공해요. 매크로는 마지막 수단으로만 사용해야 해요. 명시적인 것이 암시적인 것보다 낫고(Explicit is better than implicit), 명확한 코드가 간결한 코드보다 낫다는 걸 기억하세요.

첫 번째 매크로

Elixir에서 매크로는 defmacro/2로 정의해요.

이 가이드에서는 IEx에서 코드 샘플을 실행하는 대신 파일을 사용할게요. 코드 샘플이 여러 줄에 걸쳐 있어서 IEx에 전부 타이핑하는 건 오히려 비생산적이기 때문이에요. 코드 샘플은 macros.exs 파일에 저장해서 elixir macros.exsiex macros.exs로 실행하면 돼요.

매크로가 어떻게 동작하는지 더 잘 이해하기 위해, if/2의 반대를 하는 unless매크로로도, 함수로도 구현하는 새 모듈을 만들어 볼게요:

defmodule Unless do
  def fun_unless(clause, do: expression) do
    if(!clause, do: expression)
  end

  defmacro macro_unless(clause, do: expression) do
    quote do
      if(!unquote(clause), do: unquote(expression))
    end
  end
end

함수 버전은 인자를 받아 if/2에 그대로 넘겨요. 하지만 앞 가이드에서 배웠듯이, 매크로는 인용 표현(quoted expression)을 받아 그것을 quote 안에 주입하고, 마지막에 또 다른 인용 표현을 반환해요.

위 모듈로 iex를 시작해서:

$ iex macros.exs

정의들을 사용해 볼게요:

iex> require Unless
iex> Unless.macro_unless(true, do: IO.puts("this should never be printed"))
nil
iex> Unless.fun_unless(true, do: IO.puts("this should never be printed"))
"this should never be printed"
nil

매크로 구현에서는 문장이 출력되지 않았지만, 함수 구현에서는 출력됐어요. 왜냐하면 함수 호출의 인자는 함수를 호출하기 전에 먼저 평가되기 때문이에요. 하지만 매크로는 인자를 평가하지 않아요. 대신 인자를 인용 표현으로 받아서 또 다른 인용 표현으로 변환하죠. 이 경우 우리는 unless 매크로를 내부적으로 if/2가 되도록 다시 쓴 거예요.

다시 말해, 이렇게 호출하면:

Unless.macro_unless(true, do: IO.puts("this should never be printed"))

우리의 macro_unless 매크로는 다음을 받았어요:

macro_unless(true, [do: {{:., [], [{:__aliases__, [], [:IO]}, :puts]}, [], ["this should never be printed"]}])

그리고 다음과 같은 인용 표현을 반환했어요:

{:if, [],
 [{:!, [], [true]},
  [do: {{:., [],
   [{:__aliases__,
    [], [:IO]},
   :puts]}, [], ["this should never be printed"]}]]}

이게 정말 그런지 Macro.expand_once/2로 확인해 볼 수 있어요:

iex> expr = quote do: Unless.macro_unless(true, do: IO.puts("this should never be printed"))
iex> res = Macro.expand_once(expr, __ENV__)
iex> IO.puts(Macro.to_string(res))
if(!true) do
  IO.puts("this should never be printed")
end
:ok

Macro.expand_once/2는 인용 표현을 받아 현재 환경에 따라 확장해요. 이 경우 Unless.macro_unless/2 매크로를 확장(호출)하고 그 결과를 반환했죠. 그리고 그 반환된 인용 표현을 문자열로 바꿔 출력한 거예요 (__ENV__에 대해서는 이 장의 뒤에서 다시 다룰게요).

바로 이게 매크로의 전부예요. 인용 표현을 받아서 다른 것으로 변환하는 것이죠. 사실 Elixir의 if/2도 매크로로 구현되어 있어요:

defmacro if(clause, do: expression) do
  quote do
    case unquote(clause) do
      x when x in [false, nil] -> nil
      _ -> unquote(expression)
    end
  end
end

if/2, defmacro/2, def/2, defprotocol/2처럼 Elixir 표준 라이브러리 곳곳에서 쓰이는 구성요소들은 대부분 순수 Elixir로, 특히 매크로로 작성돼 있어요. 즉, 언어를 만드는 데 쓰인 구성요소를 개발자들이 자신이 다루는 도메인에 맞춰 언어를 확장하는 데도 쓸 수 있다는 뜻이에요.

우리는 Elixir가 제공하는 내장 정의를 덮어쓰는 것을 포함해 어떤 함수와 매크로든 정의할 수 있어요. 유일한 예외는 Elixir로 구현되지 않아서 **덮어쓸 수 없는 특수 형태(special form)**예요. 특수 형태의 전체 목록은 Kernel.SpecialForms에서 볼 수 있어요.

매크로 위생 (Macro hygiene)

Elixir 매크로는 "지연 해석(late resolution)"을 가져요. 이는 quote 안에서 정의된 변수가 매크로가 확장되는 문맥에서 정의된 변수와 충돌하지 않는다는 걸 보장해 줘요. 예를 들어:

defmodule Hygiene do
  defmacro no_interference do
    quote do: a = 1
  end
end

defmodule HygieneTest do
  def go do
    require Hygiene
    a = 13
    Hygiene.no_interference()
    a
  end
end

HygieneTest.go()
# => 13

위 예시에서 매크로가 a = 1을 주입하지만, go/0 함수가 정의한 a 변수에는 영향을 주지 않아요. 매크로가 문맥에 명시적으로 영향을 주고 싶다면 var!/1을 쓰면 돼요:

defmodule Hygiene do
  defmacro interference do
    quote do: var!(a) = 1
  end
end

defmodule HygieneTest do
  def go do
    require Hygiene
    a = 13
    Hygiene.interference()
    a
  end
end

HygieneTest.go()
# => 1

위 코드는 동작하지만 경고를 내요: variable "a" is unused. 매크로가 원래 값을 덮어써서, 원래 값은 영영 쓰이지 않기 때문이에요.

변수 위생이 동작할 수 있는 건 Elixir가 변수에 문맥(context)을 주석으로 달기 때문이에요. 예를 들어 모듈의 3번째 줄에서 정의된 변수 x는 다음과 같이 표현돼요:

{:x, [line: 3], nil}

하지만 인용된 변수(quoted variable)는 이렇게 표현돼요:

defmodule Sample do
  def quoted do
    quote do: x
  end
end

Sample.quoted() #=> {:x, [line: 3], Sample}

인용된 변수의 세 번째 요소가 nil 대신 atom Sample인 걸 볼 수 있어요. 이는 그 변수가 Sample 모듈에서 왔다는 표시예요. 따라서 Elixir는 이 두 변수가 서로 다른 문맥에서 왔다고 보고 그에 맞게 다뤄요.

Elixir는 import와 alias에도 비슷한 메커니즘을 제공해요. 그래서 매크로가 확장되는 대상 모듈과 충돌하는 대신, 자기 원본 모듈이 규정한 대로 동작한다는 걸 보장해 줘요. 위생은 var!/2alias!/1 같은 매크로로 특정 상황에서 우회할 수 있지만, 이들은 사용자 환경을 직접 바꾸기 때문에 쓸 때 조심해야 해요.

때로는 변수 이름을 동적으로 만들어야 할 때가 있어요. 그럴 땐 Macro.var/2로 새 변수를 정의할 수 있어요:

defmodule Sample do
  defmacro initialize_to_char_count(variables) do
    Enum.map(variables, fn name ->
      var = Macro.var(name, nil)
      length = name |> Atom.to_string() |> String.length()

      quote do
        unquote(var) = unquote(length)
      end
    end)
  end

  def run do
    initialize_to_char_count([:red, :green, :yellow])
    [red, green, yellow]
  end
end

> Sample.run() #=> [3, 5, 6]

Macro.var/2의 두 번째 인자를 주의 깊게 보세요. 이것이 사용되는 문맥이고, 다음 절에서 설명할 위생을 결정해요. 그리고 고유한 이름의 변수를 생성해야 할 때는 Macro.unique_var/2도 확인해 보세요.

환경 (The environment)

이 장 앞부분에서 Macro.expand_once/2를 호출할 때 __ENV__/0 특수 형태를 사용했어요.

__ENV__/0컴파일 환경에 대한 유용한 정보를 담는 Macro.Env 구조체를 반환해요. 현재 모듈, 파일, 줄을 비롯해 현재 스코프에서 정의된 모든 변수, 그리고 import, require 등을 포함하죠:

iex> __ENV__.module
nil
iex> __ENV__.file
"iex"
iex> __ENV__.requires
[IEx.Helpers, Kernel, Kernel.Typespec]
iex> require Integer
nil
iex> __ENV__.requires
[IEx.Helpers, Integer, Kernel, Kernel.Typespec]

Macro 모듈의 많은 함수가 Macro.Env 환경을 기대해요. 이 함수들에 대해 더 알고 싶으면 Macro에서, 컴파일 환경 자체에 대해 더 배우고 싶으면 Macro.Env에서 읽어 보세요.

비공개 매크로 (Private macros)

Elixir는 defmacrop으로 비공개 매크로도 지원해요. 비공개 함수처럼, 이런 매크로는 정의한 모듈 안에서만, 거기다 오직 컴파일 타임에만 쓸 수 있어요.

매크로는 사용하기 전에 정의되어야 한다는 게 중요해요. 호출 전에 매크로를 정의하지 않으면 런타임에 오류가 발생해요. 매크로가 확장되지 않고 함수 호출로 바뀌어 버리기 때문이죠:

iex> defmodule Sample do
...>   def four, do: two() + two()
...>   defmacrop two, do: 2
...> end
** (CompileError) iex:2: function two/0 undefined

매크로를 책임감 있게 쓰자

매크로는 강력한 구성요소이고, Elixir는 매크로를 책임감 있게 쓰도록 보장하는 많은 메커니즘을 제공해요.

  • 매크로는 위생적이에요(hygienic): 기본적으로 매크로 안에서 정의된 변수는 사용자 코드에 영향을 주지 않아요. 게다가 매크로 문맥에서 사용 가능한 함수 호출과 alias도 사용자 문맥으로 새어 나가지 않아요.
  • 매크로는 어휘적이에요(lexical): 코드나 매크로를 전역으로 주입하는 건 불가능해요. 매크로를 쓰려면 그 매크로를 정의한 모듈을 명시적으로 require하거나 import해야 해요.
  • 매크로는 명시적이에요(explicit): 매크로를 명시적으로 호출하지 않고는 실행할 수 없어요. 어떤 언어는 parse transform이나 리플렉션 같은 매커니즘으로 함수를 뒤에서 완전히 다시 쓰게 해 주는데, Elixir에서는 매크로가 컴파일 타임에 호출자에서 명시적으로 호출되어야 해요.
  • 매크로의 언어는 명확해요(clear): 많은 언어가 quoteunquote를 위한 문법 단축키를 제공해요. Elixir는 매크로 정의와 그 인용 표현의 경계를 명확히 구분하기 위해, 그것들을 명시적으로 풀어 쓰는 걸 선호했어요.

이런 보장이 있어도, 매크로를 책임감 있게 쓰는 데는 개발자의 역할이 커요. 매크로에 의존해야 한다고 확신한다면, 매크로는 당신의 API가 아니다라는 걸 기억하세요. 인용된 내용을 포함해 매크로 정의를 짧게 유지하세요. 예를 들어 이런 매크로 대신:

defmodule MyModule do
  defmacro my_macro(a, b, c) do
    quote do
      do_this(unquote(a))
      # ...
      do_that(unquote(b))
      # ...
      and_that(unquote(c))
    end
  end
end

이렇게 쓰세요:

defmodule MyModule do
  defmacro my_macro(a, b, c) do
    quote do
      # 여기서 해야 하는 일을 최소한으로 유지하고
      # 나머지는 전부 함수로 옮긴다
      MyModule.do_this_that_and_that(unquote(a), unquote(b), unquote(c))
    end
  end

  def do_this_that_and_that(a, b, c) do
    do_this(a)
    ...
    do_that(b)
    ...
    and_that(c)
  end
end

이렇게 하면 do_this_that_and_that/3를 직접 호출하고 테스트할 수 있어서, 코드가 더 명확하고 테스트·유지보수가 쉬워져요. 매크로에 의존하고 싶지 않은 개발자를 위한 실제 API를 설계하는 데도 도움이 되죠.

이 가이드로 매크로 소개를 마칠게요. 다음 가이드는 매크로와 모듈 속성을 섞어 모듈과 함수를 주석 달고 확장하는 방법을 보여 주는 **DSL(도메인 특화 언어)**에 대한 간단한 논의예요.

더 알아보기