- Published on
Elixir GenServer를 순수 함수로 리팩터링 6단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 프로세스가 상태를 들고 있고, handle_call/3 과 handle_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_item 과 checkout 의 규칙을 순수 함수로 만듭니다. 여기서는 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/2와CartStore및 외부 모듈
이렇게 분리하면 장애 분석이 쉬워집니다. 예를 들어 결제 실패는 순수 로직이 아니라 Shop.Payments 경로에서만 발생합니다.
5단계: 저장소와 외부 의존성을 포트로 추상화하기
리팩터링의 다음 병목은 테스트입니다. GenServer가 Shop.CartStore 나 Shop.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단계를 요약하면 다음과 같습니다.
- GenServer 상태를 순수 데이터 구조로 명시한다
- 도메인 규칙을 순수 함수로 옮긴다
- 부작용을 커맨드로 모델링해 반환한다
- GenServer는 커맨드 해석기 역할만 한다
- 저장소/외부 호출을 포트로 추상화해 테스트 가능하게 만든다
- 직렬화 범위를 명확히 하고, 순수 로직 테스트를 중심에 둔다
이 구조로 바꾸면, 코드가 "동작"하는 것을 넘어 "설명 가능"해집니다. 장애가 났을 때 어디를 봐야 하는지, 어떤 변경이 어떤 리스크를 가지는지, 테스트가 무엇을 보장하는지 선명해집니다.
GenServer는 Elixir의 강력한 무기입니다. 하지만 GenServer 안에 모든 것을 넣는 순간 그 무기는 양날이 됩니다. 순수 함수로 핵심을 분리하면, GenServer는 더 단단하고 예측 가능한 형태로 오래 유지됩니다.