- Published on
Elixir GenServer를 순수함수로 리팩터링하는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 프로세스가 상태를 들고 있고, handle_call/3·handle_cast/2에 로직이 뭉쳐 있는 GenServer는 처음엔 편합니다. 하지만 기능이 늘수록 다음 문제가 빠르게 쌓입니다.
- 상태 전이 규칙이 콜백 곳곳에 흩어져 “이 상태에서 이 이벤트가 오면 무엇이 되는지” 추적이 어려움
- I/O(디비, HTTP, 파일, PubSub)가 로직과 섞여 테스트가 무거워짐
- 동시성/재시도/타임아웃 같은 운영 이슈가 도메인 로직과 결합됨
이 글은 GenServer의 장점(동시성 캡슐화)은 유지하면서, 핵심 로직을 순수함수(pure function) 로 분리해 “테스트 가능한 코어 + 얇은 프로세스 셸(shell)”로 리팩터링하는 방법을 다룹니다.
참고로, 이런 리팩터링은 런타임 장애 진단과도 궁합이 좋습니다. 운영 관점의 문제 해결 습관을 함께 가져가면 디버깅 시간이 줄어듭니다. 예: Kubernetes CrashLoopBackOff 원인별 10분 진단
목표 아키텍처: Functional Core, Imperative Shell
핵심 아이디어는 간단합니다.
- Functional Core: 상태(
state)와 입력(event)을 받아 새 상태와 효과(effect) 를 계산하는 순수함수 - Imperative Shell: GenServer는 메시지 수신/타이머/모니터링 같은 프로세스 책임만 맡고, 코어가 계산한 effect를 실행
여기서 effect는 “무엇을 해야 하는지”를 데이터로 표현한 것입니다. 예: {:reply, term()}, {:persist, record}, {:broadcast, topic, payload}, {:schedule, ms, msg} 등.
이렇게 하면:
- 상태 전이 규칙을 한 파일(또는 몇 개 모듈)에서 선언적으로 확인 가능
- effect를 실행하는 부분을 목(mock)으로 대체해 빠른 단위 테스트 가능
- GenServer는 얇아져 장애/성능 튜닝 포인트가 명확해짐
예제: 카운터 GenServer를 코어/셸로 분리하기
먼저 흔한 형태의 GenServer를 보겠습니다.
defmodule CounterServer do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, %{value: 0}, opts)
end
def inc(pid, n \\ 1), do: GenServer.call(pid, {:inc, n})
def get(pid), do: GenServer.call(pid, :get)
@impl true
def init(state), do: {:ok, state}
@impl true
def handle_call({:inc, n}, _from, state) do
new_state = %{state | value: state.value + n}
{:reply, new_state.value, new_state}
end
def handle_call(:get, _from, state) do
{:reply, state.value, state}
end
end
이 예제는 단순하지만, 실제 서비스에서는 handle_call/3 내부에 검증, 권한 체크, DB 저장, 이벤트 발행, 타이머 등록이 섞이면서 복잡도가 급증합니다.
1단계: 상태 전이를 순수함수로 추출
코어 모듈을 만들고 “입력 이벤트에 따른 전이”를 함수로 정의합니다.
defmodule Counter.Core do
@type state :: %{value: integer()}
@type event :: :get | {:inc, integer()}
@type effect ::
{:reply, term()}
| {:noreply}
@spec init() :: state()
def init(), do: %{value: 0}
@spec apply(state(), event()) :: {state(), [effect()]}
def apply(state, :get) do
{state, [{:reply, state.value}]}
end
def apply(state, {:inc, n}) when is_integer(n) do
new_state = %{state | value: state.value + n}
{new_state, [{:reply, new_state.value}]}
end
end
여기서 중요한 점:
apply/2는 새 상태와 effect 목록만 반환합니다.- 외부 세계(로그, DB, PubSub)는 전혀 모릅니다.
2단계: GenServer는 코어를 호출하고 effect를 실행
defmodule CounterServer do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, :ok, opts)
end
def inc(pid, n \\ 1), do: GenServer.call(pid, {:inc, n})
def get(pid), do: GenServer.call(pid, :get)
@impl true
def init(:ok), do: {:ok, Counter.Core.init()}
@impl true
def handle_call(event, _from, state) do
{new_state, effects} = Counter.Core.apply(state, event)
run_effects(effects, new_state)
end
defp run_effects(effects, state) do
# 이 예제는 reply 하나만 있다고 가정
case effects do
[{:reply, reply}] -> {:reply, reply, state}
_ -> {:reply, :ok, state}
end
end
end
이제 “상태 전이 규칙”은 Counter.Core에만 존재합니다. GenServer는 메시지 전달과 effect 실행만 담당합니다.
실전형 패턴: effect를 데이터로 설계하기
실제 시스템에서는 effect가 다양합니다.
- DB 저장/조회
- 외부 API 호출
- PubSub 이벤트 발행
- 타이머 등록
- 다른 프로세스에 메시지 전송
- Telemetry/로그
이를 순수함수 코어에 넣지 않으려면, effect를 명세(데이터) 로 만들고 셸이 해석하도록 합니다.
예제: 주문 상태 머신의 effect 모델
defmodule Orders.Core do
@type state :: %{
order_id: String.t(),
status: :new | :paid | :shipped,
total: non_neg_integer()
}
@type event ::
{:pay, %{amount: non_neg_integer()}}
| {:ship, %{tracking: String.t()}}
@type effect ::
{:reply, term()}
| {:persist, map()}
| {:publish, String.t(), map()}
| {:schedule, non_neg_integer(), term()}
def apply(state, {:pay, %{amount: amount}}) when amount == state.total do
new_state = %{state | status: :paid}
effects = [
{:persist, %{order_id: state.order_id, status: :paid}},
{:publish, "orders.paid", %{order_id: state.order_id}},
{:reply, :ok}
]
{new_state, effects}
end
def apply(state, {:pay, _}) do
{state, [{:reply, {:error, :invalid_amount}}]}
end
def apply(%{status: :paid} = state, {:ship, %{tracking: t}}) do
new_state = %{state | status: :shipped}
effects = [
{:persist, %{order_id: state.order_id, status: :shipped, tracking: t}},
{:publish, "orders.shipped", %{order_id: state.order_id, tracking: t}},
{:schedule, 86_400_000, {:check_delivery, state.order_id}},
{:reply, :ok}
]
{new_state, effects}
end
def apply(state, {:ship, _}) do
{state, [{:reply, {:error, :not_paid}}]}
end
end
코어는 “DB에 저장하라”, “이벤트 발행하라”, “하루 뒤 메시지를 스케줄하라”를 데이터로만 말합니다.
셸(GenServer)에서 effect 해석기(interpreter) 만들기
defmodule Orders.Server do
use GenServer
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, opts)
def pay(pid, amount), do: GenServer.call(pid, {:pay, %{amount: amount}})
def ship(pid, tracking), do: GenServer.call(pid, {:ship, %{tracking: tracking}})
@impl true
def init(opts) do
state = %{
core: Keyword.fetch!(opts, :core_state),
deps: Keyword.fetch!(opts, :deps)
}
{:ok, state}
end
@impl true
def handle_call(event, _from, %{core: core_state} = state) do
{new_core, effects} = Orders.Core.apply(core_state, event)
state = %{state | core: new_core}
# reply를 포함한 effect들을 실행하고, 최종적으로 GenServer tuple을 반환
interpret_call_effects(effects, state)
end
defp interpret_call_effects(effects, state) do
{reply, state} = Enum.reduce(effects, {:ok, state}, fn eff, {reply_acc, st} ->
case eff do
{:reply, r} -> {r, st}
other -> {reply_acc, run_effect(other, st)}
end
end)
{:reply, reply, state}
end
defp run_effect({:persist, record}, %{deps: deps} = state) do
deps.repo.save(record)
state
end
defp run_effect({:publish, topic, payload}, %{deps: deps} = state) do
deps.pubsub.publish(topic, payload)
state
end
defp run_effect({:schedule, ms, msg}, state) do
Process.send_after(self(), msg, ms)
state
end
end
핵심은 deps 주입입니다. repo, pubsub 같은 의존성을 서버가 들고 있고, 코어는 의존성을 전혀 모릅니다.
이 구조는 “권한/의존성 문제를 단계적으로 추적”하는 습관과도 잘 맞습니다. 인프라/권한 문제를 다룰 때처럼, 의존성을 명시적으로 분리하면 원인 격리가 쉬워집니다. 예: Terraform apply 후 403? AWS IAM 권한추적 8단계
테스트 전략: 코어는 단위 테스트, 셸은 얇게 통합 테스트
코어 단위 테스트 예시
defmodule Orders.CoreTest do
use ExUnit.Case, async: true
test "pay succeeds and emits persist + publish" do
state = %{order_id: "o-1", status: :new, total: 100}
{new_state, effects} = Orders.Core.apply(state, {:pay, %{amount: 100}})
assert new_state.status == :paid
assert {:reply, :ok} in effects
assert {:persist, %{order_id: "o-1", status: :paid}} in effects
assert {:publish, "orders.paid", %{order_id: "o-1"}} in effects
end
test "ship fails when not paid" do
state = %{order_id: "o-1", status: :new, total: 100}
{new_state, effects} = Orders.Core.apply(state, {:ship, %{tracking: "t"}})
assert new_state == state
assert effects == [{:reply, {:error, :not_paid}}]
end
end
- 외부 시스템 없이도 빠르게 검증됩니다.
- “어떤 effect가 발생해야 하는지”가 명확한 계약(contract)이 됩니다.
셸 테스트는 의존성 목으로 최소화
deps.repo.save/1, deps.pubsub.publish/2 같은 호출은 목으로 대체하고 “호출이 발생했는지”만 확인하면 됩니다. Elixir에서는 Mox 같은 라이브러리를 자주 사용합니다.
자주 하는 실수와 해결책
1) effect가 너무 풍성해져서 또 다른 복잡도가 생김
effect 타입이 늘어나면 interpreter가 커질 수 있습니다. 이때는:
- effect를 도메인별로 모듈화:
Orders.Effects,Billing.Effects - interpreter를 계층화: 공통 effect(
:reply,:schedule)와 인프라 effect(:persist,:publish) 분리 - effect를 “데이터”로 유지: 함수 캡처를 effect로 넣는 방식(예:
{:run, fun})은 테스트는 쉬워도 추적성이 떨어질 수 있음
2) 코어에서 시간/랜덤/UUID를 직접 생성
DateTime.utc_now/0, :rand.uniform/0, Ecto.UUID.generate/0 같은 값은 순수하지 않습니다.
해결책:
- 이벤트에 값을 포함시키기:
{:pay, %{paid_at: paid_at}} - 또는 코어가
{:need, :uuid}같은 effect를 내고 셸이 채워서 다시 코어에 재진입시키는 2단계 패턴
실무에서는 “입력에 포함시키기”가 가장 단순합니다.
3) GenServer 상태에 코어 상태와 런타임 상태가 섞임
%{core: ..., deps: ..., timers: ...} 처럼 분리해 두면 좋습니다.
core: 순수 상태deps: 외부 의존성runtime: 모니터 ref, 타이머 ref, 캐시 등
이렇게 해야 코어 상태를 스냅샷/리플레이(이벤트 재생)하기가 쉬워집니다.
4) call/cast/info가 섞일 때의 일관성
call:{:reply, ...}effect를 기대cast: reply 없이 상태만 업데이트info: 타이머/모니터 메시지 처리
코어 API도 이를 반영해 apply_call/2, apply_cast/2, apply_info/2처럼 나누거나, apply/2는 동일하게 두되 effect에 {:reply, ...}가 있느냐로 구분할 수 있습니다.
리팩터링 체크리스트
다음 순서로 진행하면 안전합니다.
- 기존 GenServer에서 “상태 변경 로직”만 먼저 함수로 추출
- 추출한 함수를 별도 모듈(코어)로 이동하고, 입력과 출력 형태를 고정
- I/O를 effect로 치환하고, 기존 I/O는 셸로 이동
- 코어 단위 테스트를 먼저 촘촘히 작성
- 셸은 최소한의 통합 테스트만 유지
- 운영 로깅/Telemetry는 셸에 두되, 코어 이벤트/상태 전이 지점을 구조화해 남김
마무리: GenServer를 “상태 머신 실행기”로 만들기
GenServer는 동시성 모델을 제공하는 훌륭한 도구지만, 도메인 로직까지 떠안기 시작하면 복잡도가 폭발합니다. 순수함수 코어로 상태 전이를 고립시키면:
- 변경이 쉬워지고
- 테스트가 빨라지며
- 장애 상황에서 “무슨 일이 일어났는지”를 재현하기 쉬워집니다.
결국 GenServer는 “도메인 상태 머신을 실행하는 얇은 런타임”이 되고, 핵심 비즈니스 규칙은 순수함수로 남습니다. 이 분리가 Elixir 코드베이스의 수명을 크게 늘려줍니다.
추가로, 시스템이 커질수록 디버깅은 코드만이 아니라 실행 환경까지 포함됩니다. 브라우저/런타임/인프라에서 원인을 좁혀나가는 방식도 함께 익혀두면 좋습니다. 예: Firefox에서 CLS 급증 원인 추적과 디버깅 가이드