에이전트로 단순한 상태 다루기
에이전트로 단순한 상태 다루기
이번 장에서는 여러 엔티티 사이에서 상태를 보관하고 공유하는 법을 배워요. 프로그래밍 경험이 있다면 전역 공유 변수를 떠올릴 수도 있는데요, 여기서 배울 모델은 꽤 달라요. 다음 장들에서 지금 소개할 개념들이 더 일반화돼요.
Getting Started 가이드를 건너뛰었거나 오래 전에 읽었다면, Processes 장을 다시 읽어 보세요. 출발점으로 삼을 거예요.
본문
(가변) 상태의 문제점
Elixir는 불변 언어라서 기본적으로 아무것도 공유되지 않아요. 정보를 공유하고 싶으면 보통 프로세스 사이에서 메시지를 주고받아요.
하지만 프로세스를 다룰 때 직접 손으로 만들기보다는 Elixir와 OTP가 제공하는 추상화를 쓰는 경우가 많아요.
Agent- 상태에 대한 단순한 래퍼GenServer- 상태를 캡슐화하고 동기·비동기 호출을 제공하며 코드 리로딩 등을 지원하는 "제네릭 서버"(프로세스)Task- 프로세스를 만들고 나중에 결과를 가져올 수 있는 비동기 계산 단위
여기서는 에이전트를 사용해서, 다른 프로세스가 읽고 수정할 수 있게 키-값 항목을 저장하는 KV.Bucket 모듈을 만들어 볼 거예요.
Agents 101
Agent는 상태에 대한 단순한 래퍼예요. 프로세스에서 원하는 게 상태를 유지하는 것뿐이라면 에이전트가 딱 맞아요. 프로젝트 안에서 iex 세션을 시작해 볼게요.
$ iex -S mix
에이전트로 조금 놀아 봐요.
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.57.0>}
iex> Agent.update(agent, fn list -> ["eggs" | list] end)
:ok
iex> Agent.get(agent, fn list -> list end)
["eggs"]
iex> Agent.stop(agent)
:ok
빈 리스트를 초기 상태로 하는 에이전트를 시작했어요. start_link/1이 에이전트의 PID를 담은 :ok 튜플을 돌려줬죠. 앞으로의 모든 상호작용에 이 PID를 사용해요. 그다음 에이전트의 상태를 갱신해서 새 항목을 리스트의 머리(head)에 추가했어요. Agent.update/3의 두 번째 인자는 에이전트의 현재 상태를 입력으로 받아 새 상태를 돌려주는 함수예요. 마지막으로 전체 리스트를 꺼냈어요. Agent.get/3의 두 번째 인자는 상태를 입력으로 받아 Agent.get/3 자신이 돌려줄 값을 반환하는 함수예요. 에이전트를 다 썼으면 Agent.stop/3으로 에이전트 프로세스를 종료할 수 있어요.
Agent.update/3 함수는 두 번째 인자로 인자를 하나 받고 값을 돌려주는 어떤 함수든 받아요.
iex> {:ok, agent} = Agent.start_link(fn -> [] end)
{:ok, #PID<0.338.0>}
iex> Agent.update(agent, fn _list -> 123 end)
:ok
iex> Agent.update(agent, fn content -> %{a: content} end)
:ok
iex> Agent.update(agent, fn content -> [12 | [content]] end)
:ok
iex> Agent.update(agent, fn list -> [:nop | list] end)
:ok
iex> Agent.get(agent, fn content -> content end)
[:nop, 12, %{a: 123}]
보시다시피 에이전트 상태를 원하는 방식으로 마음껏 바꿀 수 있어요. 그래서 코드의 여러 곳에서 Agent API를 직접 쓰는 건 피하는 게 좋아요. 대신 모든 Agent 관련 기능을 하나의 모듈로 캡슐화하는데, 그게 바로 KV.Bucket이에요. 구현하기 전에 우리 모듈이 노출할 API를 정리한 테스트부터 작성해 볼게요.
test/kv/bucket_test.exs 파일(.exs 확장자임을 기억하세요)을 만들고 다음 내용을 넣어요.
defmodule KV.BucketTest do
use ExUnit.Case, async: true
test "stores values by key" do
{:ok, bucket} = KV.Bucket.start_link([])
assert KV.Bucket.get(bucket, "milk") == nil
KV.Bucket.put(bucket, "milk", 3)
assert KV.Bucket.get(bucket, "milk") == 3
end
end
use ExUnit.Case는 우리 모듈을 테스트할 수 있게 설정하고 test/2 매크로 같은 테스트 관련 기능을 많이 임포트해 줘요.
첫 번째 테스트는 빈 옵션 리스트를 넘기며 start_link/1을 호출해 새 KV.Bucket을 시작해요. 그다음 그 위에서 get/2와 put/3 연산을 수행하며 결과를 확인해요.
ExUnit.Case에 전달된 async: true 옵션도 눈여겨보세요. 이 옵션은 테스트 케이스가 머신의 여러 코어를 사용해 다른 :async 테스트 케이스와 병렬로 실행되게 해 줘요. 테스트 스위트를 빠르게 하는 데 아주 유용하죠. 다만 :async는 테스트 케이스가 전역 값을 사용하거나 바꾸지 않을 때만 설정해야 해요. 예를 들어 테스트가 파일 시스템에 쓰거나 데이터베이스에 접근해야 한다면 테스트 간 레이스 컨디션을 피하기 위해 동기로 유지(:async 옵션 생략)해야 해요.
async든 아니든, 우리의 새 테스트는 당연히 실패해야 해요. 테스트 대상 모듈에 아무 기능도 구현돼 있지 않으니까요.
1) test stores values by key (KV.BucketTest)
test/kv/bucket_test.exs:4
** (UndefinedFunctionError) function KV.Bucket.start_link/1 is undefined (module KV.Bucket is not available)
실패하는 테스트를 고치기 위해 lib/kv/bucket.ex 파일을 만들고 아래 내용을 넣어요. 아래 구현을 훔쳐보기 전에 직접 에이전트로 KV.Bucket 모듈을 구현해 보는 것도 좋아요.
defmodule KV.Bucket do
use Agent
@doc """
Starts a new bucket.
All options are forwarded to `Agent.start_link/2`.
"""
def start_link(opts) do
Agent.start_link(fn -> %{} end, opts)
end
@doc """
Gets a value from the `bucket` by `key`.
"""
def get(bucket, key) do
Agent.get(bucket, &Map.get(&1, key))
end
@doc """
Puts the `value` for the given `key` in the `bucket`.
"""
def put(bucket, key, value) do
Agent.update(bucket, &Map.put(&1, key, value))
end
end
구현의 첫 단계는 use Agent를 호출하는 거예요. 이 패턴은 가이드 곳곳에서 보게 될 텐데요, 다음 장에서 깊이 있게 이해할 거예요.
그다음 에이전트를 실제로 시작하는 start_link/1 함수를 정의해요. 항상 옵션 리스트를 받는 start_link/1 함수를 정의하는 것이 관례예요. 그리고 에이전트의 초기 상태를 돌려주는 익명 함수와 우리가 받은 옵션 리스트를 넘겨 Agent.start_link/2를 호출해요.
키와 값을 저장하기 위해 에이전트 안에 맵을 유지하고 있어요. 맵에서 값을 얻고 넣는 건 Agent API와 Getting Started 가이드에서 소개한 캡처 연산자 &로 해요. Agent.get/2와 Agent.update/2가 호출될 때 에이전트가 자신의 상태를 &1 인자를 통해 익명 함수에 넘겨줘요.
이제 KV.Bucket 모듈이 정의됐으니 테스트가 통과할 거예요! 직접 mix test를 실행해 확인해 보세요.
프로세스 이름 짓기
KV.Bucket을 시작할 때 Agent.start_link/2로 전달할 옵션 리스트를 넘겨요. Agent.start_link/2가 받는 옵션 중 하나가 이름 옵션인데, 프로세스에 이름을 붙여서 PID 대신 이름으로 상호작용할 수 있게 해 줘요.
테스트로 하나 작성해 볼게요. KV.BucketTest에 다음을 추가해요.
test "stores values by key on a named process" do
{:ok, _} = KV.Bucket.start_link(name: :shopping_list)
assert KV.Bucket.get(:shopping_list, "milk") == nil
KV.Bucket.put(:shopping_list, "milk", 3)
assert KV.Bucket.get(:shopping_list, "milk") == 3
end
다만 이름은 현재 노드 안에서 공유된다는 점을 기억하세요. 두 테스트가 동시에 :shopping_list라는 이름의 프로세스 두 개를 만들려고 하면 하나는 성공하고 다른 하나는 실패해요. 그래서 Elixir에서는 테스트 중에 시작하는 프로세스에 테스트 자신의 이름을 붙이는 것이 흔한 관례예요.
test "stores values by key on a named process", config do
{:ok, _} = KV.Bucket.start_link(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
테스트 이름 뒤에 오는 config 인자는 테스트 문맥인데, 현재 테스트에 대한 설정과 메타데이터를 담고 있어요. 이런 상황에서 유용하죠.
다른 에이전트 동작
값을 얻고, 에이전트 상태를 갱신하는 것 외에도 에이전트는 Agent.get_and_update/2로 값을 얻는 동시에 상태를 갱신할 수 있게 해 줘요. 버킷에서 키를 삭제하고 그 현재 값을 돌려주는 KV.Bucket.delete/2 함수를 구현해 볼게요.
@doc """
Deletes `key` from `bucket`.
Returns the current value of `key`, if `key` exists.
"""
def delete(bucket, key) do
Agent.get_and_update(bucket, &Map.pop(&1, key))
end
이제 위 기능에 대한 테스트를 직접 작성해 보는 건 어떨까요? Agent 모듈의 문서도 살펴보면서 더 배워 보세요.
에이전트의 클라이언트/서버
다음 장으로 넘어가기 전에 에이전트의 클라이언트/서버 이분법을 이야기해 볼게요. 방금 구현한 delete/2 함수를 확장해 봐요.
def delete(bucket, key) do
Agent.get_and_update(bucket, fn map ->
Map.pop(map, key)
end)
end
에이전트에 넘긴 함수 안쪽에 있는 모든 것은 에이전트 프로세스에서 일어나요. 이 경우 에이전트 프로세스가 우리 메시지를 받고 응답하는 쪽이므로 에이전트 프로세스가 서버예요. 함수 바깥에 있는 모든 것은 클라이언트에서 일어나요.
이 구분은 중요해요. 비싼 작업이 있다면 클라이언트에서 하는 게 나을지 서버에서 하는 게 나을지 생각해 봐야 해요. 예를 들어:
def delete(bucket, key) do
Process.sleep(1000) # puts client to sleep
Agent.get_and_update(bucket, fn map ->
Process.sleep(1000) # puts server to sleep
Map.pop(map, key)
end)
end
서버에서 오래 걸리는 작업을 수행하면 그 특정 서버에 대한 다른 모든 요청이 작업이 끝날 때까지 기다리게 되고, 일부 클라이언트가 타임아웃될 수도 있어요.
GenServer 같은 일부 API는 클라이언트와 서버를 더 명확하게 구분해요. 다음 장들에서 살펴볼 거예요. 이제 이름 짓기, 애플리케이션, 감독자에 대해 이야기해 볼게요.