Published on

Elixir GenServer 교착·상태꼬임을 순수함수로 리팩터링

Authors

서버 사이드 Elixir에서 GenServer는 강력하지만, 모든 로직을 프로세스 상태에 얹기 시작하면 어느 순간부터 “가끔 멈춤”, “상태가 뒤틀림”, “재현이 어려운 타임아웃” 같은 증상이 생깁니다. 대개는 교착에 준하는 대기(특히 GenServer.call/3 타임아웃) 또는 상태 전이의 비원자성(여러 메시지/요청이 섞이며 기대와 다른 순서로 적용) 때문에 발생합니다.

이 글은 GenServer를 버리자는 이야기가 아닙니다. GenServer는 얇게, 도메인 로직은 순수함수로 두껍게 가져가면, 교착·상태꼬임을 줄이고 테스트 가능성까지 크게 올릴 수 있습니다.

GenServer에서 자주 터지는 문제 패턴

1) call 체인으로 만든 “사실상 교착”

Elixir/Erlang은 공유 메모리 대신 메시지 패싱이라 고전적 의미의 락 교착은 덜하지만, 아래 패턴은 실무에서 교착처럼 보이는 정지를 쉽게 만듭니다.

  • A가 B에 GenServer.call을 보냄
  • B가 처리 도중 A에 GenServer.call을 보냄
  • 둘 다 상대의 응답을 기다리며 블로킹

특히 한 프로세스가 handle_call 안에서 다른 call을 동기적으로 기다리면 위험합니다.

def handle_call({:reserve, item_id}, _from, state) do
  # 나쁜 예: handle_call 안에서 다른 GenServer.call을 기다림
  price = GenServer.call(Pricing, {:price, item_id}, 5_000)
  {:reply, {:ok, price}, state}
end

위 코드는 Pricing이 다시 Cartcall을 보내는 순간, 서로 대기하며 타임아웃이 연쇄적으로 발생할 수 있습니다.

2) 장시간 작업을 handle_call에서 수행

handle_call은 호출자에게 응답하기 전까지 서버가 다음 메시지를 처리하지 못합니다. DB 쿼리, HTTP 호출, 파일 IO, 암호화 등 느릴 수 있는 작업handle_call에서 하면 서버가 “멈춘 것처럼” 보입니다.

  • 큐가 쌓이고
  • 호출자는 타임아웃이 나고
  • 그 사이 상태는 더 꼬이기 쉬워집니다

이건 DB 데드락과도 비슷한 면이 있습니다. 병목이 생기면 대기가 꼬리를 물고, 결국은 타임아웃이나 재시도 폭주로 이어집니다. 데드락 관점이 익숙하다면 이 글도 같이 보면 도움이 됩니다: MySQL InnoDB 데드락 1213 에러 원인·해결

3) 상태를 “여기저기서” 직접 조립

상태 갱신 로직이 여러 handle_call/handle_cast/handle_info에 흩어지면, 다음 문제가 생깁니다.

  • 어떤 이벤트가 어떤 불변식을 깨는지 추적이 어려움
  • 특정 순서에서만 깨지는 버그가 생김
  • 테스트가 GenServer 통합 테스트로만 가능해짐

핵심은 상태 전이 규칙을 한 곳에 모으는 것입니다.

리팩터링 목표: GenServer는 어댑터, 도메인은 순수함수

리팩터링의 이상적인 구조는 이렇습니다.

  • 도메인 모듈: 순수함수로 상태와 명령을 받아 새 상태와 결과를 반환
  • GenServer: 메시지 수신, 상태 보관, 사이드이펙트 실행, 타이머/모니터링 처리

즉, GenServer는 “프로세스와 메시지”라는 런타임 관심사만 담당하고, 비즈니스 규칙은 순수함수로 분리합니다.

아래는 장바구니를 예로 든 구조입니다.

예시 1: 상태 전이를 순수함수로 분리하기

(1) 도메인: CartDomain은 상태 전이만 담당

defmodule CartDomain do
  @type item_id :: binary()
  @type qty :: pos_integer()

  @type t :: %{
          items: %{optional(item_id()) => qty()},
          version: non_neg_integer()
        }

  def new(), do: %{items: %{}, version: 0}

  @spec add_item(t(), item_id(), qty()) :: {:ok, t()} | {:error, term()}
  def add_item(state, item_id, qty) when is_binary(item_id) and qty > 0 do
    items = Map.update(state.items, item_id, qty, &(&1 + qty))
    {:ok, bump(%{state | items: items})}
  end

  def add_item(_state, _item_id, _qty), do: {:error, :invalid_input}

  @spec remove_item(t(), item_id()) :: {:ok, t()} | {:error, term()}
  def remove_item(state, item_id) do
    if Map.has_key?(state.items, item_id) do
      {:ok, bump(%{state | items: Map.delete(state.items, item_id)})}
    else
      {:error, :not_found}
    end
  end

  defp bump(state), do: %{state | version: state.version + 1}
end

여기서 중요한 점:

  • 함수는 입력 상태를 받아 새 상태를 돌려줍니다
  • 외부 IO가 없습니다
  • 불변식(예: qty > 0)을 도메인에서 강제합니다

(2) GenServer: 메시지를 도메인 함수에 위임

defmodule CartServer do
  use GenServer

  def start_link(opts) do
    name = Keyword.fetch!(opts, :name)
    GenServer.start_link(__MODULE__, :ok, name: name)
  end

  def init(:ok) do
    {:ok, CartDomain.new()}
  end

  def add_item(server, item_id, qty) do
    GenServer.call(server, {:add_item, item_id, qty})
  end

  def remove_item(server, item_id) do
    GenServer.call(server, {:remove_item, item_id})
  end

  def handle_call({:add_item, item_id, qty}, _from, state) do
    case CartDomain.add_item(state, item_id, qty) do
      {:ok, new_state} -> {:reply, :ok, new_state}
      {:error, reason} -> {:reply, {:error, reason}, state}
    end
  end

  def handle_call({:remove_item, item_id}, _from, state) do
    case CartDomain.remove_item(state, item_id) do
      {:ok, new_state} -> {:reply, :ok, new_state}
      {:error, reason} -> {:reply, {:error, reason}, state}
    end
  end
end

이 구조로 얻는 효과:

  • 상태 전이 로직이 CartDomain에 모여서 상태꼬임 원인 추적이 쉬워짐
  • 도메인은 순수함수라 단위 테스트가 매우 쉬움
  • GenServer는 얇아서 교착/타임아웃 문제도 진단 포인트가 명확해짐

예시 2: 장시간 작업을 “명령”으로 분리하고 비동기화

장시간 작업을 handle_call에서 처리하지 말고, 도메인에서 “해야 할 일”을 명령(command) 으로 반환한 뒤 GenServer가 실행하게 만들면 좋습니다.

(1) 도메인: 상태 전이 + 커맨드 생성

defmodule CheckoutDomain do
  @type t :: %{cart: map(), status: :idle | :checking_out}

  def new(cart), do: %{cart: cart, status: :idle}

  # 커맨드를 반환해 GenServer가 사이드이펙트를 실행하게 함
  def begin_checkout(%{status: :idle} = state) do
    new_state = %{state | status: :checking_out}
    commands = [{:fetch_tax, state.cart}, {:reserve_inventory, state.cart}]
    {:ok, new_state, commands}
  end

  def begin_checkout(state), do: {:error, :invalid_state, state, []}

  def checkout_succeeded(state), do: {:ok, %{state | status: :idle}}
  def checkout_failed(state, _reason), do: {:ok, %{state | status: :idle}}
end

(2) GenServer: 커맨드를 Task로 실행하고 결과를 메시지로 받기

defmodule CheckoutServer do
  use GenServer

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: opts[:name])

  def init(opts) do
    {:ok, %{domain: CheckoutDomain.new(opts[:cart]), pending: MapSet.new()}}
  end

  def begin_checkout(server) do
    GenServer.call(server, :begin_checkout)
  end

  def handle_call(:begin_checkout, _from, %{domain: domain} = state) do
    case CheckoutDomain.begin_checkout(domain) do
      {:ok, new_domain, commands} ->
        pending =
          Enum.reduce(commands, state.pending, fn cmd, acc ->
            ref = Task.Supervisor.async_nolink(CheckoutTasks, fn -> run(cmd) end).ref
            MapSet.put(acc, ref)
          end)

        {:reply, :ok, %{state | domain: new_domain, pending: pending}}

      {:error, reason, same_domain, _} ->
        {:reply, {:error, reason}, %{state | domain: same_domain}}
    end
  end

  def handle_info({ref, {:ok, _result}}, state) do
    Process.demonitor(ref, [:flush])
    pending = MapSet.delete(state.pending, ref)

    # 모든 작업이 끝났을 때 상태 전이
    state =
      if MapSet.size(pending) == 0 do
        {:ok, domain} = CheckoutDomain.checkout_succeeded(state.domain)
        %{state | domain: domain, pending: pending}
      else
        %{state | pending: pending}
      end

    {:noreply, state}
  end

  def handle_info({:DOWN, ref, :process, _pid, reason}, state) do
    pending = MapSet.delete(state.pending, ref)
    {:ok, domain} = CheckoutDomain.checkout_failed(state.domain, reason)
    {:noreply, %{state | domain: domain, pending: pending}}
  end

  defp run({:fetch_tax, cart}) do
    # 외부 API 호출 가정
    _ = cart
    {:ok, :tax_done}
  end

  defp run({:reserve_inventory, cart}) do
    _ = cart
    {:ok, :inventory_done}
  end
end

핵심 포인트:

  • begin_checkout은 빠르게 응답하고, 느린 작업은 Task로 분리
  • GenServer는 작업 완료 이벤트만 받아 상태를 정리
  • 도메인 상태 전이는 여전히 순수함수

이 패턴은 트래픽이 몰릴 때 타임아웃과 재시도가 폭주하는 상황(예: 429 대응)에서도 유사한 설계 감각이 필요합니다. 레이트리밋/재시도 설계 관점은 OpenAI 429와 Rate Limit 헤더로 재시도 설계도 참고할 만합니다.

상태꼬임을 줄이는 체크리스트

1) handle_call 안에서 다른 GenServer.call을 피하기

  • 정말 필요하다면 cast로 바꾸거나
  • 아예 “커맨드 반환 후 비동기 실행” 구조로 바꾸세요

2) 상태는 “한 곳”에서만 바꾸기

  • 도메인 모듈에 apply_event 또는 command 함수로 모읍니다
  • GenServer는 state = Domain.f(state, ...) 형태로만 갱신하도록 제한합니다

3) 불변식과 검증은 도메인에서

  • 입력 검증이 핸들러 여기저기에 흩어지면 순서에 따라 예외 케이스가 생깁니다
  • 도메인에서 일관되게 반환 타입을 유지하세요

4) 관측 가능성: 타임아웃과 큐 길이를 보라

  • GenServer.call 타임아웃이 늘면 “서버가 오래 잡고 있다”는 뜻입니다
  • 메시지 큐가 길어지는지(프로세스 mailbox) 관측하면 병목을 빨리 찾습니다

테스트 전략: GenServer 통합 테스트를 줄이기

도메인이 순수함수면 ExUnit으로 아주 싸고 빠르게 테스트할 수 있습니다.

defmodule CartDomainTest do
  use ExUnit.Case, async: true

  test "add_item increments qty and bumps version" do
    s0 = CartDomain.new()
    {:ok, s1} = CartDomain.add_item(s0, "A", 2)
    {:ok, s2} = CartDomain.add_item(s1, "A", 1)

    assert s2.items["A"] == 3
    assert s2.version == s0.version + 2
  end

  test "remove_item fails when missing" do
    s0 = CartDomain.new()
    assert {:error, :not_found} = CartDomain.remove_item(s0, "X")
  end
end

GenServer 테스트는 “메시지 라우팅이 맞는지”, “비동기 커맨드 완료 시 상태가 정리되는지” 정도로 최소화하는 것이 유지보수에 유리합니다.

언제 GenServer가 꼭 필요하고, 언제 과한가

  • 꼭 필요한 경우

    • 단일 프로세스 직렬화가 곧 비즈니스 요구인 경우(예: 특정 리소스에 대한 순차 처리)
    • 캐시, 세션, rate limit 토큰 버킷처럼 “프로세스가 상태를 들고 있는 것”이 자연스러운 경우
  • 과한 경우

    • 단순 CRUD에 가까운데 상태를 과하게 쌓는 경우
    • 외부 IO를 handle_call에서 다 처리하며 서버를 병목으로 만드는 경우

GenServer는 “상태를 안전하게 보관하는 상자”이지, “모든 로직을 넣는 블랙박스”가 되면 디버깅 비용이 급증합니다.

마무리: 교착·상태꼬임을 줄이는 가장 확실한 방법

정리하면, GenServer 문제의 상당수는 동기 대기(call)의 전염상태 전이 규칙의 분산에서 시작합니다. 해결책은 어렵지 않습니다.

  • 도메인 로직을 순수함수로 분리해 상태 전이를 한 곳에 모으고
  • GenServer는 메시지 수신과 사이드이펙트 실행에 집중하며
  • 장시간 작업은 커맨드로 분리해 비동기화하세요

이렇게 리팩터링하면, “가끔 멈추는” 시스템이 “항상 같은 규칙으로 움직이는” 시스템으로 바뀝니다. Debugging도, 테스트도, 운영도 훨씬 단단해집니다.