GenServer
GenServer (behaviour)
클라이언트-서버 관계에서 서버를 구현하기 위한 behaviour 모듈이에요. GenServer는 다른 Elixir 프로세스와 똑같은 프로세스로, 상태를 유지하거나 코드를 비동기로 실행하는 등에 쓸 수 있어요. 이 모듈로 구현한 일반 서버 프로세스(GenServer)를 쓰는 장점은, 표준적인 인터페이스 함수 집합을 가지면서 추적(tracing)과 오류 보고 기능을 포함한다는 거예요. 또 감독 트리(supervision tree)에도 잘 들어맞죠.
graph BT
C(Client #3) ~~~ B(Client #2) ~~~ A(Client #1)
A & B & C -->|request| GenServer
GenServer -.->|reply| A & B & C
출처: GenServer
본문
예시 (Example)
GenServer behaviour는 흔한 클라이언트-서버 상호작용을 추상화해 줘요. 개발자는 자신이 관심 있는 콜백과 기능만 구현하면 돼요. 코드 예시로 시작한 뒤 사용 가능한 콜백을 살펴볼게요. GenServer로 스택(stack)처럼 동작해서 요소를 push·pop할 수 있는 서비스를 구현한다고 상상해 보세요. 콜백 세 개를 구현해 일반 GenServer를 우리 모듈로 커스터마이즈할 거예요. init/1은 초기 인자를 GenServer의 초기 상태로 바꿔요. handle_call/3은 서버가 동기 pop 메시지를 받으면 실행돼서 스택에서 요소를 꺼내 사용자에게 돌려줘요. handle_cast/2는 서버가 비동기 push 메시지를 받으면 실행돼서 요소를 스택에 넣죠.
defmodule Stack do
use GenServer
# Callbacks
@impl true
def init(elements) do
initial_state = String.split(elements, ",", trim: true)
{:ok, initial_state}
end
@impl true
def handle_call(:pop, _from, state) do
[to_caller | new_state] = state
{:reply, to_caller, new_state}
end
@impl true
def handle_cast({:push, element}, state) do
new_state = [element | state]
{:noreply, new_state}
end
end
시작, 메시지 전달, 메시지 루프 같은 프로세스 장치는 GenServer behaviour에 맡기고, 우리는 스택 구현에만 집중해요. 이제 프로세스를 만들고 메시지를 보내 GenServer API로 서비스와 상호작용할 수 있어요.
# Start the server
{:ok, pid} = GenServer.start_link(Stack, "hello,world")
# This is the client
GenServer.call(pid, :pop)
#=> "hello"
GenServer.cast(pid, {:push, "elixir"})
#=> :ok
GenServer.call(pid, :pop)
#=> "elixir"
start_link/2를 호출해 서버 구현 모듈과, 쉼표로 구분된 요소 목록인 초기 인자를 넘겨 Stack을 시작해요. GenServer behaviour는 init/1 콜백을 호출해 초기 GenServer 상태를 만드는데, 이 시점부터 GenServer가 제어권을 갖게 돼요. 그래서 클라이언트에서 두 종류의 메시지를 보내 상호작용해요. call 메시지는 서버의 응답을 기대하고(따라서 동기적), cast 메시지는 그렇지 않아요.
GenServer.call/3을 호출할 때마다 GenServer의 handle_call/3 콜백이 처리해야 하는 메시지가 생겨요. cast/2 메시지는 handle_cast/2가 처리해야 하고요. GenServer는 8개의 콜백을 지원하지만, init/1만 필수예요.
use GenServer
use GenServer를 하면 GenServer 모듈이 @behaviour GenServer를 설정하고 child_spec/1 함수를 정의해, 여러분의 모듈을 감독 트리의 자식으로 쓸 수 있게 해줘요.
클라이언트 / 서버 API (Client / Server APIs)
위 예시에서는 GenServer.start_link/3 등을 써서 서버를 직접 시작하고 통신했지만, 대부분의 경우 GenServer 함수를 직접 호출하지 않아요. 대신 호출을 서버의 공개 API를 나타내는 새 함수로 감싸죠. 이런 얇은 래퍼를 **클라이언트 API(client API)**라고 불러요. 다음은 Stack 모듈의 더 나은 구현이에요.
defmodule Stack do
use GenServer
# Client
def start_link(default) when is_binary(default) do
GenServer.start_link(__MODULE__, default)
end
def push(pid, element) do
GenServer.cast(pid, {:push, element})
end
def pop(pid) do
GenServer.call(pid, :pop)
end
# Server (callbacks)
@impl true
def init(elements) do
initial_state = String.split(elements, ",", trim: true)
{:ok, initial_state}
end
@impl true
def handle_call(:pop, _from, state) do
[to_caller | new_state] = state
{:reply, to_caller, new_state}
end
@impl true
def handle_cast({:push, element}, state) do
new_state = [element | state]
{:noreply, new_state}
end
end
실무에서는 서버·클라이언트 함수를 같은 모듈에 두는 게 흔해요. 서버나 클라이언트 구현이 복잡해지면 다른 모듈로 나누고 싶을 수도 있어요. 다음 다이어그램이 클라이언트와 서버의 상호작용을 요약해 줘요. Client와 Server는 모두 프로세스이고 통신은 메시지로 일어나요(실선). Server ↔ Module 상호작용은 GenServer 프로세스가 여러분의 코드를 호출할 때 일어나요(점선).
sequenceDiagram
participant C as Client (Process)
participant S as Server (Process)
participant M as Module (Code)
note right of C: Typically started by a supervisor
C->>+S: GenServer.start_link(module, arg, options)
S-->>+M: init(arg)
M-->>-S: {:ok, state} | :ignore | {:error, reason}
S->>-C: {:ok, pid} | :ignore | {:error, reason}
note right of C: call is synchronous
C->>+S: GenServer.call(pid, message)
S-->>+M: handle_call(message, from, state)
M-->>-S: {:reply, reply, state} | {:stop, reason, reply, state}
S->>-C: reply
note right of C: cast is asynchronous
C-)S: GenServer.cast(pid, message)
S-->>+M: handle_cast(message, state)
M-->>-S: {:noreply, state} | {:stop, reason, state}
note right of C: send is asynchronous
C-)S: Kernel.send(pid, message)
S-->>+M: handle_info(message, state)
M-->>-S: {:noreply, state} | {:stop, reason, state}
감독하는 방법 (How to supervise)
GenServer는 대부분 감독 트리 아래에서 시작돼요. use GenServer를 호출하면 child_spec/1 함수를 자동으로 정의해, Stack을 감독자 바로 아래에서 시작할 수 있게 해줘요. ["hello", "world"] 기본 스택을 감독자 아래에서 시작하려면 이렇게 해요.
children = [
{Stack, "hello,world"}
]
Supervisor.start_link(children, strategy: :one_for_all)
모듈 MyServer를 지정하는 것은 튜플 {MyServer, []}를 지정하는 것과 같다는 점을 기억하세요. use GenServer는 자식 명세(child specification)를 설정하고 그래서 감독자 아래에서 어떻게 실행되는지 정하는 옵션 목록도 받아요. 생성된 child_spec/1은 다음 옵션으로 커스터마이즈할 수 있어요.
:id— 자식 명세 식별자. 기본값은 현재 모듈이에요.- :restart — 자식을 언제 재시작할지. 기본값은
:permanent예요. - :shutdown — 자식을 즉시 종료할지, 아니면 종료 시간을 줄지. 기본값은
5_000이에요.
예를 들어:
use GenServer, restart: :transient, shutdown: 10_000
더 자세한 내용은 Supervisor 모듈의 "Child specification" 절을 보세요. use GenServer 바로 앞에 오는 @doc 어노테이션은 생성된 child_spec/1 함수에 붙어요. 콜백에서 {:stop, reason, new_state} 튜플을 반환하는 식으로 GenServer를 중지할 때, exit 이유를 감독자가 GenServer를 재시작해야 하는지 결정하는 데 사용해요. Supervisor 모듈의 "Exit reasons and restarts" 절을 참고하세요.
이름 등록 (Name registration)
start_link/3과 start/3 모두 :name 옵션을 통해 시작 시 GenServer가 이름을 등록하도록 지원해요. 등록된 이름은 종료 시 자동으로 정리되기도 해요. 지원되는 값은 다음과 같아요.
nil(기본값) — GenServer가 이름으로 등록되지 않아요.- 아톰 — GenServer가 Process.register/2를 사용해 주어진 이름으로 로컬(현재 노드)에 등록돼요.
{:global, term}— GenServer가 :global 모듈의 함수를 사용해 주어진 term으로 전역 등록돼요.{:via, module, term}— GenServer가 주어진 메커니즘과 이름으로 등록돼요.:via옵션은register_name/2,unregister_name/1,whereis_name/1, send/2를 내보내는 모듈을 기대해요. 예를 들어 :global 모듈이 있는데, 이 함수들로 Elixir 노드 네트워크에서 전역으로 사용 가능한 프로세스 이름과 그에 연결된 PID 목록을 유지해요. Elixir는 동적으로 생성되는 이름을 로컬에 저장하는, 로컬·비중앙·확장 가능한 레지스트리인 Registry도 함께 제공해요.
예를 들어 Stack 서버를 로컬에서 시작하고 등록할 수 있어요.
# Start the server and register it locally with name MyStack
{:ok, _} = GenServer.start_link(Stack, "hello", name: MyStack)
# Now messages can be sent directly to MyStack
GenServer.call(MyStack, :pop)
#=> "hello"
서버가 시작되면 이 모듈의 나머지 함수(call/3, cast/2 등)도 아톰이나 {:global, ...}·{:via, ...} 튜플을 받아들여요. 일반적으로 다음 형식이 지원돼요.
- PID
- 서버가 로컬 등록돼 있으면 아톰
- 서버가 다른 노드에 로컬 등록돼 있으면
{atom, node} - 서버가 전역 등록돼 있으면
{:global, term} - 서버가 대체 레지스트리를 통해 등록돼 있으면
{:via, module, name}
동적 이름을 로컬에 등록하고 싶다면 아톰을 쓰지 마세요. 아톰은 가비지 컬렉션되지 않아서 동적으로 생성된 아톰은 정리되지 않으니까요. 그런 경우 Registry 모듈을 사용해 자신만의 로컬 레지스트리를 만들 수 있어요. 예를 들어:
{:ok, _} = Registry.start_link(keys: :unique, name: :stacks)
name = {:via, Registry, {:stacks, "stack 1"}}
{:ok, _pid} = GenServer.start_link(Stack, "hello", name: name)
GenServer.whereis(name)
#=> #PID<0.150.0>
"일반" 메시지 받기 (Receiving "regular" messages)
GenServer의 목표는 "receive" 루프를 개발자에게서 추상화해, 시스템 메시지를 자동으로 처리하고 코드 변경과 동기 호출 등을 지원하는 거예요. 그래서 GenServer 콜백 안에서 자신만의 "receive"를 절대 호출하면 안 돼요. 그렇게 하면 GenServer가 오작동하니까요. call/3과 cast/2가 제공하는 동기·비동기 통신 외에도, send/2, Process.send_after/4 같은 함수가 보내는 "일반" 메시지는 handle_info/2 콜백 안에서 처리할 수 있어요.
handle_info/2는 Process.monitor/1이 보내는 모니터 DOWN 메시지 처리 등 여러 상황에서 쓰여요. 또 Process.send_after/4의 도움으로 주기적인 작업을 수행하는 데도 쓰여요.
defmodule MyApp.Periodically do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{})
end
@impl true
def init(state) do
# Schedule work to be performed on start
schedule_work()
{:ok, state}
end
@impl true
def handle_info(:work, state) do
# Do the desired work here
# ...
# Reschedule once more
schedule_work()
{:noreply, state}
end
defp schedule_work do
# We schedule the work to happen in 2 hours (written in milliseconds).
# Alternatively, one might write :timer.hours(2)
Process.send_after(self(), :work, 2 * 60 * 60 * 1000)
end
end
타임아웃 (Timeouts)
init/1이나 어떤 handle_* 콜백의 반환 값에 밀리초 단위의 타임아웃 값이 포함될 수 있어요. 없으면 :infinity로 가정해요. 타임아웃은 들어오는 메시지의 고요한 구간(lull)을 감지하는 데 쓸 수 있어요. timeout() 값은 이렇게 사용돼요.
timeout()값이 반환될 때 프로세스에 이미 대기 중인 메시지가 있으면, 타임아웃은 무시되고 대기 중인 메시지가 평소처럼 처리돼요. 즉0밀리초 타임아웃조차 실행이 보장되지 않아요(즉시·무조건 다른 조치를 취하고 싶다면:continue지시를 쓰세요).- 지정된 밀리초가 지나기 전에 메시지가 도착하면 타임아웃은 해제되고 그 메시지가 평소처럼 처리돼요.
- 그 외에 지정된 밀리초가 메시지 없이 경과하면
handle_info/2가 첫 인자:timeout로 호출돼요.
예를 들어:
defmodule Counter do
use GenServer
@timeout to_timeout(second: 5)
@impl true
def init(count) do
{:ok, count, @timeout}
end
@impl true
def handle_call(:increment, _from, count) do
new_count = count + 1
{:reply, new_count, new_count, @timeout}
end
@impl true
def handle_info(:timeout, count) do
{:stop, :normal, count}
end
end
Counter 서버는 초기화 후 또는 마지막 :increment 호출 후 5초 동안 메시지가 없으면 :normal로 종료돼요.
{:ok, counter_pid} = GenServer.start(Counter, 50)
GenServer.call(counter_pid, :increment)
#=> 51
# After 5 seconds
Process.alive?(counter_pid)
#=> false
GenServer를 (안) 써야 할 때 (When (not) to use a GenServer)
지금까지 GenServer가 동기·비동기 호출을 처리하는 감독된 프로세스로 쓰일 수 있다는 걸 배웠어요. 또 주기적 메시지나 모니터링 이벤트 같은 시스템 메시지도 처리할 수 있고, 이름을 가질 수도 있어요. GenServer, 나아가 프로세스는 시스템의 런타임 특성을 모델링하는 데 사용해야 해요. GenServer를 코드 구성(code organization) 목적으로는 절대 쓰면 안 돼요. Elixir에서 코드 구성은 모듈과 함수로 하고, 프로세스는 필요 없어요. 예를 들어 계산기(calculator)를 구현하면서 모든 계산기 연산을 GenServer 뒤에 두기로 했다고 상상해 보세요.
def add(a, b) do
GenServer.call(__MODULE__, {:add, a, b})
end
def subtract(a, b) do
GenServer.call(__MODULE__, {:subtract, a, b})
end
def handle_call({:add, a, b}, _from, state) do
{:reply, a + b, state}
end
def handle_call({:subtract, a, b}, _from, state) do
{:reply, a - b, state}
end
이건 안티 패턴이에요. 계산기 로직을 뒤엉키게 할 뿐 아니라 계산기 로직을 단일 프로세스 뒤에 두면, 특히 호출 수가 늘어날수록 시스템의 병목이 될 수 있으니까요. 대신 함수를 직접 정의하세요.
def add(a, b) do
a + b
end
def subtract(a, b) do
a - b
end
프로세스가 필요 없다면, 프로세스가 필요 없는 거예요. 프로세스는 가변 상태·동시성·실패 같은 런타임 속성만 모델링하는 데 쓰고, 절대 코드 구성에는 쓰지 마세요.
:sys 모듈로 디버깅하기 (Debugging with the :sys module)
GenServer는 특수 프로세스(special processes)로서 :sys 모듈로 디버깅할 수 있어요. 이 모듈은 다양한 훅을 통해 프로세스 상태를 들여다보고 실행 중 발생하는 시스템 이벤트(받은 메시지, 보낸 응답, 상태 변경 등)를 추적하게 해줘요. 디버깅에 쓰이는 :sys 모듈의 기본 함수를 살펴볼게요.
- :sys.get_state/2 — 프로세스의 상태를 가져올 수 있어요. GenServer 프로세스의 경우 마지막 인자로 콜백 함수에 전달되는 콜백 모듈 상태가 돼요.
- :sys.get_status/2 — 프로세스의 상태(status)를 가져올 수 있어요. 이 상태에는 프로세스 딕셔너리, 프로세스가 실행 중인지 일시 중지됐는지, 부모 PID, 디버거 상태, 그리고 :sys.get_state/2가 반환하는 콜백 모듈 상태를 포함한 behaviour 모듈 상태가 담겨요. 선택적 GenServer.format_status/1 콜백을 정의해 이 상태를 어떻게 표현할지 바꿀 수 있어요.
- :sys.trace/3 — 모든 시스템 이벤트를
:stdio에 출력해요. - :sys.statistics/3 — 프로세스 통계 수집을 관리해요.
- :sys.no_debug/2 — 주어진 프로세스의 모든 디버그 핸들러를 꺼요. 디버깅이 끝나면 꺼두는 게 아주 중요해요. 과도한 디버그 핸들러나 껐어야 하는데 안 끈 핸들러는 시스템 성능을 심각하게 해칠 수 있어요.
- :sys.suspend/2 — 프로세스를 일시 중지해서 시스템 메시지에만 응답하고 다른 메시지에는 응답하지 않게 해요. 일시 중지된 프로세스는 :sys.resume/2로 다시 활성화할 수 있어요.
이 함수들로 앞서 정의한 스택 서버를 디버깅하는 방법을 볼게요.
iex> {:ok, pid} = Stack.start_link("")
iex> :sys.statistics(pid, true) # turn on collecting process statistics
iex> :sys.trace(pid, true) # turn on event printing
iex> Stack.push(pid, 1)
*DBG* <0.122.0> got cast {push,1}
*DBG* <0.122.0> new state [1]
:ok
iex> :sys.get_state(pid)
[1]
iex> Stack.pop(pid)
*DBG* <0.122.0> got call pop from <0.80.0>
*DBG* <0.122.0> sent 1 to <0.80.0>, new state []
1
iex> :sys.statistics(pid, :get)
{:ok,
[
start_time: {{2016, 7, 16}, {12, 29, 41}},
current_time: {{2016, 7, 16}, {12, 29, 50}},
reductions: 117,
messages_in: 2,
messages_out: 0
]}
iex> :sys.no_debug(pid) # turn off all debug handlers
:ok
iex> :sys.get_status(pid)
{:status, #PID<0.122.0>, {:module, :gen_server},
[
[
"$initial_call": {Stack, :init, 1}, # process dictionary
"$ancestors": [#PID<0.80.0>, #PID<0.51.0>]
],
:running, # :running | :suspended
#PID<0.80.0>, # parent
[], # debugger state
[
header: 'Status for generic server <0.122.0>', # module status
data: [
{'Status', :running},
{'Parent', #PID<0.80.0>},
{'Logged events', []}
],
data: [{'State', [1]}]
]
]}
더 알아보기
GenServer에 대해 더 알고 싶다면 Elixir Getting Started 가이드에 튜토리얼식 소개가 있어요. Erlang의 문서와 링크도 추가 통찰을 얻을 수 있어요.