- Published on
Elixir GenServer 상태폭발? 불변+순수함수로 리팩터
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
GenServer를 처음 도입할 때는 “상태를 들고 메시지를 처리하는 프로세스”라는 단순한 모델이 매력적입니다. 그런데 기능이 늘면서 state가 커지고, handle_call/3, handle_cast/2, handle_info/2가 서로의 부작용을 전제로 움직이기 시작하면, 어느 순간부터는 작은 변경도 무서워집니다. 흔히 말하는 상태폭발(state explosion) 입니다.
이 글에서는 GenServer를 “상태를 가진 순수한 상태 머신”으로 되돌리는 방식, 즉 불변 데이터 + 순수함수 중심으로 리팩터링하는 방법을 정리합니다. 핵심은 GenServer를 똑똑하게 만들지 말고, 도메인 로직을 순수 모듈로 빼서 GenServer는 “입출력과 직렬화(serialize)된 실행”만 담당하게 만드는 것입니다.
GenServer 상태폭발의 전형적인 징후
다음 중 2개 이상이면 상태폭발이 시작된 신호로 보는 게 좋습니다.
state가 맵 한 덩어리로 커지고 “이 키는 언제 생기지?” 같은 암묵적 규약이 늘어남- 콜백마다
state의 일부만 갱신하며, 다른 콜백이 그 갱신을 전제로 동작함 - 타이머(
Process.send_after/3)나 외부 I/O(HTTP, DB, 캐시)가 콜백 내부에 섞여 테스트가 어려움 - 장애 시나리오(네트워크 실패, 타임아웃, 재시도) 처리 코드가 여기저기 흩어짐
- 새로운 기능 추가가 “상태에 필드 추가 + 콜백 수정 N곳” 패턴으로 반복됨
이때의 문제는 단순히 코드가 길어지는 것이 아니라, 상태 전이 규칙이 코드 곳곳에 분산되면서 “이 입력이 들어오면 상태가 어떻게 변해야 하는가”를 한 눈에 볼 수 없게 된다는 점입니다.
목표: GenServer를 순수 상태 머신으로 만들기
리팩터의 목표는 아래처럼 역할을 분리하는 것입니다.
- 도메인 모듈(순수): 입력(이벤트) + 현재 상태를 받아서, 다음 상태와 “해야 할 일(효과)”을 반환
- GenServer(효과 실행기): 도메인 모듈이 반환한 효과를 실행(외부 호출, 타이머 설정 등)하고, 결과를 다시 이벤트로 되돌려 상태 머신에 투입
이렇게 하면 GenServer의 콜백은 얇아지고, 복잡도는 “순수 함수들의 조합”으로 이동합니다. 순수 함수는 테스트가 쉽고, 상태 전이도 명시적으로 관리됩니다.
안티패턴 예시: 콜백 안에서 상태·I/O·타이머가 뒤섞임
아래는 흔히 볼 수 있는 형태입니다. 주문 처리 프로세스가 점점 커지며 상태와 부작용이 섞이는 구조입니다.
defmodule MyApp.OrderServer do
use GenServer
def start_link(order_id), do: GenServer.start_link(__MODULE__, order_id, name: via(order_id))
def init(order_id) do
state = %{order_id: order_id, items: [], status: :new, retry: 0}
{:ok, state}
end
def add_item(order_id, item), do: GenServer.call(via(order_id), {:add_item, item})
def checkout(order_id), do: GenServer.call(via(order_id), :checkout)
def handle_call({:add_item, item}, _from, state) do
# 상태 변경 규칙이 이 콜백에 박혀 있음
state = update_in(state.items, &[item | &1])
{:reply, :ok, state}
end
def handle_call(:checkout, _from, state) do
# 외부 I/O가 콜백에 섞임
case MyApp.Payments.charge(state.order_id, Enum.reverse(state.items)) do
{:ok, receipt} ->
state = state |> Map.put(:status, :paid) |> Map.put(:receipt, receipt)
{:reply, {:ok, receipt}, state}
{:error, reason} ->
# 재시도/타이머 로직까지 섞임
Process.send_after(self(), {:retry_charge, reason}, 1_000)
state = state |> Map.put(:status, :payment_failed) |> Map.update!(:retry, &(&1 + 1))
{:reply, {:error, reason}, state}
end
end
def handle_info({:retry_charge, _reason}, state) do
# 또 다른 상태 변경 규칙
if state.retry < 3 do
case MyApp.Payments.charge(state.order_id, Enum.reverse(state.items)) do
{:ok, receipt} ->
{:noreply, %{state | status: :paid, receipt: receipt}}
{:error, reason} ->
Process.send_after(self(), {:retry_charge, reason}, 2_000)
{:noreply, %{state | retry: state.retry + 1}}
end
else
{:noreply, %{state | status: :give_up}}
end
end
defp via(order_id), do: {:via, Registry, {MyApp.OrderRegistry, order_id}}
end
문제는 다음과 같습니다.
- “결제 실패 시 재시도”라는 정책이
handle_call/3와handle_info/2에 분산됨 - 결제 I/O가 콜백에 직접 들어가 테스트가 어려움
state의 필드가 늘면 모든 콜백이 영향을 받기 쉬움
리팩터 1단계: 상태를 구조체로 고정하고 불변 규약을 만든다
먼저 state를 구조체로 고정하면, 상태의 형태가 명시되고 필드 확장이 통제됩니다.
defmodule MyApp.OrderState do
@enforce_keys [:order_id]
defstruct order_id: nil,
items: [],
status: :new,
receipt: nil,
retry: 0
end
이 단계만으로도 “어떤 상태가 가능한가”가 드러나고, 키 오타나 암묵적 필드 추가가 줄어듭니다.
리팩터 2단계: 순수 상태 전이 함수로 분리한다
이제 핵심: 상태 전이를 순수 함수로 옮깁니다.
여기서 중요한 패턴은 “상태 전이 결과 + 효과(effect)”를 함께 반환하는 것입니다.
- 상태 전이: 다음
state - 효과: 외부 호출/타이머/로그 같은 부작용을 “나중에 실행할 작업 목록”으로 표현
defmodule MyApp.OrderDomain do
alias MyApp.OrderState
@type effect ::
{:charge, order_id :: term(), items :: list()} |
{:schedule_retry, millis :: non_neg_integer()} |
{:none}
@spec add_item(OrderState.t(), term()) :: {OrderState.t(), [effect()]}
def add_item(%OrderState{status: :new} = state, item) do
{%{state | items: [item | state.items]}, [{:none}]}
end
def add_item(state, _item) do
# 이미 결제 진행/완료면 아이템 추가 금지 같은 규칙을 명시
{state, [{:none}]}
end
@spec checkout(OrderState.t()) :: {OrderState.t(), [effect()]}
def checkout(%OrderState{status: :new} = state) do
items = Enum.reverse(state.items)
{%{state | status: :charging}, [{:charge, state.order_id, items}]}
end
def checkout(state), do: {state, [{:none}]}
@spec charge_result(OrderState.t(), {:ok, term()} | {:error, term()}) :: {OrderState.t(), [effect()]}
def charge_result(%OrderState{} = state, {:ok, receipt}) do
{%{state | status: :paid, receipt: receipt}, [{:none}]}
end
def charge_result(%OrderState{retry: retry} = state, {:error, _reason}) when retry < 3 do
next = %{state | status: :payment_failed, retry: retry + 1}
{next, [{:schedule_retry, backoff_ms(retry)}]}
end
def charge_result(%OrderState{} = state, {:error, _reason}) do
{%{state | status: :give_up}, [{:none}]}
end
defp backoff_ms(retry), do: 1_000 * (retry + 1)
end
이제 “결제 실패 시 재시도” 정책이 한 모듈에 모였고, 외부 I/O는 효과로만 표현됩니다. 이 모듈은 완전 순수 함수이므로 단위 테스트가 쉽습니다.
리팩터 3단계: GenServer는 효과를 실행하고 결과를 이벤트로 되돌린다
GenServer는 도메인 모듈이 반환한 효과를 실행하는 얇은 레이어가 됩니다.
defmodule MyApp.OrderServer do
use GenServer
alias MyApp.OrderState
alias MyApp.OrderDomain
def start_link(order_id), do: GenServer.start_link(__MODULE__, order_id, name: via(order_id))
def add_item(order_id, item), do: GenServer.call(via(order_id), {:add_item, item})
def checkout(order_id), do: GenServer.call(via(order_id), :checkout)
@impl true
def init(order_id) do
{:ok, %OrderState{order_id: order_id}}
end
@impl true
def handle_call({:add_item, item}, _from, state) do
{state, effects} = OrderDomain.add_item(state, item)
state = run_effects(effects, state)
{:reply, :ok, state}
end
def handle_call(:checkout, _from, state) do
{state, effects} = OrderDomain.checkout(state)
state = run_effects(effects, state)
{:reply, :ok, state}
end
@impl true
def handle_info(:retry_charge, state) do
# 재시도는 “다시 charge 효과를 실행”으로 단순화
items = Enum.reverse(state.items)
state = run_effects([{:charge, state.order_id, items}], state)
{:noreply, state}
end
def handle_info({:charge_result, result}, state) do
{state, effects} = OrderDomain.charge_result(state, result)
state = run_effects(effects, state)
{:noreply, state}
end
defp run_effects(effects, state) do
Enum.reduce(effects, state, fn
{:none}, acc ->
acc
{:schedule_retry, ms}, acc ->
Process.send_after(self(), :retry_charge, ms)
acc
{:charge, order_id, items}, acc ->
# 외부 호출은 Task로 분리하고 결과를 메시지로 되돌림
Task.start(fn ->
result = MyApp.Payments.charge(order_id, items)
send(self(), {:charge_result, result})
end)
acc
end)
end
defp via(order_id), do: {:via, Registry, {MyApp.OrderRegistry, order_id}}
end
이 구조의 장점은 명확합니다.
- GenServer 콜백은 “도메인 호출 → 효과 실행” 패턴으로 균질해짐
- 상태 전이 규칙은
OrderDomain에만 존재 - 외부 I/O 실패/지연은 메시지 기반으로 격리되며, 서버 프로세스는 블로킹을 피함
중요한 디테일: 효과를 “리스트”로 두는 이유
효과를 단일 값이 아니라 리스트로 두면 다음이 쉬워집니다.
- 한 이벤트가 여러 부작용을 유발(예: 결제 요청 + 감사 로그 + 메트릭)
- 효과 실행 순서가 필요할 때 제어 가능
- 테스트에서 “어떤 효과가 발생해야 하는지”를 단언하기 쉬움
예를 들어 순수 테스트는 아래처럼 됩니다.
defmodule MyApp.OrderDomainTest do
use ExUnit.Case
alias MyApp.OrderDomain
alias MyApp.OrderState
test "checkout emits charge effect" do
state = %OrderState{order_id: 1, items: [:a, :b], status: :new}
{next, effects} = OrderDomain.checkout(state)
assert next.status == :charging
assert effects == [{:charge, 1, [:b, :a]}]
end
test "charge error schedules retry up to 3" do
state = %OrderState{order_id: 1, retry: 0}
{next, effects} = OrderDomain.charge_result(state, {:error, :timeout})
assert next.retry == 1
assert {:schedule_retry, _ms} = hd(effects)
end
end
GenServer 테스트보다 훨씬 빠르고 안정적입니다.
상태폭발을 더 줄이는 설계 팁 5가지
1) 상태는 “정규화”하고 파생값은 저장하지 않는다
items_count, total_price 같은 값은 상태에 넣기 시작하면 갱신 누락이 발생합니다. 가능하면 필요할 때 계산하거나, 캐시가 필요하면 “파생값과 원본을 함께 갱신하는 순수 함수”를 반드시 통로로 만들세요.
2) 이벤트를 명시적으로 모델링한다
{:charge_result, result}처럼 메시지 형태가 늘어나면, 이벤트 타입을 하나의 모듈로 정리하는 것도 좋습니다.
defmodule MyApp.OrderEvent do
defstruct type: nil, payload: nil
end
다만 이 경우도 본문에 부등호가 노출되지 않도록, 제네릭 같은 표기는 쓰지 않는 편이 안전합니다.
3) 외부 I/O는 “요청”과 “결과”를 분리한다
콜백에서 HTTP 호출을 직접 하고 결과를 즉시 처리하면, 서버가 블로킹되거나 타임아웃 정책이 섞입니다. 위 예시처럼 Task로 분리하고 결과를 메시지로 되돌리면, GenServer는 이벤트 루프를 유지합니다.
이 패턴은 트래픽이 몰리거나 호출 제한이 걸릴 때 특히 중요합니다. 외부 API가 429로 제한을 걸면 재시도/백오프 정책이 필요해지는데, 이 또한 도메인 쪽 정책으로 분리하는 게 유지보수에 유리합니다. 관련해서는 AWS Bedrock Claude InvokeModel 429·Throttling 해결 글의 백오프/스로틀링 관점을 함께 참고하면 좋습니다.
4) 동시성 병목은 “상태를 쪼개는 것”으로 해결한다
GenServer 하나가 모든 주문을 처리하면 직렬화 병목이 생깁니다. 주문 단위 프로세스(예: Registry 기반)로 쪼개면 자연스럽게 병렬성이 생기고, 상태폭발도 “문맥 단위”로 제한됩니다.
Kubernetes 환경에서 이런 프로세스/서비스 경계가 어긋나면 “Pod는 뜨는데 서비스가 트래픽을 못 받는” 류의 장애로 이어지기도 합니다. 인프라 관점의 트러블슈팅은 EKS에서 Pod는 뜨는데 Service Endpoints가 0일 때도 함께 보면 운영 시야를 넓히는 데 도움이 됩니다.
5) 영속화가 필요하면 “상태 스냅샷”과 “이벤트 로그”를 분리하라
결제/주문 같은 도메인은 재시작 시 상태 복구가 중요합니다. 이때 GenServer 상태를 그대로 DB에 덤프하기 시작하면 스키마가 빠르게 망가집니다.
- 스냅샷: 현재 상태를 주기적으로 저장
- 이벤트 로그: 중요한 이벤트(결제 성공/실패 등)를 append-only로 기록
DB 락/경합이 생기면 애플리케이션 레벨에서 “재시도만 늘리는” 식으로 악화될 수 있습니다. 트랜잭션 경합과 데드락 감각을 잡는 데는 MySQL InnoDB 데드락 1213 재현·원인·해결도 참고할 만합니다.
체크리스트: 리팩터 후 코드가 좋아졌는지 판단하는 기준
- 도메인 규칙 변경이
OrderDomain같은 순수 모듈 수정으로 대부분 끝나는가 - GenServer 콜백이 “이벤트 수신 및 효과 실행”으로 단순한가
- 단위 테스트가 GenServer 없이도 대부분 가능한가
- 재시도/백오프/타임아웃 정책이 한 곳에 모여 있는가
- 상태 구조가 구조체로 고정되어 있고, 필드 추가가 의식적인 변경인가
마무리
GenServer의 강점은 “상태를 가진 단일 프로세스가 메시지를 직렬로 처리한다”는 단순함입니다. 상태폭발은 그 단순함이 깨질 때 생깁니다. 해결책은 반대로, GenServer를 다시 단순하게 만드는 것입니다.
- 상태는 구조체로 고정
- 상태 전이는 순수 함수로 집중
- 부작용은 효과로 모델링하고 GenServer는 실행만 담당
이 패턴으로 리팩터링하면 기능이 늘어도 복잡도가 선형으로 증가하고, 테스트와 운영 안정성이 함께 좋아집니다.