- Published on
Elixir GenServer 교착·상태꼬임을 순수함수로 리팩터링
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Elixir에서 GenServer는 강력하지만, 모든 로직을 프로세스 상태에 얹기 시작하면 어느 순간부터 “가끔 멈춤”, “상태가 뒤틀림”, “재현이 어려운 타임아웃” 같은 증상이 생깁니다. 대개는 교착에 준하는 대기(특히 GenServer.call/3 타임아웃) 또는 상태 전이의 비원자성(여러 메시지/요청이 섞이며 기대와 다른 순서로 적용) 때문에 발생합니다.
이 글은 GenServer를 버리자는 이야기가 아닙니다. GenServer는 얇게, 도메인 로직은 순수함수로 두껍게 가져가면, 교착·상태꼬임을 줄이고 테스트 가능성까지 크게 올릴 수 있습니다.
GenServer에서 자주 터지는 문제 패턴
1) call 체인으로 만든 “사실상 교착”
Elixir/Erlang은 공유 메모리 대신 메시지 패싱이라 고전적 의미의 락 교착은 덜하지만, 아래 패턴은 실무에서 교착처럼 보이는 정지를 쉽게 만듭니다.
- A가 B에
GenServer.call을 보냄 - B가 처리 도중 A에
GenServer.call을 보냄 - 둘 다 상대의 응답을 기다리며 블로킹
특히 한 프로세스가 handle_call 안에서 다른 call을 동기적으로 기다리면 위험합니다.
def handle_call({:reserve, item_id}, _from, state) do
# 나쁜 예: handle_call 안에서 다른 GenServer.call을 기다림
price = GenServer.call(Pricing, {:price, item_id}, 5_000)
{:reply, {:ok, price}, state}
end
위 코드는 Pricing이 다시 Cart로 call을 보내는 순간, 서로 대기하며 타임아웃이 연쇄적으로 발생할 수 있습니다.
2) 장시간 작업을 handle_call에서 수행
handle_call은 호출자에게 응답하기 전까지 서버가 다음 메시지를 처리하지 못합니다. DB 쿼리, HTTP 호출, 파일 IO, 암호화 등 느릴 수 있는 작업을 handle_call에서 하면 서버가 “멈춘 것처럼” 보입니다.
- 큐가 쌓이고
- 호출자는 타임아웃이 나고
- 그 사이 상태는 더 꼬이기 쉬워집니다
이건 DB 데드락과도 비슷한 면이 있습니다. 병목이 생기면 대기가 꼬리를 물고, 결국은 타임아웃이나 재시도 폭주로 이어집니다. 데드락 관점이 익숙하다면 이 글도 같이 보면 도움이 됩니다: MySQL InnoDB 데드락 1213 에러 원인·해결
3) 상태를 “여기저기서” 직접 조립
상태 갱신 로직이 여러 handle_call/handle_cast/handle_info에 흩어지면, 다음 문제가 생깁니다.
- 어떤 이벤트가 어떤 불변식을 깨는지 추적이 어려움
- 특정 순서에서만 깨지는 버그가 생김
- 테스트가 GenServer 통합 테스트로만 가능해짐
핵심은 상태 전이 규칙을 한 곳에 모으는 것입니다.
리팩터링 목표: GenServer는 어댑터, 도메인은 순수함수
리팩터링의 이상적인 구조는 이렇습니다.
- 도메인 모듈: 순수함수로 상태와 명령을 받아 새 상태와 결과를 반환
- GenServer: 메시지 수신, 상태 보관, 사이드이펙트 실행, 타이머/모니터링 처리
즉, GenServer는 “프로세스와 메시지”라는 런타임 관심사만 담당하고, 비즈니스 규칙은 순수함수로 분리합니다.
아래는 장바구니를 예로 든 구조입니다.
예시 1: 상태 전이를 순수함수로 분리하기
(1) 도메인: CartDomain은 상태 전이만 담당
defmodule CartDomain do
@type item_id :: binary()
@type qty :: pos_integer()
@type t :: %{
items: %{optional(item_id()) => qty()},
version: non_neg_integer()
}
def new(), do: %{items: %{}, version: 0}
@spec add_item(t(), item_id(), qty()) :: {:ok, t()} | {:error, term()}
def add_item(state, item_id, qty) when is_binary(item_id) and qty > 0 do
items = Map.update(state.items, item_id, qty, &(&1 + qty))
{:ok, bump(%{state | items: items})}
end
def add_item(_state, _item_id, _qty), do: {:error, :invalid_input}
@spec remove_item(t(), item_id()) :: {:ok, t()} | {:error, term()}
def remove_item(state, item_id) do
if Map.has_key?(state.items, item_id) do
{:ok, bump(%{state | items: Map.delete(state.items, item_id)})}
else
{:error, :not_found}
end
end
defp bump(state), do: %{state | version: state.version + 1}
end
여기서 중요한 점:
- 함수는 입력 상태를 받아 새 상태를 돌려줍니다
- 외부 IO가 없습니다
- 불변식(예:
qty > 0)을 도메인에서 강제합니다
(2) GenServer: 메시지를 도메인 함수에 위임
defmodule CartServer do
use GenServer
def start_link(opts) do
name = Keyword.fetch!(opts, :name)
GenServer.start_link(__MODULE__, :ok, name: name)
end
def init(:ok) do
{:ok, CartDomain.new()}
end
def add_item(server, item_id, qty) do
GenServer.call(server, {:add_item, item_id, qty})
end
def remove_item(server, item_id) do
GenServer.call(server, {:remove_item, item_id})
end
def handle_call({:add_item, item_id, qty}, _from, state) do
case CartDomain.add_item(state, item_id, qty) do
{:ok, new_state} -> {:reply, :ok, new_state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
def handle_call({:remove_item, item_id}, _from, state) do
case CartDomain.remove_item(state, item_id) do
{:ok, new_state} -> {:reply, :ok, new_state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
end
이 구조로 얻는 효과:
- 상태 전이 로직이
CartDomain에 모여서 상태꼬임 원인 추적이 쉬워짐 - 도메인은 순수함수라 단위 테스트가 매우 쉬움
- GenServer는 얇아서 교착/타임아웃 문제도 진단 포인트가 명확해짐
예시 2: 장시간 작업을 “명령”으로 분리하고 비동기화
장시간 작업을 handle_call에서 처리하지 말고, 도메인에서 “해야 할 일”을 명령(command) 으로 반환한 뒤 GenServer가 실행하게 만들면 좋습니다.
(1) 도메인: 상태 전이 + 커맨드 생성
defmodule CheckoutDomain do
@type t :: %{cart: map(), status: :idle | :checking_out}
def new(cart), do: %{cart: cart, status: :idle}
# 커맨드를 반환해 GenServer가 사이드이펙트를 실행하게 함
def begin_checkout(%{status: :idle} = state) do
new_state = %{state | status: :checking_out}
commands = [{:fetch_tax, state.cart}, {:reserve_inventory, state.cart}]
{:ok, new_state, commands}
end
def begin_checkout(state), do: {:error, :invalid_state, state, []}
def checkout_succeeded(state), do: {:ok, %{state | status: :idle}}
def checkout_failed(state, _reason), do: {:ok, %{state | status: :idle}}
end
(2) GenServer: 커맨드를 Task로 실행하고 결과를 메시지로 받기
defmodule CheckoutServer do
use GenServer
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: opts[:name])
def init(opts) do
{:ok, %{domain: CheckoutDomain.new(opts[:cart]), pending: MapSet.new()}}
end
def begin_checkout(server) do
GenServer.call(server, :begin_checkout)
end
def handle_call(:begin_checkout, _from, %{domain: domain} = state) do
case CheckoutDomain.begin_checkout(domain) do
{:ok, new_domain, commands} ->
pending =
Enum.reduce(commands, state.pending, fn cmd, acc ->
ref = Task.Supervisor.async_nolink(CheckoutTasks, fn -> run(cmd) end).ref
MapSet.put(acc, ref)
end)
{:reply, :ok, %{state | domain: new_domain, pending: pending}}
{:error, reason, same_domain, _} ->
{:reply, {:error, reason}, %{state | domain: same_domain}}
end
end
def handle_info({ref, {:ok, _result}}, state) do
Process.demonitor(ref, [:flush])
pending = MapSet.delete(state.pending, ref)
# 모든 작업이 끝났을 때 상태 전이
state =
if MapSet.size(pending) == 0 do
{:ok, domain} = CheckoutDomain.checkout_succeeded(state.domain)
%{state | domain: domain, pending: pending}
else
%{state | pending: pending}
end
{:noreply, state}
end
def handle_info({:DOWN, ref, :process, _pid, reason}, state) do
pending = MapSet.delete(state.pending, ref)
{:ok, domain} = CheckoutDomain.checkout_failed(state.domain, reason)
{:noreply, %{state | domain: domain, pending: pending}}
end
defp run({:fetch_tax, cart}) do
# 외부 API 호출 가정
_ = cart
{:ok, :tax_done}
end
defp run({:reserve_inventory, cart}) do
_ = cart
{:ok, :inventory_done}
end
end
핵심 포인트:
begin_checkout은 빠르게 응답하고, 느린 작업은Task로 분리- GenServer는 작업 완료 이벤트만 받아 상태를 정리
- 도메인 상태 전이는 여전히 순수함수
이 패턴은 트래픽이 몰릴 때 타임아웃과 재시도가 폭주하는 상황(예: 429 대응)에서도 유사한 설계 감각이 필요합니다. 레이트리밋/재시도 설계 관점은 OpenAI 429와 Rate Limit 헤더로 재시도 설계도 참고할 만합니다.
상태꼬임을 줄이는 체크리스트
1) handle_call 안에서 다른 GenServer.call을 피하기
- 정말 필요하다면
cast로 바꾸거나 - 아예 “커맨드 반환 후 비동기 실행” 구조로 바꾸세요
2) 상태는 “한 곳”에서만 바꾸기
- 도메인 모듈에
apply_event또는command함수로 모읍니다 - GenServer는
state = Domain.f(state, ...)형태로만 갱신하도록 제한합니다
3) 불변식과 검증은 도메인에서
- 입력 검증이 핸들러 여기저기에 흩어지면 순서에 따라 예외 케이스가 생깁니다
- 도메인에서 일관되게 반환 타입을 유지하세요
4) 관측 가능성: 타임아웃과 큐 길이를 보라
GenServer.call타임아웃이 늘면 “서버가 오래 잡고 있다”는 뜻입니다- 메시지 큐가 길어지는지(프로세스 mailbox) 관측하면 병목을 빨리 찾습니다
테스트 전략: GenServer 통합 테스트를 줄이기
도메인이 순수함수면 ExUnit으로 아주 싸고 빠르게 테스트할 수 있습니다.
defmodule CartDomainTest do
use ExUnit.Case, async: true
test "add_item increments qty and bumps version" do
s0 = CartDomain.new()
{:ok, s1} = CartDomain.add_item(s0, "A", 2)
{:ok, s2} = CartDomain.add_item(s1, "A", 1)
assert s2.items["A"] == 3
assert s2.version == s0.version + 2
end
test "remove_item fails when missing" do
s0 = CartDomain.new()
assert {:error, :not_found} = CartDomain.remove_item(s0, "X")
end
end
GenServer 테스트는 “메시지 라우팅이 맞는지”, “비동기 커맨드 완료 시 상태가 정리되는지” 정도로 최소화하는 것이 유지보수에 유리합니다.
언제 GenServer가 꼭 필요하고, 언제 과한가
꼭 필요한 경우
- 단일 프로세스 직렬화가 곧 비즈니스 요구인 경우(예: 특정 리소스에 대한 순차 처리)
- 캐시, 세션, rate limit 토큰 버킷처럼 “프로세스가 상태를 들고 있는 것”이 자연스러운 경우
과한 경우
- 단순 CRUD에 가까운데 상태를 과하게 쌓는 경우
- 외부 IO를
handle_call에서 다 처리하며 서버를 병목으로 만드는 경우
GenServer는 “상태를 안전하게 보관하는 상자”이지, “모든 로직을 넣는 블랙박스”가 되면 디버깅 비용이 급증합니다.
마무리: 교착·상태꼬임을 줄이는 가장 확실한 방법
정리하면, GenServer 문제의 상당수는 동기 대기(call)의 전염과 상태 전이 규칙의 분산에서 시작합니다. 해결책은 어렵지 않습니다.
- 도메인 로직을 순수함수로 분리해 상태 전이를 한 곳에 모으고
- GenServer는 메시지 수신과 사이드이펙트 실행에 집중하며
- 장시간 작업은 커맨드로 분리해 비동기화하세요
이렇게 리팩터링하면, “가끔 멈추는” 시스템이 “항상 같은 규칙으로 움직이는” 시스템으로 바뀝니다. Debugging도, 테스트도, 운영도 훨씬 단단해집니다.