Agent
Agent
Elixir로 프로그램을 짜다 보면 여러 프로세스가 공유하거나, 같은 프로세스가 시간에 따라 다른 시점에 접근해야 하는 상태를 어딘가에 보관하고 싶어질 때가 있어요. Agent 모듈은 바로 그런 상태에 대한 단순한 추상화를 제공해요. 내부적으로 간단한 서버를 구현해서, 간단한 API로 상태를 조회하고 갱신할 수 있게 해 주죠.
출처: Agent
본문
예시
다음은 카운터를 구현하는 에이전트예요.
defmodule Counter do
use Agent
def start_link(initial_value) do
Agent.start_link(fn -> initial_value end, name: __MODULE__)
end
def value do
Agent.get(__MODULE__, & &1)
end
def increment do
Agent.update(__MODULE__, &(&1 + 1))
end
end
사용법은 이렇게 돼요.
Counter.start_link(0)
#=> {:ok, #PID<0.123.0>}
Counter.value()
#=> 0
Counter.increment()
#=> :ok
Counter.increment()
#=> :ok
Counter.value()
#=> 2
에이전트 서버 프로세스 덕분에 카운터는 동시에 안전하게 증가시킬 수 있어요.
use Agent를 쓰면 Agent 모듈이 child_spec/1 함수를 정의해 줘서, 우리 모듈을 감독 트리(supervision tree)의 자식으로 넣을 수 있어요.
Agent는 (GenServer처럼) 클라이언트 API와 서버 API를 분리해요. 특히 Agent 함수에 인자로 넘기는 함수들은 에이전트(서버) 내부에서 실행돼요. 이 구분이 중요한 이유는, 에이전트 안에서 비싼 연산을 하면 요청이 끝날 때까지 에이전트가 블로킹되기 때문이에요.
이 두 예시를 비교해 볼게요.
# 에이전트/서버에서 계산
def get_something(agent) do
Agent.get(agent, fn state -> do_something_expensive(state) end)
end
# 에이전트/클라이언트에서 계산
def get_something(agent) do
Agent.get(agent, & &1) |> do_something_expensive()
end
첫 번째 함수는 에이전트를 블로킹해요. 두 번째 함수는 모든 상태를 클라이언트로 복사한 다음 클라이언트에서 연산을 실행하죠. 데이터가 충분히 커서 서버에서 처리해야 하는지, 아니면 클라이언트로 저렴하게 보내도 될 만큼 작은지를 고려해 봐야 해요. 또 데이터를 원자적으로 처리해야 하는지도 중요해요. 상태를 꺼내서 에이전트 밖에서 do_something_expensive(state)를 호출하면, 그 사이에 에이전트의 상태가 갱신될 수 있거든요. 특히 갱신의 경우, 새 상태를 서버가 아니라 클라이언트에서 계산하면 여러 클라이언트가 같은 상태를 서로 다른 값으로 갱신하려 할 때 레이스 컨디션이 생길 수 있어요.
감독하는 방법
Agent는 보통 감독 트리 아래에서 시작돼요. use Agent를 호출하면 child_spec/1 함수가 자동으로 정의되어 에이전트를 감독자 아래에서 바로 시작할 수 있게 해 줘요. 초기 카운터를 0으로 하는 에이전트를 감독자 아래에서 시작하려면 이렇게 해요.
children = [
{Counter, 0}
]
Supervisor.start_link(children, strategy: :one_for_all)
Counter 자체를 그냥 자식으로 넘길 수도 있어요.
children = [
Counter # Same as {Counter, []}
]
Supervisor.start_link(children, strategy: :one_for_all)
위 정의는 이 예시에서는 동작하지 않아요. 초기값을 빈 리스트로 시작하려고 하거든요. 하지만 자신만의 에이전트에서는 이것도 방법이 될 수 있어요. 초기값을 설정하고 카운터 프로세스에 이름을 주기 위해 키워드 리스트를 쓰는 방법도 흔해요.
def start_link(opts) do
{initial_value, opts} = Keyword.pop(opts, :initial_value, 0)
Agent.start_link(fn -> initial_value end, opts)
end
그러면 Counter, {Counter, name: :my_counter} 또는 {Counter, initial_value: 0, name: :my_counter}를 자식 명세로 쓸 수 있어요.
use Agent는 자식 명세를 설정하고 감독자 아래에서 어떻게 실행될지 결정하는 옵션 리스트도 받아요. 생성된 child_spec/1은 다음 옵션으로 커스터마이즈할 수 있어요.
:id- 자식 명세 식별자, 기본값은 현재 모듈:restart- 자식을 언제 재시작할지, 기본값은:permanent:shutdown- 자식을 즉시 종료할지, 아니면 종료 시간을 줄지
예를 들어:
use Agent, restart: :transient, shutdown: 10_000
자세한 내용은 Supervisor 모듈의 "Child specification" 섹션을 참고하세요. use Agent 바로 앞에 붙이는 @doc 주석은 생성된 child_spec/1 함수에 붙게 돼요.
이름 등록
에이전트는 GenServer와 같은 이름 등록 규칙을 따라요. 자세한 내용은 GenServer 문서에서 확인하세요.
분산 에이전트에 대한 참고
분산 에이전트의 한계를 고려하는 게 중요해요. Agent는 두 가지 API를 제공해요. 하나는 익명 함수와 함께 동작하고, 다른 하나는 명시적인 모듈·함수·인자를 받아요.
여러 노드로 구성된 분산 환경에서는 익명 함수를 받는 API가 호출자(클라이언트)와 에이전트가 같은 버전의 호출자 모듈을 가질 때만 동작해요.
이 문제는 "롤링 업그레이드"(rolling upgrades)를 할 때도 나타나요. 일부 노드를 종료하고 새 버전의 소프트웨어를 실행하는 노드로 교체해 배포하는 상황을 말해요. 그러면 환경의 일부는 한 버전의 모듈을, 다른 일부는 같은 모듈의 새 버전을 갖게 되죠.
가장 좋은 해결책은 분산 에이전트를 쓸 때 명시적인 모듈·함수·인자 API를 사용하는 거예요.
핫 코드 스와핑
에이전트는 갱신 지시문에 모듈·함수·인자 튜플을 넘기기만 하면 라이브로 코드를 핫 스와핑할 수 있어요. :sample이라는 에이전트가 있고 안쪽 상태를 키워드 리스트에서 맵으로 바꾸고 싶다고 해 볼게요.
{:update, :sample, {:advanced, {Enum, :into, [%{}]}}}
에이전트의 상태는 주어진 인자 리스트([%{}])의 첫 번째 인자로 추가돼요.
타입 (Types)
@type agent() :: pid() | { atom(), node()} | name()
@type name() :: atom() | {:global, term()} | {:via, module(), term()}
@type on_start() :: {:ok, pid()} | {:error, {:already_started, pid()} | term()}
@type state() :: term()
agent()- 에이전트 참조name()- 에이전트 이름on_start()-start*함수의 반환 값state()- 에이전트 상태
함수 (Functions)
@spec cast( agent(), ( state() -> state())) :: :ok
에이전트 상태에 cast(발사 후 망각, fire-and-forget) 연산을 수행해요. fun을 에이전트로 보내고, 에이전트가 상태를 넘기며 함수를 호출해요. fun의 반환 값이 에이전트의 새 상태가 돼요.
cast는 agent(또는 그 에이전트가 있어야 할 노드)가 존재하는지와 무관하게 즉시 :ok를 돌려준다는 점을 기억하세요.
iex> {:ok, pid} = Agent.start_link(fn -> 42 end)
iex> Agent.cast(pid, fn state -> state + 1 end)
:ok
iex> Agent.get(pid, fn state -> state end)
43
@spec cast( agent(), module(), atom(), [ term()]) :: :ok
cast/2와 같지만 익명 함수 대신 모듈·함수·인자를 받아요. 상태는 주어진 인자 리스트의 첫 번째 인자로 추가돼요.
iex> {:ok, pid} = Agent.start_link(fn -> 42 end)
iex> Agent.cast(pid, Kernel, :+, [12])
:ok
iex> Agent.get(pid, fn state -> state end)
54
# child_spec(arg) (since 1.5.0)
감독자 아래에서 에이전트를 시작하기 위한 명세를 돌려줘요. 자세한 내용은 Supervisor 모듈의 "Child specification" 섹션을 참고하세요.
@spec get( agent(), ( state() -> a), timeout()) :: a when a: var
주어진 익명 함수로 에이전트 값을 가져와요. fun을 에이전트로 보내고 에이전트가 상태를 넘기며 호출해요. 함수 호출 결과가 이 함수의 반환 값이 돼요.
timeout은 0보다 큰 정수로, 에이전트가 함수를 실행하고 결과값을 돌려주기까지 허용할 밀리초 수예요. 또는 :infinity 아톰을 주면 무한정 기다려요. 주어진 시간 안에 결과를 받지 못하면 함수 호출이 실패하고 호출자가 종료돼요.
iex> {:ok, pid} = Agent.start_link(fn -> 42 end)
iex> Agent.get(pid, fn state -> state end)
42
@spec get( agent(), module(), atom(), [ term()], timeout()) :: term()
get/3와 같지만 익명 함수 대신 모듈·함수·인자를 받아요. 상태는 인자 리스트의 첫 번째 인자로 추가돼요.
@spec get_and_update( agent(), ( state() -> {a, state()}), timeout()) :: a when a: var
주어진 익명 함수로 에이전트 상태를 한 번의 연산으로 얻고 갱신해요. fun은 값(그러니까 "get" 값)이 첫 번째, 에이전트의 새 상태가 두 번째인 두 요소 튜플을 돌려줘야 해요.
iex> {:ok, pid} = Agent.start_link(fn -> 42 end)
iex> Agent.get_and_update(pid, fn state -> {state, state + 1} end)
42
iex> Agent.get(pid, fn state -> state end)
43
@spec get_and_update( agent(), module(), atom(), [ term()], timeout()) :: term()
get_and_update/3와 같지만 익명 함수 대신 모듈·함수·인자를 받아요. 상태는 인자 리스트의 첫 번째 인자로 추가돼요.
@spec start((-> term()), GenServer.options()) :: on_start()
링크 없이 (감독 트리 밖에서) 에이전트 프로세스를 시작해요. 자세한 내용은 start_link/2를 참고하세요.
iex> {:ok, pid} = Agent.start(fn -> 42 end)
iex> Agent.get(pid, fn state -> state end)
42
@spec start( module(), atom(), [ term()], GenServer.options()) :: on_start()
주어진 모듈·함수·인자로 링크 없는 에이전트를 시작해요. 자세한 내용은 start_link/4를 참고하세요.
@spec start_link((-> term()), GenServer.options()) :: on_start()
주어진 함수로 현재 프로세스에 링크된 에이전트를 시작해요. 보통 감독 트리의 일부로 에이전트를 시작할 때 써요.
에이전트가 생성되면 주어진 함수 fun이 서버 프로세스에서 실행되고 초기 에이전트 상태를 돌려줘야 해요. start_link/2는 주어진 함수가 반환할 때까지 기다렸다가 돌아온다는 점을 기억하세요.
옵션을 보면:
:name- 모듈 문서에서 설명한 대로 등록에 사용돼요.:timeout- 초기화에 허용할 밀리초 수예요. 넘으면 에이전트가 종료되고 시작 함수가{:error, :timeout}을 돌려줘요.:debug- 있으면:sys모듈의 해당 함수가 호출돼요.:spawn_opt- 있으면 그 값이Process.spawn/4처럼 밑바탕 프로세스의 옵션으로 전달돼요.
서버가 성공적으로 만들어지고 초기화되면 {:ok, pid}를 돌려줘요. 같은 이름의 에이전트가 이미 있으면 {:error, {:already_started, pid}}를 돌려줘요. 주어진 함수 콜백이 실패하면 {:error, reason}을 돌려줘요.
iex> {:ok, pid} = Agent.start_link(fn -> 42 end)
iex> Agent.get(pid, fn state -> state end)
42
iex> {:error, {exception, _stacktrace}} = Agent.start(fn -> raise "oops" end)
iex> exception
%RuntimeError{message: "oops"}
@spec start_link( module(), atom(), [ term()], GenServer.options()) :: on_start()
현재 프로세스에 링크된 에이전트를 시작해요. start_link/2와 같지만 익명 함수 대신 모듈·함수·인자를 받아요. module의 fun이 주어진 인자 args와 함께 호출되어 상태를 초기화해요.
@spec stop( agent(), reason :: term(), timeout()) :: :ok
주어진 reason으로 에이전트를 동기적으로 종료해요. 주어진 이유로 에이전트가 종료되면 :ok을 돌려주고, 다른 이유로 종료되면 호출이 종료돼요.
이 함수는 오류 보고에 관한 OTP 의미론을 유지해요. reason이 :normal, :shutdown, {:shutdown, _}가 아니면 오류 보고가 로그에 남아요.
iex> {:ok, pid} = Agent.start_link(fn -> 42 end)
iex> Agent.stop(pid)
:ok
@spec update( agent(), ( state() -> state()), timeout()) :: :ok
주어진 익명 함수로 에이전트 상태를 갱신해요. fun을 에이전트로 보내고 에이전트가 상태를 넘기며 호출해요. fun의 반환 값이 에이전트의 새 상태가 돼요. 이 함수는 항상 :ok을 돌려줘요.
iex> {:ok, pid} = Agent.start_link(fn -> 42 end)
iex> Agent.update(pid, fn state -> state + 1 end)
:ok
iex> Agent.get(pid, fn state -> state end)
43
@spec update( agent(), module(), atom(), [ term()], timeout()) :: :ok
update/3와 같지만 익명 함수 대신 모듈·함수·인자를 받아요. 상태는 인자 리스트의 첫 번째 인자로 추가돼요.
iex> {:ok, pid} = Agent.start_link(fn -> 42 end)
iex> Agent.update(pid, Kernel, :+, [12])
:ok
iex> Agent.get(pid, fn state -> state end)
54
더 알아보기
- GenServer: 클라이언트/서버 API 분리와 이름 등록 규칙
- Supervisor: "Child specification" 섹션과 감독 트리
- Agent 시작하기 문서: 에이전트에 대한 개념 소개
- Process 모듈: 밑바탕 프로세스 옵션(
:spawn_opt)