Published on

Elixir OTP 함수형 상태 관리 - GenServer 안티패턴 5가지

Authors

서버 프로세스 하나에 상태를 모아두고 GenServer.call/2 로 모든 것을 처리하는 방식은 Elixir에서 아주 흔합니다. 문제는 이 패턴이 너무 쉽게 “전역 락 + 단일 병목 + 장애 전파”로 변질된다는 점입니다. OTP는 상태를 프로세스에 격리하고, 실패를 감시 트리로 통제하며, 동시성을 메시지로 자연스럽게 확장하도록 설계됐습니다.

이 글에서는 “함수형 상태 관리” 관점에서 GenServer를 과용할 때 자주 등장하는 안티패턴 5가지를 정리하고, 각 케이스별로 더 OTP다운 설계를 제안합니다. (참고로 운영 관점에서 장애를 빠르게 복구하는 패턴은 systemd 서비스 자동 재시작 - 죽었다 깨도 복구 같은 글과도 결이 맞습니다. OTP의 감독 트리도 같은 철학입니다.)

전제: 함수형 상태 관리란 무엇인가

Elixir는 기본적으로 불변 데이터 모델입니다. “상태 관리”는 보통 다음 중 하나로 구현합니다.

  • 상태를 값으로 전달: 순수 함수로 state -> {result, new_state} 형태
  • 상태를 프로세스에 격리: 프로세스 메일박스와 루프로 상태를 캡슐화
  • 상태를 외부 저장소로 이동: DB, 캐시, 이벤트 로그 등

GenServer는 두 번째 선택지의 대표 구현입니다. 하지만 GenServer 자체가 “함수형”인 것은 아닙니다. 내부에서 어떤 데이터를 어떻게 업데이트하고, 어떤 호출을 동기화하며, 어떤 부수효과를 어디에서 수행하는지에 따라 함수형 설계가 될 수도, 전형적인 공유 가변 상태처럼 동작할 수도 있습니다.

이제 안티패턴으로 들어가 보겠습니다.

안티패턴 1) 모든 요청을 call 로 처리해 단일 병목 만들기

증상

  • 읽기 요청까지 전부 GenServer.call/2
  • 응답 시간이 늘수록 메일박스가 적체
  • 트래픽이 증가하면 특정 프로세스가 “핫스팟”이 됨

GenServer는 한 번에 한 메시지만 처리합니다. 즉, call 을 남발하면 사실상 전역 락처럼 동작합니다.

나쁜 예시

defmodule Counter do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, 0, name: __MODULE__)

  def value(), do: GenServer.call(__MODULE__, :value)
  def inc(), do: GenServer.call(__MODULE__, :inc)

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

  @impl true
  def handle_call(:value, _from, n), do: {:reply, n, n}

  @impl true
  def handle_call(:inc, _from, n), do: {:reply, n + 1, n + 1}
end

개선 방향

  1. 읽기와 쓰기를 분리합니다. 읽기는 ETS 같은 공유 읽기 최적화 스토어를 사용하고, 쓰기만 단일 프로세스로 직렬화합니다.

  2. “정말로 동기 응답이 필요한가”를 재검토합니다. 단순 카운팅/집계는 cast 로 보내고, 필요할 때만 스냅샷을 읽는 모델이 더 잘 맞습니다.

대안 예시: ETS로 읽기 확장

defmodule Counter do
  use GenServer

  @table :counter_table

  def start_link(_), do: GenServer.start_link(__MODULE__, 0, name: __MODULE__)

  def value() do
    case :ets.lookup(@table, :value) do
      [{:value, v}] -> v
      [] -> 0
    end
  end

  def inc(), do: GenServer.cast(__MODULE__, :inc)

  @impl true
  def init(n) do
    :ets.new(@table, [:named_table, :public, read_concurrency: true])
    :ets.insert(@table, {:value, n})
    {:ok, n}
  end

  @impl true
  def handle_cast(:inc, n) do
    new_n = n + 1
    :ets.insert(@table, {:value, new_n})
    {:noreply, new_n}
  end
end

이렇게 하면 읽기 경로는 GenServer 병목을 타지 않습니다.

안티패턴 2) GenServer를 “도메인 서비스”처럼 비대화시키기

증상

  • handle_call/3 에 비즈니스 규칙, 검증, IO, 포맷팅이 뒤섞임
  • 상태 구조가 거대해지고, 메시지 타입이 끝없이 늘어남
  • 테스트가 어려워지고, 작은 변경이 큰 리스크가 됨

GenServer는 “상태를 가진 프로세스 루프”일 뿐, 도메인 로직의 집합소가 아닙니다.

나쁜 예시(의미상)

  • handle_call({:create_order, params}, ...) 내부에서
    • 검증
    • 가격 계산
    • DB 저장
    • 외부 결제 API 호출
    • 상태 업데이트
    • 로그/메트릭

개선 방향

  • 도메인 로직은 순수 함수 모듈로 분리하고
  • GenServer는 상태 전이와 동시성 제어만 담당합니다.

대안 예시: 순수 리듀서로 상태 전이 분리

defmodule Cart do
  defstruct items: %{}

  def add_item(%Cart{} = cart, sku, qty) when qty > 0 do
    new_items = Map.update(cart.items, sku, qty, &(&1 + qty))
    {:ok, %Cart{cart | items: new_items}}
  end

  def add_item(_cart, _sku, _qty), do: {:error, :invalid_qty}
end

defmodule CartServer do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, %Cart{}, name: __MODULE__)
  def add_item(sku, qty), do: GenServer.call(__MODULE__, {:add_item, sku, qty})

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

  @impl true
  def handle_call({:add_item, sku, qty}, _from, cart) do
    case Cart.add_item(cart, sku, qty) do
      {:ok, new_cart} -> {:reply, :ok, new_cart}
      {:error, reason} -> {:reply, {:error, reason}, cart}
    end
  end
end

이 구조의 장점은 명확합니다.

  • Cart 는 순수 함수라 단위 테스트가 쉽고
  • CartServer 는 동시성/상태 캡슐화만 담당합니다.

안티패턴 3) handle_call 안에서 느린 IO 수행하기

증상

  • DB 쿼리, HTTP 호출, 파일 IO를 handle_call/3 안에서 수행
  • 호출자가 타임아웃(GenServer.call 기본 5000ms)에 자주 걸림
  • 메일박스 적체로 연쇄 지연 발생

GenServer는 한 번에 하나의 메시지를 처리하므로, 느린 IO는 곧 “프로세스 전체 정지”와 같습니다.

나쁜 예시

@impl true
def handle_call({:fetch_user, id}, _from, state) do
  user = MyHttpClient.get_user(id) # 느린 네트워크 IO
  {:reply, user, state}
end

개선 방향

  • 동기 응답이 필요하면 작업을 다른 프로세스에서 수행하고 결과만 수신합니다.
  • Task 를 사용하되, 결과를 기다리는 동안 GenServer가 막히지 않게 설계합니다.
  • 또는 애초에 API를 비동기(cast)로 바꾸고, 결과는 나중에 조회하게 합니다.

대안 예시: handle_info 로 결과 수신(GenServer는 즉시 응답)

defmodule UserCache do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, %{users: %{}}, name: __MODULE__)

  def warm(id), do: GenServer.cast(__MODULE__, {:warm, id})
  def get(id), do: GenServer.call(__MODULE__, {:get, id})

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

  @impl true
  def handle_cast({:warm, id}, state) do
    task = Task.async(fn -> {id, MyHttpClient.get_user(id)} end)
    {:noreply, Map.put(state, :task, task)}
  end

  @impl true
  def handle_call({:get, id}, _from, state) do
    {:reply, Map.get(state.users, id), state}
  end

  @impl true
  def handle_info({ref, {id, user}}, state) do
    Process.demonitor(ref, [:flush])
    new_state = put_in(state, [:users, id], user)
    {:noreply, new_state}
  end

  @impl true
  def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do
    # 실패 처리
    {:noreply, state}
  end
end

핵심은 “느린 작업은 외부로”입니다. GenServer는 빠르게 메시지를 소비해야 합니다.

운영에서 타임아웃/지연을 진단하는 감각은 DB 튜닝 글인 PostgreSQL 느린 쿼리 튜닝 - auto_explain+pg_stat_statements 같은 접근과도 유사합니다. 병목을 한 곳에 가두지 말고, 관측 가능하게 분해해야 합니다.

안티패턴 4) 상태를 “거대한 맵”으로 두고 선형 탐색/복사를 반복하기

증상

  • 상태가 %{} 하나로 계속 커짐
  • 매 요청마다 Enum.find 같은 선형 탐색
  • 큰 리스트를 매번 갱신하며 GC 압박 증가

Elixir 자료구조는 영속 자료구조라 복사가 “전부” 일어나진 않지만, 큰 구조를 자주 업데이트하면 비용이 누적됩니다. 특히 GenServer는 단일 프로세스이므로 GC가 길어지면 처리 지연이 직접 체감됩니다.

나쁜 예시

# state = %{sessions: [%{id: "...", ...}, ...]}
@impl true
def handle_call({:get_session, id}, _from, state) do
  sess = Enum.find(state.sessions, fn s -> s.id == id end)
  {:reply, sess, state}
end

개선 방향

  • 조회 키가 명확하면 리스트 대신 Map 으로 바꿉니다.
  • 읽기가 많으면 ETS로 분리합니다.
  • “세션/커넥션/작업”처럼 개별 생명주기가 있으면 프로세스 분할이 더 OTP답습니다.

대안 예시: 세션 하나당 프로세스(Registry + DynamicSupervisor)

defmodule Session do
  use GenServer

  def start_link(id) do
    GenServer.start_link(__MODULE__, %{id: id}, name: via(id))
  end

  def via(id), do: {:via, Registry, {SessionRegistry, id}}

  def get(id), do: GenServer.call(via(id), :get)

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

  @impl true
  def handle_call(:get, _from, state), do: {:reply, state, state}
end

이 구조는 상태를 “한 덩어리”로 두지 않고, 자연스럽게 분산시킵니다. 장애도 세션 단위로 격리됩니다.

안티패턴 5) GenServer를 데이터 영속성의 단일 진실로 착각하기

증상

  • “GenServer가 살아있는 동안만” 유효한 상태를 핵심 데이터로 사용
  • 재시작 시 상태 유실
  • 배포/노드 장애 시 데이터 일관성 붕괴

GenServer 상태는 메모리입니다. 중요한 데이터라면 영속 계층이 필요합니다. OTP의 재시작은 “프로세스 복구”이지 “데이터 복구”가 아닙니다.

나쁜 예시

  • 주문, 결제, 포인트 같은 핵심 데이터를 GenServer 상태에만 저장
  • 주기적으로만 DB에 flush
  • flush 전에 크래시하면 손실

개선 방향

  • “진실의 원천”을 DB나 이벤트 로그로 둡니다.
  • GenServer는 캐시, 집계, rate limit 같은 파생 상태에 집중합니다.
  • 꼭 메모리 상태가 필요하면 스냅샷/저널링을 명시적으로 설계합니다.

대안 예시: DB를 소스로, GenServer는 캐시로

defmodule UserRepo do
  def get_user(id) do
    # DB에서 조회한다고 가정
    {:ok, %{id: id, name: "alice"}}
  end
end

defmodule UserCache do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  def get(id), do: GenServer.call(__MODULE__, {:get, id})

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

  @impl true
  def handle_call({:get, id}, _from, state) do
    case Map.fetch(state, id) do
      {:ok, user} ->
        {:reply, {:ok, user}, state}

      :error ->
        with {:ok, user} <- UserRepo.get_user(id) do
          {:reply, {:ok, user}, Map.put(state, id, user)}
        end
    end
  end
end

이렇게 하면 캐시가 날아가도 DB에서 복구 가능합니다.

체크리스트: “OTP다운 상태”를 위한 설계 질문

아래 질문에 “아니오”가 많다면 GenServer 설계를 재검토할 타이밍입니다.

  1. 이 상태는 정말로 한 프로세스에 직렬화되어야 하는가?
  2. 읽기 경로가 call 병목을 만들지 않는가?
  3. handle_call 안에 네트워크/DB/파일 IO가 들어있지 않은가?
  4. 도메인 로직이 GenServer에 붙어 비대해지지 않았는가?
  5. 이 상태가 날아가면(재시작/배포/노드 장애) 서비스가 깨지는가?

마무리

GenServer는 “상태를 안전하게 감싸는 도구”이지, 상태를 한 곳에 모으는 만능 컨테이너가 아닙니다. 함수형 상태 관리의 핵심은 상태 전이를 순수 함수로 만들고, 동시성 병목을 구조적으로 제거하며, 실패를 격리하는 것입니다.

  • 병목이 보이면 읽기/쓰기 분리(ETS)나 프로세스 분할(Registry, DynamicSupervisor)
  • 복잡해지면 도메인 로직을 순수 함수로 분리
  • IO는 GenServer 밖에서 수행하고 결과만 메시지로 합류
  • 핵심 데이터는 메모리 상태가 아니라 영속 계층을 진실로

이 5가지만 지켜도 GenServer는 훨씬 가볍고, 테스트 가능하며, 장애에 강한 방향으로 바뀝니다.