9 minute read

Elixir에서 Behaviour란 용어가 나온다. Erlang의 어깨 위에 올라서서 만든 언어라 Erlang에서 사용하는 Bahaviour라는 용어를 그대로 가져온 것 같다. C++의 pure virtual function만 정의한 클래스 혹은 C#의 interface와 비슷한 것으로 정리하고 넘어갔다.

Functional Web Development with Elixir, OTP, and Phoenix (Lance Halvorsen, 2018)’ 책에서 그냥 Application, GenServer, Supervisor라고 부르지 않고 꼬박꼬박 Behaviour를 붙이는 걸 봤다. 대충 넘어가지 말고 한 번 정리해야겠단 생각을 했다.

Behaviour 모듈과 Callback 모듈

’Behaviours’ in Elixir (and Erlang) are a way to separate and abstract the generic part of a component (which becomes the ’behaviour’ module) from the specific part (which becomes the ’callback’ module).

A ’behaviour’ module defines a set of functions and macros (referred to as ’callbacks’) that ’callback’ modules implementing that ’behaviour’ must export. This “interface” identifies the specific part of the component.

Elixir(및 Erlang)의 ’Behaviours’은 컴포넌트의 일반 부분(’Behaviours’ 모듈이 되는)과 특정 부분(’callback’ 모듈이 되는)을 분리하고 추상화하는 방법입니다.

’Behaviour’ 모듈은 해당 ’Behaviour’을 구현하는 ’callback’ 모듈이 내보내야 하는 함수 및 매크로(’callback’이라고 함)의 집합을 정의합니다. 이 “인터페이스”는 컴포넌트의 특정 부분을 식별합니다.

Typespecs reference — Elixir v1.17.2 - hexdocs.pm

Behaviour 모듈은 Interface Class처럼 계약을 정의한다. Callback 모듈은 Behaviour 모듈이 정의한 계약을 구현한다. Interface를 구현한 Implementation Class가 생각난다.

예를 들어 Parser 라는 Behaviour 모듈을 아래와 같이 정의할 수 있다.

defmodule Parser do
  @doc """
  Parses a string.
  """
  @callback parse(String.t) :: {:ok, term} | {:error, atom}

  @doc """
  Lists all supported file extensions.
  """
  @callback extensions() :: [String.t]
end

JSON과 CSV 타입에 대한 Callback 모듈을 구현해 보자.

defmodule JSONParser do
  @behaviour Parser # <-- 1

  @impl Parser
  def parse(str), do: {:ok, "some json " <> str} # ... parse JSON

  @impl Parser
  def extensions, do: [".json"]
end
defmodule CSVParser do
  @behaviour Parser # <-- 1

  @impl Parser
  def parse(str), do: {:ok, "some csv " <> str} # ... parse CSV

  @impl Parser
  def extensions, do: [".csv"]
end

1번 코드로 Parser Behaviour 모듈을 구현한다고 선언한다. 만약 Callback 함수를 구현하지 않으면 컴파일 에러가 난다. 런타임에 해당 함수가 존재한다는 걸 보장할 수 있다. Behaviour는 Callback 모듈에 함수 구현을 강제해서 동적 타입 언어인 Elixir, Erlang에서 정적 타입 언어가 보장하는 타입 안정성을 살짝 담궜다가 빼서 향만 맡을 수 있게 한다. 호출하는 함수 코드에 오타가 있다고 했을 때, 런타임에 해당 코드를 실행해야 에러가 난다. 동적 타입의 가혹한 세계란.

defmodule ParserDrive do
  @spec parse_path(Path.t(), [module()]) :: {:ok, term} | {:error, atom}
  def parse_path(filename, parsers) do
    with {:ok, ext} <- parse_extension(filename),
         {:ok, parser} <- find_parser(ext, parsers),
         {:ok, contents} <- File.read(filename) do
      parser.parse(contents) # <-- 1
    end
  end

  defp parse_extension(filename) do
    if ext = Path.extname(filename) do
      {:ok, ext}
    else
      {:error, :no_extension}
    end
  end

  defp find_parser(ext, parsers) do
    if parser = Enum.find(parsers, fn parser -> ext in parser.extensions() end) do # <-- 2
      {:ok, parser}
    else
      {:error, :no_matching_parser}
    end
  end
end

Behaviour를 써서 1번 코드와 2번 코드가 런타임에 안정되게 실행될 수 있다. Parser Behaviour에서 parse/1 함수와 extensions/0 함수를 선언했기 때문이다.

ParserDrive.parse_path("some.json", [JSONParser, CSVParser])

이런 식으로 파서를 두 번째 인자로 넘기면 된다.

Behaviour는 추상화 도구이지 다형성(Polymorphism) 도구는 아니다. Elixir에서 다형성은 Protocol로 구현한다.

위 소스코드는 ’Typespecs reference — Elixir v1.17.2 - hexdocs.pm’ 문서에서 가져왔다.

Behaviour와 Best Practice?

’Behaviours’ grew out of the experience of early Erlang developers at Ericsson. Concurrency is hard. Fault tolerance is hard. The Erlang team put a lot of work into getting them right. ’Behaviours’ standardize their best practices and make them easy to use in our own applications.

OTP defines ’Behaviours’ for different types of specialized processes that we can use to build our own applications. We’ve already mentioned ’GenServer’ and ’Application’, but there are others. There’s one for finite state machines, one for creating and handling system events, and one for creating supervisors for fault tolerance. We can also define our own custom ’Behaviours’ to work in our own domains if we need to.

’Behaviour’는 에릭슨의 초기 Erlang 개발자들의 경험에서 비롯된 것입니다. 동시성은 어렵습니다. 내결함성은 어렵습니다. Erlang 팀은 이를 제대로 구현하기 위해 많은 노력을 기울였습니다. ’Behaviour’은 모범 사례(Best Practice)를 표준화하여 자체 애플리케이션에서 쉽게 사용할 수 있도록 합니다.

OTP는 자체 애플리케이션을 구축하는 데 사용할 수 있는 다양한 유형의 특수 프로세스에 대한 ’Behaviour’을 정의합니다. 이미 GenServer와 Application을 언급했지만 다른 것들도 있습니다. 유한 상태 머신을 위한 것, 시스템 이벤트를 생성하고 처리하는 것, 내결함성을 위한 Supervisor를 생성하는 것 등이 있습니다. 또한 필요한 경우 자체 도메인에서 작동하도록 자체 사용자 정의 ’Behaviour’을 정의할 수도 있습니다.

Functional Web Development with Elixir, OTP, and Phoenix (Lance Halvorsen, 2018)

반복해서 사용하는 코드 패턴을 Behaviour를 통해 표준화했다. 코드 재사용이라는 용어보다는 모범 사례(Best Practice)를 표준화했다는 표현이 인상적이다. 반드시 구현해야 하는 Callback 함수는 사용자에게 구현을 강요하고 다른 Callback은 기본 구현을 하고 사용자가 Override 가능하게 뒀을 것 같다. Template method pattern 느낌이랄까?

많이 사용하는 Behaviour 구현을 한 번 살펴보자.

GenServer Behaviour는 어떻게 구현했을까?

GenServer behaviour

A behaviour module for implementing the server of a client-server relation.

GenServer — Elixir v1.17.2 - hexdocs.pm

많이 사용하는 GenServer behaviour 구현을 살펴보기 전에 사용하는 방식은 아래와 같다.

defmodule Stack do
  use GenServer

  # Callbacks

  @impl true
  def init(elements) do
    initial_state = String.split(elements, ",", trim: true)
    {:ok, initial_state}
  end

  @impl true
  def handle_call(:pop, _from, state) do
    [to_caller | new_state] = state
    {:reply, to_caller, new_state}
  end

  @impl true
  def handle_cast({:push, element}, state) do
    new_state = [element | state]
    {:noreply, new_state}
  end
end

use GenServer 문으로 Behaviour 코드를 현재 모듈(위 코드에선 Stack)에 삽입(inject)한다. 그리고 필요한 콜백을 구현한다. 위 코드에선 @impl true Module attribute가 붙은 init/1, handle_call/3, handle_cast/2 함수가 콜백 함수이다.

우선 GenServer Behaviour 부터 살펴보자. Elixir 1.17.2 GenServer 소스코드를 살펴봤다.

defmodule GenServer do
  @callback init(init_arg :: term) ::
              {:ok, state}
              | {:ok, state, timeout | :hibernate | {:continue, continue_arg :: term}}
              | :ignore
              | {:stop, reason :: any}
            when state: any

  @callback handle_call(request :: term, from, state :: term) ::
              {:reply, reply, new_state}
              | {:reply, reply, new_state,
                 timeout | :hibernate | {:continue, continue_arg :: term}}
              | {:noreply, new_state}
              | {:noreply, new_state, timeout | :hibernate | {:continue, continue_arg :: term}}
              | {:stop, reason, reply, new_state}
              | {:stop, reason, new_state}
            when reply: term, new_state: term, reason: term

  @callback handle_cast(request :: term, state :: term) ::
              {:noreply, new_state}
              | {:noreply, new_state, timeout | :hibernate | {:continue, continue_arg :: term}}
              | {:stop, reason :: term, new_state}
            when new_state: term

  @callback handle_info(msg :: :timeout | term, state :: term) ::
              {:noreply, new_state}
              | {:noreply, new_state, timeout | :hibernate | {:continue, continue_arg :: term}}
              | {:stop, reason :: term, new_state}
            when new_state: term

  @callback handle_continue(continue_arg, state :: term) ::
              {:noreply, new_state}
              | {:noreply, new_state, timeout | :hibernate | {:continue, continue_arg}}
              | {:stop, reason :: term, new_state}
            when new_state: term, continue_arg: term

  @callback terminate(reason, state :: term) :: term
            when reason: :normal | :shutdown | {:shutdown, term} | term

  @callback code_change(old_vsn, state :: term, extra :: term) ::
              {:ok, new_state :: term}
              | {:error, reason :: term}
            when old_vsn: term | {:down, term}

  @doc since: "1.17.0"
  @callback format_status(status :: :gen_server.format_status()) ::
              new_status :: :gen_server.format_status()

  @doc deprecated: "Use format_status/1 callback instead"
  @callback format_status(reason, pdict_and_state :: list) :: term
            when reason: :normal | :terminate

  @optional_callbacks code_change: 3,
                      terminate: 2,
                      handle_info: 2,
                      handle_cast: 2,
                      handle_call: 3,
                      format_status: 1,
                      format_status: 2,
                      handle_continue: 2
end

GenServer Callback 모듈을 정의한다면 init/1 함수는 반드시 정의해야 한다. 나머지 Callback 함수는 모두 @optional_callbacks 로 정의되어 있어서 정의하지 않아도 괜찮다.

use GenServer 문을 사용하면 어떤 코드가 추가되는 걸까?

defmodule GenServer do
  defmacro __using__(opts) do
    quote location: :keep, bind_quoted: [opts: opts] do
      @behaviour GenServer # <-- 1

      def child_spec(init_arg) do
        default = %{
          id: __MODULE__,
          start: {__MODULE__, :start_link, [init_arg]}
        }

        Supervisor.child_spec(default, unquote(Macro.escape(opts)))
      end

      defoverridable child_spec: 1 # <-- 2

      # ...
    end
  end

1번 코드로 GenServer Behaviour를 사용한다고 선언한다. child_spec/1 기본 함수를 제공한다. Supervisor에 필요한 함수인데, 별다른 정의 없이 자식으로 GenServer를 추가할 수 있는 이유다. 기본 함수 덕분이다. 2번 코드로 기본 함수를 override할 수 있게 허용한다. 유저가 child_spec/1 함수를 정의하면 그걸 사용한다.

use GenServer 문을 사용하면 추가되는 다른 코드를 더 보자.

defmodule GenServer do
  defmacro __using__(opts) do
    quote location: :keep, bind_quoted: [opts: opts] do
      @behaviour GenServer

      # ...

      @doc false
      def handle_call(msg, _from, state) do # <-- 1
        proc =
          case Process.info(self(), :registered_name) do
            {_, []} -> self()
            {_, name} -> name
          end

        # We do this to trick Dialyzer to not complain about non-local returns.
        case :erlang.phash2(1, 1) do
          0 ->
            raise "attempted to call GenServer #{inspect(proc)} but no handle_call/3 clause was provided"

          1 ->
            {:stop, {:bad_call, msg}, state}
        end
      end

      @doc false
      def handle_info(msg, state) do # <-- 2
        proc =
          case Process.info(self(), :registered_name) do
            {_, []} -> self()
            {_, name} -> name
          end

        :logger.error(
          %{
            label: {GenServer, :no_handle_info},
            report: %{
              module: __MODULE__,
              message: msg,
              name: proc
            }
          },
          %{
            domain: [:otp, :elixir],
            error_logger: %{tag: :error_msg},
            report_cb: &GenServer.format_report/1
          }
        )

        {:noreply, state}
      end

      @doc false
      def handle_cast(msg, state) do # <-- 3
        proc =
          case Process.info(self(), :registered_name) do
            {_, []} -> self()
            {_, name} -> name
          end

        # We do this to trick Dialyzer to not complain about non-local returns.
        case :erlang.phash2(1, 1) do
          0 ->
            raise "attempted to cast GenServer #{inspect(proc)} but no handle_cast/2 clause was provided"

          1 ->
            {:stop, {:bad_cast, msg}, state}
        end
      end

      # ...

      defoverridable code_change: 3, terminate: 2, handle_info: 2, handle_cast: 2, handle_call: 3 # <-- 4
    end
  end
end

1번, 2번, 3번 코드를 보면 handle_* 함수를 구현하지 않았는데, 해당 메시지가 날아온다면 에러를 내게 했다. 무시할 메시지라도 메시지가 날아온다면 handle_* 함수를 구현해야 한다. 친절한 기본 구현이다. 4번 코드로 기본 구현 코드를 override할 수 있게 허용한다.

다음은 GenServer Behaviour를 구현한 Callback 모듈에 사용할 수 있는 GenServer 함수들이다.

defmodule GenServer do
  # ...

  @spec start_link(module, any, options) :: on_start
  def start_link(module, init_arg, options \\ []) when is_atom(module) and is_list(options) do
    # ...
  end

  @spec stop(server, reason :: term, timeout) :: :ok
  def stop(server, reason \\ :normal, timeout \\ :infinity) do
    # ...
  end

  @spec call(server, term, timeout) :: term
  def call(server, request, timeout \\ 5000) when (is_integer(timeout) and timeout >= 0) or timeout == :infinity do
    # ...
  end

  @spec cast(server, term) :: :ok
  def cast(server, request)

  def cast({:global, name}, request) do
    # ...
  end

  def cast({:via, mod, name}, request) do
    # ...
  end

  def cast({name, node}, request) when is_atom(name) and is_atom(node) do do_send({name, node}, cast_msg(request)) end

  def cast(dest, request) when is_atom(dest) or is_pid(dest) do do_send(dest, cast_msg(request)) end

  # ...
end

GenServer.start_link/3, GenServer.cast/2 와 같은 함수들이 보인다.

정리하면 GenServer Behaviour 모듈은 세 가지를 제공한다.

  1. GenServer Behaviour 정의
    • @callback init...
  2. 기본 구현을 쉽게 추가할 수 있는 __using__ 매크로
    • use GenServer
  3. Callback 모듈에 사용할 수 있는 GenServer 함수
    • GenServer.start_link/3

마치며

Elixir Behaviour 모듈과 Callback 모듈은 OOP(Object-Oriented Programming)에서 Interface Class와 Implementation Class가 생각난다. Behaviour 모듈로 계약을 정의하고 Callback 모듈에서 구현한다. 필수 callback을 구현하지 않으면 컴파일 타임 에러가 난다. 동적 타입 언어에서는 이런 컴파일 타임 에러가 참 소중하다. 사소한 오타로 런타임 에러를 낼 수 있는 가혹한 환경이기 때문이다.

Elixir에서는 모범 사례를 Behaviour를 사용해 표준화한다. GenServer, Application, Supervisor 등등 자주 사용하는 모듈들이 Behaviour이다.

GenServer 구현을 간단하게 살펴봤다. Behaviour 정의, 기본 구현 추가를 위한 __using__ 매크로, callback 모듈에 사용할 수 있는 함수로 구성되어 있다. 기본 구현은 사용자가 override할 수 있게 제공한다.

되돌아보면 Elixir로 대규모 프로젝트를 진행했을 때, 정의했을 만한 Behaviour 들이 꽤 됐던 것 같다. GenServer 모듈처럼 세 가지를 제공했으면 모범 사례 전파가 손쉬웠을 것 같다. 좀 더 적극적으로 사용하지 않은 게 아쉽다.

링크