Published on

Elixir OTP에서 순수함수로 상태머신 구현하기

Authors

서버 프로세스(OTP)로 상태를 들고 가는 Elixir 시스템에서 가장 흔한 함정은 GenServer 콜백 안에 도메인 로직이 점점 쌓이면서 테스트가 어려워지고, 타임아웃·재시도·메시지 순서 같은 운영 이슈와 비즈니스 규칙이 뒤엉키는 것입니다.

해결책은 의외로 단순합니다. 프로세스는 메시지 라우팅과 내구성(재시작, 타임아웃, 모니터링)에 집중하고, 상태 전이 규칙은 순수함수로 분리해 “상태머신”으로 만들면 됩니다. 이 글은 OTP를 버리지 않고(오히려 더 잘 활용하면서) 순수함수 상태머신을 구현하는 방법을 단계적으로 다룹니다.

왜 순수함수 상태머신인가

1) 테스트 용이성

순수함수는 입력이 같으면 출력이 같습니다. 즉 next = transition(state, event) 형태로 만들면, 프로세스 없이도 단위 테스트가 가능합니다. GenServer 테스트에서 흔한 플래키(flaky) 원인인 타이밍·메일박스·동시성 변수를 제거할 수 있습니다.

2) 운영 복잡도 분리

OTP 프로세스는 재시작, 백오프, 모니터링 같은 “운영 로직”을 잘 처리합니다. 반면 도메인 규칙은 자주 바뀝니다. 둘을 분리하면 변경 비용이 줄고, 장애 시 원인도 빨리 좁힐 수 있습니다. 예를 들어 재시도/보상 같은 워크플로우는 상태머신 모델과 궁합이 좋습니다. 분산 트랜잭션의 재시도·보상 패턴이 궁금하다면 Temporal로 분산 트랜잭션 재시도·보상 패턴도 함께 보면 도움이 됩니다.

3) 리플레이와 감사 로그

상태 전이를 event 기반으로 설계하면, 이벤트 로그를 재생(replay)해 상태를 재구성할 수 있습니다. 이는 디버깅, 감사, 장애 복구에서 강력합니다.

설계 원칙: “상태 + 이벤트 -> 결과”

순수함수 상태머신을 만들 때 가장 실용적인 형태는 아래입니다.

  • 입력: state, event
  • 출력: {:ok, new_state, effects} 또는 {:error, reason, state}
  • effects: 외부 세계에 대한 요청(사이드이펙트)을 데이터로 표현

여기서 핵심은 사이드이펙트를 즉시 실행하지 않고, “해야 할 일”을 값으로 반환하는 것입니다. 실행은 GenServer가 담당합니다.

예제 도메인: 결제 승인 워크플로우

간단한 결제 승인 흐름을 상태머신으로 모델링해 보겠습니다.

  • :idle 주문 생성 전
  • :authorizing 결제 승인 요청 중
  • :authorized 승인 완료
  • :failed 실패

이벤트는 다음처럼 둡니다.

  • {:start, order_id, amount}
  • {:gateway_ok, auth_id}
  • {:gateway_fail, reason}
  • :timeout

효과(effects)는 예를 들어:

  • {:call_gateway, order_id, amount}
  • {:set_timer, ms, :timeout}
  • {:emit_metric, name, tags}

순수함수 상태머신 모듈 만들기

아래는 순수함수만 가진 상태머신 모듈입니다.

defmodule Payments.FSM do
  @type status :: :idle | :authorizing | :authorized | :failed

  @type state :: %{
          status: status,
          order_id: String.t() | nil,
          amount: non_neg_integer() | nil,
          auth_id: String.t() | nil,
          error: term() | nil
        }

  @type event ::
          {:start, String.t(), non_neg_integer()}
          | {:gateway_ok, String.t()}
          | {:gateway_fail, term()}
          | :timeout

  @type effect ::
          {:call_gateway, String.t(), non_neg_integer()}
          | {:set_timer, non_neg_integer(), :timeout}
          | {:emit_metric, String.t(), map()}

  @spec new() :: state
  def new do
    %{status: :idle, order_id: nil, amount: nil, auth_id: nil, error: nil}
  end

  @spec transition(state, event) ::
          {:ok, state, [effect]} | {:error, term(), state, [effect]}
  def transition(state, event)

  def transition(%{status: :idle} = state, {:start, order_id, amount}) do
    new_state = %{state | status: :authorizing, order_id: order_id, amount: amount}

    effects = [
      {:emit_metric, "payment_start", %{order_id: order_id}},
      {:call_gateway, order_id, amount},
      {:set_timer, 5_000, :timeout}
    ]

    {:ok, new_state, effects}
  end

  def transition(%{status: :authorizing} = state, {:gateway_ok, auth_id}) do
    new_state = %{state | status: :authorized, auth_id: auth_id, error: nil}

    effects = [
      {:emit_metric, "payment_authorized", %{order_id: state.order_id}}
    ]

    {:ok, new_state, effects}
  end

  def transition(%{status: :authorizing} = state, {:gateway_fail, reason}) do
    new_state = %{state | status: :failed, error: reason}

    effects = [
      {:emit_metric, "payment_failed", %{order_id: state.order_id, reason: inspect(reason)}}
    ]

    {:error, reason, new_state, effects}
  end

  def transition(%{status: :authorizing} = state, :timeout) do
    reason = :gateway_timeout
    new_state = %{state | status: :failed, error: reason}

    effects = [
      {:emit_metric, "payment_timeout", %{order_id: state.order_id}}
    ]

    {:error, reason, new_state, effects}
  end

  # 그 외 조합은 “무시” 혹은 “에러”를 명시적으로 선택
  def transition(state, event) do
    {:error, {:invalid_transition, state.status, event}, state, []}
  end
end

이 모듈에는 GenServer도 없고, 네트워크 호출도 없고, 타이머도 없습니다. 오직 전이 규칙만 있습니다.

GenServer는 “어댑터”로만 사용하기

이제 GenServer는 다음만 책임집니다.

  • 현재 상태 보관
  • 이벤트 수신 후 FSM.transition/2 호출
  • 반환된 effects 실행
  • 타이머 메시지 처리
defmodule Payments.Server do
  use GenServer

  alias Payments.FSM

  def start_link(opts) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

  # Public API
  def start_payment(pid, order_id, amount) do
    GenServer.call(pid, {:start, order_id, amount})
  end

  # GenServer callbacks
  @impl true
  def init(:ok) do
    {:ok, %{fsm: FSM.new(), timers: %{}}}
  end

  @impl true
  def handle_call({:start, order_id, amount}, _from, s) do
    {reply, s2} = apply_event(s, {:start, order_id, amount})
    {:reply, reply, s2}
  end

  @impl true
  def handle_info(:timeout, s) do
    {_reply, s2} = apply_event(s, :timeout)
    {:noreply, s2}
  end

  # 외부 시스템 결과를 메시지로 받는다고 가정
  @impl true
  def handle_info({:gateway_ok, auth_id}, s) do
    {_reply, s2} = apply_event(s, {:gateway_ok, auth_id})
    {:noreply, s2}
  end

  @impl true
  def handle_info({:gateway_fail, reason}, s) do
    {_reply, s2} = apply_event(s, {:gateway_fail, reason})
    {:noreply, s2}
  end

  defp apply_event(%{fsm: fsm} = s, event) do
    case FSM.transition(fsm, event) do
      {:ok, new_fsm, effects} ->
        s2 = %{s | fsm: new_fsm}
        s3 = run_effects(s2, effects)
        {:ok, s3}

      {:error, reason, new_fsm, effects} ->
        s2 = %{s | fsm: new_fsm}
        s3 = run_effects(s2, effects)
        {{:error, reason}, s3}
    end
  end

  defp run_effects(s, effects) do
    Enum.reduce(effects, s, fn effect, acc -> run_effect(acc, effect) end)
  end

  defp run_effect(s, {:set_timer, ms, :timeout}) do
    ref = Process.send_after(self(), :timeout, ms)
    put_in(s, [:timers, :timeout], ref)
  end

  defp run_effect(s, {:call_gateway, order_id, amount}) do
    # 실제로는 Task, Finch, Req 등을 사용
    # 여기서는 예시로 비동기 메시지로 결과가 온다고 가정
    _ = Task.start(fn ->
      # ... 결제 게이트웨이 호출 ...
      send(self(), {:gateway_ok, "AUTH-#{order_id}"})
    end)

    s
  end

  defp run_effect(s, {:emit_metric, name, tags}) do
    _ = {name, tags}  # StatsD/Telemetry 연동 지점
    s
  end
end

여기서 중요한 점:

  • 상태 전이의 정답은 FSM.transition/2 한 곳에만 존재
  • GenServer는 이벤트를 넣고, 효과를 실행할 뿐
  • apply_event/2는 테스트에서 그대로 재사용 가능(필요하면 별도 모듈로 분리)

ExUnit으로 상태머신 단위 테스트하기

GenServer 없이도 도메인 규칙을 검증할 수 있습니다.

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

  alias Payments.FSM

  test "idle -> authorizing on start" do
    s0 = FSM.new()

    assert {:ok, s1, effects} = FSM.transition(s0, {:start, "O-1", 10_000})
    assert s1.status == :authorizing
    assert s1.order_id == "O-1"

    assert {:call_gateway, "O-1", 10_000} in effects
    assert {:set_timer, 5_000, :timeout} in effects
  end

  test "authorizing -> authorized on gateway_ok" do
    s0 = FSM.new()
    {:ok, s1, _} = FSM.transition(s0, {:start, "O-2", 5_000})

    assert {:ok, s2, _} = FSM.transition(s1, {:gateway_ok, "AUTH-123"})
    assert s2.status == :authorized
    assert s2.auth_id == "AUTH-123"
  end

  test "invalid transition is rejected" do
    s0 = FSM.new()

    assert {:error, {:invalid_transition, :idle, :timeout}, ^s0, []} =
             FSM.transition(s0, :timeout)
  end
end

이 테스트는 밀리초 단위 대기 없이 순식간에 돌아가고, 실패 시 원인이 전이 규칙인지(도메인) 실행 환경인지(OTP) 즉시 분리됩니다.

효과(effects) 설계 팁: “명령”과 “사건”을 구분

상태머신에서 흔히 헷갈리는 것이 이벤트와 효과의 경계입니다.

  • 이벤트(event): 이미 발생한 사실. 예) {:gateway_ok, auth_id}
  • 효과(effect): 앞으로 해야 할 일(명령). 예) {:call_gateway, ...}

이 구분이 명확하면, 재시도 정책이나 레이트리밋 같은 운영 관심사를 GenServer 또는 별도 워커로 밀어낼 수 있습니다. 외부 API 호출이 레이트리밋으로 실패할 때의 재시도/토큰버킷 설계는 OpenAI Responses API 429 레이트리밋 토큰버킷으로 끝내기 같은 글의 접근을 그대로 차용할 수 있습니다(대상만 결제 게이트웨이로 바꾸면 됨).

타이머와 중복 메시지: 순수함수로는 못 푸는 문제를 OTP로 푼다

순수함수 상태머신이 모든 것을 해결하진 않습니다. 예를 들어:

  • 타이머 취소(기존 타이머 ref 정리)
  • 중복 메시지(게이트웨이 응답이 두 번 옴)
  • 메시지 순서 뒤바뀜

이런 문제는 OTP 프로세스가 “현실 세계의 비결정성”을 다루는 계층이므로, 그쪽에서 해결하는 편이 자연스럽습니다.

실무 팁:

  1. 상태에 request_id 같은 상관관계 키를 넣고, 이벤트에도 포함시켜서 오래된 응답을 무시
  2. :authorizing에서만 :timeout을 유효하게 처리하고, :authorized 이후의 :timeout은 무시하도록 전이 규칙을 명시
  3. 효과 실행 계층에서 타이머 ref를 저장하고, 상태가 확정되면 Process.cancel_timer(ref) 수행

이 패턴은 “상태머신은 순수하게, 현실 적응은 어댑터에서”라는 원칙을 지켜줍니다.

상태머신을 더 확장하는 방법

1) 상태를 구조체로 고정해 불변식을 강화

맵으로 시작하되, 규모가 커지면 구조체로 바꾸면 필드 누락을 줄일 수 있습니다.

defmodule Payments.State do
  defstruct status: :idle,
            order_id: nil,
            amount: nil,
            auth_id: nil,
            error: nil
end

이때도 전이는 순수함수로 유지합니다.

2) 이벤트 소싱 스타일로 로그를 남기기

전이 함수가 effects 외에 domain_events를 반환하도록 확장할 수 있습니다.

  • effects: 외부 호출/타이머/메트릭 같은 명령
  • domain_events: :payment_authorized 같은 비즈니스 사건(저장/발행)

3) 장애 분석을 위해 전이 히스토리를 저장

운영 중 “왜 이 상태가 되었는가”를 추적하려면, 최근 N개의 이벤트를 상태에 링 버퍼로 저장하는 것도 실용적입니다. 메모리 압박이나 OOM 징후가 있을 때는 애플리케이션 레벨 이벤트 버퍼가 원인일 수도 있으니, 리눅스 관점의 진단은 Linux OOM Killer 원인추적 - dmesg·cgroup·로그를 참고해 시스템 레벨에서 함께 확인하는 습관이 좋습니다.

흔한 안티패턴과 교정

안티패턴 1) FSM.transition/2 안에서 HTTP 호출

순수함수성이 깨지면서 테스트가 느려지고, 전이 규칙이 외부 상태에 종속됩니다.

  • 교정: HTTP 호출은 효과로 반환하고, 실행은 어댑터에서 처리

안티패턴 2) GenServer 상태에 “미완의 작업”이 암묵적으로 숨어 있음

예: status: :authorizing인데 실제로는 이미 게이트웨이 호출을 안 했거나, 타이머를 안 걸었거나.

  • 교정: 전이 결과에 반드시 필요한 효과를 포함시키고, 어댑터는 효과를 빠짐없이 실행

안티패턴 3) invalid transition을 조용히 무시

버그가 조용히 묻히고, 운영에서만 드러납니다.

  • 교정: 기본 케이스에서 {:error, {:invalid_transition, ...}}를 반환하고 메트릭/로그로 관측

정리

Elixir OTP에서 “상태를 가진 프로세스”는 강력하지만, 그 안에 도메인 규칙까지 모두 넣으면 복잡도가 빠르게 폭발합니다. 가장 비용 대비 효과가 큰 리팩터링은 다음입니다.

  • 상태 전이 규칙을 state + event -> result 형태의 순수함수로 고립
  • 부수효과는 effects 데이터로 반환
  • GenServer는 이벤트를 전달하고 효과를 실행하는 어댑터로 축소

이 구조를 잡아두면 테스트 속도, 변경 용이성, 장애 분석력이 동시에 좋아집니다. 다음 단계로는 이벤트 소싱, 보상 트랜잭션, 레이트리밋/재시도 정책을 효과 실행 계층에서 체계화하는 방향으로 확장해 보세요.