동적 자식 감독하기(Supervising dynamic children)

동적 자식 감독하기(Supervising dynamic children)

지금까지 슈퍼비전 트리가 애플리케이션 수명 주기의 일부로 자동으로 시작·종료되는 법을 배웠어요. :name 옵션으로 버킷에 이름을 붙일 수도 있었죠. 또 실제로는 항상 슈퍼바이저 안에서 새 프로세스를 시작해야 한다는 것도 배웠어요. 이번에는 버킷에 이름을 붙이고 감독되도록 만들어 이 통찰들을 적용해 볼게요.

출처: Supervising dynamic children

본문

자식 명세(Child specs)

슈퍼바이저가 프로세스를 시작할 수 있는 이유는 **"자식 명세(child specification)"**를 받기 때문이에요. lib/kv.ex 파일에서 우리는 단일 자식 명세로 자식 리스트를 정의했죠.

children = [
  {Registry, name: KV, keys: :unique}
]

자식 명세가 튜플(위처럼)이나 모듈이면, 그 모듈의 child_spec/1 함수를 호출하는 것과 동등해요. 그러면 완전한 명세가 반환됩니다. 위 쌍은 다음과 동등해요.

iex> Registry.child_spec(name: KV, keys: :unique)
%{
  id: KV,
  start: {Registry, :start_link, [[name: KV, keys: :unique]]},
  type: :supervisor
}

기저가 되는 map은 프로세스 시작을 위해 호출할 :id(필수), module-function-args 삼중항(필수), 프로세스의 타입 type(선택)과 그 외 선택 키들을 반환해요. 다시 말해 child_spec/1 함수는 명세를 모듈 안에서 조합하고 캡슐화할 수 있게 해 줍니다.

그러니 KV.Bucket을 감독하고 싶다면 child_spec/1 함수만 정의하면 돼요. 다행히 use Agent(또는 use GenServer, use Supervisor 등)를 호출할 때마다 합리적인 기본값을 가진 구현이 제공됩니다. 한번 실행해 볼게요. iex -S mix로 돌아와서 이렇게 해보세요.

iex> KV.Bucket.child_spec([])
%{id: KV.Bucket, start: {KV.Bucket, :start_link, [[]]}}
iex> KV.Bucket.child_spec([name: :shopping])
%{id: KV.Bucket, start: {KV.Bucket, :start_link, [[name: :shopping]]}}

이제 슈퍼바이저의 일부로 시작해 볼게요. {module, options} 형식으로 버킷 이름을 전달합니다(편의상 이름으로 아톰도 쓰고요).

iex> children = [{KV.Bucket, name: :shopping}]
iex> Supervisor.start_link(children, strategy: :one_for_one)
iex> KV.Bucket.put(:shopping, "milk", 1)
:ok
iex> KV.Bucket.get(:shopping, "milk")
1

이제 버킷 프로세스를 명시적으로 죽이면 무슨 일이 생길까요?

# Find the pid for the given name
iex> pid = Process.whereis(:shopping)
#PID<0.48.0>
# Send it a kill exit signal
iex> Process.exit(pid, :kill)
true
# But a new process is alive in its place
iex> Process.whereis(:shopping)
#PID<0.50.0>

우리 버킷은 이미 감독될 수 있으니, 이제 슈퍼비전 트리에 연결할 차례예요.

동적 슈퍼바이저(Dynamic supervisors)

버킷이 이미 감독될 수 있으니, 애플리케이션 start/2 콜백의 일부로 시작하고 싶을 거예요.

children = [
  {Registry, name: KV, keys: :unique}
  {KV.Bucket, name: {:via, Registry, {KV, "shopping"}}}
]

위 코드는 확실히 동작하지만 큰 문제가 있어요. 버킷을 단 하나만 시작한다는 거죠. 실제로는 사용자가 아무 때나 새 버킷을 만들 수 있길 원해요. 다시 말해 프로세스를 동적으로 시작하고 감독해야 합니다.

Supervisor 모듈에는 초기화 이후 자식을 시작하는 API가 있지만, 잠재적으로 수백만 개의 자식을 가지는 사용 사례를 위해 설계되거나 최적화되지는 않았어요. 그런 목적을 위해 Elixir는 대신 DynamicSupervisor 모듈을 제공해요. 사용법은 Supervisor와 꽤 비슷한데, 시작할 때 자식을 지정하는 대신 나중에 지정한다는 점이 달라요. 한번 실행해 볼게요.

iex> {:ok, sup_pid} = DynamicSupervisor.start_link(strategy: :one_for_one)
iex> DynamicSupervisor.start_child(sup_pid, {KV.Bucket, name: :another_list})
iex> KV.Bucket.put(:another_list, "milk", 1)
:ok
iex> KV.Bucket.get(:another_list, "milk")
1

모두 예상대로 동작해요. 사실 DynamicSupervisor 자체에도 이름을 붙일 수 있어서 PID를 여기저기 넘기지 않아도 되고, 레지스트리로 이름을 붙인 버킷을 시작하는 데도 쓸 수 있어요.

iex> DynamicSupervisor.start_link(strategy: :one_for_one, name: :dyn_sup)
iex> name = {:via, Registry, {KV, "yet_another_list"}}
iex> DynamicSupervisor.start_child(:dyn_sup, {KV.Bucket, name: name})
iex> KV.Bucket.put(name, "milk", 1)
:ok
iex> KV.Bucket.get(name, "milk")
1

전반적으로, Elixir 표준 라이브러리가 전부 그런 기능을 염두에 두고 설계되었기 때문에 프로세스는 슈퍼바이저든 에이전트든 상관없이 이름을 붙이고 감독할 수 있어요.

버킷을 감독하고 이름 붙일 모든 재료가 준비됐으니, lib/kv.ex 모듈을 열어 이름을 받아 해당 이름의 버킷을 만들거나 반환하는 KV.lookup_bucket/1이라는 새 함수를 추가해 봅시다.

defmodule KV do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      {Registry, name: KV, keys: :unique},
      {DynamicSupervisor, name: KV.BucketSupervisor, strategy: :one_for_one}
    ]

    Supervisor.start_link(children, strategy: :one_for_one)
  end

  @doc """
  Creates a bucket with the given name.
  """
  def create_bucket(name) do
    DynamicSupervisor.start_child(KV.BucketSupervisor, {KV.Bucket, name: via(name)})
  end

  @doc """
  Looks up the given bucket.
  """
  def lookup_bucket(name) do
    GenServer.whereis(via(name))
  end

  defp via(name), do: {:via, Registry, {KV, name}}
end

코드는 비교적 단순해요. 먼저 start/2를 바꿔 KV.BucketSupervisor라는 이름의 동적 슈퍼바이저도 시작하게 했어요. 그런 다음 버킷을 받아 레지스트리와 동적 슈퍼바이저를 사용해 시작하는 KV.create_bucket/1을 구현했죠. 그리고 같은 이름을 받아 그 PID를 찾는 KV.lookup_bucket/1도 추가했어요.

모두 예상대로 동작하는지 확인하기 위해 테스트를 작성해 볼게요. test/kv_test.exs를 열고 이걸 추가하세요.

defmodule KVTest do
  use ExUnit.Case, async: true

  test "creates and looks up buckets by any name" do
    name = "a unique name that won't be shared"
    assert is_nil(KV.lookup_bucket(name))

    assert {:ok, bucket} = KV.create_bucket(name)
    assert KV.lookup_bucket(name) == bucket

    assert KV.create_bucket(name) == {:error, {:already_started, bucket}}
  end
end

이 테스트는 어떤 이름으로든 버킷을 만들고 찾는다는 걸 보여줘요. 테스트 간 충돌을 피하기 위해 고유한 이름을 사용하도록 했습니다.

start_supervised 테스트 헬퍼

진행하기 전에 정리를 좀 할게요.

test/kv/bucket_test.exs에서 우리는 KV.Bucket.start_link/1을 명시적으로 호출해 버킷을 시작했어요. 하지만 이제 start_link/1을 직접 호출하는 것보다, 프로세스를 슈퍼비전 트리의 일부로 시작해야 한다는 걸 알죠.

테스트를 돕기 위해 ExUnit은 테스트마다 슈퍼비전 트리를 이미 시작하고, 테스트별 슈퍼비전 트리 안에서 프로세스를 시작하는 start_supervised 함수를 제공해요. 이 접근의 장점 하나는 ExUnit이 시작된 모든 프로세스를 테스트가 끝날 때 함께 종료시킨다는 거예요. 테스트를 이걸 쓰도록 다시 작성해 볼게요.

defmodule KV.BucketTest do
  use ExUnit.Case, async: true

  test "stores values by key" do
    {:ok, bucket} = start_supervised(KV.Bucket)
    assert KV.Bucket.get(bucket, "milk") == nil

    KV.Bucket.put(bucket, "milk", 3)
    assert KV.Bucket.get(bucket, "milk") == 3
  end

  test "stores values by key on a named process", config do
    {:ok, _} = start_supervised({KV.Bucket, name: config.test})
    assert KV.Bucket.get(config.test, "milk") == nil

    KV.Bucket.put(config.test, "milk", 3)
    assert KV.Bucket.get(config.test, "milk") == 3
  end
end

작은 변경이지만, 이제 우리 테스트는 관련 모범 사례를 모두 따르고 있어요. 훌륭해요!

Observer

이제 슈퍼비전 트리를 정의했으니, Erlang에 딸려 오는 Observer 도구를 소개하기 좋은 시점이에요. iex -S mix로 애플리케이션을 시작하고 이걸 입력해 보세요.

iex> :observer.start()

누락된 의존성

iex -S mix로 프로젝트 안에서 iex를 실행하면 observer가 의존성으로 제공되지 않아요. 그러려면 다음 함수들을 호출해야 합니다.

iex> Mix.ensure_application!(:observer)
iex> :observer.start()

위 호출이 실패하면 이런 경우일 수 있어요. 일부 패키지 매니저는 GUI 지원을 위한 WX 바인딩 없이 최소화된 Erlang을 기본 설치해요. 어떤 매니저에서는 headless Erlang을 더 완전한 패키지로 교체할 수 있어요(Debian/Ubuntu/Arch에서 erlang vs erlang-nox라는 패키지를 찾아보세요). 다른 매니저에서는 별도의 erlang-wx(또는 비슷한 이름) 패키지를 설치해야 할 수도 있어요.

이 경험을 개선하기 위한 논의가 향후 릴리스에서 진행 중이에요.

일반 통계에서부터 부하 차트, 실행 중인 모든 프로세스와 애플리케이션 목록까지 우리 시스템에 대한 온갖 정보를 담은 GUI가 팝업될 거예요.

Applications 탭에서는 현재 시스템에서 실행 중인 모든 애플리케이션과 함께 그 슈퍼비전 트리를 볼 수 있어요. kv 애플리케이션을 선택해 더 자세히 살펴볼 수도 있죠.

그뿐만 아니라 터미널에서 새 버킷을 만들면 Observer에 표시된 슈퍼비전 트리에서 새 프로세스가 스폰되는 걸 볼 수 있어요.

iex> KV.create_bucket("shopping")
#PID<0.89.0>

Observer가 제공하는 것을 더 자세히 탐구하는 건 여러분 몫으로 남겨 둘게요. 슈퍼비전 트리의 어떤 프로세스든 더블클릭하면 그 프로세스에 대한 더 많은 정보를 얻을 수 있고, 프로세스를 우클릭하면 "kill 시그널"을 보낼 수 있어요. 실패를 흉내 내고 슈퍼바이저가 예상대로 반응하는지 확인하기에 완벽한 방법이죠.

결국 Observer 같은 도구는, 임시 프로세스라도 항상 슈퍼비전 트리 안에서 시작해서 항상 접근·검사 가능하도록 해야 하는 이유 중 하나예요.

이제 버킷에 이름이 붙고 감독되니, 서버를 시작하고 요청을 받기 시작할 준비가 됐어요.

더 알아보기