Published on

Elixir GenServer를 순수 함수로 리팩터링 6단계

Authors

서버 프로세스가 상태를 들고 있고, handle_call/3handle_cast/2 안에서 DB 조회나 외부 API 호출까지 한 번에 처리하는 GenServer는 처음엔 빠르게 만들기 좋습니다. 하지만 시간이 지나면 문제가 쌓입니다.

  • 핵심 규칙이 콜백 내부에 흩어져 테스트가 어렵다
  • 부작용이 섞여 실패 지점이 불명확하다
  • 동시성 이슈를 재현하기 어렵고, 작은 변경이 전체 서버를 흔든다

이 글은 GenServer를 없애자는 이야기가 아닙니다. GenServer는 여전히 훌륭한 런타임 추상화입니다. 다만 "상태 머신과 도메인 규칙"을 순수 함수로 끌어내고, GenServer는 "메시지 라우팅과 부작용 실행"에만 집중시키는 방식으로 구조를 바꾸는 6단계 리팩터링을 제안합니다.

예제: 장바구니 GenServer의 전형적인 문제

먼저 흔히 보이는 형태를 간단히 만들어 보겠습니다. 장바구니에 상품을 담고, 체크아웃 시 결제를 요청한다고 가정합니다.

defmodule Shop.CartServer do
  use GenServer

  def start_link(cart_id) do
    GenServer.start_link(__MODULE__, cart_id, name: via(cart_id))
  end

  def add_item(cart_id, sku, qty) do
    GenServer.call(via(cart_id), {:add_item, sku, qty})
  end

  def checkout(cart_id, user_id) do
    GenServer.call(via(cart_id), {:checkout, user_id})
  end

  @impl true
  def init(cart_id) do
    cart = Shop.Repo.get!(Shop.Cart, cart_id)
    {:ok, cart}
  end

  @impl true
  def handle_call({:add_item, sku, qty}, _from, cart) do
    # 도메인 규칙 + DB + 상태 변경이 뒤섞임
    item = Shop.Repo.get_by!(Shop.Item, sku: sku)

    if qty <= 0 do
      {:reply, {:error, :invalid_qty}, cart}
    else
      cart = Shop.Cart.add(cart, item, qty)
      Shop.Repo.update!(cart)
      {:reply, :ok, cart}
    end
  end

  @impl true
  def handle_call({:checkout, user_id}, _from, cart) do
    # 외부 결제 호출까지 콜백에서 수행
    case Shop.Payments.charge(user_id, cart.total_price) do
      {:ok, charge_id} ->
        cart = Shop.Cart.mark_paid(cart, charge_id)
        Shop.Repo.update!(cart)
        {:reply, {:ok, charge_id}, cart}

      {:error, reason} ->
        {:reply, {:error, reason}, cart}
    end
  end

  defp via(cart_id), do: {:via, Registry, {Shop.CartRegistry, cart_id}}
end

이 코드는 동작하지만, 테스트하려면 DB와 결제 모듈을 함께 세팅해야 합니다. 또한 콜백 안에서 실패가 나면 상태 일관성도 흔들릴 수 있습니다.

이제 6단계로 순수 함수 중심으로 바꿔보겠습니다.

1단계: 상태 구조를 명시적으로 정의하기

첫 단계는 "GenServer 상태가 무엇인가"를 타입처럼 명확히 만드는 것입니다. Ecto 스키마를 그대로 상태로 쓰면 부작용 경계가 흐려집니다.

상태를 도메인 모델로 분리합니다.

defmodule Shop.Cart do
  @type t :: %__MODULE__{
          id: String.t(),
          items: %{String.t() => non_neg_integer()},
          status: :open | :paid,
          total_price: non_neg_integer(),
          charge_id: String.t() | nil
        }

  defstruct [:id, items: %{}, status: :open, total_price: 0, charge_id: nil]
end

핵심은 "이 상태는 메모리 안에서만 의미가 있는 순수 데이터"라는 점입니다. DB 스키마나 외부 의존성을 상태에서 떼어냅니다.

2단계: 도메인 연산을 Cart 모듈 순수 함수로 옮기기

이제 add_itemcheckout 의 규칙을 순수 함수로 만듭니다. 여기서는 DB 조회나 결제 호출을 하지 않습니다. 필요한 값은 인자로 받습니다.

defmodule Shop.Cart do
  @type error :: :invalid_qty | :already_paid

  @spec add_item(t(), String.t(), non_neg_integer(), non_neg_integer()) ::
          {:ok, t()} | {:error, error()}
  def add_item(%__MODULE__{status: :paid} = _cart, _sku, _qty, _unit_price),
    do: {:error, :already_paid}

  def add_item(%__MODULE__{} = cart, sku, qty, unit_price) when qty > 0 do
    new_qty = Map.get(cart.items, sku, 0) + qty

    cart = %__MODULE__{
      cart
      | items: Map.put(cart.items, sku, new_qty),
        total_price: cart.total_price + qty * unit_price
    }

    {:ok, cart}
  end

  def add_item(%__MODULE__{}, _sku, _qty, _unit_price), do: {:error, :invalid_qty}

  @spec mark_paid(t(), String.t()) :: {:ok, t()} | {:error, error()}
  def mark_paid(%__MODULE__{status: :paid}, _charge_id), do: {:error, :already_paid}

  def mark_paid(%__MODULE__{} = cart, charge_id) do
    {:ok, %__MODULE__{cart | status: :paid, charge_id: charge_id}}
  end
end

이제 핵심 규칙은 GenServer 밖으로 나왔고, ExUnit으로 순수하게 테스트할 수 있습니다.

3단계: 부작용을 "명령"으로 모델링하기

순수 함수로만 만들면 checkout 에서 결제를 어떻게 처리할지 애매해집니다. 해결책은 "부작용을 직접 실행하지 말고, 실행할 일을 데이터로 반환"하는 것입니다.

간단한 커맨드 구조를 정의합니다.

defmodule Shop.Cart.Command do
  @type t ::
          {:charge, %{user_id: String.t(), amount: non_neg_integer()}} |
          {:persist, %{cart: Shop.Cart.t()}}
end

그리고 도메인 함수가 상태 변경과 함께 커맨드를 반환하도록 바꿉니다.

defmodule Shop.Cart do
  alias Shop.Cart.Command

  @spec plan_checkout(t(), String.t()) ::
          {:ok, t(), [Command.t()]} | {:error, :already_paid}
  def plan_checkout(%__MODULE__{status: :paid}, _user_id), do: {:error, :already_paid}

  def plan_checkout(%__MODULE__{} = cart, user_id) do
    cmds = [
      {:charge, %{user_id: user_id, amount: cart.total_price}}
    ]

    {:ok, cart, cmds}
  end
end

여기서 중요한 점은 plan_checkout/2 가 결제를 실행하지 않는다는 것입니다. "결제가 필요하다"는 사실만 커맨드로 표현합니다.

4단계: GenServer는 "해석기" 역할만 하게 만들기

이제 GenServer는 도메인 함수를 호출하고, 반환된 커맨드를 실행하는 해석기 역할만 합니다.

defmodule Shop.CartServer do
  use GenServer

  alias Shop.Cart

  def start_link(cart_id), do: GenServer.start_link(__MODULE__, cart_id, name: via(cart_id))

  def add_item(cart_id, sku, qty), do: GenServer.call(via(cart_id), {:add_item, sku, qty})
  def checkout(cart_id, user_id), do: GenServer.call(via(cart_id), {:checkout, user_id})

  @impl true
  def init(cart_id) do
    cart = Shop.CartStore.load(cart_id)
    {:ok, cart}
  end

  @impl true
  def handle_call({:add_item, sku, qty}, _from, cart) do
    # DB 조회는 여기서 하되, 규칙은 Cart에 둔다
    unit_price = Shop.Catalog.unit_price!(sku)

    case Cart.add_item(cart, sku, qty, unit_price) do
      {:ok, cart} ->
        :ok = Shop.CartStore.save(cart)
        {:reply, :ok, cart}

      {:error, reason} ->
        {:reply, {:error, reason}, cart}
    end
  end

  @impl true
  def handle_call({:checkout, user_id}, _from, cart) do
    case Cart.plan_checkout(cart, user_id) do
      {:ok, cart, cmds} ->
        case run_commands(cmds, cart) do
          {:ok, cart, result} ->
            {:reply, result, cart}

          {:error, reason} ->
            {:reply, {:error, reason}, cart}
        end

      {:error, reason} ->
        {:reply, {:error, reason}, cart}
    end
  end

  defp run_commands(cmds, cart) do
    Enum.reduce_while(cmds, {:ok, cart, :ok}, fn
      {:charge, %{user_id: user_id, amount: amount}}, {:ok, cart, _} ->
        case Shop.Payments.charge(user_id, amount) do
          {:ok, charge_id} ->
            {:ok, cart} = Cart.mark_paid(cart, charge_id)
            :ok = Shop.CartStore.save(cart)
            {:cont, {:ok, cart, {:ok, charge_id}}}

          {:error, reason} ->
            {:halt, {:error, reason}}
        end

      {:persist, %{cart: cart}}, {:ok, _cart, result} ->
        :ok = Shop.CartStore.save(cart)
        {:cont, {:ok, cart, result}}
    end)
  end

  defp via(cart_id), do: {:via, Registry, {Shop.CartRegistry, cart_id}}
end

handle_call/3 의 책임이 명확해집니다.

  • 상태 전이 규칙은 Shop.Cart
  • 부작용은 run_commands/2CartStore 및 외부 모듈

이렇게 분리하면 장애 분석이 쉬워집니다. 예를 들어 결제 실패는 순수 로직이 아니라 Shop.Payments 경로에서만 발생합니다.

5단계: 저장소와 외부 의존성을 포트로 추상화하기

리팩터링의 다음 병목은 테스트입니다. GenServer가 Shop.CartStoreShop.Payments 를 직접 호출하면 여전히 통합 테스트 위주가 됩니다.

Elixir에서는 Behaviour로 포트를 만들고, 런타임에 구현체를 주입하는 패턴이 실용적입니다.

defmodule Shop.PaymentsPort do
  @callback charge(String.t(), non_neg_integer()) :: {:ok, String.t()} | {:error, term()}
end

defmodule Shop.CartStorePort do
  @callback load(String.t()) :: Shop.Cart.t()
  @callback save(Shop.Cart.t()) :: :ok | {:error, term()}
end

GenServer는 모듈을 옵션으로 받게 합니다.

def init({cart_id, deps}) do
  payments = Keyword.fetch!(deps, :payments)
  store = Keyword.fetch!(deps, :store)

  cart = store.load(cart_id)
  {:ok, %{cart: cart, payments: payments, store: store}}
end

이렇게 하면 테스트에서 Mox 같은 도구로 결제/저장소를 모킹하거나, 인메모리 구현체를 넣을 수 있습니다.

이 단계는 "동시성 컴포넌트"와 "도메인"의 경계를 더 단단히 만들어, 대규모 시스템에서 변경 비용을 낮춥니다. 분산 환경에서 중복 이벤트나 정확히 한 번 처리 같은 이슈를 다룰 때도 이런 경계가 큰 도움이 됩니다. 관련해서는 이벤트 중복 진단 관점의 글인 Kafka 정확히-한번? MSA 중복이벤트 5분 진단도 함께 읽어보면 좋습니다.

6단계: 동시성 전략을 명시하고, 순수 로직을 중심으로 테스트하기

마지막 단계는 "이제 무엇을 GenServer가 보장해야 하는가"를 명확히 하는 것입니다. GenServer는 본질적으로 프로세스 단위 직렬화를 제공합니다. 하지만 모든 것을 한 프로세스에 몰아넣는 것이 능사는 아닙니다.

6-1. 무엇을 직렬화할지 결정하기

  • 장바구니 단위로 직렬화가 필요하면 cart_id 별 GenServer 유지
  • 결제는 느리고 실패 가능성이 높으므로, 필요하면 별도 워커나 Task.Supervisor 로 분리
  • 저장은 빈번하면 배치하거나 스냅샷 전략 고려

핵심은 "직렬화가 필요한 상태 전이"만 GenServer에 남기는 것입니다.

6-2. 순수 함수 테스트를 먼저 촘촘히 깔기

Shop.Cart 는 완전히 순수하므로 테스트가 빠르고 결정적입니다.

defmodule Shop.CartTest do
  use ExUnit.Case, async: true

  alias Shop.Cart

  test "add_item increases total_price" do
    cart = %Cart{id: "c1"}

    assert {:ok, cart} = Cart.add_item(cart, "SKU1", 2, 1000)
    assert cart.items["SKU1"] == 2
    assert cart.total_price == 2000
  end

  test "cannot add_item when already paid" do
    cart = %Cart{id: "c1", status: :paid}
    assert {:error, :already_paid} = Cart.add_item(cart, "SKU1", 1, 1000)
  end

  test "plan_checkout returns charge command" do
    cart = %Cart{id: "c1", total_price: 3000}

    assert {:ok, _cart, cmds} = Cart.plan_checkout(cart, "u1")
    assert cmds == [{:charge, %{user_id: "u1", amount: 3000}}]
  end
end

이 테스트들은 DB도 없고, 프로세스도 없고, 타이밍 이슈도 없습니다. 변경에 강한 테스트 기반이 됩니다.

6-3. GenServer 테스트는 "해석"만 검증하기

GenServer 테스트에서는 순수 로직이 아니라 "커맨드를 올바르게 실행하는가"를 얕게 확인합니다. 이때 포트 추상화가 큰 역할을 합니다.

또한 운영 관점에서 장애 격리와 재시도 정책을 명시적으로 설계해야 합니다. 예를 들어 결제 성공 후 저장 실패 같은 케이스는 사가 패턴이나 보상 트랜잭션으로 풀어야 하는데, 이 맥락에서는 DDD에서 분산 트랜잭션 없이 SAGA 구현하기가 직접적인 힌트를 줍니다.

마무리: GenServer는 남기고, 복잡도는 밖으로 뺀다

6단계를 요약하면 다음과 같습니다.

  1. GenServer 상태를 순수 데이터 구조로 명시한다
  2. 도메인 규칙을 순수 함수로 옮긴다
  3. 부작용을 커맨드로 모델링해 반환한다
  4. GenServer는 커맨드 해석기 역할만 한다
  5. 저장소/외부 호출을 포트로 추상화해 테스트 가능하게 만든다
  6. 직렬화 범위를 명확히 하고, 순수 로직 테스트를 중심에 둔다

이 구조로 바꾸면, 코드가 "동작"하는 것을 넘어 "설명 가능"해집니다. 장애가 났을 때 어디를 봐야 하는지, 어떤 변경이 어떤 리스크를 가지는지, 테스트가 무엇을 보장하는지 선명해집니다.

GenServer는 Elixir의 강력한 무기입니다. 하지만 GenServer 안에 모든 것을 넣는 순간 그 무기는 양날이 됩니다. 순수 함수로 핵심을 분리하면, GenServer는 더 단단하고 예측 가능한 형태로 오래 유지됩니다.