레지스트리와 감독 트리

레지스트리와 감독 트리 (Registries and supervision trees)

이전 장에서 에이전트(Agent)로 버킷(bucket)을 표현했어요. 이번에는 프로세스에 이름을 붙이는 방법, 그리고 프로세스를 감독 트리 안에서 시작하는 방법을 배워요. mix와 OTP를 본격적으로 다루기 시작하는 장이에요.

출처: Registries and supervision trees

본문

이전 장에서 버킷에 이름을 붙일 수 있다는 걸 배웠어요. 예를 들어 KV.Bucket.start_link(name: :shopping)처럼 아톰 이름으로 프로세스를 시작하고, 그 이름으로 put/get을 호출했죠. 그런데 동적 프로세스에 아톰으로 이름을 붙이는 것은 아주 나쁜 생각이에요!

왜냐하면 아톰은 가비지 컬렉션되지 않기 때문이에요. 한 번 생성된 아톰은 절대 회수되지 않아요. 외부 클라이언트가 보낸 버킷 이름을 아톰으로 변환하면, 사용자가 다양한 이름을 계속 주입해 시스템 메모리를 고갈시킬 수 있어요. 실제로는 메모리가 부족해지기 전에 아톰 개수의 Erlang VM 한계에 먼저 도달해서 시스템이 죽는 경우가 더 많아요.

다행히 Elixir(와 Erlang)에는 프로세스 이름을 붙이는 내장 추상화인 네임 레지스트리(name registry) 가 있어요. 각각 트레이드오프가 조금씩 달라요.

로컬·분산·확장 가능한 레지스트리

Elixir는 단일 노드용 프로세스 레지스트리 모듈로 Registry를 제공해요. 핵심 기능은 아톰뿐 아니라 어떤 Elixir 값이든 프로세스 이름으로 쓸 수 있다는 거예요.

iex> Registry.start_link(name: KV, keys: :unique)
iex> name = {:via, Registry, {KV, "shopping"}}
iex> KV.Bucket.start_link(name: name)
{:ok, #PID<0.43.0>}
iex> KV.Bucket.put(name, "milk", 1)
:ok
iex> KV.Bucket.get(name, "milk")
1

:name 옵션에 아톰을 넘기는 대신 {:via, registry_module, {registry_name, process_name}} 모양의 튜플을 넘겼더니 모든 게 동작해요. process_name으로는 정수나 맵 같은 아무 값이나 쓸 수 있어요. Elixir의 모든 내장 behaviour, 에이전트, 슈퍼바이저, 태스크 등은 "via" 튜플 형식으로 넘기기만 하면 네임 레지스트리와 호환돼요.

버킷에 이름을 붙이려면 Registry.start_link/1로 레지스트리를 시작하면 되는데, 문제는 어디에 둘지예요.

애플리케이션(Application) 이해하기

모든 Elixir 프로젝트는 애플리케이션이에요. Elixir 자체도 :elixir이라는 애플리케이션으로 정의돼 있고, ExUnit.Case 모듈은 :ex_unit 애플리케이션에 속해 있어요.

우리는 사실 이번 내내 한 애플리케이션 안에서 작업하고 있었어요. mix compile을 실행할 때마다 컴파일 출력에 Generated kv app 메시지가 보였죠. 생성된 .app 파일은 _build/dev/lib/kv/ebin/kv.app에 있어요.

{application, kv,
             [{applications, [kernel, stdlib, elixir, logger]},
              {description, "kv"},
              {modules, ['Elixir.KV', 'Elixir.KV.Bucket']},
              {registered, []},
              {vsn, "0.1.0"}]}.

이 파일은 Erlang 문법으로 쓰인 에런그 텀들이에요. 애플리케이션 버전, 정의된 모든 모듈, 그리고 의존하는 애플리케이션 목록(Erlang의 kernel, elixir 자체, logger)을 담고 있어요. logger 애플리케이션은 Elixir의 일부로 배포되는데, mix.exs:extra_applications 리스트에 지정해서 필요하다고 선언했어요.

요약하면, 애플리케이션은 .app 파일에 정의된 모든 모듈(그 파일 자체 포함)로 구성돼요. Mix가 .app 파일을 생성·관리하지만, mix.exsapplication/0 함수에 항목을 추가해서 내용을 커스터마이즈할 수 있어요.

애플리케이션 시작하기

시스템의 각 애플리케이션은 시작하고 중지할 수 있어요. 시작·중지 규칙도 .app 파일에 정의돼 있어요. iex -S mix를 실행하면 Mix가 애플리케이션을 컴파일한 뒤 시작해요.

iex> Application.start(:kv)
{:error, {:already_started, :kv}}

이미 시작되어 있네요. Mix는 현재 애플리케이션과 모든 의존성을 자동으로 시작해요. mix test 등 여러 Mix 명령도 마찬가지예요. 애플리케이션을 중지했다가 다시 시작하면:

iex> Application.stop(:kv)
:ok
iex> Application.start(:kv)
{:error, {:not_started, :logger}}

:kv가 의존하는 애플리케이션(:logger)이 시작되지 않았기 때문에 오류가 나요. 이럴 때는 Application.ensure_all_started/1을 호출하면 의존성을 포함해 모두 순서대로 시작돼요.

iex> Application.ensure_all_started(:kv)
{:ok, [:logger, :kv]}

실전에서는 도구가 알아서 애플리케이션을 시작해 주므로 이런 걸 직접 신경 쓸 일은 거의 없지만, 뒤에서 어떻게 돌아가는지 아는 것은 좋아요.

애플리케이션 콜백

iex -S mix를 실행하면 Mix가 Application.start(:kv)를 호출해 애플리케이션을 시작해요. 그런데 애플리케이션이 시작될 때 무슨 일이 일어날지 커스터마이즈할 수도 있어요. 이를 위해 애플리케이션 콜백(application callback) 을 정의해요.

첫 단계는 애플리케이션 정의(예: .app 파일)에 어떤 모듈이 애플리케이션 콜백을 구현할지 알려주는 거예요. mix.exsdef application을 다음과 같이 바꿔요.

def application do
  [
    extra_applications: [:logger],
    mod: {KV, []}
  ]
end

:mod 옵션은 "애플리케이션 콜백 모듈"과 애플리케이션 시작 시 전달할 인자를 지정해요. 이 모듈은 use Application을 호출하는 모듈이면 돼요. KV를 콜백으로 지정했으니 lib/kv.ex에 있는 KV 모듈을 바꿔요.

defmodule KV do
  use Application
end

그런데 use Applicationstart/2 함수 구현을 기대하는 behaviour예요. 정의하지 않으면 컴파일 경고가 나오고, 애플리케이션도 부팅되지 않아요.

warning: function start/2 required by behaviour Application is not implemented

start/2 콜백을 구현하는 건 간단해요. 감독 트리를 시작하고 {:ok, root_supervisor_pid}를 돌려주면 돼요. Supervisor.start_link/2가 바로 그 일을 하는데, 자식 리스트와 감독 전략만 받아요. 지금은 빈 자식 리스트를 넘겨 봐요.

defmodule KV do
  use Application
  @impl true
  def start(_type, _args) do
    Supervisor.start_link([], strategy: :one_for_one)
  end
end

mix test를 다시 실행하면 앱이 부팅되고 실패가 하나 보일 거예요 — KV.hello/0을 테스트하던 기본 보일러플레이트 테스트가 깨진 것뿐이니 그 테스트를 지우면 돼요.

정말 적은 코드로 아주 강력한 일을 했어요. 이제 KV.start/2라는, 애플리케이션이 시작될 때마다 호출되는 함수가 생겼고, 이는 키-벨류 레지스트리를 시작하기에 완벽한 장소예요. Application 모듈은 stop/1 콜백과 다른 기능도 정의할 수 있게 해줘요.

감독 트리(Supervision trees)

이제 start/2 콜백이 있으니 레지스트리를 시작할 수 있어요. 유혹이 되는 방법이 있어요.

def start(_type, _args) do
  Registry.start_link(name: KV, keys: :unique)
  Supervisor.start_link([], strategy: :one_for_one)
end

하지만 이건 좋은 생각이 아니에요. Elixir에서는 보통 프로세스를 감독 트리 안에서 시작해요. 사실 start_link 함수로 프로세스를 시작하는 일은 (감독 트리의 루트 자체를 제외하면) 거의 없어요. 대신 이렇게 해요.

def start(_type, _args) do
  children = [
    {Registry, name: KV, keys: :unique}
  ]
  Supervisor.start_link(children, strategy: :one_for_one)
end

슈퍼바이저는 하나 이상의 자식 스펙을 받아서 각 자식을 정확히 어떻게 시작할지 알려줘요. 자식 스펙은 보통 {module, options} 쌍으로 표현되고, 때로는 모듈 이름 하나만 써요. 이 자식들이 때로는 슈퍼바이저 자신이어서 감독 트리를 만들어요.

이제 iex -S mix로 새 콘솔을 시작하면(감독 트리를 새로고침하지 않으므로 recompile()만으로는 부족해요) 레지스트리가 애플리케이션의 일부로 시작되어, iex 안에서 직접 레지스트리를 시작할 필요 없이 버킷에 이름을 붙일 수 있어요.

iex> name = {:via, Registry, {KV, "shopping"}}
iex> KV.Bucket.start_link(name: name)
{:ok, #PID<0.43.0>}
iex> KV.Bucket.put(name, "milk", 1)
:ok
iex> KV.Bucket.get(name, "milk")
1

프로세스를 슈퍼바이저 안에서 시작하면 중요한 속성들을 얻어요.

  • 내부 관찰(Introspection): 각 애플리케이션에 대해 감독 트리의 각 프로세스, 메모리 사용량, 메시지 큐 등을 완전히 관찰·시각화할 수 있어요.
  • 회복력(Resilience): 프로세스가 예상치 못한 이유로 실패하면 슈퍼바이저가 재시작 여부와 방식을 제어해서 자기 치유 시스템(self-healing) 을 만들어요.
  • 우아한 종료(Graceful shutdown): 애플리케이션 종료 시 감독 트리 자식들이 시작된 순서의 반대 순서로 종료돼 우아하게 마무리돼요.

프로젝트 vs 애플리케이션

Mix는 프로젝트(project)애플리케이션(application) 을 구분해요. 우리는 :kv 애플리케이션을 정의하는 Mix 프로젝트를 갖고 있다고 말할 수 있어요.

"프로젝트"라고 하면 Mix를 떠올리면 돼요. Mix는 프로젝트를 관리하는 도구로, 컴파일하고 테스트하는 법 등을 알아요. "애플리케이션"이라고 하면 OTP를 말해요. 애플리케이션은 런타임이 하나의 단위로 시작·중지하는 엔티티예요. 애플리케이션과 시스템 부팅·종료의 관계는 Application 모듈 문서에서 더 배울 수 있어요.

정리

이 장에서 중요한 개념을 배웠어요.

  • 네임 레지스트리 — 주어진 머신(그리고 앞으로 배울 클러스터)에서 프로세스를 찾을 수 있게 해줘요.
  • 애플리케이션 — 모듈, 의존성, 그리고 코드가 시작·중지되는 방식을 묶어요.
  • 프로세스는 슈퍼바이저의 일부로 시작 — 내부 관찰과 장애 허용을 위해서예요.

다음 장에서는 모든 버킷이 이름을 갖고 감독되도록 하는 동적 슈퍼바이저(dynamic supervisors) 를 배워요.

더 알아보기

  • 레지스트리: Registry 모듈
  • 애플리케이션: Application 모듈, use Application
  • 감독: Supervisor 모듈, Supervisor.start_link/2
  • 간단한 상태 관리: Agent 모듈
  • 이전/다음 장: "Simple state with agents" → "Supervising dynamic children"