#elixirlang phoenix 웹 프레임워크의 관절 plug 매크로 간단 버전 구현

7 minute read

피닉스(phoenix) 웹 프레임워크를 사용하다 보면 plug 매크로를 자주 보게 된다. 어떻게 구현했는지 궁금해서 찾아보고 편의 기능과 예외 처리를 제외한 간단한 버전을 구현해봤다.

plug?

웹 애플리케이션은 연속된 함수 호출이다. 함수 호출 순서에 따라 이전 함수의 결과물을 다음 호출 함수의 입력으로 넣어줘야 한다. plug는 이런 함수의 구성을 도와준다.

defmodule DemoWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :demo

  @session_options [
    store: :cookie,
    key: "_demo_key",
    signing_salt: "OBd7pw1h"
  ]

  plug Plug.Static,
    at: "/",
    from: :demo,
    gzip: false,
    only: ~w(css fonts images js favicon.ico robots.txt)

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug DemoWeb.Router
end

HTTP 요청(request) 처리는 DemoWeb.Endpoint 모듈이 담당한다. font, image 같은 static 파일 서빙(serving), 세션(session) 저장 등을 모두 다 plug로 정의했다. 웹 애플리케이션은 plug들의 파이프라인이라는 말이 과장이 아니다.

defmodule DemoWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :demo

  def call(%Plug.Conn{} = conn) do
    with conn = %{halted: false} <-
         Plug.Static.call(conn,
           at: "/",
           from: :demo,
           gzip: false,
           only: ~w(css fonts images js favicon.ico robots.txt)
         ),
           conn = %{halted: false} <- Plug.RequestId.call(conn),
           conn = %{halted: false} <- Plug.Telemetry.call(conn, event_prefix: [:phoenix, :endpoint]) do
         else
           # 에러 처리
           %{halted: true} ->
             nil
         end
  end
end

plug가 없다면 이런 식으로 복잡하게 클라이언트 요청이 오면 실행해야 할 함수를 복잡하게 나열해야 한다. plug는 조합 가능한 함수를 구현할 수 있게 해주고 모듈에 선언하듯이 실행하는 일련의 함수를 정의하기 때문에 어떤 함수들이 실행되는지 쉽게 확인할 수 있게 한다.

안에 내용을 채워 넣기 전에 모듈에 plug를 여러 개 정의하고 컴파일할 때, 이름만 사용하는 껍데기를 구현해본다.

껍데기 구현

모듈 안에 plug를 정의할 수 있게 하고 before_compile 훅(hook)을 설치해 정의한 plug들을 호출할 수 있는 run 함수를 만든다. decorator에서 함수를 추가하는 방법과 똑같다.

defmodule MyPlug do
  defmacro plug(plug) do
    quote do
      @plugs unquote(plug)
    end
  end
end

plug 매크로를 호출해 plug를 등록하면 그걸 plugs 모듈 속성(attribute)으로 정의한다.

defmodule MyPlug do
  defmacro __using__(_opts) do
    quote do
      import MyPlug

      Module.register_attribute(__MODULE__, :plugs, accumulate: true)
      @before_compile MyPlug
    end
  end
end

모듈 속성 기본 옵션은 밑에 정의한 놈이 이긴다. 똑같은 속성일 때, 덮어쓰는 게 기본 옵션이기 때문이다. plugs 모듈 속성을 덮어쓰지 않고 누적해 리스트로 만들기 위해 accumulate: true 옵션을 추가한다. 그리고 컴파일 전에 호출하는 before_compile 훅을 설치한다.

defmodule MyPlug do
  defmacro __before_compile__(env) do
    Module.get_attribute(env.module, :plugs)
    |> IO.inspect()

    quote do
      def run(token) do
        IO.inspect(token)
        :ok
      end
    end
  end
end

훅에서는 누적한 plugs 모듈 속성을 출력하고 지금은 아무 동작을 하지 않는 run/1 함수를 모듈에 삽입(inject)한다. 이후에 여기서 모듈에 정의한 plug를 모두 호출할 예정이다.

defmodule Awesome do
  use MyPlug

  plug(Plug1)
  plug(Plug2)
end

테스트 모듈에 Plug1, Plug2 모듈을 plug로 정의한다. 지금은 이 모듈에 있는 함수를 호출하지 않음므로 관련 구현이 없어도 괜찮다.

Awesome.run(:ok)

실행해본다.

Compiling 1 file (.ex)
[Plug2, Plug1]
Generated my_plug app
:ok

plugs 모듈 속성에 Plug1, Plug2 가 추가됐고 run 함수 삽입도 잘 됐다. plugs 모듈 속성에 누적될 때, 앞에서부터 삽입되기 때문에 정의한 순서대로 호출하려면 뒤집어야겠다.

call 함수만 정의한 plug 기본 모델 구현

plug로 추가한 모듈의 call/1 함수를 호출하는 기능을 추가한다. call/1 함수는 map으로 된 token을 받아서 리턴한다.

defmodule Plug1 do
  def call(token) do
    Map.put(token, :plug1, "plug1 called")
  end
end

defmodule Plug2 do
  def call(token) do
    Map.put(token, :plug2, "plug2 called")
  end
end

map에 key와 value를 추가하는 간단한 plug를 정의한다.

defmodule MyPlug do
  defmacro __before_compile__(env) do
    modules = Module.get_attribute(env.module, :plugs) |> Enum.reverse()

    quote do
      def run(token) do
        Enum.reduce(unquote(modules), token, fn module_name, token ->
          apply(module_name, :call, [token])
        end)
      end
    end
  end
end

컴파일 전에 호출되는 __before_compile__/1 매크로를 수정한다. plug를 순서대로 호출하기 위해 Enum.reverse/1 함수를 호출해 순서를 뒤집는다. run/1 함수 인자인 token을 plug에 전달하고 plug가 리턴하는 token을 다시 다음 plug에 인자로 전달한다.

Awesome.run(%{}) |> IO.inspect()

Awesome.run/1 함수 인자로 빈 map을 넘겨서 호출하고 결괏값을 찍어보면

%{plug1: "plug1 called", plug2: "plug2 called"}

의도대로 Plug1, Plug2 함수에 있는 call 함수가 호출되고 token에 값이 누적된다.

composite 패턴 구현

Plug1, Plug2로 구성한 Awesome 모듈을 추가했다. 이 Awesome 모듈을 다른 모듈의 plug로 추가할 수 있게 하려면 어떻게 해야 할까? 컴포지트 패턴(Composite pattern)을 구현해서 Plug1, Plug2로 구성한 Plug를 만들고 다른 Plug와 동일하게 다룰 수 있게 하면 된다.

defmodule MyPlug do
  defmacro __before_compile__(env) do
    modules = Module.get_attribute(env.module, :plugs) |> Enum.reverse()

    quote do
      def call(token) do
        Enum.reduce(unquote(modules), token, fn module_name, token ->
          apply(module_name, :call, [token])
        end)
      end
    end
  end
end

run/1 함수가 아닌 call/1 함수를 삽입하게 변경하면 된다. Plug 모듈의 호출하는 call 함수와 똑같이 정의하면 단일 Plug인지 Plug들을 호출하는 모듈인지 구분 없이 호출할 수 있게 된다.

이대로 마무리 지어도 call/1 함수만 잘 구현한다면 원하는 대로 동작한다. 좀 더 친절한 기능을 추가해보자. call/1 함수를 구현 안 했을 때, 런타임 에러가 아니라 컴파일 에러를 낼 수 있을까?

defmodule MyPlug do
  @callback call(map) :: map

  defmacro __using__(_opts) do
    quote do
      import MyPlug
      @behaviour MyPlug

      Module.register_attribute(__MODULE__, :plugs, accumulate: true)
      @before_compile MyPlug
    end
  end
end

elixir도 인터페이스(interface)와 구현(implementation)의 분리를 지원한다. 인터페이스 파트를 behaviour 모듈이라고 하고 구현 파트를 callback 모듈이라고 한다. callback 모듈 속성으로 call/1 함수를 callback 모듈에서 정의해야 할 함수로 선언한다. 그리고 behaviour 모듈 속성을 사용해 call/1 함수를 가진 MyPlug behaviour를 구현하는 callback 모듈로 정의한다.

defmodule Plug1 do
  @behaviour MyPlug
  def call(token) do
    Map.put(token, :plug1, "plug1 called")
  end
end

defmodule Plug2 do
  @behaviour MyPlug
  def call(token) do
    Map.put(token, :plug2, "plug2 called")
  end
end

Plug1, Plug2 모듈도 MyPlug behaviour의 callback 모듈이므로 @behaviour MyPlug 모듈 속성을 추가해준다.

defmodule Plug1 do
  @behaviour MyPlug
end

MyPlug behaviour 구현을 하지 않고 컴파일을 해보면

warning: function call/1 required by behaviour MyPlug is not implemented (in module Plug1)
  lib/my_plug.ex:33: Plug1 (module)

컴파일러가 경고를 띄워준다.

defmodule CompositePlug do
  use MyPlug

  plug(Plug1)
  plug(Plug2)
end

defmodule Awesome do
  use MyPlug

  plug(CompositePlug)
end

Plug1, Plug2로 구성된 CompositePlug 모듈을 만들고 이 plug를 Awesome 모듈이 사용하게 한다.

Awesome.call(%{}) |> IO.inspect()
%{plug1: "plug1 called", plug2: "plug2 called"}

잘 된다. Plug1, Plug2로 구성된 CompositePlug 모듈도 plug와 똑같이 동작한다.

컴포지트 패턴(Composite pattern)을 구현해서 plug와 plug들의 모음을 똑같이 처리할 수 있게 했다. 그리고 behaviour 모듈을 정의해서 plug에서 함수를 정의하지 않으면 컴파일러가 경고를 띄워주게 했다.

함수도 지원

MyPlug behaviour를 구현한 모듈뿐만 아니라 함수도 plug로 정의할 수 있게 지원해본다. elixir 세상에서 일급 시민(First-class citizen)인 함수에 이정도 대우는 해줘야 한다.

defmodule Awesome do
  use MyPlug

  plug(CompositePlug)
  plug(:plug_func)

  def plug_func(token) do
    Map.put(token, :plug_func, "plug_func called")
  end
end

정의할 때, 모듈은 그냥 모듈 이름을 사용하고 함수는 atom을 사용해서 모듈과 함수를 구분한다. 모듈과 함수는 호출 방법이 다르다. 함수는 token을 인자로 넣어서 호출하고 모듈은 call 함수에 token을 인자로 넣어서 호출해야 한다. 어떻게 모듈과 함수를 구분해야 할까?

Atom.to_charlist/1 함수를 사용하는 elixir-plug 라이브러리 Plug.Builder.init_plug/2 함수 구현을 참고했다.

iex> Atom.to_charlist(Plug1)
'Elixir.Plug1'
iex> Atom.to_charlist(:Plug1)
'Plug1'
iex> Atom.to_charlist(:plug_func)
'plug_func'

elixir 관례인 CamelCase를 사용하는 모듈 이름을 쓰면 앞에 Elixir. 문자를 붙여준다. 이걸로 atom을 사용하는 함수와 elixir 모듈을 구분할 수 있다.

defmodule MyPlug do
  def plug_kind(module_or_func) do
    case Atom.to_charlist(module_or_func) do
      ~c"Elixir." ++ _ -> :module
      _ -> :function
    end
  end
end

모듈인지 함수인지 구분하는 MyPlug.plug_kind/1 함수를 추가한다.

iex> MyPlug.plug_kind(Plug1)
:module
iex> MyPlug.plug_kind(:plug_func)
:function

모듈과 함수 구분이 되니 이제 이 정보를 사용해서 호출하는 코드를 작성한다.

defmodule MyPlug do
  defmacro __before_compile__(env) do
    modules_or_funcs =
      env.module
      |> Module.get_attribute(:plugs)
      |> Enum.map(fn name -> {plug_kind(name), name} end)
      |> Enum.reverse()

    caller = __CALLER__.module

    quote do
      def call(token) do
        Enum.reduce(unquote(modules_or_funcs), token, fn {kind, module_or_func}, token ->
          case kind do
            :module -> apply(module_or_func, :call, [token])
            :function -> apply(unquote(caller), module_or_func, [token])
          end
        end)
      end
    end
  end
end

__CALLER__ 매크로를 사용해서 MyPlug 모듈을 사용한 모듈 환경을 불러올 수 있다. CompositePlug에서 MyPlug를 사용하고 plug 매크로를 사용했다면 __CALLER__.module 값으로 CompositePlug가 들어가게 된다.

MyPlug.call/1 함수를 수정해 function일 때는 __CALLER__ 모듈의 함수를 호출하는 코드로 변경한다.

defmodule CompositePlug do
  use MyPlug

  plug(Plug1)
  plug(Plug2)
  plug(:plug_func1)

  def plug_func1(token) do
    Map.put(token, :plug_func1, "plug_func1 called")
  end
end

defmodule Awesome do
  use MyPlug

  plug(CompositePlug)
  plug(:plug_func2)

  def plug_func2(token) do
    Map.put(token, :plug_func2, "plug_func2 called")
  end
end

CompositePlug, Awesome 모듈에 함수 plug를 추가한다. Awesome.call(%{}) 코드를 실행하면

%{
  plug1: "plug1 called",
  plug2: "plug2 called",
  plug_func1: "plug_func1 called",
  plug_func2: "plug_func2 called"
}

추가한 함수 플러그를 포함해 모듈 플러그까지 모두 호출된다.

마치며

phoenix 프레임워크는 plug를 사용해 여러 기능을 조합한다. 정의도 쉽고 plug를 만들어 기능을 추가하기도 편하다. plug 기능 중 일부만 구현해봤다. telsa 라이브러리에서 반복적인 코드를 편하게 조합할 수 있는 middleware도 plug와 비슷하게 구현되어 있다. 코드를 삽입(inject)하는 방법은 decorator와 비슷했다. 이전에 매크로를 사용해 decorator를 구현해본 경험이 코드를 분석하고 간단한 plug 코드를 짜는 데 도움이 됐다. 막히는 부분은 elixir-plug 라이브러리를 참고했다.

이 글을 적으면서 짠 전체 코드다.

defmodule MyPlug do
  @callback call(map) :: map

  defmacro __using__(_opts) do
    quote do
      import MyPlug
      @behaviour MyPlug

      Module.register_attribute(__MODULE__, :plugs, accumulate: true)
      @before_compile MyPlug
    end
  end

  defmacro plug(plug) do
    quote do
      @plugs unquote(plug)
    end
  end

  defmacro __before_compile__(env) do
    modules_or_funcs =
      env.module
      |> Module.get_attribute(:plugs)
      |> Enum.map(fn name -> {plug_kind(name), name} end)
      |> Enum.reverse()

    caller = __CALLER__.module

    quote do
      def call(token) do
        Enum.reduce(unquote(modules_or_funcs), token, fn {kind, module_or_func}, token ->
          case kind do
            :module -> apply(module_or_func, :call, [token])
            :function -> apply(unquote(caller), module_or_func, [token])
          end
        end)
      end
    end
  end

  defp plug_kind(module_or_func) do
    case Atom.to_charlist(module_or_func) do
      ~c"Elixir." ++ _ -> :module
      _ -> :function
    end
  end
end

defmodule Plug1 do
  @behaviour MyPlug
  def call(token) do
    false = Map.has_key?(token, :plug1)
    Map.put(token, :plug1, "plug1 called")
  end
end

defmodule Plug2 do
  @behaviour MyPlug
  def call(token) do
    false = Map.has_key?(token, :plug2)
    Map.put(token, :plug2, "plug2 called")
  end
end

defmodule CompositePlug do
  use MyPlug

  plug(Plug1)
  plug(Plug2)
  plug(:plug_func1)

  def plug_func1(token) do
    false = Map.has_key?(token, :plug_func1)
    Map.put(token, :plug_func1, "plug_func1 called")
  end
end

defmodule Awesome do
  use MyPlug

  plug(CompositePlug)
  plug(:plug_func2)

  def plug_func2(token) do
    false = Map.has_key?(token, :plug_func2)
    Map.put(token, :plug_func2, "plug_func2 called")
  end
end