[Clone Coding] Make Wordle using Phoenix LiveView: OTP and Process

posted by donghyun

6 min read

태그

이전 시리즈

  1. 소개
  2. 초기 세팅
  3. 게임 코어 만들기

Phoenix LiveView로 wordle 만들어보기 3번째: OTP와 프로세스

저번 시간까지 프로젝트를 세팅하고, 게임의 코어 모듈을 만들고, 게임의 핵심 로직을 속성 기반 테스트를 이용해 검증해보았다.

이번 시간에는 OTP와 프로세스를 이용해 게임 서버(프로세스)를 띄워보도록 하겠다.

wordle 게임은 혼자서 문제를 푸는 게임이므로, 게임에 접속한 사용자마다 각각 게임 서버가 실행되어 게임을 플레이하게된다. 게임 서버는 이전 시간에 작성한 게임 비즈니스 모듈의 상태값을 가지고 게임의 동작을 수행하는 API들을 제공한다.

wordle은 간단한 게임이므로 2가지의 API만 제공할 것이다. 정답 단어를 추측하는 함수와 현재 게임의 상태를 조회하는 API다.

정리하자면 게임 서버는 다음 요구사항을 만족해야한다.

  1. 각 사용자마다 동적으로 게임 서버가 실행되어야 하고
  2. 게임을 조작하고 상태를 조회하는 API를 제공해야한다.

이를 위해 게임서버를 GenServer로 만든 뒤 이를 DynamicSupervisor를 이용해 동적으로 여러 프로세스를 띄울 수 있게 해볼 것이다.

GenServer는 잘 알려진 OTP의 기본 behaviour이니 넘어가고, elixir가 특별히 제공하는 DynamicSupervisor에 대해서만 짧게 설명하자면, DynamicSupervisorSupervisor이지만 일반 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의 기능을 활용해 슈퍼비전 트리를 그려보았다.

supervision tree 1

게임을 실행하고 다시 트리를 그려보면 다음과 같이 GameSupervisor 밑에 자식 프로세스들이 생긴걸 볼 수 있다.

supervision tree 2

Conclusion

이번 시간에는 지난 시간까지 만든 게임의 코어를 OTP 와 프로세스를 통해 동적으로 서버를 띄우고 게임을 플레이할 수 있도록 만들었다. 우리는 비즈니스 로직 관심사와 OTP 프로세스 관리에 대한 관심사를 분리한 덕에 OTP에 대해서만 생각할 수 있었다. 다음 시간에는 게임 서버에 사용자 인터페이스를 결합해 플레이 가능한 게임을 완성해보도록 하겠다.