- Published on
Elixir GenServer를 순수 함수로 테스트하는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 프로세스(GenServer)를 테스트할 때 가장 흔한 함정은 start_link로 띄운 뒤 GenServer.call·cast를 날리는 방식에만 기대는 것입니다. 이 방식은 실제 동작을 가까이서 검증할 수 있지만, 테스트가 느려지고(프로세스 생성·메시지 왕복), 비결정적 요인(타이밍, mailbox 잔여 메시지, 다른 테스트의 간섭)에 취약해집니다.
해결책은 간단합니다. GenServer의 핵심 로직을 “상태 전이(state transition)”로 보고 순수 함수로 분리한 뒤, GenServer는 그 함수를 호출해 상태를 갱신하고 부수효과를 실행하는 얇은 어댑터로 만드는 것입니다. 그러면 대부분의 테스트는 순수 함수에 대해 수행할 수 있어 빠르고 안정적이며, GenServer 통합 테스트는 최소한으로 줄일 수 있습니다.
아래에서는 handle_call·handle_cast·handle_info 로직을 순수 함수로 옮기는 패턴, 부수효과를 “명령(command)”으로 모델링하는 방법, 그리고 ExUnit에서의 테스트 전략을 예제로 설명합니다.
왜 순수 함수 테스트가 GenServer에 특히 잘 맞나
GenServer는 본질적으로 다음을 반복합니다.
- 입력(메시지)을 받는다
- 현재 상태와 입력으로 다음 상태를 계산한다
- 필요하면 부수효과(로그, DB, 외부 API, 타이머)를 실행한다
- 응답을 돌려준다
여기서 2번은 대부분 결정적(deterministic) 입니다. 즉, 같은 상태와 같은 입력이면 결과가 같습니다. 이 부분을 순수 함수로 분리하면 다음 장점이 생깁니다.
- 속도: 프로세스 없이 함수 호출만으로 테스트
- 결정성: 타이밍·스케줄링 영향 최소화
- 커버리지: 예외 케이스(경계값, 상태 조합)를 촘촘히 테스트 가능
- 설계 개선: 상태와 이벤트가 명확해져 코드가 읽기 쉬워짐
이 접근은 “생각이 막힐 때 문제를 더 작은 질문으로 쪼개는” 디버깅 습관과도 통합니다. 메시지 처리 전체를 한 번에 디버깅하기보다, 상태 전이 규칙을 작은 단위로 분해해 검증하면 문제의 원인이 훨씬 빨리 드러납니다. 관련해서는 Chain-of-Thought 막히면? Self-Ask+ReAct 디버깅 글의 접근이 비슷한 결입니다.
목표 구조: Core(순수) + Server(GenServer)
권장 구조는 다음과 같습니다.
MyApp.Counter.Core: 순수 함수로만 구성. 상태 타입, 이벤트, 전이 함수, 검증 로직MyApp.Counter.Server: GenServer. 메시지 수신, Core 호출, 부수효과 실행
핵심은 GenServer 콜백이 “로직의 주인공”이 아니라, Core를 호출하는 라우터가 되게 만드는 것입니다.
예제: 카운터 GenServer를 순수 함수로 분리
요구사항을 약간 현실적으로 만들어 보겠습니다.
inc(n)로 카운트를 증가- 최대값
max를 넘기면 에러 - 증가할 때마다 감사(audit) 이벤트를 기록(부수효과)
1) Core 모듈: 상태 전이와 커맨드 반환
부수효과를 직접 수행하지 말고, “무엇을 해야 하는지”를 나타내는 커맨드 목록을 반환합니다.
defmodule MyApp.Counter.Core do
@enforce_keys [:value, :max]
defstruct [:value, :max]
@type t :: %__MODULE__{value: non_neg_integer(), max: non_neg_integer()}
@type command ::
{:audit, map()}
| {:reply, term()}
@spec new(keyword()) :: t()
def new(opts) do
max = Keyword.fetch!(opts, :max)
%__MODULE__{value: 0, max: max}
end
@spec inc(t(), non_neg_integer()) :: {:ok, t(), [command()]} | {:error, term()}
def inc(%__MODULE__{} = state, n) when is_integer(n) and n >= 0 do
new_value = state.value + n
if new_value <= state.max do
new_state = %{state | value: new_value}
commands = [
{:audit, %{event: "inc", by: n, value: new_value}},
{:reply, {:ok, new_value}}
]
{:ok, new_state, commands}
else
{:error, :max_exceeded}
end
end
end
여기서 inc/2는 순수합니다. 외부 세계에 손대지 않고, 상태와 커맨드만 계산합니다.
2) Server 모듈: GenServer는 Core 결과를 실행
defmodule MyApp.Counter.Server do
use GenServer
alias MyApp.Counter.Core
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name))
end
def inc(server, n), do: GenServer.call(server, {:inc, n})
@impl true
def init(opts) do
{:ok, Core.new(opts)}
end
@impl true
def handle_call({:inc, n}, _from, state) do
case Core.inc(state, n) do
{:ok, new_state, commands} ->
{reply, _effects} = run_commands(commands)
{:reply, reply, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
defp run_commands(commands) do
reply =
commands
|> Enum.find_value(fn
{:reply, value} -> value
_ -> nil
end)
effects =
commands
|> Enum.filter(&match?({:audit, _}, &1))
|> Enum.map(fn {:audit, payload} ->
# 실제 서비스라면 Logger, Telemetry, DB insert 등이 될 수 있음
send(self(), {:audit_emitted, payload})
:ok
end)
{reply, effects}
end
end
run_commands/1은 예제에서는 send(self(), ...)로 단순화했지만, 실제로는 Logger·Telemetry·Repo 등으로 확장됩니다. 중요한 점은 Core는 부수효과를 모른다는 것입니다.
순수 함수(Core) 테스트 작성
GenServer를 띄우지 않고도 대부분의 규칙을 검증할 수 있습니다.
defmodule MyApp.Counter.CoreTest do
use ExUnit.Case, async: true
alias MyApp.Counter.Core
test "inc increases value and emits audit command" do
state = Core.new(max: 10)
assert {:ok, new_state, commands} = Core.inc(state, 3)
assert new_state.value == 3
assert {:audit, %{event: "inc", by: 3, value: 3}} in commands
assert {:reply, {:ok, 3}} in commands
end
test "inc rejects when max exceeded" do
state = Core.new(max: 5)
assert {:error, :max_exceeded} = Core.inc(state, 6)
end
test "inc with 0 is allowed" do
state = Core.new(max: 5)
assert {:ok, new_state, commands} = Core.inc(state, 0)
assert new_state.value == 0
assert {:reply, {:ok, 0}} in commands
end
end
이 테스트는 매우 빠르고, 타이밍 이슈가 없으며, 상태 전이 규칙을 직접 검증합니다.
최소한의 GenServer 통합 테스트만 남기기
GenServer 테스트는 “Core가 맞게 호출되는지”와 “커맨드 실행이 연결되는지” 정도로만 제한합니다.
defmodule MyApp.Counter.ServerTest do
use ExUnit.Case, async: true
alias MyApp.Counter.Server
test "server delegates to core and replies" do
{:ok, pid} = Server.start_link(max: 10)
assert {:ok, 2} = Server.inc(pid, 2)
assert {:ok, 5} = Server.inc(pid, 3)
end
test "server emits audit side-effect (example)" do
{:ok, pid} = Server.start_link(max: 10)
assert {:ok, 1} = Server.inc(pid, 1)
# 예제에서는 run_commands/1이 self()로 send하므로
# 테스트 프로세스에서 받으려면 구조를 조금 바꿔야 함.
# 실무에서는 audit를 별도 모듈로 분리하고 Mox로 검증하는 편이 일반적.
assert is_pid(pid)
end
end
위의 두 번째 테스트가 애매하게 느껴지는 이유가 핵심 포인트입니다. 부수효과 검증은 보통 포트/외부 API/DB 등과 얽혀서 통합 테스트 비용이 커집니다. 그래서 실무에서는 부수효과를 별도 Behaviour로 추출하고 목(mock)으로 검증하는 패턴을 곁들입니다.
부수효과를 Behaviour로 분리하고 목으로 검증(Mox)
커맨드 실행부에서 직접 Repo.insert 같은 것을 호출하지 말고, Audit 모듈을 주입 가능하게 만듭니다.
1) Behaviour 정의
defmodule MyApp.Audit do
@callback emit(map()) :: :ok | {:error, term()}
end
2) 기본 구현체
defmodule MyApp.Audit.LoggerImpl do
@behaviour MyApp.Audit
require Logger
@impl true
def emit(payload) do
Logger.info("audit=" <> inspect(payload))
:ok
end
end
3) Server에서 의존성 주입
defmodule MyApp.Counter.Server do
use GenServer
alias MyApp.Counter.Core
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name))
end
def inc(server, n), do: GenServer.call(server, {:inc, n})
@impl true
def init(opts) do
audit_impl = Keyword.get(opts, :audit, MyApp.Audit.LoggerImpl)
{:ok, %{core: Core.new(opts), audit: audit_impl}}
end
@impl true
def handle_call({:inc, n}, _from, %{core: core, audit: audit} = state) do
case Core.inc(core, n) do
{:ok, new_core, commands} ->
reply = run_commands(commands, audit)
{:reply, reply, %{state | core: new_core}}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
defp run_commands(commands, audit) do
Enum.each(commands, fn
{:audit, payload} ->
_ = audit.emit(payload)
:ok
_ ->
:ok
end)
Enum.find_value(commands, fn
{:reply, value} -> value
_ -> nil
end)
end
end
4) Mox로 감사 이벤트 검증
테스트에서 Audit를 목으로 교체해 “부수효과가 호출되었는지”만 확인합니다.
# test/test_helper.exs
ExUnit.start()
Mox.defmock(MyApp.AuditMock, for: MyApp.Audit)
defmodule MyApp.Counter.ServerAuditTest do
use ExUnit.Case, async: true
import Mox
alias MyApp.Counter.Server
setup :verify_on_exit!
test "server calls audit implementation" do
expect(MyApp.AuditMock, :emit, fn payload ->
assert payload.event == "inc"
assert payload.by == 2
assert payload.value == 2
:ok
end)
{:ok, pid} = Server.start_link(max: 10, audit: MyApp.AuditMock)
assert {:ok, 2} = Server.inc(pid, 2)
end
end
이렇게 하면 GenServer 통합 테스트도 여전히 빠르고 안정적입니다. 네트워크나 DB 없이 “호출 계약”만 검증하기 때문입니다.
handle_info와 타이머도 순수 함수로 가져오기
GenServer의 어려운 부분은 종종 handle_info에서 발생합니다(주기 작업, 타이머, 외부 메시지). 이때도 동일한 전략을 씁니다.
- Core에
tick(state)같은 순수 함수를 만든다 - Core는
{:schedule_after, ms, :tick}같은 커맨드를 반환한다 - Server는 그 커맨드를
Process.send_after(self(), :tick, ms)로 실행한다
커맨드 타입 예시:
@type command ::
{:reply, term()}
| {:audit, map()}
| {:schedule_after, non_neg_integer(), term()}
이 패턴을 쓰면 “시간”이 로직에 섞이지 않아 테스트가 쉬워집니다. Core 테스트에서는 스케줄 커맨드가 생성되는지만 확인하면 됩니다.
테스트 전략 요약: 무엇을 어디서 검증할까
- Core 단위 테스트(대부분)
- 상태 전이 규칙
- 입력 검증
- 커맨드 생성(부수효과 요청)
- 에러 케이스
- Server 통합 테스트(소수)
- 메시지 라우팅이 올바른지
- Core 결과가 상태에 반영되는지
- Behaviour 호출이 수행되는지(Mox)
이 분리는 운영 환경 장애 분석에도 도움이 됩니다. 문제를 “상태 전이 규칙의 버그”와 “부수효과/인프라 문제”로 나눠 보면 원인 추적이 빨라집니다. 예를 들어 서비스 레벨에서 타임아웃이 급증할 때도 애플리케이션 로직 자체인지, 인프라 타임아웃 설정인지 분리해 점검해야 하는데, 이런 관점은 AWS ALB 502/504 급증 - 타임아웃 7곳 점검 같은 글에서 다루는 “체크포인트 기반 분해”와 유사합니다.
자주 하는 실수와 체크리스트
Core에 시간·랜덤·환경 의존을 넣어버림
System.system_time·DateTime.utc_now·Enum.random같은 호출이 Core에 들어가면 순수성이 깨집니다.- 해결: 필요한 값(현재 시간 등)을 인자로 받거나, 커맨드로 요청하세요.
상태가 지나치게 커져서 테스트가 어려움
- 상태 구조체가 커지면 케이스 조합이 폭발합니다.
- 해결: 상태를 의미 단위로 분리(서브 구조체), 이벤트 기반 전이로 단순화.
GenServer 테스트에서 :timer.sleep로 동기화
- 테스트 신뢰성을 크게 떨어뜨립니다.
- 해결: 타이머는 커맨드로 모델링하고, 필요한 경우
assert_receive로 이벤트를 기다리거나, 스케줄러를 추상화하세요.
결론
GenServer를 “프로세스”로만 테스트하면 느리고 불안정한 테스트 스위트가 되기 쉽습니다. 반대로, GenServer의 본질을 상태 전이 + 부수효과로 분해하고, 상태 전이를 Core 순수 함수로 옮기면 다음을 얻습니다.
- 빠르고 결정적인 단위 테스트
- 부수효과는 Behaviour와 목으로 얇게 검증
- GenServer는 최소 통합 테스트만 유지
실무에서는 이 패턴이 코드 품질뿐 아니라, 장애 분석과 리팩터링 속도까지 끌어올립니다. GenServer를 작성하기 전에 먼저 “이 서버의 상태와 이벤트는 무엇인가”를 Core로 정의해두면, 구현과 테스트가 동시에 쉬워지는 것을 체감할 수 있을 것입니다.