이번에 분석할 코드는 지난 번에 작성했던 Card Deck Server에서 조금 더 발전시킨 것이다.
신규 코드
defmodule Card.Server do
use GenServer
# Client API
def start do
GenServer.start_link(__MODULE__, sorted_deck())
end
def new(pid) do
GenServer.call(pid, :new)
end
def shuffle(pid) do
GenServer.call(pid, :shuffle)
end
def count(pid) do
GenServer.call(pid, :count)
end
def deal(pid, n \\ 1) do
GenServer.call(pid, {:deal, n})
end
# Server Callbacks
@impl true
def init(deck) do
{:ok, deck}
end
@impl true
def handle_call(:new, _from, _state) do
{:reply, :ok, sorted_deck()}
end
@impl true
def handle_call(:shuffle, _from, state) do
{:reply, :ok, Enum.shuffle(state)}
end
@impl true
def handle_call(:count, _from, state) do
{:reply, length(state), state}
end
@impl true
def handle_call({:deal, n}, _from, state) when n <= length(state) and n > 0 do
{deal_deck, remaining_deck} = Enum.split(state, n)
{:reply, {:ok, deal_deck}, remaining_deck}
end
@impl true
def handle_call({:deal, n}, _from, state) when n > length(state) do
{:reply, {:error, "Not enough cards"}, state}
end
@impl true
def handle_call({:deal, n}, _from, state) when n <= 0 do
{:reply, {:error, "Number must be greater than zero"}, state}
end
# Private function to create a sorted deck
defp sorted_deck do
suits = [:clover, :diamond, :heart, :spade]
ranks = [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, :ace]
for suit <- suits, rank <- ranks, do: {suit, rank}
end
end
일단 두 코드 모두 카드 덱을 다루는 서버를 구현한 것이지만, 사용하는 방식과 메커니즘이 다르다. 신규 코드에서는 GenServer를 사용할 것인데, 일단 지난 시간에 공부했던 GenServer에 대해서 다시 한번 리뷰를 하고 가자.
GenServer
GenServer
는 Elixir에서 제공하는 OTP 라이브러리의 일부로, "Generic Server"의 약자다. OTP(Open Telecom Platform)는 병렬 프로그래밍, 분산 프로그래밍, 신뢰성 등을 지원하기 위한 Erlang의 일련의 모듈과 기준을 말한다. GenServer
는 이러한 복잡한 부분들을 추상화해서 사용자가 더 쉽게 서버 프로세스를 만들고 관리할 수 있게 도와준다.
주요 특징과 장점:
-
메시지 핸들링:
GenServer
는 메시지를 받고 처리하는 공식적인 패턴을 제공한다.handle_call
,handle_cast
등의 콜백 함수를 정의하면 된다. -
상태 관리: 서버 프로세스의 상태를 관리하는 것이 쉽다. 초기 상태는
init
콜백에서 설정하고, 이후에 상태는 콜백 함수들을 통해 업데이트할 수 있다. -
에러 복구: 프로세스가 실패할 경우, OTP의 Supervisor 구조를 통해 자동으로 재시작할 수 있다.
-
캡슐화:
GenServer
는 내부 상태를 잘 캡슐화하고, 외부에서는 정의된 API 함수를 통해서만 상태를 변경하거나 조회할 수 있다.
기본 사용 예시:
-
서버 시작:
GenServer.start_link/3
함수로 서버 프로세스를 시작한다.{:ok, pid} = GenServer.start_link(MyServer, :ok, [])
-
콜백 함수:
MyServer
모듈 내부에서 필요한 콜백 함수를 정의한다.def handle_call(:ping, _from, state) do {:reply, :pong, state} end
-
클라이언트 API: 클라이언트가 사용할 API를 정의한다.
def ping(pid) do GenServer.call(pid, :ping) end
-
서버 호출: 클라이언트 코드에서 API를 사용해서 서버를 호출한다.
MyServer.ping(pid)
이렇게 하면 GenServer
의 기본적인 흐름과 구조를 이해할 수 있다. 기본적인 메시지 패싱과 상태 관리를 GenServer
가 대신 해주기 때문에, 개발자는 비즈니스 로직에 더 집중할 수 있다.
CardServer (기존 코드)
- Low-Level API 사용:
CardServer
모듈에서는 Erlang/Elixir의 low-level 메시지 패싱과 프로세스 관리 API를 사용하고 있다.spawn_link
,receive
,send
와 같은 함수를 이용해 프로세스를 생성하고, 메시지를 수신하며, 다른 프로세스에 메시지를 보낸다.
-
CardServer
모듈에서는 Elixir(Erlang의 구문과 기능을 확장한 언어)의 low-level(저수준) API를 사용해서 프로세스를 관리하고 있다. 저수준이라는 것은 컴퓨터의 시스템과 가까운 레벨에서 작동하는 것을 말한다. 여기서는 Elixir에서 제공하는 가장 기본적인 프로세스와 메시지 관련 함수를 사용하고 있다. 이러한 기본적인 함수를 사용하면 많은 세부 사항을 직접 관리해야 하지만, 더 유연한 구현이 가능하다.spawn_link
: 이 함수는 새로운 프로세스를 생성한다. 이 프로세스는 독립적으로 실행되며,spawn_link
함수는 새로 생성된 프로세스의 PID(Process ID)를 반환한다. 여기서link
라는 용어는 생성된 프로세스와 현재 프로세스가 어떤 방식으로든 연결되어 있다는 것을 의미한다. 하나가 죽으면 다른 하나도 죽게 되는 등의 연결이다.
pid = spawn_link(fn -> loop(sorted_deck()) end)
receive
: 이 함수는 프로세스의 메시지 큐에서 메시지를 가져온다. 메시지는 다른 프로세스에서 보낸 것이거나, 혹은 같은 프로세스 내부에서 보낸 것일 수 있다.receive
블록 내에서 패턴 매칭을 사용하여 어떤 종류의 메시지를 처리할 것인지 결정한다.
receive do {:new, caller} -> send(caller, :new_deck_created) loop(sorted_deck()) # ... end
send
: 이 함수는 특정 프로세스에 메시지를 보낸다. 여기서는caller
라고 불리우는 프로세스에게 결과를 보내고 있다.
send(caller, :new_deck_created)
-
이러한 함수들은 Elixir에서 멀티프로세싱을 할 때 가장 기본적인 방법을 제공한다. 하지만 이러한 저수준의 작업은 복잡하고 오류가 발생하기 쉽다. 따라서 대부분의 경우, 더 높은 수준의 추상화를 제공하는
GenServer
같은 라이브러리를 사용한다.GenServer
를 사용하면, 메시지 패싱, 상태 관리, 에러 처리 등이 훨씬 쉬워진다.
-
에러 핸들링: 이 방식에서는 에러 핸들링이 명시적으로 되어 있지 않다. 예를 들어 프로세스가 죽으면 어떻게 다시 시작할지에 대한 로직이 없다.
-
상태 관리:
loop
함수 내에서 재귀를 사용해 상태(deck)을 관리하고 있다.
Card.Server (신규 코드)
-
GenServer 사용: 이 경우에는 Elixir의
GenServer
라이브러리를 사용한다.GenServer
는 OTP의 일부로, 서버 프로세스를 쉽게 만들 수 있게 해주는 추상화 레이어를 제공한다. -
에러 핸들링과 장애 복구:
GenServer
를 사용하면 OTP의 Supervisor 구조를 이용하여 에러 핸들링과 프로세스의 재시작을 손쉽게 할 수 있다.
"에러 핸들링과 장애 복구"는 소프트웨어에서 중요한 측면 중 하나다. 프로그램이 실행되는 동안 여러 가지 예상치 못한 상황이 발생할 수 있고, 이러한 상황을 잘 처리하지 않으면 전체 시스템이 다운될 위험이 있다. 이 때, Elixir와 Erlang의 OTP 라이브러리는 Supervisor
라는 구조를 제공해 이 문제를 해결한다.
Supervisor란 무엇인가?
Supervisor
는 OTP의 구성 요소 중 하나로, 다른 프로세스를 모니터링하는 역할을 한다. 만약 모니터링 대상인 프로세스가 실패하면 Supervisor
는 설정에 따라 그 프로세스를 재시작한다. 이렇게 하여 프로그램의 전체적인 안정성과 지속성을 유지할 수 있다.
GenServer와 Supervisor의 관계
GenServer
는 Supervisor
에 의해 모니터링될 수 있는 프로세스다. GenServer
가 실패하면, Supervisor
가 알아서 재시작을 해주기 때문에 개발자는 복잡한 에러 복구 로직을 작성할 필요가 없다.
사용 예:
-
Supervisor 설정:
Supervisor
를 설정할 때, 어떤 자식 프로세스를 관리할 것인지, 그리고 어떤 전략을 사용할 것인지를 명시한다. 전략에는:one_for_one
,:one_for_all
,:rest_for_one
등이 있다.defmodule MyApp.Supervisor do use Supervisor def start_link do Supervisor.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do children = [ worker(MyApp.Worker, [], restart: :temporary) ] supervise(children, strategy: :one_for_one) end end
-
자식 프로세스 등록: 위 예에서
MyApp.Worker
는GenServer
를 사용한 예라고 가정하자. 이 프로세스가 실패하면Supervisor
가 재시작을 해준다. -
재시작 전략:
restart: :temporary
라고 설정했으면, 프로세스가 죽어도 재시작되지 않는다.:permanent
을 설정하면 프로세스가 죽으면 항상 재시작한다.
이렇게 Supervisor
와 GenServer
를 함께 사용하면, 복잡한 에러 핸들링과 장애 복구 로직을 간단하게 구현할 수 있다. 프로세스가 실패할 경우 Supervisor
가 알아서 재시작을 해주기 때문에, 개발자는 비즈니스 로직에만 집중할 수 있다.
- 콜백 함수로 상태 관리:
GenServer
는 상태를 관리하기 위해 명시적인 콜백 함수를 사용한다 (handle_call
). 상태를 변경하려면 콜백 함수의 반환 값으로 새로운 상태를 전달한다.
콜백 함수로 상태 관리
GenServer
에서는 handle_call
, handle_cast
, handle_info
같은 콜백 함수를 통해 상태를 관리한다. 이러한 콜백 함수는 GenServer
프로세스가 메시지를 받을 때 호출되며, 콜백 함수의 반환 값으로 상태를 갱신할 수 있다.
예를 들어, handle_call
함수에서 상태를 갱신하려면 다음과 같이 할 수 있다.
def handle_call(:increment, _from, state) do
new_state = state + 1
{:reply, :ok, new_state}
end
이 예에서, state
는 현재 상태를 나타내고 new_state
는 새로운 상태다. {:reply, :ok, new_state}
를 반환하면 GenServer
의 상태가 new_state
로 갱신된다.
장점
-
캡슐화: 상태는 콜백 함수 내에서만 다루어지기 때문에, 외부에서 상태를 직접 조작할 수 없다. 이런 캡슐화는 버그를 줄이고 코드의 안정성을 높인다.
-
명시성: 상태의 변화는 명시적인 콜백 함수 내에서만 발생한다. 따라서 코드를 읽을 때 상태가 어떻게 변화하는지 쉽게 파악할 수 있다.
-
분리와 재사용: 로직과 상태 관리가 분리되어 있기 때문에, 로직을 재사용하기 쉽다.
단점
-
복잡성: 여러 콜백 함수가 있는 경우, 상태를 어떻게 관리해야 할지가 복잡해질 수 있다. 특히 복잡한 로직과 상태 변화가 함께 있는 경우 이해하기 어렵다.
-
디버깅 어려움: 상태가 여러 콜백 함수를 거치면서 변화하기 때문에, 문제가 생겼을 때 어떤 콜백 함수에서 문제가 발생했는지 찾기 어렵다.
-
학습 곡선:
GenServer
와 같은 동시성 모델을 처음 접하는 사람에게는 학습 곡선이 있을 수 있다. 콜백 함수와 상태 관리 방법을 이해하는 데 시간이 걸릴 수 있다.
상태 관리 방식은 애플리케이션의 요구사항과 복잡성에 따라 선택해야 하며, GenServer
의 이러한 상태 관리 방식은 많은 경우에 잘 맞는다.
- Client API:
GenServer
구조에서는 클라이언트 API와 서버 콜백이 명확하게 분리된다.new
,shuffle
,count
,deal
과 같은 함수가 클라이언트 API로, 실제 로직은handle_call
콜백에서 처리한다.
GenServer
에서 Client API란, 외부에서 GenServer
프로세스에 어떻게 접근할지 정의한 함수들을 말한다. 이 함수들은 일반적으로 GenServer.call
, GenServer.cast
와 같은 내장 함수를 사용해 GenServer
프로세스에 메시지를 보낸다. 이 메시지는 다음에 GenServer
가 실행할 콜백 함수와 인자를 결정하는 데 사용된다.
예를 들면, new
, shuffle
, count
, deal
함수들은 클라이언트 코드가 GenServer
프로세스와 상호작용하기 위한 인터페이스(API)다. 이 함수들이 호출되면 내부적으로는 GenServer.call
이나 GenServer.cast
함수를 통해 실제 GenServer
프로세스에 메시지를 보낸다.
def new(pid) do
GenServer.call(pid, :new)
end
def shuffle(pid) do
GenServer.call(pid, :shuffle)
end
그리고 이 메시지는 GenServer
프로세스 내의 적절한 콜백 함수, 예를 들어 handle_call
, 에서 처리된다.
def handle_call(:new, _from, _state) do
{:reply, :ok, sorted_deck()}
end
def handle_call(:shuffle, _from, state) do
{:reply, :ok, Enum.shuffle(state)}
end
이렇게 Client API와 Server Callbacks이 명확하게 분리되어 있으면, 어떤 코드가 실제 비즈니스 로직을 처리하는지(handle_call
), 어떤 코드가 외부와의 인터페이스를 담당하는지(new
, shuffle
등의 함수) 쉽게 구분할 수 있다. 이로 인해 코드의 가독성과 유지보수성이 향상된다.
- 명세화:
GenServer
는@impl true
어노테이션을 사용하여 OTP 표준에 따른 구현을 명세화한다. 이는 코드의 가독성을 높이고 문서화에도 도움이 된다.
요약
CardServer
는 low-level API를 사용하여 세세한 부분까지 제어한다.Card.Server
는GenServer
를 사용하여 추상화 레벨을 높이고, 에러 핸들링이나 상태 관리를 더 쉽고 안전하게 한다.
이러한 차이점 때문에 대규모 애플리케이션 또는 복잡한 로직에서는 GenServer
를 사용하는 것이 일반적이다. 하지만 단순한 애플리케이션에서는 CardServer
와 같은 방식도 충분히 유용할 수 있다.
이제 본격적으로 코드를 자세히 분석해보자.
def start do
GenServer.start_link(__MODULE__, sorted_deck())
end
GenServer.start_link(__MODULE__, sorted_deck())
이 코드는 GenServer
프로세스를 생성하고 시작하는 역할을 한다. 코드를 한 부분씩 살펴보자.
-
GenServer.start_link
: 이 함수는GenServer
프로세스를 생성하고, 해당 프로세스와 현재 호출하는 프로세스를 연결(link)한다. 연결이라는 것은 두 프로세스 사이에 오류 전파를 의미한다. 즉, 하나의 프로세스가 죽으면 연결된 다른 프로세스도 죽게 된다. -
__MODULE__
: 이는 현재 모듈의 이름을 의미한다. 여기서는Card.Server
모듈이 될 것이다.GenServer.start_link
는 이 모듈 내에서 콜백 함수들(handle_call
,handle_cast
등)을 찾아 실행한다. -
sorted_deck()
: 이 함수는GenServer
프로세스의 초기 상태를 설정한다. 여기서는 카드 덱을 정렬한 결과가 초기 상태가 될 것이며, 코드의 뒤쪽에 정의가 되어있다.
따라서 이 코드의 의미는 "현재 모듈(Card.Server
)에 정의된 콜백 함수들을 사용하며, sorted_deck()
로 초기 상태를 설정한 GenServer
프로세스를 생성하고 시작하라"라고 볼 수 있다.
이렇게 생성된 GenServer
프로세스는 비동기적으로 동작하며, 정의된 콜백 함수를 통해 다양한 메시지를 처리한다. 초기 상태는 이후 콜백 함수들이 호출될 때 그 상태 인자로 전달되며, 상태를 업데이트할 수도 있다.
def deal(pid, n \\ 1) do
GenServer.call(pid, {:deal, n})
end
GenServer.call(pid, {:deal, n})
이 코드는 GenServer
프로세스에 동기식으로 메시지를 보내고, 응답을 기다린다. 코드를 분석해보자.
-
GenServer.call
: 이 함수는GenServer
프로세스에 동기식으로 메시지를 보낸다. 동기식이라는 것은 메시지를 보내고, 그에 대한 응답을 받을 때까지 기다린다는 의미다. -
pid
: 이 인자는 메시지를 받을GenServer
프로세스의 식별자(Process Identifier)다. 여기서는Card.Server
의GenServer
프로세스가 될 것이다. -
{:deal, n}
: 이 튜플은GenServer
에 보낼 메시지다.:deal
은 어떤 종류의 작업을 수행할지를 나타내는 심볼이고,n
은 그 작업에 필요한 추가 정보(여기서는 카드를 몇 장 'deal'할지의 수)다.
포맷에 대한 설명:
- 이런 튜플 형태의 메시지는
GenServer
에서 메시지를 분류하고 처리하기 쉽게 해준다. 예를 들어,handle_call
콜백 함수에서는 이 튜플의 첫 번째 요소(:deal
)를 보고 어떤 로직을 실행할지 결정할 수 있다. - 튜플의 두 번째 요소(
n
)는 로직을 실행할 때 필요한 추가 정보를 제공한다. 이런 방식으로 메시지를 구조화하면 유지보수가 쉽고 코드의 가독성도 좋아진다.
이렇게 GenServer.call
함수를 사용하면, 해당 GenServer
프로세스의 handle_call
함수가 호출되며, 거기에 {:deal, n}
이 인자로 전달된다. 그리고 handle_call
함수에서 이 메시지를 처리하고 결과를 반환하게 된다.
@impl true
def init(deck) do
{:ok, deck}
end
init(deck) do {:ok, deck} end
함수는 GenServer
프로세스가 시작될 때 한 번 호출되는 초기화 함수다. 이 함수의 역할은 GenServer
의 초기 상태를 설정하는 것이다. 여기서 deck
이 초기 상태로 설정되고, {:ok, deck}
형태로 반환되면 GenServer
프로세스가 성공적으로 초기화된 것이다. 이 deck
값은 이후 handle_call
등의 콜백 함수에서 상태로 사용된다.
@impl true
는 이 함수가 어떤 behaviour의 구현이라는 것을 명시적으로 나타내기 위한 애노테이션이다. Elixir에서 behaviour는 특정 모듈이 가져야 할 함수의 명세(시그니처)를 정의한 것인데, GenServer
는 그 중 하나다. 이 애노테이션을 사용하면, 컴파일러가 이 함수가 GenServer
behaviour에 정의된 init
함수를 구현하는 것이라는 것을 알 수 있다.
@impl true
를 사용하지 않으면,
- 명시성이 떨어져서 이 함수가 어떤 behaviour의 구현인지 명확하게 알 수 없다.
- 컴파일러가 behaviour에 맞게 구현되었는지 체크할 수 없어, 실수할 여지가 있다.
따라서 @impl true
를 사용하는 것은 좋은 습관이다.
@impl true
에서 true
는 해당 함수가 어떤 behavior의 구현이라는 것을 명시하려는 의미다. @impl
애노테이션을 단순히 @impl
로만 쓰면, 그것은 최상위(가장 마지막에 정의된) behavior에 대한 구현으로 간주된다. 그러나 여러 behavior를 구현할 가능성이 있기 때문에, @impl GenServer
처럼 behavior의 이름을 명시적으로 적어 줄 수도 있다.
true
를 사용하는 이유는 Elixir의 관례로, 가장 마지막에 use
나 import
된 behavior에 대한 구현을 의미한다고 볼 수 있다. 이렇게 하면, 어떤 behavior의 구현인지 명확하게 하거나 여러 behavior가 있는 경우에 대비할 수 있다.
간단하게 말하면, true
는 "이 함수는 behavior의 구현이고, 이것을 명시적으로 표시하고 싶다"라는 의미다.
@impl true
는 오버라이딩(overriding)의 개념과는 약간 다르다. 오버라이딩은 상속받은 메소드를 하위 클래스에서 재정의하는 것을 의미한다. Elixir는 객체지향 언어가 아니라 함수형 언어라서 "상속"과 "오버라이딩" 개념이 적용되지 않는다.
@impl true
는 behavior를 구현한다는 것을 명시하는 역할을 한다. Behavior는 Elixir에서 특정 모듈이 가져야 할 함수의 명세(시그니처)를 정의하는 것으로, 이를 구현한 모듈에서 @impl true
를 사용하면 해당 함수가 behavior의 일부라는 것을 명시적으로 표현한다.
간단히 말해서, @impl true
는 "이 함수는 이 behavior에 정의된 것을 구현하고 있습니다"라고 알리는 역할을 한다. 이를 통해 컴파일러가 체크할 수 있게 하고, 개발자가 코드를 더 쉽게 이해할 수 있게 한다.
@impl true
def handle_call(:new, _from, _state) do
{:reply, :ok, sorted_deck()}
end
handle_call
함수는 GenServer
behavior에 정의된 콜백 함수 중 하나다. 이 함수는 클라이언트가 GenServer.call/3
함수를 호출해서 서버에게 어떤 작업을 요청할 때 실행된다.
@impl true
는 이 함수가 GenServer
behavior의 일부임을 명시한다.
함수의 시그니처를 살펴보면, handle_call(:new, _from, _state)
와 같이 정의되어 있다:
-
:new
: 이 값은 클라이언트가 호출할 때 넘기는 요청을 나타낸다. 여기서는:new
요청이 들어오면 이 함수가 호출된다. -
_from
: 이 값은 요청을 보낸 프로세스의 정보를 담고 있다._
를 붙여서 변수 이름을 시작하면, 이 값이 현재 사용되지 않음을 나타낸다. -
_state
: 이 값은 서버의 현재 상태를 나타낸다. 여기서도_state
는 사용되지 않기 때문에 변수 이름 앞에_
가 붙어 있다.
함수의 반환 값 {:reply, :ok, sorted_deck()}
은 다음과 같이 구성된다:
-
:reply
: 클라이언트에게 응답을 보내야 한다는 것을 나타낸다. -
:ok
: 실제로 클라이언트에게 보낼 응답이다. -
sorted_deck()
: 서버의 새로운 상태를 나타낸다. 이 경우에는 새로 정렬된 덱을 상태로 설정한다.
이렇게 handle_call
함수를 통해 클라이언트의 요청을 받고 처리하며, 상태를 업데이트할 수 있다.
참고로 이 handle_call(:new, _from, _state)
함수에서는 실패 케이스에 대한 처리가 명시적으로 되어 있지 않다. 함수는 단순히 새로운 정렬된 덱을 반환하고, :ok
응답을 보낸다. 실패에 대한 케이스는 고려되지 않았다.
만약 실패 케이스를 고려하고 싶다면, 예외 처리 로직을 추가할 수 있다. 예를 들어, 덱 생성에 실패했을 경우 다른 응답을 반환하도록 할 수 있다. 하지만 현재 코드 상황에서는 덱 생성이 거의 실패할 리 없어서 별도의 실패 케이스를 처리하지 않은 것으로 보인다.
@impl true
def handle_call({:deal, n}, _from, state) when n <= length(state) and n > 0 do
{deal_deck, remaining_deck} = Enum.split(state, n)
{:reply, {:ok, deal_deck}, remaining_deck}
end
이 함수 handle_call({:deal, n}, _from, state)
는 GenServer
의 콜백 함수 중 하나다. 이 함수는 클라이언트가 카드를 n
개만큼 딜링하려고 할 때 호출된다. 함수의 동작은 아래와 같다.
-
{:deal, n}
: 이 패턴은 클라이언트가 얼마나 많은 카드를 받고 싶은지를 튜플 형태로 전달한다.n
은 딜링할 카드의 개수다. -
_from
: 이 변수는 메시지를 보낸 프로세스의 정보다. 여기서는 사용되지 않아서 언더스코어(_
)로 시작한다. -
state
: 이 변수는 현재 GenServer의 상태를 담고 있다. 여기서는 현재 남아 있는 카드 덱이다. -
when n <= length(state) and n > 0
: 이 부분은 가드 문으로, 딜링할 카드의 개수n
이 현재 덱의 카드 개수보다 많지 않고, 0보다 큰 경우에만 이 함수가 동작하도록 한다. -
{deal_deck, remaining_deck} = Enum.split(state, n)
: 현재 덱(state
)을n
개의 카드로 분리한다.deal_deck
은 딜링될 카드,remaining_deck
은 남은 카드다.
Elixir에서 =
연산자는 패턴 매칭을 의미한다. 즉, Enum.split(state, n)
의 반환값을 deal_deck
와 remaining_deck
으로 분리해서 저장한다는 의미다. 세미콜론은 Elixir에서 일반적으로 사용되지 않는 구문 구분자다.
{:reply, {:ok, deal_deck}, remaining_deck}
는 함수의 반환값이다. Elixir에서 마지막 표현식의 값이 함수의 반환값이 되므로, 별도의 return
키워드는 필요하지 않다.
{deal_deck, remaining_deck} = Enum.split(state, n)
이라는 부분은 로컬 변수에 값을 할당하는 과정이고, {:reply, {:ok, deal_deck}, remaining_deck}
는 실제로 handle_call
함수의 반환값이다. 이 두 부분은 별개의 동작을 하는 것이다.
Enum.split/2
은 Elixir의 Enum 모듈에 있는 함수로, 주어진 열거형(보통 리스트)을 지정된 위치에서 두 부분으로 나눈다. 첫 번째 인자는 나눌 열거형이고, 두 번째 인자는 나눌 위치다.
예를 들어, [1, 2, 3, 4, 5]
라는 리스트에서 3의 위치에서 나누고 싶다면, Enum.split([1, 2, 3, 4, 5], 3)
을 호출하면 된다. 이 함수는 튜플로 두 부분의 리스트를 반환한다. 이 경우에는 {:ok, [1, 2, 3], [4, 5]}
가 반환될 것이다.
만약 나눌 위치가 리스트의 길이보다 크거나 같다면, 첫 번째 요소로 원래의 리스트를 그대로 반환하고, 두 번째 요소는 빈 리스트가 된다. 예: Enum.split([1, 2, 3], 10)
-> {[1, 2, 3], []}
이 함수는 주로 상태를 변경할 때나 데이터를 분리할 필요가 있을 때 유용하게 사용된다. 예를 들어, 게임에서 특정 수량의 아이템을 사용자에게 주거나 할 때, 전체 아이템 리스트에서 일부를 취하고 나머지를 다시 상태로 설정할 수 있다.
{:reply, {:ok, deal_deck}, remaining_deck}
: 이 부분은 클라이언트에게{:ok, deal_deck}
응답을 보내고, GenServer의 상태를remaining_deck
으로 업데이트한다.
이 함수는 상당히 명확하게 딜링을 처리하고, 요청이 유효한 경우에만 카드를 딜링한다. 상태 업데이트도 명확하게 다룬다.
defp sorted_deck do
suits = [:clover, :diamond, :heart, :spade]
ranks = [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, :ace]
for suit <- suits, rank <- ranks, do: {suit, rank}
end
defp sorted_deck do ... end
형식의 함수는 Elixir에서 private 함수를 정의할 때 사용한다. defp
는 "define private"의 약자로, 이 함수는 같은 모듈 내에서만 접근 가능하다.
함수의 내용을 보면, 두 개의 리스트 suits
와 ranks
를 정의하고 있다. suits
는 카드의 문양을 나타내며, ranks
는 카드의 숫자나 랭크를 나타낸다.
for suit <- suits, rank <- ranks, do: {suit, rank}
는 리스트 컴프리헨션(list comprehension)이라는 문법을 사용하고 있다. 이 문법은 suits
리스트의 각 요소(suit
)에 대해 ranks
리스트의 각 요소(rank
)를 순회하며, {suit, rank}
형태의 튜플을 만들어 새로운 리스트를 생성한다.
좀 더 쉽게 설명해보겠다. 이해를 돕기 위해 작은 예시를 들어보자.
suits
리스트에는 문양들이 저장되어 있다고 생각하자. 예를 들어, ['하트', '스페이드']라고 하고,
ranks
리스트에는 카드의 숫자가 저장되어 있다고 생각하자. 예를 들어, [1, 2]라고 하자.
이 때, for suit <- suits, rank <- ranks, do: {suit, rank}
코드가 하는 일은 다음과 같다:
-
'하트'를 첫 번째
suit
로 선택하고,- 이
suit
에 대해ranks
리스트를 순회한다.- '하트' + 1 → {'하트', 1}
- '하트' + 2 → {'하트', 2}
- 이
-
'스페이드'를 두 번째
suit
로 선택하고,- 이
suit
에 대해ranks
리스트를 순회한다.- '스페이드' + 1 → {'스페이드', 1}
- '스페이드' + 2 → {'스페이드', 2}
- 이
이렇게 하면, 결과적으로 새로운 리스트는 [{'하트', 1}, {'하트', 2}, {'스페이드', 1}, {'스페이드', 2}]가 된다. 이 과정을 통해 모든 문양(suits
)과 숫자(ranks
)의 조합을 만들어낸다.
List Comprehension
List comprehension은 리스트를 생성하는 간단하고 강력한 방법이다. 이 문법은 여러 프로그래밍 언어에서 볼 수 있다. Python, Elixir, Haskell 등에서 자주 사용되는 개념이다.
기본 문법
Elixir의 경우 기본 문법은 다음과 같다.
for variable <- list, do: expression
Python의 경우는 이렇다.
[expression for variable in list]
사용 예시
Elixir 예시
# 0에서 9까지의 수 중에서 짝수만 고르기
for n <- 0..9, rem(n, 2) == 0, do: n
# 결과: [0, 2, 4, 6, 8]
Python 예시
# 0에서 9까지의 수 중에서 짝수만 고르기
even_numbers = [n for n in range(10) if n % 2 == 0]
# 결과: [0, 2, 4, 6, 8]
필터링
List comprehension은 조건문을 통해 필터링도 할 수 있다.
Elixir 예시
# 0에서 9까지의 수 중에서 5보다 큰 짝수만 고르기
for n <- 0..9, rem(n, 2) == 0, n > 5, do: n
# 결과: [6, 8]
Python 예시
# 0에서 9까지의 수 중에서 5보다 큰 짝수만 고르기
even_numbers = [n for n in range(10) if n % 2 == 0 and n > 5]
# 결과: [6, 8]
중첩 사용
여러 리스트를 동시에 순회하거나 중첩된 list comprehension도 가능하다.
Elixir 예시
# 두 리스트의 모든 가능한 쌍을 만들기
for x <- [1, 2], y <- [3, 4], do: {x, y}
# 결과: [{1, 3}, {1, 4}, {2, 3}, {2, 4}]
Python 예시
# 두 리스트의 모든 가능한 쌍을 만들기
pairs = [(x, y) for x in [1, 2] for y in [3, 4]]
# 결과: [(1, 3), (1, 4), (2, 3), (2, 4)]
List comprehension은 간결하고 읽기 쉬운 코드를 작성할 수 있게 해주지만, 너무 복잡하게 사용하면 오히려 코드의 가독성을 떨어뜨릴 수 있다는 점을 주의해야 한다.
결과적으로, 이 함수는 4가지 문양에 대해 13가지 랭크가 있는 모든 카드 조합을 튜플의 리스트로 만들어 반환한다. 즉, 총 52개의 {문양, 랭크}
형태의 튜플을 포함하는 리스트를 만든다.
아래 코드는 매번 pid를 받아서 함수에 넣는 번거로움을 제거한 버젼이다. 단, 이렇게 하면 단일한 서버 프로세스만 돌게된다는 단점이 있다.
defmodule Card.Server do
use GenServer
use Agent
# Client API
def start do
{:ok, pid} = GenServer.start_link(__MODULE__, sorted_deck())
Agent.start_link(fn -> pid end, name: :card_server_pid)
end
def get_pid do
Agent.get(:card_server_pid, fn pid -> pid end)
end
def new do
pid = get_pid()
GenServer.call(pid, :new)
end
def shuffle do
pid = get_pid()
GenServer.call(pid, :shuffle)
end
def count do
pid = get_pid()
GenServer.call(pid, :count)
end
def deal(n \\ 1) do
pid = get_pid()
GenServer.call(pid, {:deal, n})
end
# Server Callbacks
@impl true
def init(deck) do
{:ok, deck}
end
@impl true
def handle_call(:new, _from, _state) do
{:reply, :ok, sorted_deck()}
end
@impl true
def handle_call(:shuffle, _from, state) do
{:reply, :ok, Enum.shuffle(state)}
end
@impl true
def handle_call(:count, _from, state) do
{:reply, length(state), state}
end
@impl true
def handle_call({:deal, n}, _from, state) when n <= length(state) and n > 0 do
{deal_deck, remaining_deck} = Enum.split(state, n)
{:reply, {:ok, deal_deck}, remaining_deck}
end
@impl true
def handle_call({:deal, n}, _from, state) when n > length(state) do
{:reply, {:error, "Not enough cards"}, state}
end
@impl true
def handle_call({:deal, n}, _from, state) when n <= 0 do
{:reply, {:error, "Number must be greater than zero"}, state}
end
# Private function to create a sorted deck
defp sorted_deck do
suits = [:clover, :diamond, :heart, :spade]
ranks = [2, 3, 4, 5, 6, 7, 8, 9, 10, :jack, :queen, :king, :ace]
for suit <- suits, rank <- ranks, do: {suit, rank}
end
end
댓글 없음:
댓글 쓰기