트랙
/
Elixir
Elixir
/
연습 문제
/
번호표 뽑기 디럭스
번호표 뽑기 디럭스

번호표 뽑기 디럭스

학습 연습 문제

소개

GenServer

GenServer(제네릭 서버)는 Elixir 프로세스 사이의 흔한 클라이언트-서버 상호작용을 추상화한 behaviour예요.

프로세스를 배울 때 봤던 수신 루프를 기억하나요? GenServer behaviour는 그런 루프를 구현하고, 그런 루프를 실행하는 프로세스와 메시지를 주고받기 위한 추상화를 제공해요. 덕분에 상태를 유지하고 비동기 코드를 실행하기가 더 쉬워져요.

Note

GenServer라는 이름은 여러 의미로 쓰인다는 점을 알아두세요. GenServer behaviour를 사용하는 _모듈_을 가리킬 때도 있고, GenServer behaviour를 사용하는 모듈에서 시작된 _프로세스_를 가리킬 때도 있어요.

GenServer behaviour는 필수 콜백 하나인 init/1과 몇 가지 흥미로운 선택 콜백인 handle_call/3, handle_cast/2, handle_info/3을 정의해요. GenServer를 사용하는 _클라이언트_는 이 콜백들을 직접 호출하면 안 돼요. 대신 GenServer 모듈이 클라이언트가 GenServer 프로세스와 통신할 때 쓸 수 있는 함수들을 제공해요.

흔히 하나의 모듈이 _클라이언트 API_와 _서버 콜백 구현_을 모두 정의해요. 클라이언트 API는 Elixir 앱의 다른 부분이 이 GenServer 프로세스와 통신하려고 호출하는 함수들의 모음이고, 서버 콜백 구현은 이 GenServer의 로직을 담고 있어요.

먼저 GenServer의 간단한 예제를 살펴보고, 그다음에 각 콜백이 무슨 뜻인지 알아봐요.

예제

이건 긴 자동차 여행 중에 짜증 나는 동승자가 반복해서 캐묻는 질문, 정확히는 "우리 아직 도착 안 했어요?"라는 질문에 답하는 예제 서버예요. 이 질문이 몇 번 나왔는지 세고, 갈수록 짜증이 늘어난 대답을 돌려줘요.

defmodule AnnoyingPassengerAutoresponder do
  use GenServer
  # Client API

  def start_link(init_arg) do
    GenServer.start_link(__MODULE__, init_arg)
  end

  def are_we_there_yet?(pid) do
    GenServer.call(pid, :are_we_there_yet?)
  end

  # Server callbacks

  @impl GenServer
  def init(_init_arg) do
    # the initial count of questions asked is always 0
    state = 0
    {:ok, state}
  end

  @impl GenServer
  def handle_call(:are_we_there_yet?, _from, state) do
    reply =
      cond do
        state <= 3 -> "No."
        state <= 10 -> "I told you #{state} times already. No."
        true -> "..."
      end

    # increase the count of questions asked
    new_state = state + 1
    # reply to the caller
    {:reply, reply, new_state}
  end
end

콜백

init/1

GenServer.start/3 또는 GenServer.start_link/3을 호출하면 서버를 시작할 수 있어요. 이 두 함수의 차이는 링크 개념에서 배웠어요.

이 두 함수는

  • 첫 번째 인자로 GenServer behaviour를 구현한 모듈을 받아요.
  • 두 번째 인자로는 아무 값이나 받으며, 이 인자를 init_arg라고 불러요. 이름에서 알 수 있듯이 이 인자는 init/1 콜백으로 전달돼요.
  • 세 번째 인자는 선택 사항으로, 프로세스를 실행하기 위한 고급 옵션을 받는데 지금은 다루지 않아요.

GenServer.start/3 또는 GenServer.start_link/3을 호출해 서버를 시작하면 init/1 콜백이 블로킹 방식으로 호출돼요. init/1의 반환값이 서버를 성공적으로 시작할 수 있는지를 결정해요.

init/1 콜백은 보통 다음 값 중 하나를 반환해요.

  • {:ok, state}. 서버는 state를 초기 상태로 삼아 수신 루프를 시작해요. state는 어떤 타입이든 될 수 있어요.
  • {:stop, reason}. reason은 어떤 타입이든 될 수 있어요. 서버는 수신 루프를 시작하지 않아요. 프로세스는 주어진 이유와 함께 종료돼요.

지금은 다루지 않는 더 고급 방법들도 있어요.

서버의 수신 루프가 시작되면 GenServer.start/3과 GenServer.start_link/3은 {:ok, pid} 튜플을 반환해요. 그렇지 않으면 {:error, reason}을 반환해요.

handle_call/3

답장이 필요한 메시지는 GenServer.call/2로 서버 프로세스에 보낼 수 있어요. 이 함수는 첫 번째 인자로 실행 중인 서버 프로세스의 pid를, 두 번째 인자로 메시지를 받아요. 메시지는 어떤 타입이든 될 수 있어요.

handle_call/3 콜백은 동기 메시지를 처리하고 응답하는 역할을 해요. 이 콜백은 세 개의 인자를 받아요.

  1. message - GenServer.call/2의 두 번째 인자로 전달된 값이에요.
  2. from - GenServer.call/2를 호출한 프로세스의 pid예요. 대부분의 경우 이 인자는 무시해도 돼요.
  3. state - 서버의 현재 상태예요. 초깃값이 init/1 콜백에서 설정됐다는 걸 기억해요.

handle_call/3 콜백은 보통 {:reply, reply, state}라는 원소 세 개짜리 튜플을 반환해요. 이는 튜플의 두 번째 원소인 reply(어떤 타입이든 될 수 있어요)가 호출자에게 다시 전달된다는 뜻이에요. 튜플의 세 번째 원소인 state는 이 메시지를 처리한 뒤의 서버의 새 상태예요.

지금은 다루지 않는 더 고급 방법들도 있어요.

Note

이 콜백이 하는 일을 이름으로 외우려면, 누군가에게 전화를 거는 것, 즉 "call"이라고 생각해요.

그 사람이 통화 가능하다면, 곧바로 동기적으로 답장을 받아요.

handle_cast/2

답장이 필요 없는 메시지는 GenServer.cast/2로 서버 프로세스에 보낼 수 있어요. 인자는 GenServer.call/2와 똑같아요.

handle_cast/2 콜백은 이런 메시지들을 처리하는 역할을 해요. message와 state 두 인자를 받는데, from을 빼면 handle_call/3 콜백과 같은 인자예요.

handle_cast/2 콜백은 보통 {:noreply, state}라는 원소 두 개짜리 튜플을 반환해요.

지금은 다루지 않는 더 고급 방법들도 있어요.

Note

이 콜백이 하는 일을 이름으로 외우려면, "cast"에는 "던지다"라는 뜻도 있다는 걸 기억해요.

메시지를 병에 담아 바다에 던지면, 곧바로 답장이 오기를 기대하지 않아요. 어쩌면 영영 오지 않을 수도 있고요.

call을 쓸까요, cast를 쓸까요?

클라이언트 코드가 서버의 답장을 필요로 하지 않더라도, 거의 항상 call을 사용하세요.

call을 사용하면 답장을 기다리게 되는데, 이게 배압 역할을 해요. 클라이언트가 한꺼번에 너무 많은 메시지를 보내지 않도록 막아주는 거예요. 또한 서버로부터 답장을 받는 것이 서버가 클라이언트의 메시지를 받아 처리했는지 확실히 알 수 있는 유일한 방법이에요.

handle_info/2

메시지는 GenServer.call/2나 GenServer.cast/2를 호출하는 것 말고도 다른 방법으로 서버의 메일박스에 들어올 수 있어요. 예를 들어 그냥 send/2 함수를 호출하는 경우예요.

이런 메시지를 처리하려면 handle_info/2 콜백을 사용해요. 이 콜백은 handle_cast/2와 완전히 같은 방식으로 동작해요.

GenServer behaviour는 예상치 못한 메시지에 대한 오류를 기록하는, 모든 메시지를 받아주는 handle_info/2 기본 구현을 제공해요. 이 기본 구현을 재정의한다면, 반드시 직접 모든 메시지를 받아주는 구현을 넣어야 해요. 잊어버리면 예상치 못한 메시지를 받았을 때 서버가 크래시해요.

타임아웃

위에서 설명한 네 콜백의 반환값은 튜플 원소를 하나 더 붙여 타임아웃을 함께 지정할 수 있어요. 예를 들어 init/1에서 {:ok, state} 대신 {:ok, state, timeout}을 반환하는 식이에요.

타임아웃은 특정 시간 동안 메일박스에 메시지가 들어오지 않는 것을 감지할 때 사용할 수 있어요. 서버가 콜백 중 하나에서 타임아웃을 반환했는데, 지정한 밀리초가 지나도록 메시지가 도착하지 않으면 handle_info/2가 첫 번째 인자로 :timeout을 받으며 호출돼요.

지침

기본 Take-A-Number 기계는 판매가 아주 잘 되고 있었지만, 시중에 나와 있는 다른 모델들에 비해 고급 기능이 부족하다고 불만을 제기하는 사용자들이 있었어요.

제조사는 사용자 피드백을 반영해 더 많은 기능을 갖춘 디럭스 모델을 출시하기로 했고, 여러분은 다시 한번 이 기계의 소프트웨어를 작성하는 일을 맡게 됐어요.

디럭스 모델에 추가된 새로운 기능은 다음과 같아요:

  • 현재 대기 중인 번호 추적
  • 최소 및 최대 번호 설정. 이를 통해 한 시설의 서로 다른 부서에 고객을 대기시키기 위해 여러 대의 디럭스 Take-A-Number 기계를 사용할 수 있고, 번호 범위로 부서를 구분할 수 있어요.
  • 특정 번호가 대기열을 건너뛰어 임산부와 어르신에게 우선 서비스를 제공할 수 있도록 하는 기능
  • 실수로 주말 내내 기계를 켜 두어 에너지를 낭비하는 것을 막는 자동 종료

기계의 비즈니스 로직은 동료가 이미 구현해 두었고, TakeANumberDeluxe.State 모듈에서 찾을 수 있어요. 이제 여러분의 과제는 이 로직을 GenServer로 감싸는 것이에요.

1. 기계 시작하기

TakeANumberDeluxe 모듈에서 GenServer behaviour를 사용해요.

start_link/1 함수와 필요한 GenServer 콜백을 구현해요.

start_link/1에 전달되는 인자는 키워드 리스트예요. 여기에는 :min_number와 :max_number 키가 들어 있어요. 이 키들의 값은 TakeANumberDeluxe.State.new/2 함수에 전달해야 해요.

TakeANumberDeluxe.State.new/2가 {:ok, state} 튜플을 반환하면, 반환된 상태를 자신의 상태로 사용해 기계가 시작되어야 해요. 대신 {:error, error} 튜플을 반환하면, 반환된 오류를 종료 이유로 삼아 기계가 중지되어야 해요.

TakeANumberDeluxe.start_link(min_number: 1, max_number: 9)
# => {:ok, #PID<0.174.0>}

TakeANumberDeluxe.start_link(min_number: 9, max_number: 1)
# => {:error, :invalid_configuration}

TakeANumberDeluxe.State.new/2 함수가 선택적인 세 번째 인자 auto_shutdown_timeout도 받는다는 걸 눈치챘을 수도 있어요. 이 인자는 이 연습 문제의 마지막 단계에서 사용할 거예요.

2. 기계 상태 보고하기

report_state/1 함수와 필요한 GenServer 콜백을 구현해요. 기계는 호출자에게 현재 상태를 응답해야 해요.

{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.report_state(machine)
# => %TakeANumberDeluxe.State{
#      max_number: 10,
#      min_number: 1,
#      queue: %TakeANumberDeluxe.Queue{in: [], out: []},
#      auto_shutdown_timeout: :infinity,
#    }

3. 새 번호 대기열에 넣기

queue_new_number/1 함수와 필요한 GenServer 콜백을 구현해요.

기계의 현재 상태를 가지고 TakeANumberDeluxe.State.queue_new_number/1 함수를 호출해야 해요.

TakeANumberDeluxe.State.queue_new_number/1이 {:ok, new_number, new_state} 튜플을 반환하면, 기계는 호출자에게 새 번호를 응답하고 새 상태를 자신의 상태로 설정해야 해요. 대신 {:error, error} 튜플을 반환하면, 기계는 호출자에게 오류를 응답하고 상태를 변경하지 않아야 해요.

{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 2)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}

TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 2}

TakeANumberDeluxe.queue_new_number(machine)
# => {:error, :all_possible_numbers_are_in_use}

4. 대기 중인 다음 번호 처리하기

serve_next_queued_number/2 함수와 필요한 GenServer 콜백을 구현해요.

기계의 현재 상태와 두 번째 선택적 인자 priority_number를 가지고 TakeANumberDeluxe.State.serve_next_queued_number/2 함수를 호출해야 해요.

TakeANumberDeluxe.State.serve_next_queued_number/2가 {:ok, next_number, new_state} 튜플을 반환하면, 기계는 호출자에게 다음 번호를 응답하고 새 상태를 자신의 상태로 설정해야 해요. 대신 {:error, error} 튜플을 반환하면, 기계는 호출자에게 오류를 응답하고 상태를 변경하지 않아야 해요.

{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}

TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:ok, 1}

TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:error, :empty_queue}

5. 상태 초기화하기

reset_state/1 함수와 필요한 GenServer 콜백을 구현해요.

현재 상태의 min_number와 max_number를 사용해 새 상태를 만들기 위해 TakeANumberDeluxe.State.new/2 함수를 호출해야 해요. 기계는 새 상태를 자신의 상태로 설정해야 해요. 호출자에게 응답하지 않아야 해요.

{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)

TakeANumberDeluxe.reset_state(machine)
# => :ok

6. 자동 종료 구현하기

기계 시작 부분을 수정해요. init_arg로 전달된 키워드 리스트에서 :auto_shutdown_timeout 키의 값을 읽어 TakeANumberDeluxe.State.new/3의 세 번째 인자로 전달해야 해요. :auto_shutdown_timeout이 주어지지 않았다면 기본값 :infinity를 사용해요.

기계 상태 초기화 부분도 수정해서 auto_shutdown_timeout을 TakeANumberDeluxe.State.new/3에 전달하도록 해요.

구현한 모든 콜백(init/1과 모든 handle_* 콜백)의 반환값을 수정해 타임아웃을 설정해요. 현재 기계 상태의 :auto_shutdown_timeout 키 값을 사용해요. init/1의 {:stop, reason} 반환값에는 타임아웃을 추가하지 마세요. 타임아웃은 서버가 수신 루프를 시작한 뒤에만 적용돼요.

주어진 타임아웃 안에 다른 메시지를 받지 못하면 기계로 전송되는 :timeout 메시지를 처리할 GenServer 콜백을 구현해요. 이 콜백은 :normal이라는 이유로 프로세스를 종료해야 해요.

예상치 못한 메시지도 무시해서 처리하도록 해요.

{:ok, machine} =
  TakeANumberDeluxe.start_link(
    min_number: 1,
    max_number: 10,
    auto_shutdown_timeout: :timer.hours(2)
  )

# after 3 hours...

TakeANumberDeluxe.queue_new_number(machine)
# => ** (exit) exited in: GenServer.call(#PID<0.171.0>, :queue_new_number, 5000)
#        ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
#       (elixir 1.13.0) lib/gen_server.ex:1030: GenServer.call/3
GitHub에서 편집 링크가 새 창이나 탭에서 열려요
Elixir Exercism

번호표 뽑기 디럭스 문제를 시작해 볼 준비가 됐나요?

Exercism에 가입하고 Elixir 트랙을 개념 58개연습 문제 168개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.