PartitionSupervisor — 같은 자식을 여러 파티션으로 나누는 슈퍼바이저

PartitionSupervisor — 같은 자식을 여러 파티션으로 나누는 슈퍼바이저

시스템이 커지면 특정 프로세스 하나가 병목(bottleneck)이 되기 쉬워요. 그런데 그 프로세스의 상태를 의존성 없이 쉽게 쪼갤 수 있다면 어떨까요? 그럴 때 여러 개의 격리된 독립 파티션을 만들어 주는 슈퍼바이저가 바로 PartitionSupervisor예요.

출처: PartitionSupervisor

본문

큰 시스템에서 특정 프로세스가 병목이 될 수 있어요. 그 프로세스의 상태를 서로 의존 관계 없이 **간단히 파티셔닝(partitioning)**할 수 있다면, PartitionSupervisor로 여러 개의 격리되고 독립적인 파티션을 만들 수 있어요.

PartitionSupervisor가 시작되면 {:via, PartitionSupervisor, {name, key}} 형식으로 자식에게 디스패치할 수 있어요. 여기서 namePartitionSupervisor의 이름이고, key는 라우팅에 쓰여요.

이 모듈은 Elixir v1.14.0에서 도입되었어요.

간단한 예시

그 자체로는 유용하지 않지만, 파티션이 어떻게 시작되고 메시지가 어떻게 라우팅되는지 보여 주는 예시부터 시작할게요.

다음은 받은 메시지를 그냥 모으는 장난감 GenServer예요. 쉽게 보여 주기 위해 메시지를 출력해요:

defmodule Collector do
  use GenServer

  def start_link(args) do
    GenServer.start_link(__MODULE__, args)
  end

  def init(args) do
    IO.inspect([__MODULE__, " got args ", args, " in ", self()])
    {:ok, _initial_state = []}
  end

  def collect(server, msg) do
    GenServer.call(server, {:collect, msg})
  end

  def handle_call({:collect, msg}, _from, state) do
    new_state = [msg | state]
    IO.inspect(["current messages:", new_state, " in process", self()])
    {:reply, :ok, new_state}
  end
end

이런 프로세스를 여러 개 실행하려면, 감독 트리(supervision tree)에 이렇게 넣어 PartitionSupervisor 아래에서 시작하면 돼요:

{PartitionSupervisor,
 child_spec: Collector.child_spec([some: :arg]),
 name: MyApp.PartitionSupervisor}

이제 "via 튜플"을 이용해 메시지를 보낼 수 있어요:

# key는 메시지를 특정 인스턴스로 라우팅하는 데 쓰인다.
key = 1
Collector.collect({:via, PartitionSupervisor, {MyApp.PartitionSupervisor, key}}, :hi)
# ["current messages:", [:hi], " in process", #PID<0.602.0>]
:ok
Collector.collect({:via, PartitionSupervisor, {MyApp.PartitionSupervisor, key}}, :ho)
# ["current messages:", [:ho, :hi], " in process", #PID<0.602.0>]
:ok

# key가 다르면 메시지는 다른 인스턴스로 라우팅된다.
key = 2
Collector.collect({:via, PartitionSupervisor, {MyApp.PartitionSupervisor, key}}, :a)
# ["current messages:", [:a], " in process", #PID<0.603.0>]
:ok
Collector.collect({:via, PartitionSupervisor, {MyApp.PartitionSupervisor, key}}, :b)
# ["current messages:", [:b, :a], " in process", #PID<0.603.0>]
:ok

같은 key를 쓰면 같은 인스턴스(같은 PID)로, 다른 key를 쓰면 다른 인스턴스로 메시지가 가는 걸 볼 수 있어요. 이제 유용한 예시로 넘어갈게요.

DynamicSupervisor 예시

DynamicSupervisor는 다른 프로세스를 시작하는 일을 담당하는 단일 프로세스예요. 어떤 애플리케이션에서는 DynamicSupervisor가 병목이 될 수도 있어요. 이 문제를 해결하려면 PartitionSupervisor를 통해 DynamicSupervisor 인스턴스를 여러 개 시작하고, 자식을 시작할 "무작위" 인스턴스를 고르면 돼요.

단일 DynamicSupervisor를 시작하는 대신에:

children = [
  {DynamicSupervisor, name: MyApp.DynamicSupervisor}
]

Supervisor.start_link(children, strategy: :one_for_one)

그리고 그 다이나믹 슈퍼바이저에 자식을 직접 시작하는 대신:

DynamicSupervisor.start_child(MyApp.DynamicSupervisor, {Agent, fn -> %{} end})

이렇게 PartitionSupervisor 아래에서 다이나믹 슈퍼바이저들을 시작할 수 있어요:

children = [
  {PartitionSupervisor,
   child_spec: DynamicSupervisor,
   name: MyApp.DynamicSupervisors}
]

Supervisor.start_link(children, strategy: :one_for_one)

그리고 이렇게 하죠:

DynamicSupervisor.start_child(
  {:via, PartitionSupervisor, {MyApp.DynamicSupervisors, self()}},
  {Agent, fn -> %{} end}
)

위 코드에서, 기본적으로 머신의 각 코어마다 다이나믹 슈퍼바이저를 하나씩 시작하는 파티션 슈퍼바이저를 시작해요. 그러고 나서 DynamicSupervisor를 이름으로 부르는 대신, {:via, PartitionSupervisor, {name, key}} 형식으로 파티션 슈퍼바이저를 통해 호출해요. 라우팅 키로 self()를 골랐는데, 이는 각 프로세스가 기존 다이나믹 슈퍼바이저 중 하나에 배정된다는 뜻이에요. PartitionSupervisor가 지원하는 모든 옵션은 start_link/1을 보면 알 수 있어요.

구현 참고사항

PartitionSupervisor는 모든 파티션을 관리하기 위해 **ETS 테이블이나 Registry**를 사용해요. 내부적으로 PartitionSupervisor는 각 파티션에 대해 자식 스펙(child spec)을 생성한 뒤, 평범한 슈퍼바이저처럼 동작해요. 각 자식 스펙의 ID는 파티션 번호예요.

라우팅에는 두 가지 전략이 쓰여요. key가 정수라면 rem(abs(key), partitions)로 라우팅하는데, 여기서 partitions는 파티션 수예요. 그 외에는 :erlang.phash2(key, partitions)를 써요. 특정 라우팅 방식은 미래에 바뀔 수 있으므로 그것에 의존하면 안 돼요. 특정 key에 해당하는 PID를 직접 가져오고 싶다면 GenServer.whereis({:via, PartitionSupervisor, {name, key}})를 쓰세요.

더 알아보기

  • PartitionSupervisor.start_link/1 — 지원되는 모든 옵션
  • DynamicSupervisor — 파티션으로 나눌 수 있는 동적 슈퍼바이저
  • Supervisor — join 감독 트리 시작
  • Registry — 파티션 관리에 쓰이는 레지스트리