Page

관련 포스팅

2023년 9월 20일 수요일

Week03 - Elixir 기초: Generic Server

이번 포스팅에서는 Generic Server에 대해서 공부하고 정리한다.

Generic server (GenServer)

Generic Server는 Erlang과 Elixir에서 자주 사용되는 디자인 패턴 중 하나이다. 이 패턴은 서버 프로세스를 일반화하여 구현하는 데 사용된다.

"서버 프로세스를 일반화하여 구현한다"는 특정 업무나 로직에 구애받지 않고, 재사용 가능한 서버 프로세스의 틀을 만든다는 의미다. 이러한 일반화된 서버 프로세스는 다양한 상황과 요구사항에 적응할 수 있으며, 이를 통해 개발 시간을 단축하고 유지 보수성을 향상시킬 수 있다.

일반화된 서버 프로세스의 장점

  1. 코드 재사용: 일반화된 서버 프로세스를 기반으로 다양한 애플리케이션과 시스템을 빠르게 개발할 수 있다.
  2. 유지 보수: 기본적인 서버 기능이 일반화된 코드에 구현되어 있으므로, 비즈니스 로직에만 집중하여 코드를 수정하거나 추가할 수 있다.
  3. 확장성: 일반화된 서버는 새로운 기능이나 변경 사항을 쉽게 수용할 수 있게 설계된다.

예를 들어,

GenServer 같은 Elixir의 일반화된 서버 프로세스 라이브러리를 사용하면, 메시지 루프, 상태 관리, 요청과 응답 등의 기본적인 서버 로직을 이미 구현한 상태에서 시작할 수 있다. 개발자는 이런 기본 로직에 특별한 비즈니스 로직만 추가하면 된다.

예시

GenServer에서 제공하는 handle_call, handle_cast 같은 함수를 오버라이딩하여 원하는 로직을 적용한다. 이렇게 하면 메시지 수신, 상태 관리 등의 일반적인 부분은 GenServer가 알아서 처리해주고, 개발자는 오직 비즈니스 로직에만 집중할 수 있다.

defmodule MyServer do
  use GenServer

  def handle_call(:get_data, _from, state) do
    # 비즈니스 로직
    {:reply, {:ok, state}, state}
  end

  def handle_cast({:update_data, new_data}, state) do
    # 비즈니스 로직
    {:noreply, new_data}
  end
end

이렇게 일반화된 서버 프로세스를 사용하면, 같은 패턴이나 구조를 가진 다양한 서버 프로세스를 쉽고 빠르게 개발할 수 있다.

그리고 Generic Server는 주로 OTP (Open Telecom Platform)의 일부로서 동작하며, 복잡한 병렬 시스템을 쉽게 만들 수 있도록 도와준다.

Generic Server의 주요 개념

1. Process

Process는 Elixir에서 병렬 실행의 기본 단위이다. Java에서의 스레드와 유사한 개념이지만, Elixir의 Process는 더 가볍고, 운영체제의 스레드가 아니라 Erlang VM에서 관리된다. 각 프로세스는 자신만의 메모리 공간과 상태를 가지며, 다른 프로세스와는 메시지를 주고받는 형태로만 통신한다. 이렇게 함으로써, 하나의 프로세스가 실패하더라도 다른 프로세스에는 영향을 미치지 않는 격리성을 달성한다.

2. Message Loop

Message Loop는 서버 프로세스가 무한 루프를 돌면서 메시지를 받고 처리하는 구조를 말한다. 프로세스는 메시지 큐에 들어온 메시지를 하나씩 꺼내서 해당 메시지에 맞는 로직을 실행한다. Java에서 이벤트 루프나 메시지 큐를 사용해 이와 유사한 작업을 수행할 수 있다.

예를 들어, 계산기 서비스를 제공하는 프로세스가 있다면:

  • 메시지 큐에 {:add, 1, 2} 메시지가 들어온다.
  • 프로세스는 이 메시지를 꺼내서 덧셈 로직을 실행한다.
  • 다음 메시지를 대기한다.

3. Call & Cast

Call과 Cast는 클라이언트가 서버 프로세스에게 요청을 보낼 때 사용하는 두 가지 방법이다.

  • Call: 동기식 요청. 클라이언트가 서버에게 요청을 보내고, 그 응답을 기다린다. 예를 들어, "이 계산이 끝날 때까지 기다려, 그리고 결과를 알려줘" 같은 상황에서 사용한다.
  • Cast: 비동기식 요청. 클라이언트가 서버에게 요청을 보내고 응답을 기다리지 않는다. 예를 들어, "이 데이터를 업데이트해, 결과는 나중에 확인할게" 같은 상황에서 사용한다.

Call은 주로 데이터를 조회하거나, 정확한 결과를 받아야 할 때 사용하고, Cast는 데이터를 업데이트하거나 결과를 확인할 필요가 없을 때 사용한다.

이러한 구조는 Java의 Future나 Promise와 유사하다고 볼 수 있다. Call은 Future에서 값을 가져오기 전까지 기다리는 것과 유사하고, Cast는 Promise를 반환하고 나중에 확인하는 것과 유사하다.

작동 원리 설명

1. 서버 프로세스가 생성되고, 초기 상태를 가진다.

서버 프로세스가 생성될 때, 초기 상태가 설정된다. 이 초기 상태는 메모리에 저장되어 서버 프로세스의 생명주기 동안 계속 사용된다. 예를 들어, 채팅 서버의 경우 초기 상태에는 빈 메시지 리스트나 사용자 정보 등이 포함될 수 있다.

2. 서버는 메시지를 수신 대기 상태로 들어간다.

서버 프로세스는 자신의 메시지 큐에서 새로운 메시지가 들어오기를 기다린다. 이 상태는 "블로킹 상태"라고도 하며, 메시지가 도착할 때까지 다른 작업을 하지 않고 대기한다.

3. 메시지가 도착하면, 해당 메시지를 처리하는 함수가 호출된다.

메시지 큐에 새로운 메시지가 도착하면, 그 메시지를 처리할 수 있는 함수가 자동으로 호출된다. 예를 들어, {:add, 1, 2} 형태의 메시지가 들어온다면, 덧셈을 처리하는 함수가 호출되어 연산을 수행한다.

4. 함수는 상태를 업데이트하고, 필요하다면 응답을 보낸다.

호출된 함수는 필요한 연산을 수행한 후, 서버 프로세스의 상태를 업데이트한다. 이 상태는 다음에 들어올 메시지를 처리할 때 사용될 수 있다. 예를 들어, 채팅 서버에서 새 메시지가 추가되면, 서버 상태에 저장된 메시지 리스트에 그 메시지가 추가된다. 또한, 요청한 클라이언트에게 응답을 보낼 수도 있다.

5. 다시 메시지 수신 대기 상태로 돌아간다.

함수가 메시지 처리를 마치고 상태를 업데이트하면, 서버 프로세스는 다시 메시지를 수신 대기 상태로 돌아간다. 이 과정이 반복되면서 서버 프로세스는 계속해서 메시지를 받고 처리한다.

이렇게 서버 프로세스는 각 단계에서 특정 작업을 수행하며, 이 모든 것이 함께 작동하여 서버 프로세스가 메시지를 처리하고 상태를 유지하게 된다.

Elixir 예시

defmodule MyServer do
  use GenServer

  # 서버 시작
  def start_link(initial_state) do
    GenServer.start_link(__MODULE__, initial_state)
  end

  # 초기화
  def init(initial_state) do
    {:ok, initial_state}
  end

  # call 처리
  def handle_call(:get_state, _from, state) do
    {:reply, state, state}
  end

  # cast 처리
  def handle_cast({:set_state, new_state}, state) do
    {:noreply, new_state}
  end
end

MyServer 모듈 설명

defmodule MyServer do

이 줄은 MyServer라는 이름의 Elixir 모듈을 정의한다.

use GenServer

GenServer를 사용하겠다고 명시한다. 이렇게 하면 GenServer에서 제공하는 함수들과 콜백들을 이 모듈에서 사용할 수 있게 된다. use GenServer라고 쓰면, GenServer 모듈에 있는 기능들을 현재 모듈에서도 쉽게 쓸 수 있게 해준다. 즉, GenServer에서 미리 만들어 둔 여러가지 코드나 기능을, 우리가 만드는 MyServer 모듈에서도 그대로 쓰거나 조금 수정해서 쓸 수 있게 해준다.

예를 들어, 서버를 시작하거나 메시지를 처리하는 기능들이 이미 GenServer에 준비되어 있는데, 이런 기능들을 우리가 다시 새로 만들 필요 없이 그냥 가져다 쓰면 되는 것이다.

이렇게 하면, 코드를 짧게 만들 수 있고, 이미 잘 만들어진 GenServer의 기능을 활용할 수 있어서 더 안정적인 프로그램을 만들 수 있다.

"콜백"이라는 것은 어떤 이벤트나 조건이 만족되었을 때 자동으로 호출되는 함수를 말한다. 예를 들어, 사용자가 버튼을 누르면 어떤 작업을 수행하는 함수가 있다고 치자. 여기서 그 함수는 버튼을 누르는 이벤트에 "콜백"으로 등록되어 있을 것이다.

GenServer에서도 콜백 개념이 사용된다. 메시지가 도착하면 어떤 함수(handle_call이나 handle_cast 등)를 자동으로 호출하는 것이 그 예이다. 이런 콜백 함수들은 개발자가 직접 정의해야 하는데, GenServer가 어떤 상황에서 어떤 콜백을 호출할지 이미 정의되어 있기 때문에, 개발자는 그 상황에 맞는 로직만 콜백 함수에 작성하면 된다.

간단하게 말하면, 콜백은 특정 상황이 되면 알아서 작동되는 함수라고 생각하면 된다.

def start_link(initial_state) do

start_link 함수를 정의한다. 이 함수는 서버 프로세스를 시작할 때 사용되며, initial_state라는 초기 상태를 매개변수로 받는다.

start_link 함수는 GenServer에서 일반적으로 사용되는 패턴이긴 하지만, GenServer 모듈에 이미 정의되어 있는 함수가 아니다. 여기서 MyServer.start_linkMyServer 모듈에서 새롭게 정의하는 함수다. 이 함수 내부에서 GenServer.start_link를 호출하고 있다.

GenServer.start_link는 실제로 프로세스를 생성하고 초기화하는 GenServer 모듈에 있는 함수다. 이 함수는 고수준의 로직을 담당하고, start_link는 그 고수준 로직에 필요한 초기 상태나 모듈 이름(__MODULE__) 같은 매개변수를 제공한다.

간단하게 말하면, MyServer.start_link는 사용자가 직접 호출할 함수로, 내부적으로는 GenServerstart_link를 이용해서 실제로 프로세스를 시작하게 된다. 오버라이딩이 아니라, GenServer의 기능을 내부에서 활용하는 것이다.

GenServer.start_link(__MODULE__, initial_state)

GenServer.start_link 함수를 호출한다. 첫 번째 인자는 현재 모듈(__MODULE__)을 나타내며, 두 번째 인자는 초기 상태(initial_state)이다. 이 함수는 서버 프로세스를 생성하고 시작한다.

__MODULE__는 Elixir에서 현재 모듈의 이름을 담고 있는 특별한 표현식이다. 이 표현식을 사용하면 코드 안에서 현재 모듈의 이름을 쉽게 참조할 수 있다.

예를 들어, MyServer라는 이름의 모듈 안에서 __MODULE__을 사용하면, 이것은 MyServer라는 값으로 대체된다. 이것이 유용한 이유는, 모듈 이름을 여러 번 쓰지 않고도 그 이름을 참조할 수 있기 때문이다.

특히 GenServer.start_link(__MODULE__, initial_state)와 같이 사용하면, 이 코드가 어떤 모듈에서 실행되는지 알 필요 없이, 현재 모듈을 GenServer로 사용하겠다는 것을 표현할 수 있다. 나중에 모듈의 이름을 바꿔도 __MODULE__ 부분은 자동으로 업데이트되기 때문에 코드의 유지보수가 쉽다.

__MODULE__를 사용하지 않을 경우, 현재 모듈의 이름을 직접 써야 한다. 예를 들어 MyServer 모듈에서 GenServer.start_link를 호출하는 코드가 있다면, __MODULE__ 대신에 MyServer를 직접 적어줘야 한다.

__MODULE__를 사용하는 코드:

GenServer.start_link(__MODULE__, initial_state)

__MODULE__를 사용하지 않는 코드:

GenServer.start_link(MyServer, initial_state)

이렇게 직접 모듈 이름을 적으면, 나중에 모듈 이름을 바꿀 때 해당 이름을 사용하는 모든 코드도 일일이 바꿔줘야 한다. 그래서 __MODULE__를 사용하면 코드의 유지보수가 더 쉽다.

def init(initial_state) do

init 함수를 정의한다. 이 함수는 서버 프로세스가 처음 시작될 때 한 번만 호출되며, 초기 상태를 설정한다.

init 함수는 여기서 새롭게 정의하는 것이다. init은 GenServer의 콜백 함수 중 하나로, 서버 프로세스가 처음 생성될 때 한 번 호출된다. 이 함수에서는 서버의 초기 상태를 설정하고, 그 상태를 반환하는 역할을 한다.

init 함수의 역할은 주로 두 가지다:

  1. 서버의 초기 상태를 설정한다. 이 상태는 handle_call이나 handle_cast 같은 다른 콜백 함수에서도 사용될 것이다.
  2. {:ok, initial_state} 같은 튜플을 반환한다. 이렇게 반환된 상태는 서버 프로세스가 메시지를 처리할 때 사용된다.

init 함수는 프로세스가 시작될 때 한 번만 호출되기 때문에, 여기서 데이터베이스 연결을 초기화하거나 필요한 리소스를 준비하는 등의 작업을 수행하기도 한다.

{:ok, initial_state}

초기화가 성공했다는 의미로 :ok 심볼과 초기 상태를 튜플 형태로 반환한다.

{:ok, initial_state}가 반환된다는 것은, 서버 프로세스가 문제 없이 시작되었고, 초기 상태로 initial_state 값을 사용할 것이라는 의미다.

GenServer는 이렇게 설정된 초기 상태(initial_state)를 내부에 저장해 두고, 나중에 메시지를 처리할 때 이 상태 정보를 사용한다. 예를 들어, 클라이언트가 서버에게 어떤 작업을 요청하면(call이나 cast), 이 초기 상태는 그 작업을 처리하는 데 필요한 정보로 쓰일 수 있다.

간단하게 말하면, {:ok, initial_state}는 "모든 게 잘 됐어, 초기 상태로 initial_state를 쓸 거야"라고 GenServer에게 알려주는 것이다.

initial_state는 GenServer 프로세스 내부에 저장된다. Elixir의 프로세스는 각각의 상태를 가질 수 있고, 이 상태는 프로세스가 살아있는 동안 내부에 보관된다. GenServer에서도 마찬가지로, init 함수에서 반환한 initial_state는 내부 상태로 저장되어 다른 콜백 함수들에서 사용할 수 있다.

예를 들어, handle_call이나 handle_cast 같은 콜백 함수에서는 이 상태를 마지막 인자로 받게 된다. 그리고 이 함수들은 처리 결과와 함께 새로운 상태를 반환할 수 있는데, 이 새로운 상태도 다시 내부에 저장되어 다음에 호출되는 콜백 함수에서 사용된다.

간단히 말하면, initial_state는 GenServer 프로세스의 내부에 저장되고, 이후의 모든 메시지 처리에서 그 상태가 활용된다.

그리고 여기서 "프로세스가 살아있는 동안"이라는 말은 그 Elixir 프로세스(여기서는 GenServer 인스턴스)가 실행 중이고 종료되지 않은 시간 동안을 의미한다. 이는 서버 전체가 구동되고 있는 동안이라는 것과는 다를 수 있다.

하나의 서버 운영체제 내에서 여러 Elixir 프로세스가 돌아갈 수 있고, 각각은 독립적으로 시작되거나 종료될 수 있다. 따라서, 특정 GenServer 프로세스가 종료되었다고 해서 전체 서버가 종료되는 것은 아니다. 반대로, 전체 서버가 실행 중이더라도 특정 GenServer 프로세스는 종료되거나 재시작될 수 있다.

요약하면, "프로세스가 살아있는 동안"은 그 프로세스가 시작되어 종료되기 전까지의 시간을 의미하며, 이는 서버 전체의 상태와는 직접적인 연관이 없다.

def handle_call(:get_state, _from, state) do

handle_call 함수를 정의하는데, :get_state 메시지를 처리하기 위한 로직이다. _from은 메시지를 보낸 클라이언트의 정보, state는 현재 서버의 상태이다.

handle_call은 GenServer 특성에서 정의된 콜백 인터페이스다. 그래서 여기서 오버라이딩하는 것이 맞다. GenServer를 사용할 때는 이 handle_call 함수를 구현해서, 클라이언트로부터 오는 "call" 타입의 요청을 어떻게 처리할지를 정의해야 한다.

handle_call은 서버의 내부 상태를 인자로 받아서, 요청을 처리한 후에 응답과 새로운 상태를 반환하는 역할을 한다. 이렇게 구현된 handle_call 함수는 GenServer 프로세스가 실행되면서 자동으로 호출되므로, 원하는 로직에 맞게 이 함수를 오버라이딩해서 구현하면 된다.

:get_state는 아톰(atom)으로, Elixir에서는 아톰을 주로 식별자나 상수처럼 사용한다. 여기서 :get_statehandle_call 함수에서 처리해야 할 요청의 종류를 나타낸다. 클라이언트가 서버에 상태를 물어보는 call 요청을 할 때, 이 요청에 :get_state라는 아톰을 사용해서 명시적으로 어떤 종류의 요청인지를 표현한다.

예를 들어, 클라이언트가 다음과 같은 코드로 상태를 물어볼 수 있다.

GenServer.call(pid, :get_state)

여기서 pid는 GenServer 프로세스의 Process ID다. 이렇게 요청을 하면, 서버 측의 handle_call 함수에서 첫 번째 인자로 :get_state를 받게 되고, 이에 따라 적절한 로직을 실행할 수 있다.

간단히 말해, :get_state는 이 요청이 서버의 상태를 가져오는 것과 관련된 요청임을 나타내는 식별자로 사용된다.

_fromhandle_call 함수의 두 번째 인자로, 요청을 보낸 프로세스의 정보를 담고 있다. 일반적으로 이 정보는 응답을 보낼 때 사용되지만, 여기서는 _from 변수가 실제로 사용되지 않기 때문에 변수 이름 앞에 언더스코어(_)를 붙였다.

Elixir에서는 언더스코어를 변수 이름 앞에 붙이면 "이 변수는 사용되지 않을 것이다"라는 의미로 취급된다. 이렇게 하면 컴파일러 경고를 피할 수 있다. 코드를 읽는 사람에게도 "이 변수는 여기서는 중요하지 않다"라는 정보를 제공한다.

즉, _from을 사용하는 이유는 이 변수가 현재 함수 로직에서는 사용되지 않지만, 함수의 시그니처(인자)를 유지하기 위해서이다.

{:reply, state, state}

메시지를 보낸 클라이언트에게 응답(:reply)을 보내고, 응답 내용으로 현재 상태(state)를 보낸다. 마지막의 state는 서버의 상태를 업데이트하는데, 여기서는 상태를 그대로 유지한다.

{:reply, state, state} 튜플은 handle_call 함수에서 반환하는 값으로, 세 부분으로 구성돼 있다.

  1. :reply: 이 부분은 클라이언트에게 "응답을 보낼 거다"라고 알려주는 역할을 한다.

  2. 첫 번째 state: 이 부분은 실제로 클라이언트에게 보낼 응답 내용이다. 여기서는 서버의 현재 상태(state)를 클라이언트에게 보낸다.

  3. 두 번째 state: 이 부분은 서버의 상태를 업데이트하는데 사용된다. 여기서는 상태를 변경하지 않고 그대로 유지하기 때문에, 기존의 state 값을 그대로 둔다.

즉, {:reply, state, state}을 반환하면, 서버는 클라이언트에게 현재 상태(state)를 응답으로 보내고, 서버 자신의 상태는 그대로 유지한다는 의미다.

def handle_cast({:set_state, new_state}, state) do

handle_cast 함수를 정의한다. :set_state 메시지와 새로운 상태(new_state)를 매개변수로 받아 처리한다.

{:noreply, new_state}

응답을 보내지 않겠다는 :noreply를 반환하고, 서버의 상태를 new_state로 업데이트한다.

{:noreply, new_state}를 리턴한다는 것은 두 가지 주요한 정보를 담고 있다.

  1. :noreply: 이 부분은 서버가 클라이언트에게 별도의 응답을 보내지 않겠다는 것을 의미한다. handle_cast는 원래 비동기적으로 작동하기 때문에, 클라이언트는 이 함수가 호출되었을 때 응답을 기다리지 않는다.

  2. new_state: 이 부분은 서버의 내부 상태를 어떻게 업데이트할지를 지정한다. new_state 값으로 서버의 상태가 업데이트된다.

예를 들어, 만약 서버가 램프의 상태(on/off)를 관리한다고 하면, handle_cast를 사용해서 "램프를 켜" 또는 "램프를 끄"라는 비동기 명령을 처리할 수 있다. 명령을 받으면, 서버는 {:noreply, :on} 또는 {:noreply, :off}를 리턴해서 내부 상태를 '켜짐' 또는 '꺼짐'으로 업데이트하고, 클라이언트에게는 별도의 응답을 보내지 않는다.

즉, {:noreply, new_state}는 "응답은 보내지 않고, 내부 상태를 new_state로 업데이트해"라는 의미를 담고 있다.

handle_call 과 handel_cast의 차이

handle_callhandle_cast는 Elixir의 GenServer에서 사용되는 콜백 함수로, 서버 프로세스가 클라이언트로부터 받은 메시지를 처리하는 역할을 한다. 두 함수는 비슷해 보이지만 사용 목적과 작동 방식이 다르다.

handle_call

  • handle_call은 클라이언트가 서버에게 요청을 보내고 그에 대한 응답을 기다리는 상황에서 사용한다. 즉, 동기적(synchronous) 요청을 처리한다.
  • 클라이언트가 요청을 보내면 서버는 handle_call 함수를 실행하여 요청을 처리하고, 응답을 보내야 클라이언트가 다음 작업을 진행할 수 있다.

handle_cast

  • handle_cast는 클라이언트가 서버에게 요청을 보내되 응답을 기다리지 않는 상황에서 사용한다. 즉, 비동기적(asynchronous) 요청을 처리한다.
  • 클라이언트는 요청을 보내고 바로 다음 작업을 진행할 수 있다. 서버는 별도로 응답을 보내지 않고, 내부 상태만 업데이트하거나 필요한 작업을 수행한다.

간단한 예로, handle_call은 "현재 날씨가 어떤가?"와 같은 질문에 "맑다"라고 응답해야 하는 상황에 사용할 수 있다. 반면 handle_cast는 "문을 닫아줘"와 같이 명령을 내리고 결과를 기다리지 않는 상황에 사용할 수 있다.

각 라인은 이런 식으로 동작하며, 전체적으로 GenServer를 사용하여 상태를 가지는 서버 프로세스를 간단하게 구현한 코드이다.

서버 사용 예시

# 서버를 시작하고 프로세스 ID를 얻기
{:ok, pid} = MyServer.start_link(0)

# 상태 가져오기 (Call)
result = GenServer.call(pid, :get_state)

# 상태 설정하기 (Cast)
GenServer.cast(pid, {:set_state, 42})

Call과 Cast 차이점

  • Call: 클라이언트가 서버로부터 응답을 기다리는 동기 요청이다. 예를 들어, 데이터를 조회할 때 사용한다.
  • Cast: 클라이언트가 서버로부터 응답을 기다리지 않는 비동기 요청이다. 예를 들어, 데이터를 업데이트하고 결과를 확인할 필요가 없을 때 사용한다.

Java에서의 멀티스레딩과 비교하면, 각 스레드가 작업을 수행하는 것과 유사하지만, Elixir에서는 메시지 패싱을 통한 병렬 처리가 이루어진다. 또한, Elixir의 프로세스는 OS 레벨의 프로세스나 스레드가 아니라 Erlang VM 위에서 동작하는 가벼운 프로세스이다. 이렇게 해서 더 많은 병렬성과 높은 응답성을 달성한다.


아래는 수업시간에 다뤘던 예제 코드이다. 이제 이 코드를 분석해 가면서 다시 한번 복습을 해보자.

Generic Server Code Example

defmodule GenericServer do
  def start(m,  arg \\ nil) do
    spawn(fn -> loop(m, state) end)
  end

  def call(pid, msg) do
    send(pid, {:call, self(), msg})
    receive do
      reply -> reply
    end
  end

  def cast(pid, msg) do
    send(pid, {:cast, msg})
  end

  defp loop(m, state) do
    new_state = 
      receive do
        {:call, from, msg} -> 
          {reply, new_state} = m.handle_call(msg, state)
          send(from, reply)
          new_state
        {:cast, msg} ->
          m.handle_cast(msg, state)
      end
    loop(m, new_state)
  end
end

이 코드는 Elixir로 작성된 간단한 일반 서버(Generic Server)의 구현 예시다. 이 GenericServer 모듈은 다음과 같은 기능들을 수행한다:

  1. start(m, arg \\ nil): 서버 프로세스를 시작한다. m은 콜백 모듈을 나타내고, arg는 초기 상태가 될 수 있다. 이 함수는 spawn을 사용해 별도의 프로세스에서 loop 함수를 실행시킨다.

arg \\ nil 구문은 함수의 인자에 대한 기본값을 설정하는 Elixir의 문법이다. 이 경우에 arg 인자의 기본값은 nil이다. 즉, 이 함수를 호출할 때 arg 값을 지정하지 않으면 자동으로 nil이 할당된다.

예를 들어, start(m)처럼 arg를 지정하지 않고 함수를 호출하면, 내부적으로는 start(m, nil)처럼 동작하게 된다. 반면에 start(m, some_value)처럼 arg를 지정하면, 그 값이 그대로 사용된다.

이렇게 하면 함수를 좀 더 유연하게 사용할 수 있고, 필요에 따라 인자를 넘겨줄 수도 있고 넘겨주지 않을 수도 있다.

spawn 함수는 Elixir에서 새로운 프로세스를 생성하는 데 사용한다. 이 함수가 호출되면, 인자로 받은 익명 함수 fn -> loop(m, state) end가 새로운 프로세스에서 실행되고, 해당 프로세스의 식별자(PID)가 반환된다.

여기서 fn -> loop(m, state) end는 익명 함수이며, 이 익명 함수가 하는 일은 loop(m, state) 함수를 호출하는 것이다.

  • m: 모듈 이름, 이 모듈에는 handle_callhandle_cast 같은 콜백 함수가 정의되어 있어야 한다.
    • m은 모듈 이름을 나타내고, 이 모듈 내부에는 handle_callhandle_cast 같은 함수가 미리 정의되어 있어야 한다. 즉, m 모듈은 서버 로직을 담고 있는 곳이라고 생각하면 된다.
    • 예를 들어, mChatServer라는 모듈을 가리킨다고 해보자. ChatServer 모듈 안에는 사용자가 채팅 메시지를 보낼 때 어떻게 처리할지(handle_call), 누군가가 채팅방에 들어왔을 때 어떻게 처리할지(handle_cast) 등을 정의해 놓아야 한다.
    • 이렇게 m 모듈 안에 정의된 handle_call이나 handle_cast 함수는, GenericServerloop 함수에서 메시지를 받았을 때 어떻게 처리할지 알려주는 역할을 한다.
    • 간단하게 말하면, m 모듈은 서버가 어떻게 동작해야 하는지 정의하는 '규칙'을 담고 있고, GenericServer는 이 '규칙'에 따라 실제로 동작한다고 생각하면 되겠다.
  • state: 초기 상태, 이 상태는 loop 함수 내에서 프로세스의 상태로 사용된다.

즉, spawn(fn -> loop(m, state) end)를 호출하면 다음과 같은 일이 일어난다:

  1. 새로운 프로세스가 생성된다.
  2. 해당 프로세스에서 loop(m, state) 함수가 실행된다.
  3. 새로 생성된 프로세스의 PID가 반환된다.

이렇게 생성된 프로세스는 독립적으로 loop(m, state) 함수를 실행하면서 메시지를 수신 대기하거나 처리하는 등의 작업을 수행한다.

  1. call(pid, msg): 클라이언트가 서버에게 메시지를 보내고 응답을 기다린다. pid는 서버 프로세스의 ID고, msg는 전달할 메시지다.

send(pid, {:cast, msg})는 Erlang 또는 Elixir에서 메시지를 보내는 기본 함수인 send를 사용한 것이다. 여기서 pid는 프로세스 아이디를 나타내며, 이 프로세스에 메시지 {:cast, msg}를 보낸다.

  1. pid: 이것은 메시지를 받을 프로세스의 고유 식별자다. 프로세스를 생성할 때 얻을 수 있고, 이것으로 특정 프로세스에 메시지를 보낼 수 있다.

  2. {:cast, msg}: 이것은 튜플 형태의 메시지다. :cast는 이 메시지가 어떤 유형인지 나타내고, msg는 실제로 전달하려는 데이터다.

동작 설명:

  • send 함수를 호출하면, pid로 지정된 프로세스의 메시지 큐에 {:cast, msg}가 저장된다.
  • 이후에 해당 프로세스가 receive 문을 통해 메시지를 확인하고 처리할 수 있다.
  • 이 경우에 loop 함수 내의 receive 블록이 {:cast, msg}를 받아서 m.handle_cast(msg, state)를 호출하게 된다. 여기서 m은 우리가 전달한 모듈 이름이고, 이 모듈 안에 정의된 handle_cast 함수가 호출된다.

간단히 말하면, send(pid, {:cast, msg})pid 식별자를 가진 프로세스에게 '비동기 메시지'를 보내고, 그 프로세스는 나중에 이 메시지를 처리한다.

receive do ... end 블록은 Elixir에서 메시지를 수신하는 기본적인 방법 중 하나다. 이 블록은 프로세스의 메시지 큐에서 메시지를 찾아 처리한다.

reply -> reply 코드의 의미는 다음과 같다:

  • receive 문이 시작되면, 프로세스의 메시지 큐를 확인한다.
  • reply라는 변수에 들어온 메시지를 저장한다.
  • 그리고 동일한 reply를 그대로 반환한다.

이 경우, reply 변수는 외부에서 보낸 메시지를 담는 역할을 하며, 그것을 그대로 반환한다. 이건 사실상 어떤 형태의 메시지든 받을 수 있게 하는 '와일드카드' 같은 역할을 한다.

예를 들어, call 함수에서 send로 메시지를 보내고 나서, receive를 통해 응답을 기다리는 상황에서 사용된다. send로 메시지를 보낸 후에, 그 응답이 이 receive 블록에 의해 받아지고 반환된다.

간단히 말해서, 이 receive do ... end 블록은 메시지를 기다리고, 들어온 메시지를 그대로 반환하는 역할을 한다.

Elixir에서의 프로세스는 운영체제의 프로세스와는 다르다. Elixir 프로세스는 Erlang 가상 머신(VM) 위에서 돌아가는 경량의 실행 단위다. 이 프로세스들은 매우 작은 메모리를 사용하고, 수천에서 수백만 개까지 쉽게 생성할 수 있다.

Elixir에서 프로세스는 다음과 같은 특징을 가진다:

  1. 독립성: 각 프로세스는 자신만의 메모리 공간과 메시지 큐를 가지고 있다.
  2. 메시지 패싱: 프로세스끼리 데이터를 공유하지 않고 메시지를 통해 통신한다.
  3. 병렬성: 여러 프로세스는 동시에 실행될 수 있으므로 병렬 처리가 가능하다.

이러한 프로세스를 사용해서 동시성(concurrency), 병렬성(parallelism), 분산 컴퓨팅(distributed computing) 등을 구현한다.

프로세스의 메시지 큐란, Elixir에서 프로세스가 받은 메시지를 저장하는 일종의 '대기열'이다. 여러 프로세스가 send 함수를 통해 메시지를 보내면, 해당 프로세스의 메시지 큐에 메시지가 쌓인다.

"프로세스의 메시지 큐에서 메시지를 찾아 처리한다"는 말의 의미는 다음과 같다:

  1. 프로세스가 시작되면 자신만의 메시지 큐를 가진다.
  2. 다른 프로세스들이 이 프로세스에 메시지를 보내면, 그 메시지들은 이 큐에 쌓인다.
  3. receive 블록이 실행되면, 이 큐에서 메시지를 하나씩 꺼내어 처리한다.

예를 들어, A 프로세스와 B 프로세스가 있고, A가 B에게 메시지를 보낸다면, B의 메시지 큐에 그 메시지가 저장된다. B 프로세스에서 receive 블록을 실행하면, 메시지 큐에서 메시지를 꺼내와 처리하게 된다.

Elixir의 프로스세와 Java/C++ 프로세스의 공통점과 차이점

Elixir의 프로세스와 Java나 C++에서의 쓰레드나 프로세스는 비슷한 목적을 가지고 있지만, 구현과 특성에 큰 차이가 있다.

공통점

  1. 병렬 실행: 둘 다 병렬 실행을 위한 단위이다.
  2. 상태 유지: 상태 정보를 유지할 수 있다.

차이점

  1. 메모리 공간:

    • Elixir 프로세스는 서로 독립적인 메모리를 가진다. 메시지 패싱을 통해 통신한다.
    • Java/C++ 쓰레드는 같은 메모리 공간을 공유한다.
  2. 생성 오버헤드:

    • Elixir 프로세스는 경량이고 빠르게 생성된다.
    • Java/C++에서의 쓰레드나 프로세스는 상대적으로 무겁고 생성이 느릴 수 있다.
  3. 오류 격리:

    • Elixir 프로세스는 오류가 발생해도 다른 프로세스에 영향을 주지 않는다.
    • Java/C++ 쓰레드에서 오류가 발생하면 같은 메모리를 공유하는 다른 쓰레드에도 영향을 줄 수 있다.
  4. 통신 방식:

    • Elixir는 메시지 패싱을 이용해서 명시적으로 통신한다.
    • Java/C++는 메모리 공간을 공유하거나 락을 사용하는 등의 방법으로 통신한다.
  5. 스케줄링:

    • Elixir 프로세스는 Erlang VM에 의해 관리되고 스케줄링된다.
    • Java/C++ 쓰레드는 운영체제에 의해 스케줄링되는 경우가 많다.
  6. 생명주기 관리:

    • Elixir는 프로세스 감시(supervision) 트리를 통해 프로세스의 생명주기를 관리한다.
    • Java/C++는 이러한 기능이 내장되어 있지 않아서 개발자가 직접 관리해야 한다.

이런 차이들 때문에 Elixir의 프로세스는 분산 컴퓨팅이나 고장 허용성(fault-tolerance) 같은 문제를 다룰 때 강점을 가진다. Java나 C++는 고성능 계산이나 메모리 공유가 필요할 때 장점을 가진다.

  1. cast(pid, msg): 클라이언트가 서버에게 메시지를 보내지만 응답을 기다리지 않는다.
  defp loop(m, state) do
    new_state = 
      receive do
        {:call, from, msg} -> 
          {reply, new_state} = m.handle_call(msg, state)
          send(from, reply)
          new_state
        {:cast, msg} ->
          m.handle_cast(msg, state)
      end
    loop(m, new_state)
  end
  1. loop(m, state): 이것은 서버의 메인 루프다. 이 함수는 서버가 메시지를 계속 받아 처리하도록 하는 역할을 한다.

loop 함수에서는 receive 블록을 사용해 메시지를 받는다. 메시지가 :call이면 m.handle_call을 호출하고, 메시지가 :cast이면 m.handle_cast를 호출한다.

이 함수는 loop라고 부르며, 이것은 GenericServer에서 클라이언트의 요청을 계속해서 받아 처리하는 역할을 한다. m은 여기서 쓰이는 모듈 이름을 나타내고, state는 서버의 현재 상태를 나타낸다.

defp loop(m, state) do

  • defp는 private 함수를 정의할 때 사용한다. 여기서는 loop라는 함수를 정의하고 있다.
  • m은 호출될 콜백 함수들(handle_call, handle_cast)이 정의되어 있는 모듈의 이름이다.
  • state는 이 서버 프로세스의 현재 상태를 나타낸다.

new_state =

  • 여기서 new_state라는 변수를 선언하고 있다. 이 변수에는 이번 루프에서 새로 업데이트될 상태 정보가 저장될 것이다.

receive do

  • receive 블록은 이 프로세스의 메시지 큐에서 메시지를 받아 처리할 내용을 담고 있다.

{:call, from, msg} ->

  • 메시지가 :call 타입일 때 실행될 코드이다.
  • from은 메시지를 보낸 클라이언트의 프로세스 ID이고, msg는 전달받은 메시지 내용이다.

{reply, new_state} = m.handle_call(msg, state)

  • handle_call 함수를 호출해서 msg를 처리하고 결과로 replynew_state를 받는다.

send(from, reply)

  • from에게 결과 reply를 전송한다.

new_state

  • 이 부분에서 새로운 상태 new_state를 반환한다. 이 값이 다음 루프에서 state로 들어간다.

{:cast, msg} ->

  • 메시지가 :cast 타입일 때 실행될 코드이다.

m.handle_cast(msg, state)

  • handle_cast 함수를 호출해서 msg를 처리하고, 그 결과로 새로운 상태를 반환한다. 이 상태가 new_state에 저장된다.

loop(m, new_state)

  • 새로운 상태 new_state로 다시 loop 함수를 호출한다. 이렇게 해서 계속해서 메시지를 받을 수 있게 된다.

loop 함수는 기본적으로 메시지를 받고 처리하고 새로운 상태로 업데이트하는 작업을 무한히 반복한다.

이런 식으로, 이 코드는 서버와 클라이언트 간의 상호작용을 위한 기본적인 프레임워크를 제공한다. 서버는 메시지를 받아 처리하고, 필요한 경우 응답을 반환한다.

Generic Server의 응용

defmodule ArithServer do
  # interface
  def start() do
    GenericServer.start(__MODULE__)
  end

  def square(pid, x) do
    GenericServer.call(pid, {:square, x})
  end

  def sqrt(pid, x) do
    GenericServer.call(pid, {:sqrt, x})
  end

  # implementation
  def handle_call({:square, x}, _state) do
    x * x
  end

  def handle_call({:sqrt, x}, _state) do
    if x >= 0, do: :math.sqrt(x), else: :error
  end
end

알겠다, 코드와 흐름을 순서대로 나열하면서 설명해보겠다. 이해를 돕기 위해 코드의 핵심 부분을 나열하고 각 단계를 설명하겠다.

  1. 서버 시작: GenericServer.start(__MODULE__)

    • start 함수가 호출되면 spawn(fn -> loop(m, state) end)로 새 프로세스를 생성하고, loop 함수를 실행한다.
  2. 클라이언트 요청: GenericServer.call(pid, {:square, x})

    • 클라이언트가 call 함수를 호출하면 send(pid, {:call, self(), msg})가 실행된다.
    • send 함수는 서버의 메시지 큐에 {:call, self(), {:square, x}} 형태의 메시지를 넣는다.
  3. 메시지 큐와 receive:

    defp loop(m, state) do
      new_state = 
        receive do
          {:call, from, msg} -> 
            {reply, new_state} = m.handle_call(msg, state)
            send(from, reply)
            new_state
        end
      loop(m, new_state)
    end
    
    • 서버는 receive 블록에서 메시지를 기다린다.
    • {:call, from, msg} 형태의 메시지가 들어오면 패턴 매칭을 통해 frommsg를 추출한다.
  4. handle_call 호출:

    • m.handle_call(msg, state)를 호출한다. 여기서 msg{:square, x} 형태일 것이다.
    • handle_call 함수는 x * x를 계산하고 그 값을 reply로 반환한다.
  5. 응답 보내기:

    • send(from, reply)를 통해 계산된 결과를 클라이언트에게 보낸다.
  6. 상태 업데이트와 다음 루프:

    • 상태(state)를 업데이트하고 loop 함수를 재귀적으로 호출하여 다시 receive 상태로 돌아간다.

위의 6단계를 통해 전체 흐름을 이해할 수 있다. 클라이언트의 요청을 받아서 서버가 처리하고 응답을 보내는 전체 과정을 설명한 것이다.

defmodule CounterServer do
  def start() do
    GenericServer.start(__MODULE__, 0)
  end

  def inc(pid) do
    GenericServer.cast(pid, :inc)
  end

  def dec(pid) do
    GenericServer.cast(pid, :dec)
  end

  def value(pid) do
    GenericServer.call(pid, :value)
  end

  # implementation
  def handle_cast(:inc, state) do
    state + 1
  end

  def handle_cast(:dec, state) do
    state - 1
  end

  def handle_call(:value, state) do
    {state, state}
  end
end

CounterServer 모듈은 카운터 값을 증가, 감소시키고 현재 값을 가져오는 간단한 서버다. 이제 코드를 차례대로 설명하겠다.

  1. 서버 시작: CounterServer.start()

    • GenericServer.start(__MODULE__, 0) 함수를 호출한다.
    • __MODULE__는 현재 모듈인 CounterServer를 나타낸다.
    • 0은 초기 상태값(state)이다.
    • GenericServer.start에서 spawn 함수를 사용해 loop 함수를 실행하는 새 프로세스를 생성한다.
  2. 카운터 증가: CounterServer.inc(pid)

    • GenericServer.cast(pid, :inc)를 호출한다.
    • send(pid, {:cast, :inc})를 통해 메시지를 서버의 메시지 큐에 넣는다.
  3. 카운터 감소: CounterServer.dec(pid)

    • GenericServer.cast(pid, :dec)를 호출한다.
    • send(pid, {:cast, :dec})를 통해 메시지를 서버의 메시지 큐에 넣는다.
  4. 카운터 값 가져오기: CounterServer.value(pid)

    • GenericServer.call(pid, :value)를 호출한다.
    • send(pid, {:call, self(), :value})를 통해 메시지를 서버의 메시지 큐에 넣는다.
  5. 메시지 큐와 receive:

    defp loop(m, state) do
      new_state = 
        receive do
          {:cast, msg} ->
            m.handle_cast(msg, state)
          {:call, from, msg} -> 
            {reply, new_state} = m.handle_call(msg, state)
            send(from, reply)
            new_state
        end
      loop(m, new_state)
    end
    
    • 서버의 loop 함수는 receive 블록에서 메시지를 기다린다.
    • {:cast, :inc} 또는 {:cast, :dec} 메시지가 오면 handle_cast 함수를 호출하여 상태를 업데이트한다.
    • {:call, from, :value} 메시지가 오면 handle_call 함수를 호출하여 상태를 반환한다.
  6. handle_cast와 상태 변경:

    • :inc 메시지를 받으면 상태를 1 증가시킨다.
    • :dec 메시지를 받으면 상태를 1 감소시킨다.
  7. handle_call과 상태 반환:

    • :value 메시지를 받으면 현재 상태를 반환한다.
  8. 상태 업데이트와 다음 루프:

    • 상태를 업데이트하고 loop 함수를 다시 호출한다.

위의 8단계로 전체 흐름을 설명했다.

handle_call(:value, state) 함수는 클라이언트가 현재 상태(카운터 값)를 요청할 때 호출되는 함수다. 이 함수가 하는 일은 아주 간단하다. 현재 상태(state)를 그대로 반환하는 것이다.

여기서 {state, state}라고 반환하는 이유는 GenericServerloop 함수 내에서 handle_call을 호출할 때 반환값을 {reply, new_state} 형태로 기대하기 때문이다. 여기서 reply는 클라이언트에게 보낼 응답이고, new_state는 업데이트된 상태값이다.

이 경우에는 상태값(state)이 변경되지 않으므로, 클라이언트에게 보낼 응답(reply)도 현재 상태(state)이고, 업데이트된 상태(new_state)도 현재 상태(state)이다. 그래서 {state, state}라고 반환하는 것이다.

여기서 call과 cast의 차이점을 살펴볼 수 있는데, handle_call(:inc, state)handle_call(:dec, state)를 사용할 수는 있지만, 그렇게 하면 이 작업들이 동기적(synchronous)으로 수행될 것이다. 즉, 클라이언트가 :inc:dec 메시지를 보내고 나서 서버로부터 응답을 받을 때까지 기다려야 한다.

Elixir나 Erlang에서는 이런 간단한 증가/감소 연산같은 경우에는 비동기적(asynchronous)으로 처리하는 것이 일반적이다. 이를 위해 handle_cast를 사용하면 클라이언트는 서버로 메시지를 보내고 응답을 기다리지 않아도 된다.

만약 동기적으로 처리하려면 handle_call을 사용할 수 있으나, 이 경우 응답을 기다리는 시간동안 클라이언트나 서버가 다른 일을 하지 못할 수 있어 비효율적일 수 있다. 따라서 :inc:dechandle_cast로, 상태 조회(:value)는 상태가 필요하기 때문에 handle_call로 처리하는 것이 일반적이다.

댓글 없음:

댓글 쓰기

관련 포스팅