#elixirlang 매크로를 사용해 decorator를 구현하는 방법

6 minute read

defmodule Loader do
  def load_some_a() do
    Process.sleep(:timer.seconds(1))
  end

  def load_some_b() do
    Process.sleep(:timer.seconds(2))
  end
end

Loader.load_some_a/0, Loader.load_some_b/0 를 실행할 때, 걸리는 시간을 출력하는 decorator를 만들고 싶다.

Loader.load_some_a()
Loader.load_some_b()

즉 이렇게 함수를 호출하면

Loading load_some_a...
Loaded load_some_a - 1015ms
Loading load_some_b...
Loaded load_some_b - 2016ms

이런 식으로 출력되는 코드를 작성하려고 한다.

데코레이터 패턴(decorator patterns)를 간단히 설명하면 인터페이스는 똑같고 기존에 있는 기능에 기능을 덧붙일 때, 사용하는 패턴이다. Loader.load_some_a/0 함수 body에 있는 Process.sleep(:timer.seconds(1)) 를 똑같이 호출하는데, body를 실행하기 전 Loading load_some_a... 메시지를 출력하고 body를 실행하는데, 걸리는 시간을 계산해서 Loaded load_some_a - 1015ms 를 출력하는 decorator를 만들어볼 생각이다.

defoverridable/1

defmodule Loader do
  def load_some_a() do
    Process.sleep(:timer.seconds(1))
  end
end

이런 함수가 있을 때, 매크로를 사용해 삽입할(inject) 코드는 다음과 같다.

defmodule Loader do
  def load_some_a() do
    Process.sleep(:timer.seconds(1))
  end

  # 매크로를 사용해 삽입할 함수
  def load_some_a() do
    IO.puts("Loading load_some_a...")
    {u_secs, return_val} = :timer.tc(fn -> Process.sleep(:timer.seconds(1)) end)
    IO.puts("Loaded load_some_a - #{div(u_secs, 1000)}ms")
    return_val
  end
end

매크로를 사용해 함수 자체를 고치는 것보다 같은 시그니처(signature)를 가진 함수를 삽입하는 게 훨씬 더 쉬우므로 함수를 삽입하는 방법을 선택한다. 하지만 이렇게 삽입을 한다면

Compiling 1 file (.ex)
warning: this clause cannot match because a previous clause at line 49 always matches
  lib/loader.ex:53

다음과 같은 warning이 발생한다. Loader.load_some_a/0 를 호출할 때, 위에서부터 아래로 일치하는(match) 함수를 찾는데, 같은 시그니처를 가진 함수가 있기 때문에 뒤에 정의한 Loader.load_some_a/0 함수는 실행되지 않기 때문이다.

defmodule Loader do
  def load_some_a() do
    Process.sleep(:timer.seconds(1))
  end

  defoverridable load_some_a: 0
  # 매크로를 사용해 삽입할 함수
  def load_some_a() do
    # ...
  end
end

이런 경우 모듈 내에서 함수를 덮어쓰는 게 필요하다. defoverridable/1 매크로를 사용하면 앞에 함수를 덮어쓸 수 있다.

Loader.load_some_a()
Loading load_some_a...
Loaded load_some_a - 1015ms

원하는 결과가 나온다.

원래 함수 구현인 Process.sleep(:timer.seconds(1)) 를 그대로 호출했는데, super/1 함수를 호출해도 된다.

defmodule Loader do
  def load_some_a() do
    Process.sleep(:timer.seconds(1))
  end

  defoverridable load_some_a: 0
  # 매크로를 사용해 삽입할 함수
  def load_some_a() do
    IO.puts("Loading load_some_a...")
    {u_secs, return_val} = :timer.tc(fn -> super() end) # <--- 1
    IO.puts("Loaded load_some_a - #{div(u_secs, 1000)}ms")
    return_val
  end
end

1번 코드처럼 super/0 함수를 호출하면 defoverridable/1 매크로를 사용해 덮어쓴 기본 구현을 호출한다.

super를 사용해도 되지만 body 부분을 그대로 호출하는 게 매크로를 사용해 코드를 생성할 때, 더 편해서 super 대신 기존 함수를 그대로 호출하는 식으로 decorator를 만들어볼 생각이다.

decorator로 함수를 만들어내는데 필요한 재료 - on_definition 훅(hook)

decorator 함수를 만들어서 기존 함수를 덮어쓰는 준비를 할 차례다. 함수를 추가하는데 필요한 재료는 on_definition 훅을 통해 알아낼 수 있다.

defmodule Decorator do
  def on_definition(env, kind, fun, args, guards, body) do
    IO.inspect(env.module, label: :module)
    IO.inspect(kind, label: :kind)
    IO.inspect(fun, label: :fun)
    IO.inspect(args, label: :args)
    IO.inspect(guards, label: :guards)
    IO.inspect(body, label: :body)
  end
end

defmodule Loader do
  @on_definition {Decorator, :on_definition}

  def load_some_a() do
    Process.sleep(:timer.seconds(1))
  end
end

on_definition 훅으로 실행할 함수를 정의했다. 어떤 값이 넘어오는지 확인하려고 IO.inspect/2 함수로 인자(argument)를 출력하게 했다.

$ mix compile

module: Loader
kind: :def
fun: :load_some_a
args: []
guards: []
body: [
  do: {
        {:., [line: 63], [{:__aliases__, [line: 63], [:Process]}, :sleep]},
        [line: 63], [{
            {:., [line: 63], [:timer, :seconds]}, [line: 63], [1]
          }]
      }
]

모듈 이름, 함수 이름, 함수 종류, 인자, 가드(guards), 구현 부(body)를 모두 얻을 수 있다. decorator 함수를 새로 정의할 때, 필요한 재료를 모두 얻을 수 있다.

새로운 코드를 삽입(inject)해야 하는데, 그건 on_definition 혹으로 할 수 없다. 그래서 필요한 재료를 가공해서 전달하도록 한다. 컴파일 시간에 전달하려면 모듈 속성(attribute)을 사용해야 한다.

defmodule Decorator do
  def on_definition(env, kind, fun, args, _guards, body) do
    Module.put_attribute(env.module, :decorated, {kind, fun, args, body})
  end
end

decorated 속성에 함수 종류(kind), 함수 이름, 인자, body를 튜플로 저장한다. 이후에 decorated 속성에 저장된 값으로 decorate 함수를 만든다.

decorator 함수 생성 - before_compile 훅

on_definition 훅을 설치해 삽입할 코드 정보를 수집했다. 이제 코드를 삽입할 차례다. 컴파일하기 전에 삽입해야 한다. 그래야 삽입한 코드도 같이 컴파일한다. 컴파일하기 전에 호출하는 before_compile 훅을 사용하면 된다.

defmodule Decorator do
  defmacro before_compile(env) do
    Module.get_attribute(env.module, :decorated)
    |> Enum.map(fn {kind, fun, args, body} ->
      body = decorate(fun, body)

      quote do
        defoverridable [{unquote(fun), unquote(Enum.count(args))}]
        unquote(kind)(unquote(fun)(unquote_splicing(args)), unquote(body))
      end
    end)
  end
end

before_compile 훅으로 설치할 Decorator.before_compile/1 매크로를 구현한다. 이전에 on_definition 훅을 설치해 삽입할 함수에 대한 정보를 수집했다. 수집한 정보를 before_compile 훅에 전달하는 방법으로 decorated 속성을 사용했다. 이 decorated 속성을 읽어서 함수를 삽입한다. 삽입할 함수가 기존 함수를 덮어쓸 수 있도록 defoverridable/1 매크로로 정의해준다. 그리고 뒤에서 설명할 decorate/1 함수로 body를 조작한 다음 그걸로 똑같은 시그니처를 가진 함수를 정의한다.

If it’s a macro, its returned value will be injected at the end of the module definition before the compilation starts.

Module - hexdocs.pm/elixir

매크로로 구현해야 한다. 그냥 함수로 구현하면 코드 삽입이 안 된다. 이걸로 삽질을 좀 했다.

defmodule Decorator do
  defp decorate(fun, [{:do, body}]) do
    body =
      quote do
        IO.puts("Loading #{unquote(fun)}...")
        {u_secs, return_val} = :timer.tc(fn -> unquote(body) end)
        IO.puts("Loaded #{unquote(fun)} - #{div(u_secs, 1000)}ms")
        return_val
      end

    [do: body]
  end
end

decorate/1 함수는 간단하다. body를 호출하는 시간을 :timer.tc/1 함수로 측정해서 걸린 시간을 출력한다.

defmodule Loader do
  @before_compile {Decorator, :before_compile}
end

이제 만든 매크로를 before_compile 훅에 설치한다.

하나 더 남았다. on_definition 훅에서 before_compile 훅에 정보를 넘기려고 사용한 decorated 속성에 대한 처리를 해줘야 한다. 내장된(built-in) 속성이 아니라 커스텀(custom) 속성이라 등록을 해줘야 한다.

defmodule Loader do
  Module.register_attribute(__MODULE__, :decorated, accumulate: true)
end

Module.register_attribute/3 함수를 사용해 decorated 속성을 등록했다. 속성 기본 동작은 덮어쓰기다. 즉, decorated 속성 정의가 여러 개면 제일 뒤에 정의한 decorated 속성만 살아남는다. 여러 함수에 정의할 수 있게 accumulate 옵션을 true로 설정해서 decorated 속성을 등록한다.

defmodule Decorator do
  def on_definition(env, kind, fun, args, _guards, body) do
    Module.put_attribute(env.module, :decorated, {kind, fun, args, body})
  end

  defmacro before_compile(env) do
    Module.get_attribute(env.module, :decorated)
    |> Enum.map(fn {kind, fun, args, body} ->
      body = decorate(fun, body)

      quote do
        defoverridable [{unquote(fun), unquote(Enum.count(args))}]
        unquote(kind)(unquote(fun)(unquote_splicing(args)), unquote(body))
      end
    end)
  end

  defp decorate(fun, [{:do, body}]) do
    body =
      quote do
        IO.puts("Loading #{unquote(fun)}...")
        {u_secs, return_val} = :timer.tc(fn -> unquote(body) end)
        IO.puts("Loaded #{unquote(fun)} - #{div(u_secs, 1000)}ms")
        return_val
      end

    [do: body]
  end
end

defmodule Loader do
  @on_definition {Decorator, :on_definition}
  @before_compile {Decorator, :before_compile}

  Module.register_attribute(__MODULE__, :decorated, accumulate: true)

  def load_some_a() do
    Process.sleep(:timer.seconds(1))
  end

  def load_some_b() do
    Process.sleep(:timer.seconds(2))
  end
end

여기까지 전체 코드다. Loader.load_some_a/0, Loader.load_some_b/0 함수를 호출하면

Loading load_some_a...
Loaded load_some_a - 1016ms
Loading load_some_b...
Loaded load_some_b - 2000ms

짠~ 의도한 대로 동작한다.

선택적으로 decorator를 함수에 적용

현재는 모듈에 있는 모든 함수에 소요 시간을 출력하는 decorator가 적용된다. 이걸 특정 함수에만 적용하는 코드를 추가해본다. 원하는 함수 위에 decorate 속성을 붙여서 구분한다.

defmodule Loader do
  @on_definition {Decorator, :on_definition}
  @before_compile {Decorator, :before_compile}

  Module.register_attribute(__MODULE__, :decorated, accumulate: true)

  def load_some_a() do
    IO.puts("run load_some_a")
    Process.sleep(:timer.seconds(1))
  end

  @decorate true
  def load_some_b() do
    IO.puts("run load_some_b")
    Process.sleep(:timer.seconds(2))
  end

  def load_some_c() do
    IO.puts("run load_some_c")
    Process.sleep(:timer.seconds(1))
  end
end

load_some_b/0 함수에만 decorator가 동작하도록 함수 위에 decorate 속성을 추가한다.

defmodule Decorator do
  def on_definition(env, kind, fun, args, _guards, body) do
    if Module.get_attribute(env.module, :decorate) do
      Module.put_attribute(env.module, :decorated, {kind, fun, args, body})
    end
  end
end

무조건 decorated 속성을 추가하는 대신 decorate 속성이 있는 경우에만 속성을 추가해서 해당 함수만 decorator가 동작하게 한다.

Loader.load_some_a()
Loader.load_some_b()
Loader.load_some_c()

실행

run load_some_a
Loading load_some_b...
run load_some_b
Loaded load_some_b - 2016ms
Loading load_some_c...
run load_some_c
Loaded load_some_c - 1015ms

load_some_b/0 함수에는 잘 붙는다. 하지만 load_some_c/0 함수까지 적용이 된다.

왜 그럴까? 함수 속성이란 게 애초에 없기 때문이다. 속성은 모듈에만 붙을 수 있다. 추가한 속성은 아래에 있는 코드에 영향을 준다.

defmodule Decorator do
  def on_definition(env, kind, fun, args, _guards, body) do
    if Module.get_attribute(env.module, :decorate) do
      Module.put_attribute(env.module, :decorated, {kind, fun, args, body})
      Module.delete_attribute(env.module, :decorate)
    end
  end
end

decorate 속성을 읽어서 before_compile 훅에서 decorator를 적용할 정보를 담은 decorated 속성을 만든 후에 decorate 속성을 제거하면 된다. 모듈 속성을 함수 속성처럼 사용할 수 있다.

run load_some_a
Loading load_some_b...
run load_some_b
Loaded load_some_b - 2000ms
run load_some_c

다시 실행해보면 load_some_b/0 함수에만 적용된 걸 알 수 있다.

전체 코드

defmodule Decorator do
  def on_definition(env, kind, fun, args, _guards, body) do
    if Module.get_attribute(env.module, :decorate) do
      Module.put_attribute(env.module, :decorated, {kind, fun, args, body})
      Module.delete_attribute(env.module, :decorate)
    end
  end

  defmacro before_compile(env) do
    Module.get_attribute(env.module, :decorated)
    |> Enum.map(fn {kind, fun, args, body} ->
      body = decorate(fun, body)

      quote do
        defoverridable [{unquote(fun), unquote(Enum.count(args))}]
        unquote(kind)(unquote(fun)(unquote_splicing(args)), unquote(body))
      end
    end)
  end

  defp decorate(fun, [{:do, body}]) do
    body =
      quote do
        IO.puts("Loading #{unquote(fun)}...")
        {u_secs, return_val} = :timer.tc(fn -> unquote(body) end)
        IO.puts("Loaded #{unquote(fun)} - #{div(u_secs, 1000)}ms")
        return_val
      end

    [do: body]
  end
end

defmodule Loader do
  @on_definition {Decorator, :on_definition}
  @before_compile {Decorator, :before_compile}

  Module.register_attribute(__MODULE__, :decorated, accumulate: true)

  def load_some_a() do
    IO.puts("run load_some_a")
    Process.sleep(:timer.seconds(1))
  end

  @decorate true
  def load_some_b() do
    IO.puts("run load_some_b")
    Process.sleep(:timer.seconds(2))
  end

  def load_some_c() do
    IO.puts("run load_some_c")
    Process.sleep(:timer.seconds(1))
  end
end

마무리

decorator를 직접 구현해보면서 elixir 매크로의 위력을 실감할 수 있었다. 컴파일 시간에 decorator로 동작할 함수 코드를 추가하고 컴파일한다. AST(abstract syntax tree)를 조작할 수 있는 매크로 덕분에 elixir 언어에서 decorator 같은 기능을 제공하지 않아도 필요하다면 만들어서 쓸 수 있다.

이 글에 나온 decorator 코드는 arjan/decorator 코드를 참고했다. decorate 함수를 정의할 수 있고 함수 개별 적용은 물론 모듈에 있는 모든 함수에 적용하는 기능까지 갖추고 있다. decorator가 필요하다면 이 라이브러리를 사용하면 된다.