Published on

Elixir GenServer에서 불변 상태·순수함수로 레이스 버그 잡기

Authors

GenServer는 한 번에 하나의 메시지만 처리하므로 흔히 “레이스 컨디션이 원천적으로 없다”고 설명됩니다. 하지만 실무에서 겪는 버그는 대개 프로세스 내부의 동시성이 아니라, 상태 전이와 외부 세계(I/O) 사이의 경계가 흐려져 발생합니다. 특히 다음 상황에서 레이스처럼 보이는 문제가 쉽게 생깁니다.

  • handle_call/3 또는 handle_cast/2 안에서 DB/HTTP 같은 느린 작업을 수행해 메일박스가 밀리고, 뒤이어 들어온 메시지가 “늦게 처리되어” 상태가 예상과 다르게 보이는 경우
  • “현재 상태를 읽고 계산한 뒤 다시 쓰는” 로직이 곳곳에 흩어져 상태 전이 규칙이 깨지는 경우
  • 타이머(Process.send_after/3)나 외부 콜백이 섞여 이벤트 순서가 바뀌는 경우

이 글은 GenServer의 상태를 불변 데이터로 유지하고, 상태 전이를 순수함수로 분리해 레이스성 버그를 줄이는 패턴을 설명합니다. 핵심은 “GenServer는 메시지를 직렬로 처리하지만, 우리가 짜는 코드가 직렬성을 깨뜨릴 수 있다”는 점을 인정하고 구조로 방어하는 것입니다.

GenServer에서 레이스처럼 보이는 버그가 생기는 지점

1) 상태 전이 규칙이 코드 여기저기에 흩어질 때

예를 들어 “재고 감소” 같은 로직을 여러 핸들러에서 각자 구현하면, 어떤 경로에서는 검증을 빼먹거나, 어떤 경로에서는 로그만 남기고 상태 업데이트를 누락하는 식의 불일치가 생깁니다. 이건 동시성 레이스가 아니라 상태 머신이 망가진 것이지만, 운영에서는 “가끔만 터지는” 형태로 나타나 레이스처럼 보입니다.

2) GenServer 내부에서 I/O를 수행할 때

handle_call/3에서 외부 API 호출을 하면 그 동안 서버는 다른 메시지를 처리하지 못합니다. 결과적으로

  • 타임아웃
  • 큐 적체
  • 타이머 이벤트 지연

이 발생하고, 이를 “순서가 바뀐 것 같은” 증상으로 체감합니다. 특히 재시도 로직이 섞이면 더 복잡해집니다. 재시도 전략 자체는 별도 글인 Claude API 529·429 재시도 전략과 구현 패턴 같은 패턴을 참고하되, GenServer에서는 I/O를 상태 전이에서 분리하는 게 우선입니다.

3) 타이머와 외부 이벤트가 섞일 때

send_after로 “n초 뒤 만료”를 구현했는데, 그 사이에 갱신 이벤트가 오면 만료 메시지가 늦게 도착해도 처리 순서상 “갱신 후 만료”가 되어버릴 수 있습니다. 이때 필요한 건 “이 메시지가 지금 상태에 유효한가?”를 검증하는 버전/토큰입니다.

해결 전략: 불변 상태 + 순수 상태 전이 함수

GenServer의 상태는 원래 불변 데이터(맵, 구조체)지만, 진짜 중요한 건 다음 두 가지 규율입니다.

  1. 상태 전이를 만드는 로직을 순수함수로 고립한다.
  2. I/O는 상태 전이 밖으로 밀어내고, 핸들러에서는 “결정”만 한다.

이렇게 하면

  • 상태 전이 규칙을 한 곳에서 테스트할 수 있고
  • 메시지 순서가 바뀌어도 “유효성 검증”으로 무력화할 수 있으며
  • 핸들러가 짧아져 운영 중 디버깅이 쉬워집니다.

아래는 실무에서 자주 쓰는 형태로, State 구조체와 reduce 계열 순수함수를 두는 패턴입니다.

예제 1: 나쁜 패턴에서 출발하기

아래 코드는 “카운터 증가 후 외부로 보고” 같은 흔한 형태입니다. 문제는 handle_cast/2 안에서 I/O를 하고, 상태 변경 규칙이 핸들러에 박혀 있다는 점입니다.

defmodule MyApp.CounterServer do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{count: 0}, name: __MODULE__)
  end

  def inc(), do: GenServer.cast(__MODULE__, :inc)

  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_cast(:inc, state) do
    new_state = %{state | count: state.count + 1}

    # 느린 I/O가 여기서 발생하면 메일박스가 밀릴 수 있음
    MyApp.Metrics.report("counter", new_state.count)

    {:noreply, new_state}
  end
end

증상은 보통 이렇게 나타납니다.

  • 트래픽이 몰릴 때 inc가 지연되어 “카운터가 덜 오른 것처럼” 보임
  • report가 실패하면 상태는 이미 증가했는데 외부 보고는 누락
  • 재시도를 넣으면 중복 보고가 발생

예제 2: 상태 전이를 순수함수로 분리하기

핵심은 “이 이벤트를 받았을 때 상태가 어떻게 바뀌는가”를 순수함수로 만들고, 그 결과로 “해야 할 부수효과”를 명시적으로 반환하는 것입니다.

defmodule MyApp.CounterState do
  @enforce_keys [:count]
  defstruct count: 0

  @type t :: %__MODULE__{count: non_neg_integer()}

  @type effect :: {:report_metric, String.t(), non_neg_integer()}

  @spec apply_event(t(), :inc) :: {t(), [effect()]}
  def apply_event(%__MODULE__{} = state, :inc) do
    new_state = %__MODULE__{state | count: state.count + 1}
    effects = [{:report_metric, "counter", new_state.count}]
    {new_state, effects}
  end
end

이제 GenServer는 “상태 전이 적용”과 “부수효과 실행”을 분리합니다.

defmodule MyApp.CounterServer do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %MyApp.CounterState{count: 0}, name: __MODULE__)
  end

  def inc(), do: GenServer.cast(__MODULE__, :inc)

  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_cast(:inc, state) do
    {new_state, effects} = MyApp.CounterState.apply_event(state, :inc)

    # 부수효과는 메시지로 자기 자신에게 넘기거나 Task로 분리
    Enum.each(effects, fn eff ->
      send(self(), {:effect, eff})
    end)

    {:noreply, new_state}
  end

  @impl true
  def handle_info({:effect, {:report_metric, name, value}}, state) do
    _ = MyApp.Metrics.report(name, value)
    {:noreply, state}
  end
end

이 구조의 장점은 다음과 같습니다.

  • apply_event/2는 순수함수라 단위 테스트가 쉽고, 규칙이 한 곳에 모입니다.
  • 메트릭 보고가 실패해도 상태 전이는 이미 일관되게 끝났고, 재시도 정책을 별도로 넣기 쉽습니다.
  • handle_cast/2가 짧아져 장애 시 스택트레이스가 단순합니다.

단, handle_info/2에서의 I/O도 결국 GenServer를 블로킹할 수 있습니다. I/O가 무겁다면 Task.start/1 또는 별도 워커로 넘기는 것이 좋습니다.

예제 3: “만료 타이머”에서 레이스성 버그 막기 (토큰/버전)

자주 터지는 케이스가 “세션/락/캐시 엔트리 만료”입니다. 갱신이 일어났는데도 이전 타이머가 늦게 도착해 만료 처리해버리는 문제죠.

해결은 간단합니다. 상태에 version 같은 토큰을 두고, 타이머 메시지에 그 값을 함께 실어 보냅니다. 도착했을 때 현재 버전과 다르면 무시합니다.

defmodule MyApp.LeaseState do
  @enforce_keys [:value, :lease_ms, :version]
  defstruct value: nil, lease_ms: 5_000, version: 0

  @type t :: %__MODULE__{value: any(), lease_ms: pos_integer(), version: non_neg_integer()}

  @type effect :: {:arm_expire_timer, non_neg_integer(), pos_integer()}

  def renew(%__MODULE__{} = state, new_value) do
    new_state = %__MODULE__{state | value: new_value, version: state.version + 1}
    effects = [{:arm_expire_timer, new_state.version, new_state.lease_ms}]
    {new_state, effects}
  end

  def expire_if_current(%__MODULE__{} = state, version) do
    if state.version == version do
      {%__MODULE__{state | value: nil}, []}
    else
      {state, []}
    end
  end
end

GenServer는 효과를 실행하면서 타이머 메시지에 버전을 포함합니다.

defmodule MyApp.LeaseServer do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %MyApp.LeaseState{value: nil, lease_ms: 5_000, version: 0}, name: __MODULE__)
  end

  def renew(value), do: GenServer.cast(__MODULE__, {:renew, value})

  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_cast({:renew, value}, state) do
    {new_state, effects} = MyApp.LeaseState.renew(state, value)
    Enum.each(effects, &run_effect/1)
    {:noreply, new_state}
  end

  @impl true
  def handle_info({:expire, version}, state) do
    {new_state, _effects} = MyApp.LeaseState.expire_if_current(state, version)
    {:noreply, new_state}
  end

  defp run_effect({:arm_expire_timer, version, lease_ms}) do
    Process.send_after(self(), {:expire, version}, lease_ms)
  end
end

이제 “갱신 후에도 이전 만료 메시지가 도착해 상태를 날려버리는” 문제는 버전 불일치로 방어됩니다. 이런 방식은 DB 데드락에서 “이 트랜잭션이 아직 유효한가”를 추적하는 사고방식과도 유사합니다. 원인 추적 관점은 MySQL InnoDB 데드락 로그로 원인 쿼리 추적 글의 접근이 도움이 됩니다.

패턴 정리: 이벤트-리듀서(reducer) + 효과(effect)

위 예제들의 공통점은 다음 형태로 요약됩니다.

  • 입력: (state, event)
  • 출력: {new_state, effects}

여기서

  • new_state는 항상 불변 데이터로 새로 만들어 반환
  • effects는 I/O, 타이머, 워커 호출 같은 “해야 할 일 목록”

이 구조를 따르면 레이스성 버그의 상당수가 “상태 전이가 순수함수로 검증 가능”해지면서 줄어듭니다.

테스트가 쉬워지는 이유

apply_event/2 같은 함수는 GenServer 없이도 테스트할 수 있습니다.

defmodule MyApp.CounterStateTest do
  use ExUnit.Case, async: true

  test "inc increases count and emits effect" do
    state = %MyApp.CounterState{count: 0}

    {state2, effects} = MyApp.CounterState.apply_event(state, :inc)

    assert state2.count == 1
    assert effects == [{:report_metric, "counter", 1}]
  end
end

운영에서 “가끔 틀리는” 문제는 재현이 어렵습니다. 하지만 순수함수로 규칙을 모으면, 최소한 규칙 자체의 결함은 테스트로 빠르게 잡힙니다.

실무 팁: GenServer를 ‘상태 저장소’로만 쓰기

1) 느린 작업은 별도 프로세스로

HTTP 호출, DB 쿼리, 파일 I/O는 GenServer 내부에서 직접 하지 않는 편이 좋습니다.

  • Task.Supervisor로 비동기 실행
  • 결과는 handle_info/2로 다시 받아 상태에 반영
  • 필요하면 요청 ID를 두고 “이 응답이 아직 유효한가”를 검증

이때도 마찬가지로 request_id 또는 version을 써서 오래된 응답을 무시하면, “늦게 도착한 응답이 새 상태를 덮어씀” 같은 레이스성 버그를 막을 수 있습니다.

2) 상태는 가능한 작게

상태가 커질수록

  • 복사 비용
  • 디버깅 난이도
  • 핸들러 분기 복잡도

가 증가합니다. 큰 데이터를 들고 있기보다 ETS나 외부 저장소로 옮기고, GenServer는 인덱스/요약 상태만 유지하는 것도 방법입니다.

3) 관측 가능성: 큐 적체를 지표로 만들기

레이스처럼 보이는 문제의 상당수는 사실 “처리 지연”입니다. GenServer의 메일박스 길이, 처리 시간, 타임아웃을 관측하면 원인이 훨씬 빨리 드러납니다. 로그 폭주로 디스크가 차는 상황처럼, 병목이 생기면 2차 장애가 따라옵니다. 운영 관점의 로그/지표 정리는 리눅스 journald 로그 폭주로 디스크 꽉 찰 때 해결 같은 글의 체크리스트와도 연결됩니다.

체크리스트: “레이스 버그”를 구조로 줄이는 질문들

  • 상태 전이 규칙이 handle_call/3, handle_cast/2, handle_info/2 여기저기에 흩어져 있지 않은가?
  • 상태 변경은 항상 “이전 상태 + 이벤트”로부터 새 상태를 만들어 반환하는가?
  • 외부 I/O는 상태 전이와 분리되어 있는가?
  • 타이머/비동기 응답에는 version 또는 request_id가 포함되어, 오래된 메시지를 무시할 수 있는가?
  • GenServer가 느려질 때(메일박스 적체) 어떤 증상이 생기는지 지표로 보고 있는가?

마무리

GenServer 자체는 메시지를 직렬 처리하지만, 실무에서의 레이스성 버그는 대부분 “상태 전이 규칙의 분산”과 “I/O 경계의 혼재”에서 나옵니다. 상태를 불변 데이터로 유지하는 것만으로는 부족하고, 상태 전이를 순수함수로 고립해 규칙을 한 곳에 모으고, 부수효과를 명시적으로 분리해 시간과 순서의 문제를 제어해야 합니다.

정리하면 다음 한 줄이 가장 강력합니다.

  • GenServer는 상태를 저장하고 이벤트를 직렬로 흘려보내는 곳, 진짜 로직은 (state, event) -> {state, effects} 순수함수로 만든다.

이 패턴을 적용하면 “가끔 터지는” 문제를 “항상 재현되는” 테스트 케이스로 바꾸는 데 큰 도움이 됩니다.