설정과 분산(Configuration and distribution)

설정과 분산(Configuration and distribution)

지금까지 우리 애플리케이션은 웹 서버를 항상 4040 포트에서 띄우도록 하드코딩했어요. 그러다 보니 개발 서버와 테스트를 동시에 돌릴 수 없다는 불편함이 있었죠. 이번 장에서는 **애플리케이션 환경(application environment)**을 활용해 설정을 다루는 법을 배우고, 나아가 같은 머신에서 여러 개발 서버를 서로 다른 포트로 띄워 **분산(distribution)**까지 켜는 길을 열어볼게요.

이 마지막 가이드에서는 분산 키-값 저장소의 라우팅 테이블을 설정할 수 있게 만들고, 마지막으로 소프트웨어를 운영(프로덕션)용으로 패키징할 거예요.

바로 시작해 볼게요.

출처: Configuration and distribution

본문

애플리케이션 환경(Application environment)

레지스트리, 애플리케이션, 그리고 슈퍼바이저 챕터에서 우리 프로젝트가 하나의 애플리케이션에 의해 뒷받침된다는 걸 배웠죠. 애플리케이션은 우리 모듈들을 묶어 주고, 슈퍼비전 트리가 어떻게 시작되고 종료되는지도 정해 줍니다. 그리고 각 애플리케이션은 자기만의 설정을 가질 수 있는데, Erlang/OTP(따라서 Elixir)에서는 이걸 애플리케이션 환경이라고 불러요.

애플리케이션 환경을 이용해 우리 애플리케이션뿐 아니라 다른 애플리케이션도 설정할 수 있어요. 실제로 어떻게 쓰는지 볼까요. config/runtime.exs 파일을 만들고 다음 내용을 작성해 봅시다.

import Config

port =
  cond do
    port_env = System.get_env("PORT") ->
      String.to_integer(port_env)

    config_env() == :test ->
      4040

    true ->
      4050
  end

config :kv, :port, port

위 코드는 "PORT" 환경 변수를 읽어서 값이 정의되어 있으면 그 값을 포트로 사용하려고 해요. 그렇지 않으면 테스트에서는 4040 포트, 그 외 환경에서는 4050 포트를 기본값으로 씁니다. 이렇게 하면 전에 겪었던 환경 간 충돌이 사라져요. 그리고 이 값을 :kv 애플리케이션의 :port 키에 저장합니다.

이제 이 설정을 읽기만 하면 돼요. lib/kv.ex를 열어 start/2 함수를 다음과 같이 바꿔 봅시다.

  def start(_type, _args) do
    port = Application.fetch_env!(:kv, :port)

    children = [
      {Registry, name: KV, keys: :unique},
      {DynamicSupervisor, name: KV.BucketSupervisor, strategy: :one_for_one},
      {Task.Supervisor, name: KV.ServerSupervisor},
      Supervisor.child_spec({Task, fn -> KV.Server.accept(port) end}, restart: :permanent)
    ]

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

iex -S mix를 실행하면 다음과 같은 메시지가 출력되는 걸 볼 수 있어요.

[info] Accepting connections on port 4050

개발 서버를 죽이지 않은 채 테스트를 돌려 보면, 테스트는 4040 포트에서 실행되는 걸 확인할 수 있을 거예요.

이번 변경은 정말 직관적이었어요. :kv 환경의 port 항목을 읽기 위해 Application.fetch_env!/2를 사용했죠. get_env/2fetch_env 대신 명시적으로 fetch_env!/2를 쓴 이유는, 포트가 설정되지 않았을 때 예외를 던져서(앱이 부팅되는 걸 막아서) 문제를 즉시 드러내기 위해서예요.

컴파일 시점 vs 런타임 설정(Compile vs runtime configuration)

설정 파일은 어떤 애플리케이션이든 그 환경을 설정할 수 있는 메커니즘을 제공해요. Elixir는 두 가지 설정 진입점을 제공합니다.

  • config/config.exs — 이 파일은 빌드 시점에, 애플리케이션을 컴파일하기도 전에, 심지어 의존성을 로드하기도 전에 읽혀요. 즉 이 시점에는 우리 애플리케이션이나 의존성의 코드에 접근할 수 없어요. 하지만 그 덕분에 이들이 어떻게 컴파일되는지는 제어할 수 있어요.
  • config/runtime.exs — 이 파일은 애플리케이션과 의존성이 컴파일된 에 읽혀서, 런타임에 애플리케이션이 어떻게 동작할지를 설정할 수 있어요. 시스템 환경 변수를 읽거나(System.get_env/1) 외부 설정에 접근하고 싶다면 이곳이 적절한 위치예요.

설정에 대해 더 자세히 알고 싶다면 Config 모듈과 Config.Provider 모듈을 살펴보세요.

일반적으로 런타임 설정을 읽을 때는 Application.fetch_env!/2(와 그 계열 함수)를 사용하고, 컴파일 시점 설정을 읽을 때는 Application.compile_env/2를 사용해요. 이 덕분에 Elixir는 컴파일 환경이 바뀌었을 때 어느 모듈을 다시 컴파일해야 하는지를 추적할 수 있습니다.

이제 여러 서버를 시작할 수 있으니, 분산을 살펴볼 차례예요.

첫 번째 분산 코드(Our first distributed code)

Elixir는 노드들을 연결하고 노드 사이에서 정보를 주고받는 기능을 기본으로 제공해요. 사실 분산 환경에서도 우리는 프로세스, 메시지 전달, 메시지 수신이라는 같은 개념을 그대로 사용합니다. 왜냐하면 Elixir의 프로세스는 **위치 투명성(location transparency)**을 갖기 때문이에요. 즉 메시지를 보낼 때 수신 프로세스가 같은 노드에 있든 다른 노드에 있든 상관없이, VM이 두 경우 모두 메시지를 전달할 수 있다는 뜻이에요.

분산 코드를 실행하려면 VM에 이름을 붙여서 시작해야 해요. 이름은 짧게(같은 네트워크 안일 때) 쓸 수도 있고, 길게(전체 컴퓨터 주소가 필요할 때) 쓸 수도 있어요. 새 IEx 세션을 시작해 봅시다.

$ iex --sname foo

이제 프롬프트가 조금 달라져서 노드 이름 뒤에 컴퓨터 이름이 붙는 걸 볼 수 있어요.

Interactive Elixir - press Ctrl+C to exit (type h() ENTER for help)
iex(foo@jv)1>

제 컴퓨터 이름은 jv라서 위 예시에서는 foo@jv가 보이지만, 여러분은 다른 결과가 나올 거예요. 앞으로 예시에서는 foo@computer-name이라고 쓸 테니, 직접 코드를 실행할 때는 여러분 환경에 맞게 바꿔 주세요.

이 셸에서 Hello라는 모듈을 정의해 봅시다.

iex> defmodule Hello do
...>   def world, do: IO.puts("hello world")
...> end

같은 네트워크에 Erlang과 Elixir가 모두 설치된 다른 컴퓨터가 있다면 거기서 셸을 하나 더 시작할 수 있어요. 그런 컴퓨터가 없다면 다른 터미널에서 IEx 세션을 하나 더 시작하면 됩니다. 어느 쪽이든 짧은 이름 bar를 붙여서 시작해 봅시다.

$ iex --sname bar

이 새 IEx 세션에서는 Hello.world/0에 접근할 수 없다는 점을 주의하세요.

iex> Hello.world
** (UndefinedFunctionError) function Hello.world/0 is undefined (module Hello is not available)
    Hello.world()

하지만 bar@computer-name에서 foo@computer-name에 새 프로세스를 스폰할 수는 있어요! 한번 시도해 볼게요(여기 @computer-name은 여러분 환경에서 보이는 이름이에요).

iex> Node.spawn_link(:"foo@computer-name", fn -> Hello.world() end)
#PID<9014.59.0>
hello world

Elixir가 다른 노드에 프로세스를 스폰하고 그 PID를 반환했어요. PID 숫자가 더 이상 0으로 시작하지 않는 걸 볼 수 있는데, 이는 그 프로세스가 다른 노드에 속해 있다는 뜻이에요. 이 코드는 Hello.world/0 함수가 존재하는 다른 노드에서 실행되어 그 함수를 호출했습니다. 여기서 "hello world" 결과가 foo가 아니라 현재 노드 bar에 출력된 점을 주목하세요. 다시 말해, 출력할 메시지를 foo에서 bar로 다시 보낸 거예요. 다른 노드(foo)에서 스폰된 프로세스가 모든 출력을 원래 노드로 되돌려 보내야 한다는 걸 알기 때문입니다.

Node.spawn_link/2가 반환한 PID로 평소처럼 메시지를 보내고 받을 수도 있어요. 짧은 ping-pong 예시를 해볼게요.

iex> pid = Node.spawn_link(:"foo@computer-name", fn ->
...>   receive do
...>     {:ping, client} -> send(client, :pong)
...>   end
...> end)
#PID<9014.59.0>
iex> send(pid, {:ping, self()})
{:ping, #PID<0.73.0>}
iex> flush()
:pong
:ok

다시 말해, 다른 노드에 프로세스를 스폰하고 그 PID를 붙잡아 둔 뒤, 마치 같은 머신에서 실행되는 것처럼 메시지를 보낼 수 있어요. 이것이 바로 위치 투명성 원리죠. 그리고 우리가 만든 모든 것이 메시지 전달 위에 구축되어 있기 때문에, 키-값 저장소도 작은 수정만으로 분산형으로 바꿀 수 있을 거예요.

:global로 하는 분산 이름 레지스트리(Distributed naming registry with :global)

먼저, 우리 코드가 현재 분산 상태가 아니라는 걸 확인해 볼게요. 이렇게 새 노드를 하나 시작하고,

$ PORT=4100 iex --sname foo -S mix

다른 노드도 이렇게 시작해 봅시다.

$ PORT=4101 iex --sname bar -S mix

이제 foo@computer-name 안에서 이렇게 해 봅시다.

iex> :erpc.call(:"bar@computer-name", KV, :create_bucket, ["shopping"])
{:ok, #PID<22121.164.0>}

우리는 Node.spawn_link/2 대신 Erlang의 내장 RPC 모듈을 사용해 KV 모듈의 create_bucket 함수를 호출했어요. 인자 목록으로는 문자열 "shopping" 하나가 들어 있는 리스트를 넘겼죠. Node.spawn_link/2를 쓸 수도 있었지만, :erpc.call/4는 호출 결과를 바로 반환해 주니 훨씬 편리합니다.

여전히 foo@computer-name에서, 이 버킷에 접근해 볼게요.

iex> KV.lookup_bucket("shopping")
nil

nil을 반환하네요. 하지만 bar@computer-name에서 KV.lookup_bucket("shopping")을 실행하면 제대로 된 버킷을 반환할 거예요. 다시 말해, 노드끼리는 서로 통신할 수 있지만 한 노드에서 스폰된 버킷은 다른 노드에서 보이지 않는다는 뜻이에요.

이유는 우리가 버킷 이름을 지을 때 Elixir의 Registry를 사용하고 있는데, 이건 로컬 프로세스 레지스트리이기 때문이에요. 즉 단일 노드에서 실행되는 프로세스를 위해 설계된 것이지, 분산을 위해 만들어진 게 아니죠.

다행히 Erlang에는 :global이라는 분산 레지스트리가 기본으로 있어요. :name 옵션에 {:global, name} 튜플을 넘기면 바로 지원됩니다. lib/kv.exvia/1 함수를 이렇게,

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

이렇게 바꾸기만 하면 돼요.

  defp via(name), do: {:global, name}

위 변경을 적용하고 foo@computer-namebar@computer-name을 모두 재시작해 봅시다. 이제 다시 foo@computer-name에서 시도해 볼게요.

iex> :erpc.call(:"bar@computer-name", KV, :create_bucket, ["shopping"])
{:ok, #PID<21821.179.0>}
iex> KV.lookup_bucket("shopping")
#PID<21821.179.0>

짜잔! 사용하는 이름 레지스트리만 바꿨을 뿐인데 이제 분산 키-값 저장소가 됐어요. telnet을 이용해 서로 다른 포트의 서버에 접속해, 한 세션에서의 변경이 다른 세션에서도 보이는지 확인해 볼 수도 있어요. 정말 재밌죠!

노드 발견과 의존성(Node discovery and dependencies)

분산 키-값 저장소를 완성하는 데 꼭 필요한 요소가 하나 있어요. :global 레지스트리가 동작하려면 노드들이 서로 연결되어 있어야 합니다. 노드 이름을 넘겨 :erpc 호출을 실행하면,

:erpc.call(:"bar@computer-name", KV, :create_bucket, ["shopping"])

Elixir가 자동으로 노드들을 연결해 줍니다. 같은 머신에서 두 인스턴스가 실행 중일 때는 IEx 세션에서 이렇게 하기 쉽지만, 프로덕션 환경에서는 얘기가 달라져요. 인스턴스들이 서로 다른 머신에 있고 아무 때나 시작될 수 있으며 서로 다른 IP 주소에서 실행될 수 있으니까요.

다행히 이것도 잘 풀려 있는 문제예요. 예를 들어 프로덕션에서 Phoenix 웹 프레임워크를 쓴다면, dns_cluster 패키지가 딸려 와서 새 노드를 찾아 연결하기 위한 DNS 쿼리를 자동으로 실행해 줍니다. Kubernetes나 클라우드 프로바이더를 쓴다면, libcluster 같은 패키지에 노드를 발견하고 연결하는 여러 전략이 마련되어 있어요.

Elixir에서 의존성을 설치하는 건 정말 간단해요. 보통은 Hex 패키지 매니저를 사용해서, mix.exs 파일의 deps 함수 안에 의존성을 적어 주면 됩니다.

def deps do
  [{:dns_cluster, "~> 0.2"}]
end

이 의존성은 Hex에 올라간 dns_cluster의 0.x 버전 시리즈 중 최신 버전을 가리켜요. 버전 번호 앞에 붙은 ~>가 그걸 나타내요. 버전 요구사항을 지정하는 방법에 대해 더 자세히 알고 싶다면 Version 모듈 문서를 참고하세요.

보통 안정화된 릴리스는 Hex에 올라갑니다. 아직 개발 중인 외부 의존성에 의존하고 싶다면, Mix가 Git 의존성도 관리할 수 있어요.

def deps do
  [{:dns_cluster, git: "https://github.com/phoenixframework/dns_cluster.git"}]
end

프로젝트에 의존성을 추가하면 Mix가 mix.lock 파일을 생성해서 *반복 가능한 빌드(repeatable builds)*를 보장하는 걸 볼 수 있을 거예요. 이 lock 파일은 버전 관리 시스템에 체크인해야 해요. 그래야 이 프로젝트를 쓰는 모든 사람이 여러분과 같은 버전의 의존성을 사용하게 되니까요.

Mix는 의존성을 다루는 많은 태스크를 제공하며, mix help에서 확인할 수 있어요.

$ mix help
mix deps              # Lists dependencies and their status
mix deps.clean        # Deletes the given dependencies' files
mix deps.compile      # Compiles dependencies
mix deps.get          # Fetches unavailable and out of date dependencies
mix deps.tree         # Prints the dependency tree
mix deps.unlock       # Unlocks the given dependencies
mix deps.update       # Updates the given dependencies

가장 흔히 쓰는 태스크는 mix deps.getmix deps.update예요. 한 번 받아지면 의존성은 자동으로 컴파일됩니다. deps에 대해 더 알고 싶다면 mix help deps를 실행해 보세요.

이 장을 마무리하며, 이번 장에서 배운 교훈을 활용해 연결할 노드의 이름을 부팅할 때 받는 아주 간단한 노드 발견 메커니즘을 만들어 볼게요.

Node.connect/1

각 인스턴스가 연결해야 할 모든 노드의 이름을 담은 "NODES" 환경 변수를 지원하도록 우리 애플리케이션을 바꿀 거예요.

config/runtime.exs를 열어 맨 아래에 다음 내용을 추가해 봅시다.

nodes =
  System.get_env("NODES", "")
  |> String.split(",", trim: true)
  |> Enum.map(&String.to_atom/1)

config :kv, :nodes, nodes

환경 변수를 가져와서 빈 문자열은 버려가며 ","로 나누고, 노드 이름은 아톰이므로 각 항목을 아톰으로 변환했어요.

이제 start/2 콜백에서 start/2 함수에 이 내용을 추가할게요.

  def start(_type, _args) do
    for node <- Application.fetch_env!(:kv, :nodes) do
      Node.connect(node)
    end

이제 우리 노드를 이렇게 시작할 수 있어요.

$ NODES="foo@computer-name,bar@computer-name" PORT=4040 iex --sname foo -S mix
$ NODES="foo@computer-name,bar@computer-name" PORT=4041 iex --sname bar -S mix

그러면 서로 연결되어야 해요. 한번 실행해 보세요!

실제 프로덕션 시스템에서는 몇 가지 추가로 주의할 점이 있어요. 예를 들어 --sname 대신 --name을 쓰고 완전한 형태의 정규화된 노드 이름을 주는 경우가 많습니다.

게다가 두 인스턴스를 연결할 때는 두 인스턴스가 같은 **쿠키(cookie)**를 가져야 해요. 쿠키는 Erlang이 연결을 인가할 때 쓰는 비밀값입니다. 같은 머신에서 실행할 때는 기본적으로 같은 쿠키를 공유하지만, 클러스터로 배포할 때는 쿠키를 명시적으로 설정하거나 다른 방식으로 공유해 줘야 합니다.

이 주제들은 마지막 장에서 릴리스(release)를 다룰 때 다시 살펴볼게요.

분산 시스템의 트레이드오프(Distributed system trade-offs)

이번 장에서 우리는 :global 이름 레지스트리를 사용해 키-값 저장소를 분산형으로 만들었어요. 하지만 모든 분산 시스템은 — 라이브러리든 본격적인 데이터베이스든 — 일련의 트레이드오프를 염두에 두고 설계된다는 점을 기억하는 게 중요해요.

특히 :global은 새 버킷을 만들 때마다 알려진 모든 노드 간의 일관성을 요구해요. 예를 들어 클러스터에 노드가 세 개 있다면, 버킷을 하나 만들 때 세 노드 모두가 그 이름에 동의해야 합니다. 그래서 어떤 노드가 응답하지 않는다면 — 아마 네트워크 분할 때문일 거예요 — 등록이 성공하려면 그 노드가 다시 연결되거나 쫓겨나야 해요. 또한 클러스터가 커질수록 등록 비용은 커지는데, 조회는 항상 저렴하고 즉각적이란 점이 특징이에요. 생태계에는 Syn처럼 서로 다른 트레이드오프를 탐구하는 다른 이름 레지스트리들도 존재합니다.

저장소를 생각하면 더 복잡해집니다. 지금은 노드가 종료되면 버킷에 저장된 모든 데이터가 사라져요. 현재 설계는 각 노드가 자기 버킷을 저장하게 되어 있으므로, 데이터를 잃지 않으려면 각 노드를 백업해야 하고 복제도 해야 해요.

그런 이유로, Elixir로 프로덕션 애플리케이션을 작성할 때 여전히 데이터베이스(또는 어떤 저장 시스템)를 쓰고, Elixir로는 저장소를 넘어서는 실시간·협업 측면을 구현하는 게 흔한 패턴이에요. 예를 들어 Elixir로 특정 순간에 클러스터에 연결된 클라이언트가 누구인지 추적하거나, 버킷에 항목이 추가·제거될 때마다 사용자에게 실시간으로 알려주는 피드(feed)를 구현할 수 있어요.

사실 다음 장에서 바로 그걸 만들 거예요. 지금까지 배운 모든 것을 정리하고, Elixir 소프트웨어의 핵심 구성 요소 중 하나인 GenServer에 대해서도 이야기해 볼 수 있게 말이죠.

더 알아보기