Designing Elixir Systems with OTP (James Edward Gray II, Bruce A. Tate, 2019) 독후감

13 minute read

We believe good software design is about building layers, so perhaps the most important aspect of this book is helping good programmers understand where layers should go and how they work.

맞는 말이다. 좋은 소프트웨어 디자인은 계층(layer)을 구축하는 것이다. 항상 기술 부채로 남는 좋은 계층 구축 실패를 반복하기 싫어서 읽었다.

책에서 추천하는 계층(layers)

We recommend the software layers: data structures, a functional core, tests, boundaries, lifecycle, and workers.

6개의 계층 구축을 추천한다. 서브 디렉터리는 심플하게 간다. core, boundary, test 디렉터리만 사용한다.

data structures

If you want to write beautiful code, you need to design the right data structures that consider your primary access patterns. This rule of thumb is doubly true for functional languages because data structures are immutable.

틱택토를 구현할 때, 2차원 tuple, 2차원 list, 좌표 tuple을 키로 쓴 map을 사용하는 예를 보여주면서 올바른 자료 구조를 선택하는 얘기부터 나온다. 성능은 물론 코딩 난이도를 바꿀 정도로 올바른 자료 구조를 선택하는 건 중요하다.

Next we introduced the way functional programmers shape data, preferring many versions of a value over time rather than continuously mutating a single value.

무엇이 데이터일까? 데이터로 불변 값을 더 선호해야 한다. 은행 계좌를 예로 들면 잔액(balance)보다는 거래(transaction) 명세를 데이터로 취급해야 한다. 거래 명세는 바뀌지 않는 불변 값이다. 잔액이 필요한 거래 명세로부터 계산하면 된다. 거래 명세가 길어져 계산이 부담스러우면 스냅샷을 만들어 놓으면 된다.

이 책에선 데이터 변환을 제대로 보여준다. 기대보다 데이터를 엄청나게 많이 정의한다. 구조체(struct) 값을 변경하기보단 계산해서 다른 구조체를 만들어내는 식이다.

core 계층과 같이 core 서브 디렉터리에 위치한다.

a functional core

A functional core is a group of functions and the type definitions representing the data layer, organized into modules. Our core doesn’t access external interfaces or use any process machinery your component might use.

core 계층에 함수와 데이터 타입을 정의한다. data 계층을 따로 두지 않고 core 계층에 정의한다. core 계층에 정의하는 함수는 외부 인터페이스에 접근하지 않고 상태(state)를 가지는 elixir의 프로세스(process)를 사용하지 않아야 한다. 프로세스를 만들려고 GenServer 비헤이비어(behaviour)를 구현하다 보면 상태와 관련 없는 함수도 같은 모듈에 정의해 뚱뚱해지는 경향이 있다. 상태에 관련되지 않은 함수는 모두 core 계층으로 내려야 테스트도 쉽고 함수 재사용성도 높일 수 있다.

Your core doesn’t have to be completely pure. Some functions will have concepts like timestamps, ID generation, or random number generation that are not strictly pure. For the most part, though, a functional core gets much easier to manage if the same inputs always generate the same outputs.

동일한 입력에 동일한 출력을 보장하는 순수(pure) 함수만 core 계층에 들어갈 수 있을까? 이걸로 구분하지 않는다. 외부 인터페이스 접근 금지와 프로세스 관련 함수를 사용하지 않는 함수만 core 계층에 정의한다. 이런 구분이 더 명확하다.

One of the key concepts in functional programming is the token. Think of a token as a piece representing a player on a board game. It moves and marks concepts. If you’re familiar with the Phoenix framework, the Plug.Conn is a token. An Ecto.Changeset or Query is also a token. Pipelines of functions transform these structures, tracking progress through a program.

이름이 있는지 몰랐다. token이라고 부른다. 토큰(token)은 함수의 첫 번째 인자와 리턴 값으로 사용한다.

user = %User{}
types = %{first_name: :string, last_name: :string, email: :string}
changeset =
  {user, types}
  |> Ecto.Changeset.cast(params, Map.keys(types))
  |> Ecto.Changeset.validate_required(...)
  |> Ecto.Changeset.validate_length(...)

|> 연산자를 파이프 연산자(pipe operator)라고 부른다. 첫 번째 인자를 파이프 연산자 왼쪽 항으로 넣어준다. Ecto.Changeset.cast(params, Map.keys(types)) 함수는 사실 Ecto.Changeset.cast({user, types}, params, Map.keys(types)) 로 호출된다. 파이프 연산자를 사용한 다음 줄에 있는 Ecto.Changeset.validate_required(...) 함수 첫 번째 인자로 윗줄에서 호출한 함수의 리턴 값이 들어가게 된다.

이렇게 여러 함수를 파이프 연산자로 엮을 때, 함수의 입력으로 들어가고 출력으로 나오는 자료 구조를 토큰(token)이라고 한다. 함수형 프로그래밍에서 부수효과(side effect) 없이 데이터를 변환하려면 입력으로 받을 수밖에 없다. 이런 토큰을 사용해서 데이터를 여러 함수를 연속적으로 호출해 변환하는 방법을 사용한다.

core 계층은 계속 의식해야 덩치가 커진다. 프로세스가 있는 모듈에 함수를 추가하는 게 편해서 의식하지 않으면 프로세스가 있는 모듈이 뚱뚱해진다.

tests

test 계층은 신경을 안 써도 자연스럽게 분리가 된다. 아무리 막장이라도 테스트를 프로덕션 계층에 섞어서 짜는 건 못 봤다. test 계층 구축에 대한 내용보다는 예제 코드가 눈에 들어왔다.

describe "a right response and a wrong response" do
  setup [:right, :wrong]

  test "building responses checks answers", %{right: right, wrong: wrong} do
    assert right.correct
    refute wrong.correct
  end

  test "a timestamp is added at build time", %{right: response} do
    assert %DateTime{} = response.timestamp
    assert response.timestamp < DateTime.utc_now()
  end
end

defp right(context) do
  {:ok, Map.put(context, :right, response("3"))}
end

defp wrong(context) do
  {:ok, Map.put(context, :wrong, response("2"))}
end

setup/1 콜백 함수를 주로 정의한다. setup [:right, :wrong] 구현처럼 right, wrong 같은 함수를 정의하고 setup 콜백에서는 어떤 함수를 호출할지 리스트로 정의하는 식이다. 함수 바디(body)를 구현하는 setup/2 콜백보다는 가독성이 좋다. describe 블럭마다 필요한 setup을 골라서 호출할 수 있다는 장점도 있다.

test "a random choice is made from list generators" do
  generators = addition_generators(Enum.to_list(1..9), [0])

  assert eventually_match(generators, 1)
  assert eventually_match(generators, 9)
end

def eventually_match(generators, answer) do
  Stream.repeatedly(fn ->
    build_question(generators: generators).substitutions
  end)
  |> Enum.find(fn substitution ->
    Keyword.fetch!(substitution, :left) == answer
  end)
end

랜덤이 들어간 테스트를 Stream.repeatedly/1, Enum.find/3 함수로 구현했다. Enum.find/3 함수 중단 조건을 이용한 재미있는 구현이다. 처음 봤을 때, 저렇게 두 개만 썼을 때, 어떻게 참이 될 때까지 반복이 되는지 바로 이해가 안 됐다. Enum.find/3 함수 중단 조건은 참 같은 값(Truthy values)을 찾거나 컬렉션(collections) 끝까지 순회했을 때이다. Stream.repeatedly/1 함수는 무한으로 반복하는 함수니 끝이 없으니 Enum.find/3 함수가 참 같은 값을 찾을 때까지 반복하게 된다. 만약 절대 성공할 수 없는 케이스라면 어떻게 처리될까? test에 있는 timeout으로 테스트가 종료되고 실패 처리가 된다.

boundaries

Think about the boundary in two parts: the service layer and the API layer. A boundary needs a service layer around each individual process type and an external API for clean, direct access across services.

boundary 계층은 API 계층과 service 계층으로 이루어져 있다. 수명주기 동안 유지하는 상태(state) 같은 프로세스 기술이 필요해 core 계층에서는 구현이 안 되는 모듈과 함수가 boundary 계층으로 오게 된다. 이런 개별 기능이 service 계층에 위치하고 이런 서비스들을 서로 묶어서 외부에 API를 제공하는 API 계층이 그 위에 오게 된다.

The API layer’s job is to insulate the server layer from inconsistent data and to stitch together independent concepts from the individual GenServer implementations.

The inputs and outputs of functions in the core are often trusted and well defined, but the boundary machinery must deal with uncertainty because our boundary API must process unsanitized user input and the external systems our boundary might use can fail.

core 계층과 다르게 일관성이 없는 데이터를 검증하고 가공해야 한다. API 계층에서 이 역할을 담당한다. 여기서 일관성 있는 데이터가 오류가 없는 데이터를 뜻하지 않는다. 성공과 실패를 약속된 방법으로 구분할 수 있으면 일관성이 있다고 취급한다.

core 계층은 일관성이 있는 데이터와 예상 가능한 입력을 다루기에 |> 연산자 (pipe operator)를 주로 사용하지만 boundary 계층은 입력 검사를 더 깐깐하고 복잡하게 해야 하기에 패턴 매칭 (pattern matching) 표현식을 조합할 때, 편하게 사용할 수 있는 with/1 매크로를 많이 사용한다. core 계층에서는 with/1 매크로를 사용할 일이 거의 없다.

API 계층 파일은 최상단에 있다. 책에서 만드는 예제 프로그램 이름이 mastery 인데, 여기서 API 계층 파일은 예제 프로그램 이름과 같은 mastery.ex 파일이다. mastery 하위 디렉터리에 boundary, core 파일들이 위치한다. 각각 mastery/boundary, mastery/core 서브 디렉터리를 루트(root) 삼아 계층 구조를 만든다.

API 계층을 분리하고 service 계층의 GenServer를 엮어주는 역할을 하다 보니 GenServer 모듈에 외부에서 편하게 사용하려는 목적으로 추가하는 client API를 제거해도 되겠다. 여기서 client API는 GenServer.cast/2, GenServer.call/3 함수를 외부에서 호출하지 않고 편하게 호출할 수 있게 만든 함수를 말한다.

API 계층에서 외부 데이터 검증할 때, 사용하는 Validator 모듈 코드가 인상적이다.

defmodule Mastery.Boundary.Validator do
  def require(errors, fields, field_name, validator) do
    present = Map.has_key?(fields, field_name)
    check_required_field(present, fields, errors, field_name, validator)
  end

  def optional(errors, fields, field_name, validator) do
    if Map.has_key?(fields, field_name) do
      require(errors, fields, field_name, validator)
    else
      errors
    end
  end

  def check(true = _valid, _message), do: :ok
  def check(false = _valid, message), do: message

  defp check_required_field(true = _present, fields, errors, field_name, f) do
    valid = fields |> Map.fetch!(field_name) |> f.()
    check_field(valid, errors, field_name)
  end

  defp check_required_field(_present, _fields, errors, field_name, _f) do
    errors ++ [{field_name, "is required"}]
  end

  defp check_field(:ok, _errors, _field_name), do: :ok

  defp check_field({:error, message}, errors, field_name) do
    errors ++ [{field_name, message}]
  end

  defp check_field({:errors, messages}, errors, field_name) do
    errors ++ Enum.map(messages, &{field_name, &1})
  end
end

require/4, optional/4 함수는 에러 리스트를 토큰으로 사용해서 |> 연산자로 파이프라인을 만들 수 있다. check/2 함수는 Validator 모듈에 사용할 검증 함수를 만들 때, 사용할 수 있는 helper 함수로 중요한 함수는 아니다.

defmodule Mastery.Boundary.TemplateValidator do
  import Mastery.Boundary.Validator

  def errors(fields) when is_list(fields) do
    fields = Map.new(fields)

    []
    |> require(fields, :name, &validate_name/1)
    |> require(fields, :category, &validate_name/1)
    |> optional(fields, :instructions, &validate_instructions/1)
    |> require(fields, :raw, &validate_raw/1)
    |> require(fields, :generators, &validate_generators/1)
    |> require(fields, :checker, &validate_checker/1)
  end

  def errors(_fields), do: [{nil, "A keyword list of fields is required"}]

  def validate_name(name) when is_atom(name), do: :ok
  def validate_name(_name), do: {:error, "must be an atom"}
  def validate_instructions(instructions) when is_binary(instructions), do: :ok
  def validate_instructions(_instructions), do: {:error, "must be a binary"}

  def validate_raw(raw) when is_binary(raw) do
    check(String.match?(raw, ~r{\S}), {:error, "can't be blank"})
  end

  def validate_raw(_raw), do: {:error, "must be a string"}
end

필드 검증을 TemplateValidator.errors/1 함수 리턴 값이 비어있는지로 확인한다. error 리스트를 리턴하기에 비어 있으면 통과 하나라도 있으면 검증에 통과하지 못한 거다. Ecto.Changeset 모듈 함수가 생각난다. 책에서도 Ecto.Changeset 모듈을 사용할까 했지만 간단한 validator를 만드는 게 목표라서 비슷하게 구현했다고 한다. 깔끔하고 단순해서 나도 마음에 든다.

lifecycle

프로세스 수명 관리를 하는 Supervisor, DynamicSupervisor가 있는 계층이다. application.ex 파일 또는 boundary 계층과 같은 서브 디렉터리에 놓는다.

workers

lifecycle 계층과 떨어져서 병행성(concurrency)을 제공하는 계층이다. 프로세스, 커넥션 풀(connection pool), Task 등의 도구를 사용해서 구현한다. 디렉터리 구조는 boundary 계층과 같은 서브 디렉터리에 놓는다.

생각거리 - cast 함수보다 call 함수를 사용하라

A good example is the Elixir logger. If your production code is sending log messages quicker than the logger can handle them, either because the sender is logging too many log requests or because the logger’s disk I/O is somehow compromised, we don’t want the logger to immediately stop logging messages. The Elixir logger has an excellent solution for this problem. It’s called selective back-pressure. That means that when the logger gets into trouble, it will detect this problem and start slowing the clients down by switching from cast to call .

많은 호출이 일어나는 elixir logger는 어떻게 동작하는가? 비동기 호출인 cast가 기본인데, 처리 용량을 넘어서서 프로세스가 가진 mailbox에 쌓인 메시지가 처리하기 힘든 만큼 쌓이면 동기 호출인 call로 바꾼다. 이걸 selective back-pressure라고 부른다.

Here’s the point. If your code uses handle_call instead of handle_cast, you don’t need to worry as much because you can only send messages as fast as your server can process them. It’s a great automatic governor on a server.

Rarely, you’ll want to use cast messages to start multiple workers at once, or to notify multiple workers simultaneously. Try to be judicious with this approach, though.

프로젝트에서 프로세스 간 통신을 cast로 한다면 이런 selective back-pressure 처리가 필요한데, 이걸 해줄 게 아니면 call 사용을 고민하라는 조언이다. 메시지를 동시에 여러 프로세스에 보내는 경우처럼 cast가 필요한 곳에만 선택적으로 사용하고 기본으로 call을 사용하자.

의외의 조언이고 새겨들을 게 있다. 하지만 call 호출은 꽤나 신경 써야 할 게 많다. 데드락도 신경 써야 하고 cast로 메시지를 보내면 자연스럽게 풀어지는 퍼포먼스 저하도 신경 써야 한다. selective back-pressure를 신경 써야 하는 프로세스에는 GenStage라는 훌륭한 대안도 있다.

생각거리 - persistence를 boundary 서비스로 추가

We recognize there’s some duplication in this code. That’s OK. We think separating the concerns of saving a response and operating a timed quiz is a good idea. Inevitably, the needs of the persistence layer and our Mastery boundary will diverge. Rather than bloating the model to support both concerns, we need only maintain a function doing a transformation between the two.

Ecto.Schema DB 테이블과 매핑하는 용도로 사용한다. 하지만 구조체(struct)를 만들어주기도 하기에 data 계층에 둬도 되지 않겠냔 생각이 들기도 한다. 하지만 해당 모듈에 데이터를 변경하기 위한 Ecto.Changeset 사용 함수도 들어가기에 중복 코드를 감수하고 data 계층에 두지 않는다. 그럼 어떤 계층에 둬야 할까?

You might be asking yourself, “Where are all of the layers?” In this case, almost the whole project is boundary code. In a database, it’s nearly impossible to do anything without side effects. You could call Ecto schemas core functions, but those tiny slices of code are not enough to justify another layer of ceremony by adding one more directory in lib.

DB는 사이드 이팩트 없이는 아무것도 하지 못한다. boundary 계층에 둔다. schema 같은 디렉터리를 추가해서 core 계층과 같은 위상을 가지게 할 수도 있다. 별로 이득이 없을 거라 책에선 얘기하지만 Ecto 정도로 거의 모든 프로젝트에서 사용하는 라이브러리 정도는 중복 코드가 너무 많이 만들어지는 경우엔 core 계층에서 사용해도 괜찮지 않을까 생각한다.

분리하겠다면?

umbrella project로 프로젝트를 하나 더 추가해서 이 프로젝트에서만 Ecto 라이브러리를 사용하게 한다.

def record_response(response, in_transaction \\ fn _response -> :ok end) do
  {:ok, result} =
    Repo.transaction(fn ->
      %{
        quiz_title: to_string(response.quiz_title),
        template_name: to_string(response.template_name),
        to: response.to,
        email: response.email,
        answer: response.answer,
        correct: response.correct,
        inserted_at: response.timestamp,
        updated_at: response.timestamp
      }
      |> Response.record_changeset()
      |> Repo.insert!()

      in_transaction.(response)
    end)

  result
end

DB에 레코드를 추가한 다음 인자로 넘어온 in_transaction 함수를 실행하게 한다.

use Mix.Config

config :mastery, :persistence_fn, &MasteryPersistence.record_response/2

개발 환경 및 릴리즈 환경에서만 실제 DB 호출 함수를 사용한다. test 환경에서는 값을 설정하지 않아 Application.get_env(:mastery, :persistence_fn) 함수를 호출하면 nil 값을 리턴하게 한다.

defmodule Mastery.Boundary.QuizSession do
  def handle_call({:answer_question, answer, fun}, _from, {quiz, email}) do
    fun = fun || fn r, f -> f.(r) end
    response = Response.new(quiz, email, answer)

    fun.(response, fn r ->
      quiz
      |> Quiz.answer_question(r)
      |> Quiz.select_question()
    end)
    |> maybe_finish(email)
  end
end

fun 인자는 API 계층에서 Application.get_env(:mastery, :persistence_fn) 리턴 값을 바인딩한다. DB 계층을 붙이는지 여부를 config 만으로 설정할 수 있게 한다.

프로젝트가 커지면 함수 단위로 config에 설정해서는 답이 없을 것 같다. 유지보수가 힘들 정도로 함수 개수가 늘어날 것이기 때문이다. 비헤이비어(behaviour)를 설정할 수 있게 고쳐야 할 것 같다.

좀 더 실용적인 예제와 설명을 추가했으면 하는 아쉬움이 남는 챕터였다.

마치며

이런 계층 구축에 관한 책을 읽었어야 했다. 좋은 책이다. 많이 배웠다.

꼭 책에서 추천하는 대로 계층 구축을 할 필요는 없겠지만 elixir 프로젝트라면 어디에나 잘 맞아떨어지게 계층을 잘 나눠놨다. 계층 안에서도 모두 같은 위상을 갖는 개념의 단위가 아니라서 그 안에서도 계층이 생긴다. elixir에서는 모듈 이름에 . 문자로 깊이를 표현하는데, 이게 많아질수록 더 자세한 개념을 담고 적어질수록 대표하는 넓은 개념을 담게 된다.

elixir 책답게 ecto, phoenix, otp에 대한 설명도 빼먹지 않아서 유익하다. ecto schema를 어디에 둘 건지 고민하는 내용은 elixir 언어로 된 계층 구축에 관한 책이면 당연히 다뤄야 하는 내용이다. 나는 ecto가 어디 남이가? 하는 심정으로 data, core 계층에 넣는 걸 고려하겠지만 책에서 말하는 boundary 계층에 둬야 한다는 주장도 귀 기울여 들을만 하다.

계층 유지에 노력도 많이 들어갈 것 같다. 편하게 코드를 짜다 보면 boundary 계층이 뚱뚱해지기 십상이다. 부지런히 core 계층으로 코드를 날라야 한다.

계층 구축에 관한 책과 글을 좀 더 많이 보고 싶단 생각이 들었다.

책 링크: https://pragprog.com/titles/jgotp/designing-elixir-systems-with-otp/

밑줄

  • Our functional core is what some programmers call the business logic. This inner layer does not care about any of the machinery related to processes; it does not try to preserve state; and it has no side effects (or, at least, the bare minimum that we must deal with). It is made up of functions.
  • The boundary layer deals with side effects and state. This layer is where you’ll deal with processes, and where you’ll present your API to the outside world. In Elixir, that means OTP.
  • A surprising number of Elixir developers get tripped up at this point. It’s tempting to wrap up the details of your business logic in the state management. Doing so conflates two concerns: organization and concurrency. We’ll use modules not processes to organize our code so basic strategy changes won’t necessarily lead to changing your core business logic.
  • A functional core is a group of functions and the type definitions representing the data layer, organized into modules. Our core doesn’t access external interfaces or use any process machinery your component might use.
  • Your core doesn’t have to be completely pure. Some functions will have concepts like timestamps, ID generation, or random number generation that are not strictly pure. For the most part, though, a functional core gets much easier to manage if the same inputs always generate the same outputs.
  • Our rule of thumb is to use processes only when we need them to control execution, divide work, or store common state where functions won’t work.
  • One of the key concepts in functional programming is the token. Think of a token as a piece representing a player on a board game. It moves and marks concepts. If you’re familiar with the Phoenix framework, the Plug.Conn is a token. An Ecto.Changeset or Query is also a token. Pipelines of functions transform these structures, tracking progress through a program.

링크