Elixir의 Token 패턴에 관한 정리

posted by donghyun

6 min read

태그

Elixir의 Token 패턴에 관한 정리

Token 방식이란?

간단히 말하면 어떤 프로그램의 ‘flow’를 구현하는 한 방법으로, Token 역할을 하는 struct를 하나 두고 여러 함수들은 이 struct를 받아 로직을 수행하고 다시 Token을 반환. Elixir의 디자인 패턴이라고 볼 수 있을까?

비슷한 기존 개념

Context Object - A Design Pattern for Efficient Information Sharing across Multiple System Layers

This pattern provides an efficient and application transparent way of sharing information between different layers in a software system.

실제 해당 패턴 사용하는 라이브러리들

Phoenix, plug_cowboy

  • Plug.Conn

Ecto

  • Ecto.Changeset, Ecto.Multi

Token 방식을 언제 쓸까?

엘릭서에서 여러 함수가 파이프라인으로 연결될만한 유스케이스, 비즈니스 도메인 프로세스를 구현할 때, 보통 다음과 같이 복잡도에 따라 적용을 달리 해볼 수 있다.

  1. 단순 함수 파이프라인 연결. 앞의 함수의 결과값이 다음 함수의 인자로 들어감. 가장 간단한 형태
  • 단점: 앞의 함수의 결과값이 다음 함수의 인자값에 적절히 들어가는 형태여야함. 즉 다음 함수가 {:ok, result} 튜플과 같은 형태의 인자를 처리해야할 수도 있음. 또한 파이프라인으로 연결된 함수중 일부가 실패했을때, 다음 함수가 그 에러에 대한 처리를 해야한다.
  1. with문 연결. 인자타입과 반환타입이 여러 형태인 함수들을 파이프라인처럼 연결 가능. 중간에 패턴 매칭 실패하면 중지하고 바로 에러처리가능.
  • 단점: 여러 함수들을 타면서 여러 데이터들을 조합해야할때, 구조가 복잡해지고 장황해질 수 있다. else문에 너무 다양한 에러 처리를 하게 될 경우 가독성도 떨어지고 패턴 매칭시 실수할 가능성도 높다.

  • with문에서 else에 여러 패턴 매칭을 놓는걸 안티패턴으로 보는 시각도 있다.

  1. Token을 활용한 함수 파이프라이닝
  • Token역할을 하는 struct를 두고, 각 비즈니스 함수들은 첫번째 인자로 Token을 받고 결과값으로 역시 Token을 반환

  • 1번과 똑같은 형태로 함수들을 파이프라이닝 가능. Token에 에러 필드를 놓고 각 함수들이 앞의 결과에 에러가 있으면 선택적으로 로직 스킵도 가능. 2번보다 유연한 형태. 단 2번처럼 중간에 바로 skip은 안되고, 모든 함수를 한번씩은 거쳐야함

  • Token에 필요할 필드들을 명시함으로써 어떠한 자료구조들이 필요한지 명시적으로 알 수 있고, 각 함수들에서 케이스별로 패턴매칭하기도 쉽다.

  • 무엇보다 가독성이 나아짐

예시

간단한 CLI 프로그램을 생각해보자. 입력을 받아 파라미터를 검증하고, 일련의 프로세스를 처리하고 결과를 반환하는 flow를 가지고 있다.

이것을 코드로 표현해보자. 우선 1번 단순 함수 파이프라이닝이다.

# 1
def run(args) do
  args
  |> parse_args()
  |> validate_args()
  |> process_something()
  |> report_if_error()
  |> convert_to_output()
end

여기서 각 함수들을 살펴보면, 다음과 같은 형식이 될 것이다.

defp process_something({:ok, effects}) do
  more_result = do_something(effects.some_args)
  {:ok, effects |> Map.put(:process_result, more_result)}
end

defp process_something({:error, error_reason} = result) do
  result
end

defp report_if_error({:error, error_reason}) do
  report_error(error_reason)
  {:error, error_reason}
end

defp report_if_error(result), do: result

별 문제없어보이지만, 각 함수들이 앞의 함수들과 강하게 결합되었다는 점에 주목해보자.

만약 이 중 일부 함수의 결과값을 바꾸게 되면, 그 이후의 함수들 전체의 인자값 형태를 바꿔야 할수도 있다.

또한 각 부분함수들별로 테스트를 하려면, 앞 함수의 결과값이 어떻게 나오는지를 알아서 그 형태를 주입해줘야한다.

즉 덩치가 커질수록 수정사항이 생길때마다 다른 부분이 영향받는지를 확인해줘야하고, 테스트가 어려우며 나중에 다른사람이 코드를 볼 때 가독성이 떨어질수도 있는 구조라고 할 수 있다.

이번엔 2번째 방법, with 문을 이용한 flow다.

def run(args) do
  with {:ok, args} <- parse_args(args),
      :ok <- validate_args(args),
      {:ok, result} <- process_something(args),
      {:ok, output} <- convert_to_output(result) do
      output
  else
    # parsing failed
    {:error, :parse_fail, msg} ->
      Logger.warn("error message")
      :error
    # invalid args
    {:error, :invalid, msg} ->
      Logger.warn("")
      :error
    # ...
  end
end

일단 각 부분함수들의 결과값을 명확히 알 수 있고, 각 함수들의 인자형태와 결과형태가 꼭 정확하게 통일되지 않더라도 각 케이스마다 처리를 할 수 있다는 장점이 보인다.

하지만 with문에서는 항상 else 블록이 커지는것을 경계해야한다. else문의 case가 많아질수록 가독성은 떨어지고 유지보수는 어려워진다.

그러면 결국 각 부분함수들의 에러 케이스에 대한 포맷을 최대한 통일하거나, handle_error같은 공통 에러처리 함수를 만드는 수밖에 없다.

def run(args) do
  with {:ok, args} <- parse_args(args),
      :ok <- validate_args(args),
      {:ok, result} <- process_something(args),
      {:ok, output} <- convert_to_output(result) do
      output
  else
    e ->
      handle_error(e)
  end
end

defp handle_error({:error, error_reason}), do: ...

다음은 3번째 방법, Token을 이용한 방법.

# ...

defmodule Token do
  defstruct args: nil, error: nil, time: nil, ...

  def new(args) do
    %Token{...}
  end
end

# ...
def run(args) do
  Token.new(args)
  |> parse_args()
  |> validate_args()
  |> process_something()
  |> report_if_error()
  |> convert_to_output()
end

얼핏 1번째 방법과 똑같아 보이지만, 1번째와 3번째를 놓고 비교했을때, Token 방법을 활용할 경우 전체 flow에 대한 가독성 뿐 아니라 확장성, 에러 처리에 대한 유연함, 테스트 코드 작성 등에 훨씬 더 장점을 가지게 된다.

실제로 사용해본 결과, 기능 변화로 인해 코드 수정이 생겼을때 정말 유연하게 처리가 가능했고 처리하고 싶은 부분에 집중해서 코드를 변경하고 테스트해볼 수 있었다.

주의할 점

  • Token의 필드를 업데이트하는것은 Token에 put, update같은 함수를 명시적으로 만들고 가급적 직접 struct를 조작하는건 피하는게 좋다고 한다.

  • %Token{} 으로 새 struct를 만드는것보단 new 함수를 만드는것이 낫다고 한다.

  • Token이 너무 거대해지면 가독성이 오히려 저하될 수 있을것 같다.

  • 너무 많은 데이터가 함수들 사이에서 옮겨다니는게 문제되지는 않을까? 함수형은 객체지향과 다르게 함수들 사이에서 데이터 복사는 절대 없고 업데이트되는 map들 사이에서는 최적화가 잘 이루어진다고 가정한다.

Token이 좋지 않을 수 있는 이유

또 다른 추상화 계층이다

Fundamental theorem of software engineering 에서 말하길,

“We can solve any problem by introducing an extra level of indirection. (…except for the problem of too many levels of indirection,)”

Token 사용에는 비용이 든다. 팀원들은 이 사용법을 익혀야 하고, 당신은 팀원들에게 이것이 그저 또다른 indirection 계층이 아니라 정말 유용하다는것을 설득시켜야 한다.

소비자들을 만족시키기 위한 또다른 interface 이다.

이것을 구현하는데 있어서 몇가지 질문이 따라올 것이다.

  • 어떤 필드들을 포함시켜야 하나?
  • 각 필드가 명백한 의미를 갖도록 이름을 어떻게 지어야 할까?
  • assigns 같은 임의의 데이터를 갖는 비구조화 필드를 포함시켜야 할까?
  • assigns가 너무 광범위하게 쓰이지 않도록 어떻게 막을 수 있을까?

결국 Token 이 그것의 인터페이스와 API를 설계하는 사람들에게 달려있음을 알 수 있다.

Immutability로 인해 메모리를 크게 먹을 수 있다.

매우 큰 데이터를 pipeline을 통해 전달할 생각이면, Token 방식은 적절하지 않을 수 있다.

거대한 binary들을 포함한 struct를 변경하게 되면, 메모리 문제에 봉착할 수 있다. Token에 데이터 참조를 유지하면서, data 덩어리를 GenServer로 이동하는 경우 등

참고