에이전트로 단순한 상태 다루기

에이전트로 단순한 상태 다루기

이번 장에서는 여러 엔티티 사이에서 상태를 보관하고 공유하는 법을 배워요. 프로그래밍 경험이 있다면 전역 공유 변수를 떠올릴 수도 있는데요, 여기서 배울 모델은 꽤 달라요. 다음 장들에서 지금 소개할 개념들이 더 일반화돼요.

Getting Started 가이드를 건너뛰었거나 오래 전에 읽었다면, Processes 장을 다시 읽어 보세요. 출발점으로 삼을 거예요.

출처: Simple state with agents

본문

(가변) 상태의 문제점

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/2put/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/2Agent.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는 클라이언트와 서버를 더 명확하게 구분해요. 다음 장들에서 살펴볼 거예요. 이제 이름 짓기, 애플리케이션, 감독자에 대해 이야기해 볼게요.

더 알아보기