Application behaviour

Application behaviour

애플리케이션(application)은 Erlang/OTP에서 소프트웨어를 패키징하는 관용적인 방법이에요. 다른 프로그래밍 언어의 "라이브러리" 개념과 비슷하지만 몇 가지 추가 특성이 있어요. 특정 기능을 구현하는 컴포넌트로, 표준화된 디렉토리 구조·설정·생명 주기를 가져요. 애플리케이션은 로드되고, 시작되고, 중단돼요. 각 애플리케이션은 자신만의 환경(environment)도 갖는데, 이것이 각 애플리케이션을 설정하는 통일된 API를 제공해요.

개발자는 보통 애플리케이션 환경과 그 콜백 모듈과 상호작용해요. 그래서 이 장에서는 애플리케이션 환경과 콜백 모듈을 먼저 다룬 다음, 애플리케이션 리소스 파일과 생명 주기로 넘어갈게요.

출처: Application behaviour

본문

애플리케이션 환경

각 애플리케이션은 자신만의 환경을 가져요. 환경은 아톰을 값에 대응시키는 키워드 리스트예요. 운영체제 환경과는 무관하답니다.

기본적으로 애플리케이션의 환경은 빈 리스트예요. Mix 프로젝트의 mix.exs 파일에서 application/0:env 키를 설정할 수 있어요.

def application do
  [env: [db_host: "localhost"]]
end

이제 애플리케이션 안에서 fetch_env!/2 같은 함수로 이 환경을 읽을 수 있어요.

defmodule MyApp.DBClient do
  def start_link() do
    SomeLib.DBClient.start_link(host: db_host())
  end

  defp db_host do
    Application.fetch_env!(:my_app, :db_host)
  end
end

Mix 프로젝트에서는 애플리케이션과 그 의존성의 환경을 config/config.exsconfig/runtime.exs 파일로 덮어쓸 수 있어요. 전자는 빌드 타임(코드가 컴파일되기 전)에 로드되고, 후자는 런타임(애플리케이션이 시작되기 직전)에 로드돼요. 예를 들어 여러분의 애플리케이션을 쓰는 사람이 :db_host 환경 변수를 이렇게 덮어쓸 수 있어요.

import Config
config :my_app, :db_host, "db.local"

자세한 내용은 Mix 모듈의 "Configuration" 섹션을 참고하세요. put_env/3delete_env/2 같은 함수로 애플리케이션 환경을 동적으로 바꿀 수도 있어요.

라이브러리의 애플리케이션 환경: 다른 개발자들이 쓰는 라이브러리를 만든다면, 애플리케이션 환경은 사실상 전역 저장소이기 때문에 피하는 게 좋다고 권장돼요. 이 안티패턴에 대해 더 알아보세요.

다른 애플리케이션의 환경 읽기: 각 애플리케이션은 자기 환경에 대한 책임이 있어요. 다른 애플리케이션의 환경을 직접 접근하거나 수정하는 데 이 모듈의 함수를 쓰지 마세요. 애플리케이션 환경을 바꾸면 Elixir의 빌드 도구가 그 애플리케이션에 속한 파일만 다시 컴파일해요. 그래서 다른 애플리케이션의 환경을 읽으면, 그게 바뀌어도 우리 파일이 다시 컴파일되지 않아 낡은 설정에 의존할 위험이 있어요.

컴파일 타임 환경

앞의 예시에서는 애플리케이션 환경을 런타임에 읽었어요. 즉 :my_app 애플리케이션의 :db_host 환경 키는 MyApp.DBClient가 실제로 시작될 때만 읽혀요. 런타임에 환경을 읽는 게 선호되는 방법이지만, 드물게 어떤 프로젝트의 컴파일을 설정하기 위해 애플리케이션 환경을 쓰고 싶을 때도 있어요. 다만 함수 밖에서 Application.fetch_env!/2에 접근하면:

defmodule MyApp.DBClient do
  @db_host Application.fetch_env!(:my_app, :db_host)

  def start_link() do
    SomeLib.DBClient.start_link(host: @db_host)
  end
end

경고와 오류를 볼 수 있어요.

warning: Application.fetch_env!/2 is discouraged in the module body,
use Application.compile_env/3 instead
  iex:3: MyApp.DBClient

** (ArgumentError) could not fetch application environment :db_host
for application :my_app because the application was not loaded nor
configured

이건 모듈을 정의할 때는 애플리케이션 환경이 아직 준비되지 않았기 때문이에요. 다행히 경고가 해결책을 알려 줘요. Application.compile_env/3을 쓰는 거예요.

defmodule MyApp.DBClient do
  @db_host Application.compile_env(:my_app, :db_host, "db.local")

  def start_link() do
    SomeLib.DBClient.start_link(host: @db_host)
  end
end

차이는 compile_envmix.exsdef application 함수 대신 기본값을 인자로 받아야 한다는 점이에요. 게다가 compile_env/3을 쓰면 Mix 같은 도구가 컴파일 중 사용된 값을 저장하고, 시스템이 시작될 때마다 컴파일 값과 런타임 값을 비교해서 다르면 오류를 던져요.

어쨌든 컴파일 타임 환경은 피하는 게 좋아요. 가능하면 런타임에 애플리케이션 환경을 읽는 게 첫 번째 선택이어야 해요.

애플리케이션 콜백 모듈

애플리케이션은 로드·시작·중단될 수 있어요. 보통 Mix 같은 빌드 도구가 애플리케이션과 그 모든 의존성을 시작해 주지만, 직접 할 수도 있어요.

{:ok, _} = Application.ensure_all_started(:some_app)

애플리케이션이 시작될 때 개발자는 커스텀 코드를 실행하는 콜백 모듈을 설정할 수 있어요. 개발자는 이 콜백으로 애플리케이션의 감독 트리를 시작해요.

첫 단계는 mix.exsapplication/0 정의에 :mod 키를 추가하는 거예요. 이 키는 애플리케이션 콜백 모듈과 시작 인자(보통 빈 리스트)의 튜플을 받아요.

def application do
  [mod: {MyApp, []}]
end

:mod에 준 MyApp 모듈은 Application behaviour를 구현해야 해요. 모듈에 use Application을 넣고 start/2 콜백을 구현하면 돼요.

defmodule MyApp do
  use Application

  def start(_type, _args) do
    children = []
    Supervisor.start_link(children, strategy: :one_for_one)
  end
end

use Application을 쓰면 Application 모듈이 @behaviour Application을 설정하고, Erlang/OTP가 요구하는 stop/1 함수의 재정의 가능한 정의를 만들어 줘요.

start/2 콜백은 감독자를 만들고 링크한 뒤 {:ok, pid} 또는 {:ok, pid, state}를 돌려줘야 해요. 여기서 pid는 감독자의 PID이고 state는 선택적인 애플리케이션 상태예요. args:mod 옵션에 준 튜플의 두 번째 요소예요.

start/2에 전달되는 type 인자는 보통 :normal이에요. 분산 설정에서 애플리케이션 인수인계(takeover)와 장애 조치(failover)가 구성됐을 때만 달라져요. 분산 애플리케이션은 이 문서의 범위를 벗어나요.

애플리케이션이 종료될 때, 런타임이 감독 트리를 중단한 뒤 stop/1 콜백이 호출돼요. 이 콜백으로 애플리케이션이 마지막 정리를 할 수 있어요. 인자는 start/2가 돌려준 상태(돌려줬다면) 또는 []예요. stop/1의 반환 값은 무시돼요.

Application을 쓰면 모듈은 인자를 무시하고 :ok을 돌려주는 stop/1의 기본 구현을 얻지만, 재정의할 수 있어요.

애플리케이션 콜백 모듈은 선택적 콜백 prep_stop/1도 구현할 수 있어요. 있으면 감독 트리가 종료되기 전에 prep_stop/1이 호출돼요. 인자는 start/2가 돌려준 상태 또는 []이고, 반환 값이 stop/1로 전달돼요.

애플리케이션 리소스 파일

앞의 섹션들에서 mix.exs 파일의 application/0 섹션에서 애플리케이션을 설정했어요. 결국 Mix는 이 설정으로 애플리케이션 리소스 파일을 만드는데, 그 파일 이름은 APP_NAME.app이에요. 예를 들어 OTP 애플리케이션 ex_unit의 리소스 파일은 ex_unit.app이에요.

애플리케이션 리소스 파일 생성에 대해 더 알려면 Mix.Tasks.Compile.App 문서를 참고하세요. mix help compile.app로도 볼 수 있어요.

애플리케이션 생명 주기

애플리케이션 로드하기: 애플리케이션은 로드되는데, 런타임이 그 리소스 파일을 찾아 처리한다는 뜻이에요.

Application.load(:ex_unit)
#=> :ok

애플리케이션이 로드되면 리소스 파일에 지정된 환경이 설정 파일의 덮어쓰기와 병합돼요. 애플리케이션을 로드해도 그 모듈이 로드되지는 않아요. 실제로는 로딩이 시작 프로세스의 일부라서 손으로 로드하는 일은 드물어요.

애플리케이션 시작하기: 애플리케이션은 시작도 돼요.

Application.start(:ex_unit)
#=> :ok

애플리케이션이 컴파일되면, 시스템을 실행하는 건 현재 애플리케이션과 그 의존성을 시작하는 문제예요. 다른 언어와 달리 Elixir에는 시스템을 시작하는 main 프로시저가 없어요. 대신 각자가 초기화·종료 로직을 가진 애플리케이션 하나 이상을 시작해요.

애플리케이션이 시작될 때 아직 로드 안 됐으면 Application.load/1이 자동으로 호출돼요. 그다음 리소스 파일의 applications 키에 나열된 의존성이 이미 시작됐는지 확인해요. 의존성이 하나라도 시작 안 된 상태면 오류 조건이에요. ensure_all_started/1 같은 함수가 애플리케이션과 그 모든 의존성을 시작해 줘요.

콜백 모듈이 설정돼 있지 않으면 이 시점에 시작이 끝나요. 그렇지 않으면 start/2 콜백이 호출돼요. 이 함수가 돌려준 최상위 감독자의 PID는 나중에 쓰기 위해 런타임이 저장하고, 돌려준 애플리케이션 상태도 있으면 저장해요.

애플리케이션 중단하기: 시작된 애플리케이션은 마지막으로 중단돼요.

Application.stop(:ex_unit)
#=> :ok

콜백 모듈이 없는 애플리케이션을 중단하는 건 일부 시스템 추적을 제외하면 사실상 no-op이에요.

콜백 모듈이 있는 애플리케이션을 중단하는 건 세 단계예요.

  1. 있으면 선택적 콜백 prep_stop/1을 호출한다.
  2. 최상위 감독자를 종료한다.
  3. 필수 콜백 stop/1을 호출한다.

2단계는 블로킹이라는 점을 강조하고 싶어요. 감독자의 종료는 자식 종료의 재귀 체인을 촉발해서 모든 하위 프로세스가 질서 있게 종료돼요. stop/1 콜백은 전체 감독 트리가 종료된 뒤에야 호출돼요.

실행 중인 시스템을 깨끗하게 종료하려면 System.stop/1을 호출하면 돼요. 시작한 역순으로 모든 애플리케이션을 종료해요. 기본적으로 운영체제의 SIGTERM은 자동으로 System.stop/0으로 변환돼요. :os.set_signal/2 함수로 운영체제 시그널을 더 명시적으로 제어할 수도 있어요.

도구 (Tooling)

Mix 빌드 도구는 대부분의 애플리케이션 관리 작업을 자동화해요. 예를 들어 mix test는 테스트가 실행되기 전에 애플리케이션 의존성과 애플리케이션 자체를 자동으로 시작해요. mix run --no-halt는 현재 프로젝트를 부팅하고 오래 실행되는 시스템을 시작하는 데 쓸 수 있어요.

개발자는 mix release로 릴리즈를 만들 수도 있어요. 릴리즈는 모든 소스 코드는 물론 Erlang VM까지 하나의 디렉토리에 패키징할 수 있어요. 각 애플리케이션을 어떻게 어떤 순서로 시작할지도 명시적으로 제어할 수 있죠. 시스템 시작·중단, 디버깅, 로깅, 시스템 모니터링을 위한 더 간결한 메커니즘도 제공해요.

마지막으로 Elixir는 escript(스크립트)와 archive(아카이브) 같은 도구도 제공해요. 이것들은 애플리케이션을 패키징하는 다른 메커니즘이에요. 주로 도구를 개발자들끼리 공유할 때 쓰고 배포 옵션으로는 쓰지 않아요. mix help archive.buildmix help escript.build를 참고하세요.

추가 정보

애플리케이션에 대한 자세한 내용은 Erlang :application 모듈 문서와 OTP 설계 원칙 사용자 가이드의 Applications 섹션을 확인하세요.

콜백 (Callbacks)

@callback config_change(changed, new, removed) :: :ok
when changed:  keyword(), new:  keyword(), removed: [ atom()]

코드 업그레이드 후 애플리케이션 환경이 바뀌었을 때 호출되는 콜백이에요. changed는 애플리케이션 환경에서 값이 바뀐 키와 값의 키워드 리스트, new는 모든 새 키와 값의 키워드 리스트, removed는 제거된 모든 키의 리스트예요. (optional)

@callback prep_stop( state()) ::  state()

애플리케이션을 중단하기 전에 호출돼요. 최상위 감독자가 종료되기 전에 호출되며, start/2가 돌려준 상태(또는 [])를 받아요. 반환 값은 나중에 stop/1로 전달돼요. (optional)

@callback start( start_type(), start_args ::  term()) ::
  {:ok,  pid()} | {:ok,  pid(),  state()} | {:error, reason ::  term()}

애플리케이션이 시작될 때 호출돼요. 이 함수는 애플리케이션의 최상위 프로세스(OTP 설계 원칙을 따른다면 감독 트리의 최상위 감독자)를 시작해야 해요.

start_type은 애플리케이션이 어떻게 시작되는지 정의해요.

  • :normal - 일반 시작이거나, 애플리케이션이 분산되어 있고 다른 노드로부터의 장애 조치 때문에 현재 노드에서 시작되며 :start_phases 키가 :undefined일 때
  • {:takeover, node} - 애플리케이션이 분산되어 있고 노드 node의 장애 조치 때문에 현재 노드에서 시작될 때
  • {:failover, node} - 애플리케이션이 분산되어 있고 노드 node의 장애 조치 때문에 현재 노드에서 시작되며 :start_phases 키가 :undefined가 아닐 때

start_args:mod 명세 키에서 애플리케이션에 전달된 인자예요(예: mod: {MyApp, [:my_args]}).

이 함수는 시작이 성공하면 {:ok, pid} 또는 {:ok, pid, state}를 돌려줘야 해요. pid는 최상위 감독자의 PID예요. state는 임의의 항이 될 수 있고, 생략하면 []로 기본값이 정해져요. 나중에 애플리케이션이 중단되면 statestop/1 콜백으로 전달돼요. use Applicationstart/2 콜백에 기본 구현을 제공하지 않아요.

@callback start_phase(phase ::  term(),  start_type(), phase_args ::  term()) ::
  :ok | {:error, reason ::  term()}

애플리케이션을 동기적 단계로 시작해요. start/2가 끝난 뒤, Application.start/2가 반환되기 전에 호출돼요. 애플리케이션(및 포함된 애플리케이션)의 명세에 정의된 각 시작 단계마다 나열된 순서대로 한 번씩 호출돼요. (optional)

@callback stop( state()) ::  term()

애플리케이션이 중단된 뒤 호출돼요. start/2 콜백이 한 것의 반대를 하고 필요한 정리를 수행해야 해요. 반환 값은 무시돼요. statestart/2가 돌려준 상태(또는 [])예요. prep_stop/1이 있으면 그 반환 값이 돼요. use Application은 아무것도 하지 않고 :ok을 돌려주는 기본 구현을 정의해요.

함수 (Functions)

@spec app_dir( app()) ::  String.t()

app의 디렉토리를 가져와요. 이 정보는 코드 경로를 기반으로 반환돼요.

File.mkdir_p!("foo/ebin")
Code.prepend_path("foo/ebin")
Application.app_dir(:foo)
#=> "foo"

디렉토리가 비어 있고 .app 파일이 없어도 이름 "foo/ebin"을 기반으로 애플리케이션 디렉토리로 간주돼요. 이름에 대시 -가 들어갈 수 있고, 이는 앱 버전으로 간주되어 조회 목적에서는 제거돼요.

File.mkdir_p!("bar-123/ebin")
Code.prepend_path("bar-123/ebin")
Application.app_dir(:bar)
#=> "bar-123"

코드 경로에 대한 자세한 내용은 Elixir의 Code 모듈과 Erlang의 :code 모듈을 확인하세요.

@spec app_dir( app(),  String.t() | [ String.t()]) ::  String.t()

app_dir/1 안의 주어진 경로를 돌려줘요. path가 문자열이면 그대로 app_dir/1 안의 경로로 써요. 문자열 리스트면(Path.join/1로) 이어붙여서 써요.

File.mkdir_p!("foo/ebin")
Code.prepend_path("foo/ebin")

Application.app_dir(:foo, "my_path")
#=> "foo/my_path"

Application.app_dir(:foo, ["my", "nested", "path"])
#=> "foo/my/nested/path"

그 외 주요 함수들을 한 줄로 정리하면:

  • compile_env(app, key_or_path, default \\ nil) — 컴파일 타임에 애플리케이션 환경을 읽어요.
  • compile_env!(app, key_or_path) — 컴파일 타임에 애플리케이션 환경을 읽거나 예외를 던져요.
  • delete_env(app, key, opts \\ []) — 주어진 app 환경에서 key를 삭제해요.
  • ensure_all_started(app_or_apps, type_or_opts \\ []) — 주어진 app(들)과 그 자식 애플리케이션이 시작됐는지 보장해요.
  • ensure_loaded(app) — 주어진 app이 로드됐는지 보장해요.
  • ensure_started(app, type \\ :temporary) — 주어진 apprestart_type/0으로 시작됐는지 보장해요.
  • fetch_env(app, key)app 환경에서 key의 값을 튜플로 돌려줘요.
  • fetch_env!(app, key)app 환경에서 key의 값을 돌려주거나 예외를 던져요.
  • get_all_env(app)app의 모든 키-값 쌍을 돌려줘요.
  • get_application(module) — 주어진 모듈의 애플리케이션을 가져와요.
  • get_env(app, key, default \\ nil)app 환경에서 key의 값을 돌려줘요.
  • load(app) — 주어진 app을 로드해요.
  • loaded_applications() — 로드된 애플리케이션에 대한 정보 리스트를 돌려줘요.
  • put_all_env(config, opts \\ []) — 여러 애플리케이션의 환경을 동시에 설정해요.
  • put_env(app, key, value, opts \\ []) — 주어진 appkeyvalue를 설정해요.
  • spec(app)app의 명세를 돌려줘요.
  • start(app, type \\ :temporary) — 주어진 apprestart_type/0으로 시작해요.
  • started_applications(timeout \\ 5000) — 현재 실행 중인 애플리케이션에 대한 정보 리스트를 돌려줘요.
  • stop(app) — 주어진 app을 중단해요.
  • unload(app) — 주어진 app을 내려요(unload).
  • format_error(reason)start/2, ensure_started/2, stop/1, load/1, unload/1이 돌려준 오류 이유를 문자열로 포맷해요.

더 알아보기