- Published on
Elixir OTP에서 파이프라인으로 상태관리하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드에서 Elixir를 선택하는 이유 중 하나는 OTP가 제공하는 강력한 동시성 모델과 장애 격리입니다. 하지만 실무에서 GenServer 기반 상태관리는 금방 복잡해집니다. handle_call 과 handle_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를 띄우는 통합 테스트 중심으로 기울어짐
해결 방향은 명확합니다.
- 상태 전이 로직을 순수 함수로 분리한다
- 파이프라인으로 전이 단계를 명시적으로 나열한다
- 부수효과는 마지막에 모아 실행하거나, 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 는 상태를 안전하게 보관하고 직렬화된 방식으로 갱신하는 데 탁월하지만, 그 안에 모든 로직을 넣는 순간 유지보수성이 급격히 떨어집니다. 상태 전이를 함수형 파이프라인으로 분리하면 다음을 얻을 수 있습니다.
- 전이 규칙이 단계별로 드러나서 읽기 쉬움
- 오류 전파가 일관되고 테스트가 쉬움
- 부수효과를 이벤트로 격리해 장애 전파를 줄일 수 있음
실무에서는 이 패턴을 기반으로 Command 와 Event 를 명확히 하고, 부수효과 처리부에 재시도와 관측성을 붙이면 OTP의 장점을 더 크게 살릴 수 있습니다.