Published on

Elixir GenServer를 순수함수로 리팩터링 5패턴

Authors

서버 프로세스는 Elixir의 강력한 무기지만, GenServer 하나에 상태 업데이트, 검증, 외부 I/O, 재시도, 타이머까지 몰아넣는 순간 유지보수성이 급격히 떨어집니다. 특히 handle_call/3, handle_cast/2, handle_info/2 안에서 분기와 부작용이 뒤엉키면 다음 문제가 반복됩니다.

  • 테스트가 느리고 불안정해짐(프로세스 띄우기, 메시지 대기, 타이밍 의존)
  • 장애 시 원인 파악이 어려움(상태 전이 규칙이 코드 곳곳에 흩어짐)
  • 재시도·중복 호출·순서 꼬임 같은 동시성 버그가 숨어듦

해결책은 간단합니다. GenServer는 “메시지 라우팅과 상태 보관”만 담당하고, 도메인 로직은 순수함수로 상태 전이를 표현합니다. 이 글은 그 방향으로 가는 실전 리팩터링 5패턴을 정리합니다.

참고로 “중복 호출을 막는 설계”는 Elixir뿐 아니라 웹/클라우드에서도 동일한 주제입니다. 서버 액션 중복 호출을 막는 관점은 Next.js App Router 서버액션 중복호출 막는 6가지도 함께 보면 좋습니다.

리팩터링 목표: GenServer를 얇게, 상태 전이를 두껍게

리팩터링의 핵심은 아래 3가지를 분리하는 것입니다.

  1. State: 프로세스가 들고 있는 상태 구조체
  2. Decisions: 입력(명령/이벤트)에 따른 상태 전이 규칙(순수함수)
  3. Effects: 외부 I/O, 타이머, PubSub, DB 같은 부작용(명시적으로)

이제부터 5가지 패턴으로 이를 구현합니다.


패턴 1) State를 구조체로 고정하고, 전이는 reduce/2로 모으기

가장 먼저 할 일은 맵 기반 상태를 구조체로 고정하고, handle_*에 흩어진 상태 변경을 단일 순수함수로 모으는 것입니다.

Before: 핸들러에 로직이 분산

def handle_cast({:add, item}, state) do
  items = [item | state.items]
  {:noreply, %{state | items: items, count: state.count + 1}}
end

def handle_cast({:remove, id}, state) do
  items = Enum.reject(state.items, &(&1.id == id))
  {:noreply, %{state | items: items, count: max(state.count - 1, 0)}}
end

After: 상태 전이를 한 곳에

defmodule Cart.State do
  defstruct items: [], count: 0

  @type t :: %__MODULE__{items: list(), count: non_neg_integer()}
end

defmodule Cart.Domain do
  alias Cart.State

  @spec reduce(State.t(), term()) :: State.t()
  def reduce(%State{} = state, {:add, item}) do
    %State{state | items: [item | state.items], count: state.count + 1}
  end

  def reduce(%State{} = state, {:remove, id}) do
    items = Enum.reject(state.items, &(&1.id == id))
    count = if state.count > 0, do: state.count - 1, else: 0
    %State{state | items: items, count: count}
  end

  def reduce(%State{} = state, _unknown), do: state
end

defmodule Cart.Server do
  use GenServer
  alias Cart.{State, Domain}

  def init(_), do: {:ok, %State{}}

  def handle_cast(msg, state) do
    {:noreply, Domain.reduce(state, msg)}
  end
end

얻는 효과

  • 상태 전이 규칙이 Cart.Domain.reduce/2에 집중되어 읽기 쉬움
  • GenServer 없이 reduce/2만 단위 테스트 가능
  • 나중에 동일 로직을 LiveView, Job, CLI 등 다른 진입점에서도 재사용 가능

패턴 2) “명령(Command)”과 “이벤트(Event)”를 분리하고, 도메인은 이벤트만 만든다

실무에서 GenServer는 대개 “요청을 처리한다”는 의미로 명령을 받습니다. 하지만 도메인 관점에서 중요한 것은 무슨 일이 일어났는지(이벤트) 입니다.

이 패턴은 도메인이 {:ok, new_state, events} 형태로 결과를 내고, GenServer가 이벤트를 후처리(로그, PubSub, 외부 호출)하는 구조입니다.

defmodule Inventory.State do
  defstruct stock: %{}
  @type t :: %__MODULE__{stock: map()}
end

defmodule Inventory.Domain do
  alias Inventory.State

  @type event :: {:reserved, sku :: binary(), qty :: pos_integer()} | {:rejected, binary(), pos_integer(), reason :: atom()}

  @spec handle(State.t(), {:reserve, binary(), pos_integer()}) :: {State.t(), [event()]}
  def handle(%State{} = state, {:reserve, sku, qty}) do
    current = Map.get(state.stock, sku, 0)

    if current >= qty do
      new_state = %State{state | stock: Map.put(state.stock, sku, current - qty)}
      {new_state, [{:reserved, sku, qty}]}
    else
      {state, [{:rejected, sku, qty, :insufficient_stock}]}
    end
  end
end

defmodule Inventory.Server do
  use GenServer
  alias Inventory.{State, Domain}

  def init(_), do: {:ok, %State{}}

  def handle_call({:reserve, sku, qty}, _from, state) do
    {new_state, events} = Domain.handle(state, {:reserve, sku, qty})

    Enum.each(events, &emit/1)

    reply =
      case events do
        [{:reserved, ^sku, ^qty}] -> :ok
        [{:rejected, ^sku, ^qty, reason}] -> {:error, reason}
        _ -> :ok
      end

    {:reply, reply, new_state}
  end

  defp emit({:reserved, sku, qty}) do
    Logger.info("reserved sku=#{sku} qty=#{qty}")
  end

  defp emit({:rejected, sku, qty, reason}) do
    Logger.warning("rejected sku=#{sku} qty=#{qty} reason=#{reason}")
  end
end

얻는 효과

  • 도메인 로직은 외부 세계를 모름(순수함수 유지)
  • 이벤트는 감사 로그, 메트릭, 알림을 붙이기 좋은 경계
  • “무조건 성공 응답” 같은 암묵적 처리 대신 이벤트 기반으로 명시화

패턴 3) 부작용을 Effect로 모델링하고, GenServer는 실행기(Interpreter)가 된다

패턴 2가 이벤트 중심이라면, 패턴 3은 부작용 자체를 데이터로 표현합니다. 도메인은 “무엇을 해야 하는지”를 effects로 반환하고, GenServer는 이를 실행합니다.

이 방식은 외부 API 호출, DB 저장, 재시도 정책 등을 테스트 더블로 교체하기 좋습니다. 특히 “중복 호출”이나 “재시도”가 섞이면 설계가 흐려지는데, 효과를 데이터로 만들면 통제 지점이 생깁니다. 재시도·멱등성 관점은 OpenAI 429·5xx 재시도, Idempotency 키로 중복 결제 막기도 같은 결의 글입니다.

defmodule Billing.Effect do
  @type t ::
          {:http_post, url :: binary(), body :: map(), headers :: list()} |
          {:persist, key :: term(), value :: term()} |
          {:schedule, millis :: non_neg_integer(), msg :: term()}
end

defmodule Billing.Domain do
  alias Billing.Effect

  defstruct paid: MapSet.new()

  @type state :: %__MODULE__{paid: MapSet.t()}

  @spec pay(state(), order_id :: binary(), amount :: pos_integer()) :: {state(), [Effect.t()]}
  def pay(%__MODULE__{} = state, order_id, amount) do
    if MapSet.member?(state.paid, order_id) do
      {state, []}
    else
      new_state = %__MODULE__{state | paid: MapSet.put(state.paid, order_id)}

      effects = [
        {:http_post, "https://api.example.com/pay", %{order_id: order_id, amount: amount}, []},
        {:persist, {:paid, order_id}, %{amount: amount}},
        {:schedule, 5_000, {:reconcile, order_id}}
      ]

      {new_state, effects}
    end
  end
end

defmodule Billing.Server do
  use GenServer
  alias Billing.{Domain, Effect}

  def init(_), do: {:ok, %Domain{}}

  def handle_call({:pay, order_id, amount}, _from, state) do
    {new_state, effects} = Domain.pay(state, order_id, amount)
    Enum.each(effects, &run_effect/1)
    {:reply, :ok, new_state}
  end

  def handle_info({:reconcile, order_id}, state) do
    # reconcile 로직도 동일하게 Domain으로 밀어넣는 것이 이상적
    {:noreply, state}
  end

  defp run_effect({:http_post, url, body, headers}) do
    # 실제 구현에서는 Finch, Req 등을 사용
    _ = {url, body, headers}
    :ok
  end

  defp run_effect({:persist, key, value}) do
    :persistent_term.put(key, value)
    :ok
  end

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

얻는 효과

  • 도메인 테스트에서 effects만 검증하면 됨(외부 네트워크 없이)
  • 실행기만 바꿔서 로컬/테스트/프로덕션 전략을 분리 가능
  • 타이머, 저장, HTTP 같은 부작용이 “어디서 발생하는지”가 한눈에 보임

패턴 4) call은 조회(Query), cast는 명령(Command)으로 정리하고, 조회는 순수함수로 끝낸다

GenServer는 쉽게 “읽기/쓰기”가 섞입니다. 하지만 읽기까지 내부에서 복잡한 계산을 하거나, 조회 중에 상태를 바꾸는 순간 디버깅 난이도가 올라갑니다.

  • handle_call: 조회 또는 “결과가 필요한 명령”
  • handle_cast: 비동기 명령
  • 조회 로직은 view(state) 같은 순수함수로 분리
defmodule RateLimiter.State do
  defstruct hits: %{}
end

defmodule RateLimiter.Domain do
  alias RateLimiter.State

  def hit(%State{} = state, key, now_ms) do
    hits = Map.update(state.hits, key, [now_ms], &[now_ms | &1])
    %State{state | hits: hits}
  end

  def remaining(%State{} = state, key, window_ms, limit, now_ms) do
    timestamps = Map.get(state.hits, key, [])
    alive = Enum.filter(timestamps, fn t -> now_ms - t <= window_ms end)
    max(limit - length(alive), 0)
  end
end

defmodule RateLimiter.Server do
  use GenServer
  alias RateLimiter.{State, Domain}

  def init(_), do: {:ok, %State{}}

  def handle_cast({:hit, key, now_ms}, state) do
    {:noreply, Domain.hit(state, key, now_ms)}
  end

  def handle_call({:remaining, key, window_ms, limit, now_ms}, _from, state) do
    {:reply, Domain.remaining(state, key, window_ms, limit, now_ms), state}
  end
end

얻는 효과

  • 조회는 상태를 바꾸지 않으니 테스트가 단순해짐
  • call에서 타임아웃이 나도 상태가 중간에 변하지 않아 안전
  • “읽기 때문에 락이 걸린다” 같은 오해를 줄이고, 병목을 찾기 쉬움

패턴 5) 타이머와 재시도를 “상태 전이 + 스케줄 효과”로 만들기

Process.send_after/3를 여기저기 박아두면, 어떤 메시지가 왜 오는지 추적이 어려워집니다. 타이머는 사실상 “미래에 도착하는 이벤트”이므로, 이를 도메인에서 명시적으로 다루면 깔끔해집니다.

아래 예시는 “주기적으로 플러시”하는 버퍼를 생각해볼 수 있습니다.

defmodule Buffer.Effect do
  @type t :: {:schedule, non_neg_integer(), term()} | {:flush, list()}
end

defmodule Buffer.State do
  defstruct items: [], scheduled?: false
end

defmodule Buffer.Domain do
  alias Buffer.{State, Effect}

  def add(%State{} = state, item) do
    state = %State{state | items: [item | state.items]}

    if state.scheduled? do
      {state, []}
    else
      {%State{state | scheduled?: true}, [{:schedule, 1_000, :tick}]}
    end
  end

  def tick(%State{} = state) do
    items = Enum.reverse(state.items)
    new_state = %State{state | items: [], scheduled?: false}
    {new_state, [{:flush, items}]}
  end
end

defmodule Buffer.Server do
  use GenServer
  alias Buffer.{State, Domain}

  def init(_), do: {:ok, %State{}}

  def handle_cast({:add, item}, state) do
    {new_state, effects} = Domain.add(state, item)
    run(effects)
    {:noreply, new_state}
  end

  def handle_info(:tick, state) do
    {new_state, effects} = Domain.tick(state)
    run(effects)
    {:noreply, new_state}
  end

  defp run(effects) do
    Enum.each(effects, fn
      {:schedule, ms, msg} -> Process.send_after(self(), msg, ms)
      {:flush, items} -> Logger.info("flush size=#{length(items)}")
    end)
  end
end

얻는 효과

  • 타이머가 “도메인 규칙”에 포함되어 예측 가능
  • scheduled? 같은 플래그로 중복 스케줄을 방지
  • 장애 시 타이머 폭주 여부를 상태로 설명 가능

운영 환경에서 타이밍 이슈는 종종 프로세스 재시작과 맞물려 증폭됩니다. 이런 류의 문제를 디버깅하는 관점은 Kubernetes 사례지만 본질이 유사하니 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅도 참고할 만합니다.


테스트 전략: GenServer 테스트를 줄이고, 도메인 테스트를 늘리기

순수함수로 분리하면 ExUnit 테스트가 매우 직관적으로 바뀝니다.

defmodule BufferDomainTest do
  use ExUnit.Case
  alias Buffer.{State, Domain}

  test "first add schedules tick" do
    state = %State{}
    {state2, effects} = Domain.add(state, :a)

    assert state2.scheduled? == true
    assert effects == [{:schedule, 1_000, :tick}]
  end

  test "tick flushes and resets" do
    state = %State{items: [:b, :a], scheduled?: true}
    {state2, effects} = Domain.tick(state)

    assert state2.items == []
    assert state2.scheduled? == false
    assert effects == [{:flush, [:a, :b]}]
  end
end

GenServer 자체는 “효과 실행이 호출되는지”, “메시지 라우팅이 올바른지” 정도만 얇게 통합 테스트로 남기는 편이 좋습니다.


마이그레이션 체크리스트

레거시 GenServer를 한 번에 갈아엎기 어렵다면 아래 순서가 안전합니다.

  1. 상태를 구조체로 고정하고 타입 스펙을 작성
  2. handle_*에서 상태 변경 코드를 Domain.reduce/2로 이동
  3. 외부 호출을 발견할 때마다 events 또는 effects로 끌어올리기
  4. 타이머는 send_after 직접 호출을 줄이고, {:schedule, ...}로 통일
  5. 도메인 테스트를 먼저 촘촘히 만들고, GenServer 테스트는 최소화

결론: GenServer는 동시성 컨테이너, 로직은 순수함수로

GenServer를 순수함수 중심으로 리팩터링하면 “프로세스 기반 동시성”의 장점은 유지하면서도, 코드의 대부분을 예측 가능한 함수로 바꿀 수 있습니다. 이 글의 5패턴을 요약하면 다음과 같습니다.

  • 상태 전이를 reduce/2로 모아 응집도를 올린다
  • 명령과 이벤트를 분리해 도메인 의도를 드러낸다
  • 부작용을 Effect로 데이터화해 테스트와 실행을 분리한다
  • 조회는 순수함수로 끝내고, callcast의 역할을 정리한다
  • 타이머/재시도는 상태 전이 규칙으로 끌어올려 중복과 폭주를 막는다

이 구조에 익숙해지면 GenServer는 더 이상 “거대한 클래스”가 아니라, 도메인 모델을 안전하게 실행하는 런타임 껍질로 자리 잡습니다.