Published on

Elixir GenServer를 함수형 상태머신으로 리팩터링

Authors

서버 프로세스는 Elixir/OTP에서 가장 강력한 추상화 중 하나지만, 실무에서는 GenServer 하나에 비즈니스 규칙, 상태 전이, 타이머, 외부 I/O가 뒤섞이며 "덩치 큰 핸들러"가 되기 쉽습니다. 이 상태가 되면 변경에 취약해지고, 장애 분석 시 "어떤 이벤트가 어떤 상태를 만들었는지" 추적이 어려워집니다.

이 글에서는 GenServer를 그대로 버리자는 이야기가 아니라, 상태 전이 로직을 순수 함수 기반의 상태머신으로 분리하고 GenServer는 이벤트 라우팅과 부수효과 실행에 집중하게 만드는 리팩터링 패턴을 다룹니다. 결과적으로 테스트가 쉬워지고, 전이 규칙이 명시적으로 드러나며, 장애/재시도 설계도 더 깔끔해집니다.

문제 해결 관점의 체크리스트 글을 좋아한다면, 운영 장애를 빠르게 좁혀가는 방식은 K8s CrashLoopBackOff 원인 10분 추적법도 참고할 만합니다. 상태머신도 결국 "원인-전이-결과"를 구조화하는 작업이기 때문입니다.

GenServer가 비대해지는 전형적인 징후

다음 항목 중 2~3개 이상이 보이면 리팩터링 타이밍입니다.

  • handle_call/3, handle_cast/2, handle_info/2 내부에 case 중첩이 많고, 상태별 분기가 계속 늘어난다.
  • 상태 업데이트가 여기저기 흩어져 있어 "이 필드는 언제 바뀌지?"를 따라가기 어렵다.
  • 타이머(Process.send_after/3)와 외부 호출(HTTP, DB, PubSub)이 핸들러에 직접 섞여 테스트가 힘들다.
  • 장애 시 재시도, 중복 메시지, out-of-order 이벤트를 처리하기 위한 방어 코드가 핸들러 곳곳에 분산된다.

핵심 원인은 상태 전이(순수 로직)부수효과(I/O, 타이머, 로깅) 가 분리되지 않아서입니다.

목표 아키텍처: 순수 상태 전이 + Effect

리팩터링 목표는 단순합니다.

  1. 상태 전이 함수는 순수 함수로 만든다.
  2. 전이 결과는 (new_state, effects) 형태로 반환한다.
  3. GenServer는 이벤트를 상태머신에 전달하고, 반환된 effects를 실행한다.

이 구조는 Elm/Redux 스타일과도 닮아 있습니다. 특히 "상태 전이 규칙"이 모듈 하나에 모이기 때문에 읽기와 테스트가 쉬워집니다.

예제 시나리오: 결제 워크플로우 GenServer

가령 결제를 처리하는 프로세스가 있고, 다음 이벤트가 있다고 합시다.

  • :authorize 요청
  • 승인 성공/실패 콜백
  • 일정 시간 내 콜백이 없으면 타임아웃

리팩터링 전: 비대해진 GenServer

아래 코드는 흔히 볼 수 있는 형태입니다. 분기, 타이머, 외부 호출이 한 곳에 섞입니다.

defmodule Payments.Worker do
  use GenServer

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: via(opts[:id]))

  def authorize(id), do: GenServer.call(via(id), :authorize)

  @impl true
  def init(opts) do
    state = %{
      id: opts[:id],
      status: :idle,
      auth_ref: nil,
      timer_ref: nil,
      attempts: 0
    }

    {:ok, state}
  end

  @impl true
  def handle_call(:authorize, _from, %{status: :idle} = state) do
    # 외부 I/O
    {:ok, auth_ref} = Payments.Gateway.authorize(state.id)

    # 타이머 설정
    timer_ref = Process.send_after(self(), {:timeout, auth_ref}, 5_000)

    new_state = %{state | status: :authorizing, auth_ref: auth_ref, timer_ref: timer_ref}
    {:reply, :ok, new_state}
  end

  def handle_call(:authorize, _from, state) do
    {:reply, {:error, :invalid_state}, state}
  end

  @impl true
  def handle_info({:gateway_ok, auth_ref}, %{status: :authorizing, auth_ref: auth_ref} = state) do
    if state.timer_ref, do: Process.cancel_timer(state.timer_ref)
    {:noreply, %{state | status: :authorized, timer_ref: nil}}
  end

  def handle_info({:gateway_error, auth_ref, reason}, %{status: :authorizing, auth_ref: auth_ref} = state) do
    if state.timer_ref, do: Process.cancel_timer(state.timer_ref)
    {:noreply, %{state | status: {:failed, reason}, timer_ref: nil}}
  end

  def handle_info({:timeout, auth_ref}, %{status: :authorizing, auth_ref: auth_ref} = state) do
    {:noreply, %{state | status: :timed_out, timer_ref: nil}}
  end

  def handle_info(_msg, state), do: {:noreply, state}

  defp via(id), do: {:via, Registry, {Payments.Registry, id}}
end

이 코드는 동작하지만, 시간이 지나면 다음이 생깁니다.

  • 재시도 정책 추가
  • 중복 콜백 처리
  • out-of-order 이벤트 무시
  • 상태별 메트릭/로그

그리고 그 모든 것이 handle_*에 계속 쌓입니다.

1단계: 상태머신 모듈로 전이 로직 분리

먼저 상태를 명확히 모델링합니다. 상태는 맵이어도 좋지만, 전이 규칙이 커질수록 구조체가 유리합니다.

defmodule Payments.Fsm do
  @moduledoc """
  결제 승인 워크플로우 상태머신.

  - 순수 함수만 포함한다.
  - 부수효과는 effect로 기술만 하고 실행하지 않는다.
  """

  defstruct [:id, status: :idle, auth_ref: nil, attempts: 0]

  @type status :: :idle | :authorizing | :authorized | :timed_out | {:failed, term()}
  @type t :: %__MODULE__{id: term(), status: status(), auth_ref: term() | nil, attempts: non_neg_integer()}

  @type event ::
          :authorize
          | {:gateway_ok, term()}
          | {:gateway_error, term(), term()}
          | {:timeout, term()}

  @type effect ::
          {:call_gateway_authorize, term()}
          | {:start_timer, term(), non_neg_integer()}
          | {:cancel_timer, term()}

  @spec step(t(), event()) :: {t(), [effect()]}
  def step(%__MODULE__{status: :idle} = s, :authorize) do
    s1 = %{s | status: :authorizing, attempts: s.attempts + 1}

    effects = [
      {:call_gateway_authorize, s.id}
      # auth_ref를 아직 모르므로 타이머는 authorize 결과를 받은 뒤 시작하도록 설계할 수도 있다.
    ]

    {s1, effects}
  end

  def step(%__MODULE__{status: :authorizing, auth_ref: auth_ref} = s, {:gateway_ok, auth_ref}) do
    s1 = %{s | status: :authorized}
    {s1, [{:cancel_timer, auth_ref}]}
  end

  def step(%__MODULE__{status: :authorizing, auth_ref: auth_ref} = s, {:gateway_error, auth_ref, reason}) do
    s1 = %{s | status: {:failed, reason}}
    {s1, [{:cancel_timer, auth_ref}]}
  end

  def step(%__MODULE__{status: :authorizing, auth_ref: auth_ref} = s, {:timeout, auth_ref}) do
    s1 = %{s | status: :timed_out}
    {s1, []}
  end

  # 알 수 없는 이벤트나 상태 불일치(out-of-order 포함)는 상태를 바꾸지 않는다.
  def step(s, _event), do: {s, []}

  @spec on_authorize_result(t(), {:ok, term()} | {:error, term()}) :: {t(), [effect()]}
  def on_authorize_result(%__MODULE__{status: :authorizing} = s, {:ok, auth_ref}) do
    s1 = %{s | auth_ref: auth_ref}
    {s1, [{:start_timer, auth_ref, 5_000}]}
  end

  def on_authorize_result(%__MODULE__{status: :authorizing} = s, {:error, reason}) do
    s1 = %{s | status: {:failed, reason}}
    {s1, []}
  end
end

포인트는 두 가지입니다.

  • step/2이벤트를 받아 상태를 전이하고, 필요한 작업을 effect로 기술합니다.
  • 게이트웨이 authorize처럼 "결과가 나중에 오는 호출"은 on_authorize_result/2 같은 별도 전이를 두어, 비동기 결과를 이벤트로 모델링합니다.

2단계: GenServer는 Effect 실행기(Interpreter)가 된다

이제 GenServer는 얇아집니다.

  • 외부에서 들어온 요청을 이벤트로 변환
  • Payments.Fsm.step/2 호출
  • 반환된 effects 실행
defmodule Payments.Worker do
  use GenServer

  alias Payments.Fsm

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: via(opts[:id]))
  def authorize(id), do: GenServer.call(via(id), :authorize)

  @impl true
  def init(opts) do
    fsm = %Fsm{id: opts[:id]}

    runtime = %{
      timer_refs: %{} # auth_ref => timer_ref
    }

    {:ok, %{fsm: fsm, runtime: runtime}}
  end

  @impl true
  def handle_call(:authorize, _from, %{fsm: fsm} = state) do
    {fsm1, effects} = Fsm.step(fsm, :authorize)
    state1 = %{state | fsm: fsm1}

    {state2, _} = run_effects(state1, effects)
    {:reply, :ok, state2}
  end

  @impl true
  def handle_info({:authorize_result, result}, %{fsm: fsm} = state) do
    {fsm1, effects} = Fsm.on_authorize_result(fsm, result)
    state1 = %{state | fsm: fsm1}

    {state2, _} = run_effects(state1, effects)
    {:noreply, state2}
  end

  @impl true
  def handle_info({:gateway_ok, auth_ref}, %{fsm: fsm} = state) do
    {fsm1, effects} = Fsm.step(fsm, {:gateway_ok, auth_ref})
    state1 = %{state | fsm: fsm1}

    {state2, _} = run_effects(state1, effects)
    {:noreply, state2}
  end

  @impl true
  def handle_info({:gateway_error, auth_ref, reason}, %{fsm: fsm} = state) do
    {fsm1, effects} = Fsm.step(fsm, {:gateway_error, auth_ref, reason})
    state1 = %{state | fsm: fsm1}

    {state2, _} = run_effects(state1, effects)
    {:noreply, state2}
  end

  @impl true
  def handle_info({:timeout, auth_ref}, %{fsm: fsm} = state) do
    {fsm1, effects} = Fsm.step(fsm, {:timeout, auth_ref})
    state1 = %{state | fsm: fsm1}

    {state2, _} = run_effects(state1, effects)
    {:noreply, state2}
  end

  defp run_effects(state, effects) do
    Enum.reduce(effects, {state, []}, fn eff, {st, acc} ->
      {st2, out} = run_effect(st, eff)
      {st2, [out | acc]}
    end)
  end

  defp run_effect(state, {:call_gateway_authorize, id}) do
    # 실제 I/O는 여기서만
    result = Payments.Gateway.authorize(id)

    # 결과를 self 메시지로 되돌려 이벤트 흐름을 유지
    send(self(), {:authorize_result, result})

    {state, :ok}
  end

  defp run_effect(%{runtime: rt} = state, {:start_timer, auth_ref, ms}) do
    timer_ref = Process.send_after(self(), {:timeout, auth_ref}, ms)
    rt1 = put_in(rt.timer_refs[auth_ref], timer_ref)
    {%{state | runtime: rt1}, :ok}
  end

  defp run_effect(%{runtime: rt} = state, {:cancel_timer, auth_ref}) do
    case rt.timer_refs[auth_ref] do
      nil ->
        {state, :noop}

      timer_ref ->
        _ = Process.cancel_timer(timer_ref)
        rt1 = update_in(rt.timer_refs, &Map.delete(&1, auth_ref))
        {%{state | runtime: rt1}, :ok}
    end
  end

  defp via(id), do: {:via, Registry, {Payments.Registry, id}}
end

이제 핵심 규칙은 Payments.Fsm에 있고, GenServer는 "효과 실행기"로서의 책임만 갖습니다.

이 패턴이 주는 실무 이점

1) 테스트가 순수해진다

가장 큰 이점입니다. Payments.Fsm.step/2는 순수 함수이므로, 프로세스도 타이머도 없이 단위 테스트가 가능합니다.

defmodule Payments.FsmTest do
  use ExUnit.Case, async: true

  alias Payments.Fsm

  test "idle에서 authorize하면 authorizing으로 전이하고 gateway 호출 effect를 만든다" do
    s0 = %Fsm{id: 1}

    {s1, effects} = Fsm.step(s0, :authorize)

    assert s1.status == :authorizing
    assert {:call_gateway_authorize, 1} in effects
  end

  test "authorizing에서 gateway_ok를 받으면 authorized로 전이하고 타이머 취소 effect를 만든다" do
    s0 = %Fsm{id: 1, status: :authorizing, auth_ref: :ref1}

    {s1, effects} = Fsm.step(s0, {:gateway_ok, :ref1})

    assert s1.status == :authorized
    assert {:cancel_timer, :ref1} in effects
  end

  test "상태 불일치 이벤트는 무시한다" do
    s0 = %Fsm{id: 1, status: :idle}

    {s1, effects} = Fsm.step(s0, {:gateway_ok, :ref1})

    assert s1 == s0
    assert effects == []
  end
end

핸들러 내부에서 타이머나 외부 API를 호출하던 구조에서는 이런 테스트가 어렵고 느립니다.

2) out-of-order, 중복 메시지에 강해진다

분산 환경에서는 메시지 순서가 항상 보장되지 않습니다. 상태머신은 "지금 상태에서 유효한 이벤트만 처리"하게 만들기 쉬워서, 중복/역순 이벤트에 대한 방어가 자연스럽게 들어갑니다.

특히 위 예제처럼 auth_ref를 상태에 들고 있고, 이벤트에도 auth_ref를 포함하면 "이 콜백이 현재 시도에 대한 것인지"를 쉽게 판별합니다.

3) 재시도/백오프 정책을 전이 규칙으로 표현 가능

예를 들어 타임아웃이면 attempts를 보고 다시 :authorize로 전이시키고, effect로 {:start_timer, ...} 대신 {:sleep, ms} 같은 것을 추가할 수도 있습니다.

이때 재시도 설계 자체는 데이터 파이프라인/외부 API에서도 동일한 고민이 반복됩니다. 배치/재시도 관점은 Pinecone 업서트 429·타임아웃, 배치·재시도 설계에서 다룬 방식과도 연결됩니다.

4) 관측 가능성(Observability)을 붙이기 쉽다

전이가 순수 함수로 모이면, 다음과 같은 로깅이 구조적으로 쉬워집니다.

  • 입력 이벤트
  • 이전 상태, 다음 상태
  • 발생한 effects

GenServer에서 run_effects/2 호출 직전에 상태 스냅샷을 찍고, 전이 결과를 메트릭으로 남기면 "어떤 이벤트가 어떤 전이를 만들었는지"가 선명해집니다.

리팩터링 팁: Effect는 작게, Interpreter는 단순하게

Effect 설계에서 흔히 하는 실수는 effect가 너무 구체적이거나, 반대로 너무 추상적인 것입니다.

  • 너무 구체적: {:http_post, url, headers, body} 같은 effect가 도메인 곳곳에 퍼지면 상태머신이 인프라 세부사항을 알게 됩니다.
  • 너무 추상적: {:do_something, payload}는 의미가 없어져서 유지보수에 도움이 되지 않습니다.

권장 방식은 도메인 단위로 effect를 정의하는 것입니다.

  • {:call_gateway_authorize, payment_id}
  • {:publish_event, :payment_authorized, attrs}
  • {:start_timer, key, ms}

그리고 Interpreter는 가능하면 "매핑 테이블"처럼 단순해야 합니다.

언제 굳이 상태머신으로 분리하지 않아도 되나

다음 경우는 GenServer에 로직이 조금 있어도 괜찮습니다.

  • 상태가 사실상 1~2개이고 전이 규칙이 거의 없다.
  • 외부 I/O가 없거나, 있어도 동기 호출 하나로 끝난다.
  • 테스트 요구사항이 낮고, 변경 빈도도 낮다.

하지만 워크플로우가 길어지거나(결제, 주문, 인증, 업로드), 타임아웃/재시도/콜백이 섞이는 순간부터는 상태머신 분리가 비용 대비 효과가 큽니다.

확장 아이디어: 명시적 전이 테이블과 타입 강화

상태가 더 커지면 step/2에 함수 헤드가 많아질 수 있습니다. 이때는 전이 테이블을 데이터로 만들고, 공통 가드를 적용하는 방식도 고려할 수 있습니다.

또한 Dialyzer를 쓴다면 @type status@type event를 더 촘촘히 잡는 것만으로도 "불가능한 상태"를 줄일 수 있습니다.

함수형에서 타입과 효과 분리를 통해 복잡도를 제어하는 사고방식은 Elixir에서도 그대로 유효하고, 더 강한 타입 시스템을 가진 언어에서의 패턴을 참고하는 것도 도움이 됩니다. 관련 결의는 Haskell do-notation 탈출 - ReaderT 실무 가이드처럼 "효과를 구조화"하는 글에서 힌트를 얻을 수 있습니다.

마무리 체크리스트

GenServer를 함수형 상태머신으로 리팩터링할 때, 아래를 만족하면 성공 확률이 높습니다.

  • 상태 전이는 Fsm.step/2 같은 순수 함수로만 표현된다.
  • 외부 I/O, 타이머, PubSub는 effect로만 기술되고 Interpreter가 실행한다.
  • out-of-order와 중복 메시지는 "상태 불일치면 무시" 규칙으로 자연스럽게 방어된다.
  • 단위 테스트는 프로세스 없이 step/2만으로 대부분 커버된다.
  • GenServer는 얇아지고, 장애 분석 시 전이 로그가 남는다.

이 패턴은 "GenServer를 잘 쓰는 법"이라기보다, OTP의 프로세스 모델 위에 함수형 설계를 얹는 법에 가깝습니다. 한 번 구조를 잡아두면 기능 추가가 전이 규칙 추가로 수렴하고, 운영에서의 예외 케이스도 더 예측 가능해집니다.