Published on

Elixir OTP에서 파이프라인으로 상태관리하기

Authors

서버 사이드에서 Elixir를 선택하는 이유 중 하나는 OTP가 제공하는 강력한 동시성 모델과 장애 격리입니다. 하지만 실무에서 GenServer 기반 상태관리는 금방 복잡해집니다. handle_callhandle_cast 안에서 검증, 상태 전이, 부수효과(외부 API 호출, DB 저장, 브로드캐스트)가 한 덩어리로 엉키면, 로직은 커지고 테스트는 어려워지며, 장애 시 복구 전략도 애매해집니다.

이 글은 OTP의 기본 틀은 유지하되, 상태 변경 로직을 함수형 파이프라인으로 재구성해 명확한 전이 규칙, 일관된 오류 전파, 부수효과의 격리를 얻는 방법을 다룹니다. 핵심은 GenServer 를 상태 저장소로만 쓰고, 도메인 로직은 순수 함수 파이프라인으로 빼는 것입니다.

OTP 상태관리의 전형적인 문제

전형적인 GenServer 구현은 아래처럼 시작합니다.

def handle_call({:deposit, user_id, amount}, _from, state) do
  # 검증
  if amount <= 0 do
    {:reply, {:error, :invalid_amount}, state}
  else
    # 상태 읽기
    user = Map.get(state.users, user_id)

    # 상태 전이
    new_user = %{user | balance: user.balance + amount}
    new_state = put_in(state.users[user_id], new_user)

    # 부수효과
    :ok = Audit.log(user_id, {:deposit, amount})

    {:reply, {:ok, new_user.balance}, new_state}
  end
end

처음에는 간단해 보이지만, 요구사항이 늘면 handle_call 안이 금방 커집니다.

  • 검증 규칙이 늘어나면서 조건문이 중첩됨
  • 상태 전이가 여러 단계로 분기됨
  • 부수효과가 섞이며 실패 처리 전략이 불명확해짐
  • 테스트가 GenServer 를 띄우는 통합 테스트 중심으로 기울어짐

해결 방향은 명확합니다.

  1. 상태 전이 로직을 순수 함수로 분리한다
  2. 파이프라인으로 전이 단계를 명시적으로 나열한다
  3. 부수효과는 마지막에 모아 실행하거나, OTP 메커니즘으로 분리한다

상태 전이를 파이프라인으로 모델링하기

파이프라인 기반 상태관리는 크게 두 가지 원칙을 씁니다.

  • 입력을 command 로 정규화하고, 상태와 함께 흘려보낸다
  • 각 단계는 {:ok, ctx} 또는 {:error, reason} 형태로 반환한다

여기서 ctx 는 컨텍스트(상태, 커맨드, 계산 중간값)를 담는 맵입니다.

컨텍스트와 커맨드 정의

defmodule Wallet.Command do
  defstruct [:type, :user_id, :amount, :idempotency_key]
end

defmodule Wallet.Context do
  defstruct [:state, :cmd, :user, :events, :reply]
end
  • events 는 상태 전이 결과로 발생한 도메인 이벤트 목록
  • reply 는 호출자에게 돌려줄 응답

이렇게 두면 파이프라인은 Context 를 계속 갱신합니다.

파이프라인 단계 함수

defmodule Wallet.Pipeline do
  alias Wallet.{Context, Command}

  def run(state, %Command{} = cmd) do
    %Context{state: state, cmd: cmd, events: []}
    |> ok()
    |> validate_amount()
    |> load_user()
    |> apply_deposit()
    |> build_reply()
  end

  defp ok(ctx), do: {:ok, ctx}

  defp validate_amount({:ok, %Context{cmd: %Command{amount: amount}}} = ok_ctx)
       when is_integer(amount) and amount > 0,
       do: ok_ctx

  defp validate_amount({:ok, _ctx}), do: {:error, :invalid_amount}
  defp validate_amount({:error, _} = err), do: err

  defp load_user({:ok, %Context{state: state, cmd: %Command{user_id: uid}} = ctx}) do
    case get_in(state, [:users, uid]) do
      nil -> {:error, :user_not_found}
      user -> {:ok, %Context{ctx | user: user}}
    end
  end

  defp load_user({:error, _} = err), do: err

  defp apply_deposit({:ok, %Context{state: state, user: user, cmd: %Command{amount: amount}} = ctx}) do
    new_user = %{user | balance: user.balance + amount}
    new_state = put_in(state, [:users, user.id], new_user)

    event = {:deposited, user.id, amount}

    {:ok, %Context{ctx | state: new_state, user: new_user, events: [event | ctx.events]}}
  end

  defp apply_deposit({:error, _} = err), do: err

  defp build_reply({:ok, %Context{user: user} = ctx}) do
    {:ok, %Context{ctx | reply: {:ok, user.balance}}}
  end

  defp build_reply({:error, reason}), do: {:error, reason}
end

이 구조의 장점은 다음과 같습니다.

  • 단계가 validate , load , apply 처럼 목적이 분명해져 읽기 쉬움
  • {:error, reason} 이 나오면 이후 단계가 자동으로 스킵됨
  • 상태 전이 로직이 순수 함수라 단위 테스트가 쉬움

GenServer는 파이프라인 실행기 역할만

이제 GenServer 는 메시지를 받아 파이프라인을 실행하고, 결과를 반영하는 역할만 합니다.

defmodule Wallet.Server do
  use GenServer
  alias Wallet.{Command, Pipeline}

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def deposit(user_id, amount, idempotency_key \\ nil) do
    cmd = %Command{type: :deposit, user_id: user_id, amount: amount, idempotency_key: idempotency_key}
    GenServer.call(__MODULE__, {:command, cmd})
  end

  @impl true
  def init(_opts) do
    {:ok, %{users: %{}, processed: MapSet.new()}}
  end

  @impl true
  def handle_call({:command, cmd}, _from, state) do
    case Pipeline.run(state, cmd) do
      {:ok, ctx} ->
        # 부수효과는 여기서 분리해서 처리하거나, after-reply로 넘긴다
        new_state = ctx.state
        {:reply, ctx.reply, new_state}

      {:error, reason} ->
        {:reply, {:error, reason}, state}
    end
  end
end

이렇게 하면 GenServer 내부 복잡도가 크게 줄어듭니다. 상태 전이 규칙은 Pipeline 에만 있고, 서버는 그 결과를 저장하고 답만 돌려줍니다.

부수효과를 파이프라인 밖으로 빼는 방법

상태 전이 중 외부 호출을 섞으면, 실패 시 상태를 롤백할지 말지 애매해지고 재시도도 어려워집니다. 실무에서는 상태 전이와 부수효과를 분리하는 편이 안정적입니다.

가장 단순한 방식은 events 를 모아두고, handle_call 에서 처리하는 것입니다.

def handle_call({:command, cmd}, _from, state) do
  case Pipeline.run(state, cmd) do
    {:ok, ctx} ->
      Enum.each(Enum.reverse(ctx.events), fn event ->
        Wallet.SideEffects.dispatch(event)
      end)

      {:reply, ctx.reply, ctx.state}

    {:error, reason} ->
      {:reply, {:error, reason}, state}
  end
end

다만 GenServer.call 흐름 안에서 부수효과를 수행하면 호출자가 느려지고, 외부 장애가 서버 처리량을 떨어뜨릴 수 있습니다. 이때는 다음 전략을 고려합니다.

  • 이벤트 디스패치를 GenServer.cast 로 넘겨 비동기 처리
  • Task.Supervisor 로 분리해 서버 메일박스가 막히지 않게 함
  • 외부 API 호출은 재시도와 백오프를 표준화

재시도 패턴은 외부 서비스에서 흔한 문제이므로, 백오프 설계는 별도로 정리한 글인 OpenAI 429/RateLimitError 재시도·백오프 패턴 도 같이 참고하면 좋습니다.

파이프라인에서 일관된 오류 전파 만들기

Elixir에서는 with 문도 좋은 선택지지만, 파이프라인으로 단계가 길어질수록 {:ok, ...} 래핑과 언래핑이 번거로워질 수 있습니다. 이때는 작은 헬퍼를 둬서 가독성을 올릴 수 있습니다.

defmodule Result do
  def then({:ok, v}, fun) when is_function(fun, 1), do: fun.(v)
  def then({:error, _} = err, _fun), do: err

  def map({:ok, v}, fun) when is_function(fun, 1), do: {:ok, fun.(v)}
  def map({:error, _} = err, _fun), do: err
end

이제 파이프라인을 Result.then 중심으로 구성할 수도 있습니다.

alias Result

{:ok, %Wallet.Context{state: state, cmd: cmd, events: []}}
|> Result.then(&Wallet.PipelineSteps.validate_amount/1)
|> Result.then(&Wallet.PipelineSteps.load_user/1)
|> Result.then(&Wallet.PipelineSteps.apply_deposit/1)
|> Result.then(&Wallet.PipelineSteps.build_reply/1)

이 패턴은 C++에서 std::expected 로 오류를 값으로 다루는 방식과 철학적으로 비슷합니다. 예외 대신 값으로 실패를 전달하고, 호출자가 조합 가능하게 만드는 접근인데, 관련 개념은 C++23 std - -expected로 예외 없이 오류전파+자원관리 도 참고할 만합니다.

상태관리에서 중요한 멱등성과 중복 처리

OTP 프로세스는 안정적이지만, 클라이언트 재시도나 네트워크 문제로 같은 요청이 여러 번 들어올 수 있습니다. 특히 결제나 포인트 같은 도메인은 멱등성이 필수입니다.

서버 상태에 processed 집합을 두고 idempotency_key 를 기록하는 방식으로 시작할 수 있습니다.

defp ensure_idempotent({:ok, %Context{state: state, cmd: %Command{idempotency_key: nil}} = ctx}) do
  {:ok, ctx}
end

defp ensure_idempotent({:ok, %Context{state: state, cmd: %Command{idempotency_key: key}} = ctx}) do
  if MapSet.member?(state.processed, key) do
    {:error, :duplicate_command}
  else
    new_state = %{state | processed: MapSet.put(state.processed, key)}
    {:ok, %Context{ctx | state: new_state}}
  end
end

defp ensure_idempotent({:error, _} = err), do: err

그리고 run 파이프라인 초반에 붙입니다.

%Context{state: state, cmd: cmd, events: []}
|> ok()
|> ensure_idempotent()
|> validate_amount()
|> load_user()
|> apply_deposit()
|> build_reply()

이렇게 하면 중복 요청이 들어와도 상태 전이가 한 번만 일어나도록 보장할 수 있습니다.

테스트 전략: GenServer 없이 도메인 전이부터 검증

파이프라인의 가장 큰 효과는 테스트가 쉬워진다는 점입니다. GenServer 를 띄우지 않고도 상태 전이를 검증할 수 있습니다.

defmodule Wallet.PipelineTest do
  use ExUnit.Case
  alias Wallet.{Command, Pipeline}

  test "deposit increases balance and emits event" do
    state = %{users: %{1 => %{id: 1, balance: 100}}, processed: MapSet.new()}
    cmd = %Command{type: :deposit, user_id: 1, amount: 50, idempotency_key: "k1"}

    assert {:ok, ctx} = Pipeline.run(state, cmd)
    assert ctx.reply == {:ok, 150}
    assert get_in(ctx.state, [:users, 1, :balance]) == 150
    assert Enum.member?(ctx.events, {:deposited, 1, 50})
  end
end

이 테스트는 빠르고, 실패 원인이 명확하며, 동시성이나 타이밍 이슈가 없습니다.

메일박스 압력과 파이프라인의 관계

상태 전이를 파이프라인으로 정리해도, 결국 GenServer 는 단일 프로세스이므로 메일박스가 길어지면 지연이 누적됩니다. 특히 부수효과를 동기적으로 처리하거나, 한 요청이 오래 걸리면 다음 요청이 대기합니다.

이 문제는 웹 프론트 성능에서 긴 작업을 쪼개는 원리와도 유사합니다. 긴 작업을 분해해 응답성을 높이는 사고방식은 Chrome INP 나쁨 - Long Task 쪼개기 실전 가이드 를 보면 감이 잘 옵니다. OTP에서는 이를 다음처럼 적용합니다.

  • handle_call 에서는 상태 전이만 빠르게 끝낸다
  • 외부 I/O는 별도 프로세스나 작업 큐로 넘긴다
  • 이벤트 기반으로 후속 처리를 분리한다

실무 적용 체크리스트

파이프라인이 특히 잘 맞는 경우

  • 명령 처리 흐름이 검증 -> 조회 -> 전이 -> 응답 처럼 단계적일 때
  • 실패가 정상 흐름의 일부이며, 명시적으로 다루고 싶을 때
  • 부수효과를 이벤트로 분리하고 싶을 때

주의할 점

  • 파이프라인 단계가 너무 세분화되면 오히려 추적이 어려워질 수 있음
  • Context 가 비대해지면, 필요한 필드만 유지하도록 정리해야 함
  • 부수효과를 완전히 분리하려면 이벤트 처리의 재시도, 순서, 중복 처리 전략이 필요함

마무리

Elixir OTP의 GenServer 는 상태를 안전하게 보관하고 직렬화된 방식으로 갱신하는 데 탁월하지만, 그 안에 모든 로직을 넣는 순간 유지보수성이 급격히 떨어집니다. 상태 전이를 함수형 파이프라인으로 분리하면 다음을 얻을 수 있습니다.

  • 전이 규칙이 단계별로 드러나서 읽기 쉬움
  • 오류 전파가 일관되고 테스트가 쉬움
  • 부수효과를 이벤트로 격리해 장애 전파를 줄일 수 있음

실무에서는 이 패턴을 기반으로 CommandEvent 를 명확히 하고, 부수효과 처리부에 재시도와 관측성을 붙이면 OTP의 장점을 더 크게 살릴 수 있습니다.