Published on

Elixir GenServer를 순수함수로 리팩터링하는 법

Authors

서버 프로세스가 상태를 들고 있고, handle_call/3·handle_cast/2에 로직이 뭉쳐 있는 GenServer는 처음엔 편합니다. 하지만 기능이 늘수록 다음 문제가 빠르게 쌓입니다.

  • 상태 전이 규칙이 콜백 곳곳에 흩어져 “이 상태에서 이 이벤트가 오면 무엇이 되는지” 추적이 어려움
  • I/O(디비, HTTP, 파일, PubSub)가 로직과 섞여 테스트가 무거워짐
  • 동시성/재시도/타임아웃 같은 운영 이슈가 도메인 로직과 결합됨

이 글은 GenServer의 장점(동시성 캡슐화)은 유지하면서, 핵심 로직을 순수함수(pure function) 로 분리해 “테스트 가능한 코어 + 얇은 프로세스 셸(shell)”로 리팩터링하는 방법을 다룹니다.

참고로, 이런 리팩터링은 런타임 장애 진단과도 궁합이 좋습니다. 운영 관점의 문제 해결 습관을 함께 가져가면 디버깅 시간이 줄어듭니다. 예: Kubernetes CrashLoopBackOff 원인별 10분 진단

목표 아키텍처: Functional Core, Imperative Shell

핵심 아이디어는 간단합니다.

  • Functional Core: 상태(state)와 입력(event)을 받아 새 상태와 효과(effect) 를 계산하는 순수함수
  • Imperative Shell: GenServer는 메시지 수신/타이머/모니터링 같은 프로세스 책임만 맡고, 코어가 계산한 effect를 실행

여기서 effect는 “무엇을 해야 하는지”를 데이터로 표현한 것입니다. 예: {:reply, term()}, {:persist, record}, {:broadcast, topic, payload}, {:schedule, ms, msg} 등.

이렇게 하면:

  • 상태 전이 규칙을 한 파일(또는 몇 개 모듈)에서 선언적으로 확인 가능
  • effect를 실행하는 부분을 목(mock)으로 대체해 빠른 단위 테스트 가능
  • GenServer는 얇아져 장애/성능 튜닝 포인트가 명확해짐

예제: 카운터 GenServer를 코어/셸로 분리하기

먼저 흔한 형태의 GenServer를 보겠습니다.

defmodule CounterServer do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, %{value: 0}, opts)
  end

  def inc(pid, n \\ 1), do: GenServer.call(pid, {:inc, n})
  def get(pid), do: GenServer.call(pid, :get)

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

  @impl true
  def handle_call({:inc, n}, _from, state) do
    new_state = %{state | value: state.value + n}
    {:reply, new_state.value, new_state}
  end

  def handle_call(:get, _from, state) do
    {:reply, state.value, state}
  end
end

이 예제는 단순하지만, 실제 서비스에서는 handle_call/3 내부에 검증, 권한 체크, DB 저장, 이벤트 발행, 타이머 등록이 섞이면서 복잡도가 급증합니다.

1단계: 상태 전이를 순수함수로 추출

코어 모듈을 만들고 “입력 이벤트에 따른 전이”를 함수로 정의합니다.

defmodule Counter.Core do
  @type state :: %{value: integer()}
  @type event :: :get | {:inc, integer()}

  @type effect ::
          {:reply, term()}
          | {:noreply}

  @spec init() :: state()
  def init(), do: %{value: 0}

  @spec apply(state(), event()) :: {state(), [effect()]}
  def apply(state, :get) do
    {state, [{:reply, state.value}]}
  end

  def apply(state, {:inc, n}) when is_integer(n) do
    new_state = %{state | value: state.value + n}
    {new_state, [{:reply, new_state.value}]}
  end
end

여기서 중요한 점:

  • apply/2새 상태와 effect 목록만 반환합니다.
  • 외부 세계(로그, DB, PubSub)는 전혀 모릅니다.

2단계: GenServer는 코어를 호출하고 effect를 실행

defmodule CounterServer do
  use GenServer

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

  def inc(pid, n \\ 1), do: GenServer.call(pid, {:inc, n})
  def get(pid), do: GenServer.call(pid, :get)

  @impl true
  def init(:ok), do: {:ok, Counter.Core.init()}

  @impl true
  def handle_call(event, _from, state) do
    {new_state, effects} = Counter.Core.apply(state, event)
    run_effects(effects, new_state)
  end

  defp run_effects(effects, state) do
    # 이 예제는 reply 하나만 있다고 가정
    case effects do
      [{:reply, reply}] -> {:reply, reply, state}
      _ -> {:reply, :ok, state}
    end
  end
end

이제 “상태 전이 규칙”은 Counter.Core에만 존재합니다. GenServer는 메시지 전달과 effect 실행만 담당합니다.

실전형 패턴: effect를 데이터로 설계하기

실제 시스템에서는 effect가 다양합니다.

  • DB 저장/조회
  • 외부 API 호출
  • PubSub 이벤트 발행
  • 타이머 등록
  • 다른 프로세스에 메시지 전송
  • Telemetry/로그

이를 순수함수 코어에 넣지 않으려면, effect를 명세(데이터) 로 만들고 셸이 해석하도록 합니다.

예제: 주문 상태 머신의 effect 모델

defmodule Orders.Core do
  @type state :: %{
          order_id: String.t(),
          status: :new | :paid | :shipped,
          total: non_neg_integer()
        }

  @type event ::
          {:pay, %{amount: non_neg_integer()}}
          | {:ship, %{tracking: String.t()}}

  @type effect ::
          {:reply, term()}
          | {:persist, map()}
          | {:publish, String.t(), map()}
          | {:schedule, non_neg_integer(), term()}

  def apply(state, {:pay, %{amount: amount}}) when amount == state.total do
    new_state = %{state | status: :paid}

    effects = [
      {:persist, %{order_id: state.order_id, status: :paid}},
      {:publish, "orders.paid", %{order_id: state.order_id}},
      {:reply, :ok}
    ]

    {new_state, effects}
  end

  def apply(state, {:pay, _}) do
    {state, [{:reply, {:error, :invalid_amount}}]}
  end

  def apply(%{status: :paid} = state, {:ship, %{tracking: t}}) do
    new_state = %{state | status: :shipped}

    effects = [
      {:persist, %{order_id: state.order_id, status: :shipped, tracking: t}},
      {:publish, "orders.shipped", %{order_id: state.order_id, tracking: t}},
      {:schedule, 86_400_000, {:check_delivery, state.order_id}},
      {:reply, :ok}
    ]

    {new_state, effects}
  end

  def apply(state, {:ship, _}) do
    {state, [{:reply, {:error, :not_paid}}]}
  end
end

코어는 “DB에 저장하라”, “이벤트 발행하라”, “하루 뒤 메시지를 스케줄하라”를 데이터로만 말합니다.

셸(GenServer)에서 effect 해석기(interpreter) 만들기

defmodule Orders.Server do
  use GenServer

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

  def pay(pid, amount), do: GenServer.call(pid, {:pay, %{amount: amount}})
  def ship(pid, tracking), do: GenServer.call(pid, {:ship, %{tracking: tracking}})

  @impl true
  def init(opts) do
    state = %{
      core: Keyword.fetch!(opts, :core_state),
      deps: Keyword.fetch!(opts, :deps)
    }

    {:ok, state}
  end

  @impl true
  def handle_call(event, _from, %{core: core_state} = state) do
    {new_core, effects} = Orders.Core.apply(core_state, event)
    state = %{state | core: new_core}

    # reply를 포함한 effect들을 실행하고, 최종적으로 GenServer tuple을 반환
    interpret_call_effects(effects, state)
  end

  defp interpret_call_effects(effects, state) do
    {reply, state} = Enum.reduce(effects, {:ok, state}, fn eff, {reply_acc, st} ->
      case eff do
        {:reply, r} -> {r, st}
        other -> {reply_acc, run_effect(other, st)}
      end
    end)

    {:reply, reply, state}
  end

  defp run_effect({:persist, record}, %{deps: deps} = state) do
    deps.repo.save(record)
    state
  end

  defp run_effect({:publish, topic, payload}, %{deps: deps} = state) do
    deps.pubsub.publish(topic, payload)
    state
  end

  defp run_effect({:schedule, ms, msg}, state) do
    Process.send_after(self(), msg, ms)
    state
  end
end

핵심은 deps 주입입니다. repo, pubsub 같은 의존성을 서버가 들고 있고, 코어는 의존성을 전혀 모릅니다.

이 구조는 “권한/의존성 문제를 단계적으로 추적”하는 습관과도 잘 맞습니다. 인프라/권한 문제를 다룰 때처럼, 의존성을 명시적으로 분리하면 원인 격리가 쉬워집니다. 예: Terraform apply 후 403? AWS IAM 권한추적 8단계

테스트 전략: 코어는 단위 테스트, 셸은 얇게 통합 테스트

코어 단위 테스트 예시

defmodule Orders.CoreTest do
  use ExUnit.Case, async: true

  test "pay succeeds and emits persist + publish" do
    state = %{order_id: "o-1", status: :new, total: 100}

    {new_state, effects} = Orders.Core.apply(state, {:pay, %{amount: 100}})

    assert new_state.status == :paid
    assert {:reply, :ok} in effects
    assert {:persist, %{order_id: "o-1", status: :paid}} in effects
    assert {:publish, "orders.paid", %{order_id: "o-1"}} in effects
  end

  test "ship fails when not paid" do
    state = %{order_id: "o-1", status: :new, total: 100}

    {new_state, effects} = Orders.Core.apply(state, {:ship, %{tracking: "t"}})

    assert new_state == state
    assert effects == [{:reply, {:error, :not_paid}}]
  end
end
  • 외부 시스템 없이도 빠르게 검증됩니다.
  • “어떤 effect가 발생해야 하는지”가 명확한 계약(contract)이 됩니다.

셸 테스트는 의존성 목으로 최소화

deps.repo.save/1, deps.pubsub.publish/2 같은 호출은 목으로 대체하고 “호출이 발생했는지”만 확인하면 됩니다. Elixir에서는 Mox 같은 라이브러리를 자주 사용합니다.

자주 하는 실수와 해결책

1) effect가 너무 풍성해져서 또 다른 복잡도가 생김

effect 타입이 늘어나면 interpreter가 커질 수 있습니다. 이때는:

  • effect를 도메인별로 모듈화: Orders.Effects, Billing.Effects
  • interpreter를 계층화: 공통 effect(:reply, :schedule)와 인프라 effect(:persist, :publish) 분리
  • effect를 “데이터”로 유지: 함수 캡처를 effect로 넣는 방식(예: {:run, fun})은 테스트는 쉬워도 추적성이 떨어질 수 있음

2) 코어에서 시간/랜덤/UUID를 직접 생성

DateTime.utc_now/0, :rand.uniform/0, Ecto.UUID.generate/0 같은 값은 순수하지 않습니다.

해결책:

  • 이벤트에 값을 포함시키기: {:pay, %{paid_at: paid_at}}
  • 또는 코어가 {:need, :uuid} 같은 effect를 내고 셸이 채워서 다시 코어에 재진입시키는 2단계 패턴

실무에서는 “입력에 포함시키기”가 가장 단순합니다.

3) GenServer 상태에 코어 상태와 런타임 상태가 섞임

%{core: ..., deps: ..., timers: ...} 처럼 분리해 두면 좋습니다.

  • core: 순수 상태
  • deps: 외부 의존성
  • runtime: 모니터 ref, 타이머 ref, 캐시 등

이렇게 해야 코어 상태를 스냅샷/리플레이(이벤트 재생)하기가 쉬워집니다.

4) call/cast/info가 섞일 때의 일관성

  • call: {:reply, ...} effect를 기대
  • cast: reply 없이 상태만 업데이트
  • info: 타이머/모니터 메시지 처리

코어 API도 이를 반영해 apply_call/2, apply_cast/2, apply_info/2처럼 나누거나, apply/2는 동일하게 두되 effect에 {:reply, ...}가 있느냐로 구분할 수 있습니다.

리팩터링 체크리스트

다음 순서로 진행하면 안전합니다.

  1. 기존 GenServer에서 “상태 변경 로직”만 먼저 함수로 추출
  2. 추출한 함수를 별도 모듈(코어)로 이동하고, 입력과 출력 형태를 고정
  3. I/O를 effect로 치환하고, 기존 I/O는 셸로 이동
  4. 코어 단위 테스트를 먼저 촘촘히 작성
  5. 셸은 최소한의 통합 테스트만 유지
  6. 운영 로깅/Telemetry는 셸에 두되, 코어 이벤트/상태 전이 지점을 구조화해 남김

마무리: GenServer를 “상태 머신 실행기”로 만들기

GenServer는 동시성 모델을 제공하는 훌륭한 도구지만, 도메인 로직까지 떠안기 시작하면 복잡도가 폭발합니다. 순수함수 코어로 상태 전이를 고립시키면:

  • 변경이 쉬워지고
  • 테스트가 빨라지며
  • 장애 상황에서 “무슨 일이 일어났는지”를 재현하기 쉬워집니다.

결국 GenServer는 “도메인 상태 머신을 실행하는 얇은 런타임”이 되고, 핵심 비즈니스 규칙은 순수함수로 남습니다. 이 분리가 Elixir 코드베이스의 수명을 크게 늘려줍니다.

추가로, 시스템이 커질수록 디버깅은 코드만이 아니라 실행 환경까지 포함됩니다. 브라우저/런타임/인프라에서 원인을 좁혀나가는 방식도 함께 익혀두면 좋습니다. 예: Firefox에서 CLS 급증 원인 추적과 디버깅 가이드