- Published on
Elixir GenServer를 순수함수로 리팩터링 5패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 프로세스는 Elixir의 강력한 무기지만, GenServer 하나에 상태 업데이트, 검증, 외부 I/O, 재시도, 타이머까지 몰아넣는 순간 유지보수성이 급격히 떨어집니다. 특히 handle_call/3, handle_cast/2, handle_info/2 안에서 분기와 부작용이 뒤엉키면 다음 문제가 반복됩니다.
- 테스트가 느리고 불안정해짐(프로세스 띄우기, 메시지 대기, 타이밍 의존)
- 장애 시 원인 파악이 어려움(상태 전이 규칙이 코드 곳곳에 흩어짐)
- 재시도·중복 호출·순서 꼬임 같은 동시성 버그가 숨어듦
해결책은 간단합니다. GenServer는 “메시지 라우팅과 상태 보관”만 담당하고, 도메인 로직은 순수함수로 상태 전이를 표현합니다. 이 글은 그 방향으로 가는 실전 리팩터링 5패턴을 정리합니다.
참고로 “중복 호출을 막는 설계”는 Elixir뿐 아니라 웹/클라우드에서도 동일한 주제입니다. 서버 액션 중복 호출을 막는 관점은 Next.js App Router 서버액션 중복호출 막는 6가지도 함께 보면 좋습니다.
리팩터링 목표: GenServer를 얇게, 상태 전이를 두껍게
리팩터링의 핵심은 아래 3가지를 분리하는 것입니다.
- State: 프로세스가 들고 있는 상태 구조체
- Decisions: 입력(명령/이벤트)에 따른 상태 전이 규칙(순수함수)
- Effects: 외부 I/O, 타이머, PubSub, DB 같은 부작용(명시적으로)
이제부터 5가지 패턴으로 이를 구현합니다.
패턴 1) State를 구조체로 고정하고, 전이는 reduce/2로 모으기
가장 먼저 할 일은 맵 기반 상태를 구조체로 고정하고, handle_*에 흩어진 상태 변경을 단일 순수함수로 모으는 것입니다.
Before: 핸들러에 로직이 분산
def handle_cast({:add, item}, state) do
items = [item | state.items]
{:noreply, %{state | items: items, count: state.count + 1}}
end
def handle_cast({:remove, id}, state) do
items = Enum.reject(state.items, &(&1.id == id))
{:noreply, %{state | items: items, count: max(state.count - 1, 0)}}
end
After: 상태 전이를 한 곳에
defmodule Cart.State do
defstruct items: [], count: 0
@type t :: %__MODULE__{items: list(), count: non_neg_integer()}
end
defmodule Cart.Domain do
alias Cart.State
@spec reduce(State.t(), term()) :: State.t()
def reduce(%State{} = state, {:add, item}) do
%State{state | items: [item | state.items], count: state.count + 1}
end
def reduce(%State{} = state, {:remove, id}) do
items = Enum.reject(state.items, &(&1.id == id))
count = if state.count > 0, do: state.count - 1, else: 0
%State{state | items: items, count: count}
end
def reduce(%State{} = state, _unknown), do: state
end
defmodule Cart.Server do
use GenServer
alias Cart.{State, Domain}
def init(_), do: {:ok, %State{}}
def handle_cast(msg, state) do
{:noreply, Domain.reduce(state, msg)}
end
end
얻는 효과
- 상태 전이 규칙이
Cart.Domain.reduce/2에 집중되어 읽기 쉬움 - GenServer 없이
reduce/2만 단위 테스트 가능 - 나중에 동일 로직을 LiveView, Job, CLI 등 다른 진입점에서도 재사용 가능
패턴 2) “명령(Command)”과 “이벤트(Event)”를 분리하고, 도메인은 이벤트만 만든다
실무에서 GenServer는 대개 “요청을 처리한다”는 의미로 명령을 받습니다. 하지만 도메인 관점에서 중요한 것은 무슨 일이 일어났는지(이벤트) 입니다.
이 패턴은 도메인이 {:ok, new_state, events} 형태로 결과를 내고, GenServer가 이벤트를 후처리(로그, PubSub, 외부 호출)하는 구조입니다.
defmodule Inventory.State do
defstruct stock: %{}
@type t :: %__MODULE__{stock: map()}
end
defmodule Inventory.Domain do
alias Inventory.State
@type event :: {:reserved, sku :: binary(), qty :: pos_integer()} | {:rejected, binary(), pos_integer(), reason :: atom()}
@spec handle(State.t(), {:reserve, binary(), pos_integer()}) :: {State.t(), [event()]}
def handle(%State{} = state, {:reserve, sku, qty}) do
current = Map.get(state.stock, sku, 0)
if current >= qty do
new_state = %State{state | stock: Map.put(state.stock, sku, current - qty)}
{new_state, [{:reserved, sku, qty}]}
else
{state, [{:rejected, sku, qty, :insufficient_stock}]}
end
end
end
defmodule Inventory.Server do
use GenServer
alias Inventory.{State, Domain}
def init(_), do: {:ok, %State{}}
def handle_call({:reserve, sku, qty}, _from, state) do
{new_state, events} = Domain.handle(state, {:reserve, sku, qty})
Enum.each(events, &emit/1)
reply =
case events do
[{:reserved, ^sku, ^qty}] -> :ok
[{:rejected, ^sku, ^qty, reason}] -> {:error, reason}
_ -> :ok
end
{:reply, reply, new_state}
end
defp emit({:reserved, sku, qty}) do
Logger.info("reserved sku=#{sku} qty=#{qty}")
end
defp emit({:rejected, sku, qty, reason}) do
Logger.warning("rejected sku=#{sku} qty=#{qty} reason=#{reason}")
end
end
얻는 효과
- 도메인 로직은 외부 세계를 모름(순수함수 유지)
- 이벤트는 감사 로그, 메트릭, 알림을 붙이기 좋은 경계
- “무조건 성공 응답” 같은 암묵적 처리 대신 이벤트 기반으로 명시화
패턴 3) 부작용을 Effect로 모델링하고, GenServer는 실행기(Interpreter)가 된다
패턴 2가 이벤트 중심이라면, 패턴 3은 부작용 자체를 데이터로 표현합니다. 도메인은 “무엇을 해야 하는지”를 effects로 반환하고, GenServer는 이를 실행합니다.
이 방식은 외부 API 호출, DB 저장, 재시도 정책 등을 테스트 더블로 교체하기 좋습니다. 특히 “중복 호출”이나 “재시도”가 섞이면 설계가 흐려지는데, 효과를 데이터로 만들면 통제 지점이 생깁니다. 재시도·멱등성 관점은 OpenAI 429·5xx 재시도, Idempotency 키로 중복 결제 막기도 같은 결의 글입니다.
defmodule Billing.Effect do
@type t ::
{:http_post, url :: binary(), body :: map(), headers :: list()} |
{:persist, key :: term(), value :: term()} |
{:schedule, millis :: non_neg_integer(), msg :: term()}
end
defmodule Billing.Domain do
alias Billing.Effect
defstruct paid: MapSet.new()
@type state :: %__MODULE__{paid: MapSet.t()}
@spec pay(state(), order_id :: binary(), amount :: pos_integer()) :: {state(), [Effect.t()]}
def pay(%__MODULE__{} = state, order_id, amount) do
if MapSet.member?(state.paid, order_id) do
{state, []}
else
new_state = %__MODULE__{state | paid: MapSet.put(state.paid, order_id)}
effects = [
{:http_post, "https://api.example.com/pay", %{order_id: order_id, amount: amount}, []},
{:persist, {:paid, order_id}, %{amount: amount}},
{:schedule, 5_000, {:reconcile, order_id}}
]
{new_state, effects}
end
end
end
defmodule Billing.Server do
use GenServer
alias Billing.{Domain, Effect}
def init(_), do: {:ok, %Domain{}}
def handle_call({:pay, order_id, amount}, _from, state) do
{new_state, effects} = Domain.pay(state, order_id, amount)
Enum.each(effects, &run_effect/1)
{:reply, :ok, new_state}
end
def handle_info({:reconcile, order_id}, state) do
# reconcile 로직도 동일하게 Domain으로 밀어넣는 것이 이상적
{:noreply, state}
end
defp run_effect({:http_post, url, body, headers}) do
# 실제 구현에서는 Finch, Req 등을 사용
_ = {url, body, headers}
:ok
end
defp run_effect({:persist, key, value}) do
:persistent_term.put(key, value)
:ok
end
defp run_effect({:schedule, millis, msg}) do
Process.send_after(self(), msg, millis)
:ok
end
end
얻는 효과
- 도메인 테스트에서
effects만 검증하면 됨(외부 네트워크 없이) - 실행기만 바꿔서 로컬/테스트/프로덕션 전략을 분리 가능
- 타이머, 저장, HTTP 같은 부작용이 “어디서 발생하는지”가 한눈에 보임
패턴 4) call은 조회(Query), cast는 명령(Command)으로 정리하고, 조회는 순수함수로 끝낸다
GenServer는 쉽게 “읽기/쓰기”가 섞입니다. 하지만 읽기까지 내부에서 복잡한 계산을 하거나, 조회 중에 상태를 바꾸는 순간 디버깅 난이도가 올라갑니다.
handle_call: 조회 또는 “결과가 필요한 명령”handle_cast: 비동기 명령- 조회 로직은
view(state)같은 순수함수로 분리
defmodule RateLimiter.State do
defstruct hits: %{}
end
defmodule RateLimiter.Domain do
alias RateLimiter.State
def hit(%State{} = state, key, now_ms) do
hits = Map.update(state.hits, key, [now_ms], &[now_ms | &1])
%State{state | hits: hits}
end
def remaining(%State{} = state, key, window_ms, limit, now_ms) do
timestamps = Map.get(state.hits, key, [])
alive = Enum.filter(timestamps, fn t -> now_ms - t <= window_ms end)
max(limit - length(alive), 0)
end
end
defmodule RateLimiter.Server do
use GenServer
alias RateLimiter.{State, Domain}
def init(_), do: {:ok, %State{}}
def handle_cast({:hit, key, now_ms}, state) do
{:noreply, Domain.hit(state, key, now_ms)}
end
def handle_call({:remaining, key, window_ms, limit, now_ms}, _from, state) do
{:reply, Domain.remaining(state, key, window_ms, limit, now_ms), state}
end
end
얻는 효과
- 조회는 상태를 바꾸지 않으니 테스트가 단순해짐
call에서 타임아웃이 나도 상태가 중간에 변하지 않아 안전- “읽기 때문에 락이 걸린다” 같은 오해를 줄이고, 병목을 찾기 쉬움
패턴 5) 타이머와 재시도를 “상태 전이 + 스케줄 효과”로 만들기
Process.send_after/3를 여기저기 박아두면, 어떤 메시지가 왜 오는지 추적이 어려워집니다. 타이머는 사실상 “미래에 도착하는 이벤트”이므로, 이를 도메인에서 명시적으로 다루면 깔끔해집니다.
아래 예시는 “주기적으로 플러시”하는 버퍼를 생각해볼 수 있습니다.
defmodule Buffer.Effect do
@type t :: {:schedule, non_neg_integer(), term()} | {:flush, list()}
end
defmodule Buffer.State do
defstruct items: [], scheduled?: false
end
defmodule Buffer.Domain do
alias Buffer.{State, Effect}
def add(%State{} = state, item) do
state = %State{state | items: [item | state.items]}
if state.scheduled? do
{state, []}
else
{%State{state | scheduled?: true}, [{:schedule, 1_000, :tick}]}
end
end
def tick(%State{} = state) do
items = Enum.reverse(state.items)
new_state = %State{state | items: [], scheduled?: false}
{new_state, [{:flush, items}]}
end
end
defmodule Buffer.Server do
use GenServer
alias Buffer.{State, Domain}
def init(_), do: {:ok, %State{}}
def handle_cast({:add, item}, state) do
{new_state, effects} = Domain.add(state, item)
run(effects)
{:noreply, new_state}
end
def handle_info(:tick, state) do
{new_state, effects} = Domain.tick(state)
run(effects)
{:noreply, new_state}
end
defp run(effects) do
Enum.each(effects, fn
{:schedule, ms, msg} -> Process.send_after(self(), msg, ms)
{:flush, items} -> Logger.info("flush size=#{length(items)}")
end)
end
end
얻는 효과
- 타이머가 “도메인 규칙”에 포함되어 예측 가능
scheduled?같은 플래그로 중복 스케줄을 방지- 장애 시 타이머 폭주 여부를 상태로 설명 가능
운영 환경에서 타이밍 이슈는 종종 프로세스 재시작과 맞물려 증폭됩니다. 이런 류의 문제를 디버깅하는 관점은 Kubernetes 사례지만 본질이 유사하니 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅도 참고할 만합니다.
테스트 전략: GenServer 테스트를 줄이고, 도메인 테스트를 늘리기
순수함수로 분리하면 ExUnit 테스트가 매우 직관적으로 바뀝니다.
defmodule BufferDomainTest do
use ExUnit.Case
alias Buffer.{State, Domain}
test "first add schedules tick" do
state = %State{}
{state2, effects} = Domain.add(state, :a)
assert state2.scheduled? == true
assert effects == [{:schedule, 1_000, :tick}]
end
test "tick flushes and resets" do
state = %State{items: [:b, :a], scheduled?: true}
{state2, effects} = Domain.tick(state)
assert state2.items == []
assert state2.scheduled? == false
assert effects == [{:flush, [:a, :b]}]
end
end
GenServer 자체는 “효과 실행이 호출되는지”, “메시지 라우팅이 올바른지” 정도만 얇게 통합 테스트로 남기는 편이 좋습니다.
마이그레이션 체크리스트
레거시 GenServer를 한 번에 갈아엎기 어렵다면 아래 순서가 안전합니다.
- 상태를 구조체로 고정하고 타입 스펙을 작성
handle_*에서 상태 변경 코드를Domain.reduce/2로 이동- 외부 호출을 발견할 때마다
events또는effects로 끌어올리기 - 타이머는
send_after직접 호출을 줄이고,{:schedule, ...}로 통일 - 도메인 테스트를 먼저 촘촘히 만들고, GenServer 테스트는 최소화
결론: GenServer는 동시성 컨테이너, 로직은 순수함수로
GenServer를 순수함수 중심으로 리팩터링하면 “프로세스 기반 동시성”의 장점은 유지하면서도, 코드의 대부분을 예측 가능한 함수로 바꿀 수 있습니다. 이 글의 5패턴을 요약하면 다음과 같습니다.
- 상태 전이를
reduce/2로 모아 응집도를 올린다 - 명령과 이벤트를 분리해 도메인 의도를 드러낸다
- 부작용을
Effect로 데이터화해 테스트와 실행을 분리한다 - 조회는 순수함수로 끝내고,
call과cast의 역할을 정리한다 - 타이머/재시도는 상태 전이 규칙으로 끌어올려 중복과 폭주를 막는다
이 구조에 익숙해지면 GenServer는 더 이상 “거대한 클래스”가 아니라, 도메인 모델을 안전하게 실행하는 런타임 껍질로 자리 잡습니다.