[Clone Coding] Make Wordle using Phoenix LiveView: OTP and Process
이전 시리즈
Phoenix LiveView로 wordle 만들어보기 3번째: OTP와 프로세스
저번 시간까지 프로젝트를 세팅하고, 게임의 코어 모듈을 만들고, 게임의 핵심 로직을 속성 기반 테스트를 이용해 검증해보았다.
이번 시간에는 OTP와 프로세스를 이용해 게임 서버(프로세스)를 띄워보도록 하겠다.
wordle 게임은 혼자서 문제를 푸는 게임이므로, 게임에 접속한 사용자마다 각각 게임 서버가 실행되어 게임을 플레이하게된다. 게임 서버는 이전 시간에 작성한 게임 비즈니스 모듈의 상태값을 가지고 게임의 동작을 수행하는 API들을 제공한다.
wordle은 간단한 게임이므로 2가지의 API만 제공할 것이다. 정답 단어를 추측하는 함수와 현재 게임의 상태를 조회하는 API다.
정리하자면 게임 서버는 다음 요구사항을 만족해야한다.
- 각 사용자마다 동적으로 게임 서버가 실행되어야 하고
- 게임을 조작하고 상태를 조회하는 API를 제공해야한다.
이를 위해 게임서버를 GenServer로 만든 뒤 이를 DynamicSupervisor를 이용해 동적으로 여러 프로세스를 띄울 수 있게 해볼 것이다.
GenServer는 잘 알려진 OTP의 기본 behaviour이니 넘어가고, elixir가 특별히 제공하는 DynamicSupervisor
에 대해서만 짧게 설명하자면, DynamicSupervisor
는 Supervisor
이지만 일반 Supervisor
와 다르게 아무 자식 없이 실행 된 후 start_child/2
함수를 통해 동적으로 여러 자식 프로세스를 on-demand로 생성하고, 자식들을 순서에 상관없이 관리하여 여러 자식을 동시에 종료한다던가 하는식으로 효율적으로 관리할 수 있도록 해준다.
FYI
elixir 1.14 부터는
PartitionSupervisor
도 추가되었는데, 한DynamicSupervisor
가 병목 지점이 되는것을 막기 위해DynamicSupervisor
를 머신의 코어 개수만큼 실행하여 자식 프로세스들을 실행할 때 랜덤으로DynamicSupervisor
를 골라서 실행하도록 하는 behaviour라고 한다.
먼저 게임 서버를 작성한다.
defmodule MyWordle.Runtime.Server do
use GenServer, restart: :temporary
alias MyWordle.Impl.Game
### client process
def start_link(_) do
GenServer.start_link(__MODULE__, nil)
end
### server process
def init(_) do
{:ok, Game.new_game()}
end
def handle_call({:make_move, guess}, _from, game) do
{updated_game, tally_or_error} =
case Game.make_move(game, guess) do
{:error, reason} ->
{game, {:error, reason}}
success_result ->
success_result
end
{:reply, tally_or_error, updated_game}
end
def handle_call({:tally}, _from, game) do
{:reply, Game.tally(game), game}
end
end
보시다시피 게임 서버의 로직은 매우 짧다. 서버는 시작 시 게임의 상태를 초기화하며, 2가지 종류의 메시지를 처리하는데, :make_move
를 통해 게임을 플레이하고, :tally
를 통해 게임의 상태를 조회할 수 있도록 한다. MyWordle.Impl.Game
모듈이 이전 시간에 만들었던 게임의 코어 모듈이다. 이전 시간에 말했듯이 게임의 코어 모듈을 분리함으로써 OTP 모듈들은 상대적으로 매우 가볍게 만들 수 있었으며, 이렇게 만드는 편이 유지보수도 용이하다.
이제 이 게임 서버를 관리하는 Supervisor를 만들어보자.
defmodule MyWordle.Runtime.GameSupervisor do
use DynamicSupervisor
def start_link(init_arg) do
DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
end
@spec start_game() :: DynamicSupervisor.on_start_child()
def start_game do
DynamicSupervisor.start_child(__MODULE__, MyWordle.Runtime.Server)
end
@impl true
def init(_init_arg) do
DynamicSupervisor.init(strategy: :one_for_one)
end
end
위에 말한 바와 같이 DynamicSupervisor
로 만들어, 각 자식 프로세스 실행을 start_game/0
이라는 게임 시작 API로 제공하는것을 볼 수 있다.
게임 서버를 하나 생성하여 실행시켜 보자.
iex> {:ok, pid} = MyWordle.Runtime.GameSupervisor.start_game()
{:ok, #PID<0.909.0>}
iex> GenServer.call(pid, :tally)
%{
alphabet_map: %{
65 => :none,
...,
90 => :none
},
game_status: :start,
history_words: [],
turns_left: 6
}
잘 동작하는 것을 볼 수 있다.
이제 게임을 완성하기 전에, 한 가지 더 필요한 것이 있다. 워들 게임에는 미리 정의된 5글자의 단어 모음집이 필요하다. 우리는 게임이 시작할때마다 해당 모음집에서 무작위로 단어를 하나 불러와 세팅할 것이다. 단어 모음집은 그냥 파일형태로 게임 서버가 뜰 때마다 파일을 불러와도 되겠지만, OTP를 활용하여 파일을 애플리케이션 실행시 한 번만 읽고 단어들을 저장하고 있는 글로벌 저장소를 하나 만들도록 하겠다.
defmodule Dictionary.Server do
use Agent
def start_link(_) do
Agent.start_link(&Dictionary.WordList.words_list/0, name: __MODULE__)
end
@spec random_word() :: String.t()
def random_word() do
Agent.get(__MODULE__, &Dictionary.WordList.random_word/1)
end
@spec in_dictionary?(String.t()) :: boolean()
def in_dictionary?(word) do
Agent.get(__MODULE__, &Dictionary.WordList.in_dictionary?(word, &1))
end
end
저장소라고 했지만 사실은 그냥 하나의 Agent 프로세스일 뿐이다. Agent는 GenServer를 한단계 더 추상화한 형태로 간단하게 상태값을 넣고 빼는 용도로 주로 활용된다. name을 __MODULE__
로 줌으로써 이 서버는 노드 당 하나만 유지될 수 있다.
Dictionary 서버는 실행될 때 단어 목록을 불러오며 서버의 API는 단어 목록에서 무작위로 단어 하나를 고르는 함수, 그리고 주어진 단어가 단어 목록에 들어있는지 검사하는 함수이다.
각 API의 내부 로직은 Dictionary.WordList
모듈로 분리하였다. 서버의 API와 같지만 단어 목록을 추가 인자로 받는다.
defmodule Dictionary.WordList do
def words_list do
words_path()
|> Path.expand(__DIR__)
|> File.read!()
|> String.split(" ", trim: true)
end
@spec random_word(list(String.t())) :: String.t()
def random_word([h | _] = word_list) when is_binary(h) do
Enum.random(word_list)
end
@spec in_dictionary?(String.t(), Enumerable.t()) :: boolean()
def in_dictionary?(word, word_list) when is_binary(word) do
word in word_list
end
defp words_path do
Application.get_env(:my_wordle, :words_path)
|> then(&Application.app_dir(:my_wordle, &1))
end
end
이제 만든 서버들을 애플리케이션 슈퍼바이저 밑에서 실행되도록 슈퍼비전 트리를 작성한다. application.ex
파일에 다음과 같이 넣는다.
defmodule MyWordle.Application do
use Application
@impl true
def start(_type, _args) do
children = [
# Start Dictionary Server
Dictionary.Server,
# Start The Game Server
MyWordle.Runtime.GameSupervisor,
]
opts = [strategy: :one_for_one, name: MyWordle.Supervisor]
Supervisor.start_link(children, opts)
end
end
실제 애플리케이션을 실행하고 슈퍼비전 트리를 확인해 볼 수 있다. 최근 업데이트된 Livebook의 기능을 활용해 슈퍼비전 트리를 그려보았다.
게임을 실행하고 다시 트리를 그려보면 다음과 같이 GameSupervisor 밑에 자식 프로세스들이 생긴걸 볼 수 있다.
Conclusion
이번 시간에는 지난 시간까지 만든 게임의 코어를 OTP 와 프로세스를 통해 동적으로 서버를 띄우고 게임을 플레이할 수 있도록 만들었다. 우리는 비즈니스 로직 관심사와 OTP 프로세스 관리에 대한 관심사를 분리한 덕에 OTP에 대해서만 생각할 수 있었다. 다음 시간에는 게임 서버에 사용자 인터페이스를 결합해 플레이 가능한 게임을 완성해보도록 하겠다.