Page

관련 포스팅

2023년 9월 26일 화요일

Week03- Code02 분석 (Arithmetic 분산 Server)

GenServer를 활용한 병렬 산술

이번에도 역시나 GenServer를 활용하여 간단한 병렬 환경에서의 산술 계산을 해주는 코드를 만들어 보겠다.


defmodule ArithWorker do
  use GenServer

  def start_link(_) do
    GenServer.start_link(__MODULE__, nil)
  end

  def init(_) do
    {:ok, nil}
  end

  def handle_call({:square, x}, _from, state) do
    {:reply, {self(), x * x}, state}
  end

  def handle_call({:sqrt, x}, _from, state) do
    {:reply, {self(), :math.sqrt(x)}, state}
  end
end

defmodule ArithServer do
  use GenServer

  @name __MODULE__

  def start do
    {:ok, pid} = GenServer.start_link(__MODULE__, [], name: @name)
    pids = for _ <- 1..4, do: elem(ArithWorker.start_link([]), 1)
    GenServer.cast(@name, {:set_workers, pids})
    IO.puts("Started workers: " <> Enum.join(Enum.map(pids, &inspect(&1)), ", "))
    {:ok, pid}
  end

  def init(_) do
    {:ok, %{workers: [], next_worker: 1}}
  end

  def handle_cast({:set_workers, pids}, state) do
    {:noreply, %{state | workers: pids}}
  end

  def square(x) do
    GenServer.call(@name, {:square, x})
  end

  def sqrt(x) do
    GenServer.call(@name, {:sqrt, x})
  end

  def handle_call({:square, x}, _from, %{workers: workers, next_worker: next_worker} = state) do
    worker_pid = Enum.at(workers, next_worker - 1)
    {pid_res, result} = GenServer.call(worker_pid, {:square, x})
    {:reply, {pid_res, result}, %{state | next_worker: rotate(next_worker, workers)}}
  end

  def handle_call({:sqrt, x}, _from, %{workers: workers, next_worker: next_worker} = state) do
    worker_pid = Enum.at(workers, next_worker - 1)
    {pid_res, result} = GenServer.call(worker_pid, {:sqrt, x})
    {:reply, {pid_res, result}, %{state | next_worker: rotate(next_worker, workers)}}
  end

  defp rotate(val, list) do
    rem(val, length(list)) + 1
  end
end

전체 코드 훑어보기

이 코드는 싱글 노드 내에서 여러 작업자(worker) 프로세스를 사용하여 산술 계산을 병렬로 처리할 수 있도록 설계된 서버이다.

ArithServer가 여러 개의 ArithWorker를 관리하며, ArithWorker들은 병렬로 산술 계산 작업을 처리한다. 이런 구조는 Elixir의 경량 프로세스를 활용하여 병렬 처리를 효율적으로 수행할 수 있게 해준다.

이 프로그램은 Elixir를 사용하여 병렬 산술 계산을 수행하는 서버와 작업자(worker) 모델을 구현하며, 크게 ArithServerArithWorker 두 개의 모듈로 구성되어 있다.

ArithServer

이 모듈은 본질적으로 작업자(worker)들을 관리하는 서버 역할을 한다. - start 함수에서는 4개의 ArithWorker 프로세스를 생성하고, 이들을 관리할 자신(ArithServer)도 함께 생성한다. - handle_cast 함수를 통해 작업자의 PID 목록을 상태에 저장한다. - squaresqrt 함수를 통해 산술 계산을 위임한다. - handle_call에서는 실제 산술 작업을 특정 ArithWorker에게 위임하고 결과를 반환한다.

ArithWorker

이 모듈은 실제 산술 계산을 담당한다. - handle_call 함수를 통해 제곱(square)과 제곱근(sqrt) 계산을 수행한다.

구동 원리

  1. ArithServer.start를 호출하면, 4개의 ArithWorker 인스턴스가 생성되고, 이들의 PID가 ArithServer의 상태에 저장된다.
  2. ArithServer.squareArithServer.sqrt 함수를 호출하면, 이 요청은 handle_call에 의해 처리된다.
  3. handle_call에서는 현재 상태에 저장된 ArithWorker의 PID 목록 중 하나를 선택하여 실제 계산을 위임한다.
  4. 위임받은 ArithWorker는 계산을 수행하고 결과를 ArithServer에 반환한다.
  5. ArithServer는 반환된 결과를 다시 호출자에게 전달한다.

이러한 방식으로 여러 개의 ArithWorker를 통해 병렬적으로 산술 계산이 가능하다. 하지만 이 모든 것은 하나의 노드, 또는 하나의 시스템 내에서 실행되므로 분산 시스템은 아니다.

분산 시스템

분산 시스템은 물리적으로 분리된 여러 컴퓨터 노드가 네트워크를 통해 연결되어 하나의 공동의 작업을 수행하는 컴퓨터 시스템이다. 이러한 컴퓨터들은 본래 독립적인 기계이지만, 분산 시스템 내에서는 하나의 시스템처럼 동작하게 된다.

원리와 작동 방식

  • 메시지 전달: 노드들은 서로 메시지를 주고받아 정보를 공유하고 작업을 조정한다.

  • 분산 알고리즘: 분산된 노드가 공동으로 작업을 수행하기 위해 필요한 알고리즘. 예를 들어, 분산 데이터베이스, 분산 트랜잭션 등이 있다.

  • 분산 처리: 작업이 여러 노드에 분배되어 병렬로 처리된다. 이는 계산 속도를 향상시키고 리소스를 효율적으로 활용한다.

  • 로드 밸런싱: 모든 노드가 균일하게 로드를 나눠 갖도록 작업을 분배한다.

  • 결함 허용성: 하나 또는 그 이상의 노드가 실패해도 시스템 전체가 계속 작동할 수 있도록 설계한다.

주요 특징

  • 확장성: 새로운 노드를 쉽게 추가할 수 있다.

  • 높은 가용성: 한 노드가 실패하더라도 다른 노드가 작업을 계속 수행할 수 있다.

  • 복잡성: 노드가 많을수록, 노드 간의 동기화 및 데이터 일관성 유지 등이 복잡해진다.

예시

  1. 분산 데이터베이스: 하나의 데이터베이스가 여러 서버에 분산되어 있다.

  2. 맵리듀스(MapReduce): 대용량 데이터를 여러 노드에서 동시에 처리하고 그 결과를 취합하는 프로그래밍 모델.

  3. 클러스터 컴퓨팅: 여러 대의 컴퓨터가 하나의 작업을 수행하기 위해 모여 있는 것. 예를 들어, 하둡 클러스터, 쿠버네티스 클러스터 등이 있다.

  4. 그리드 컴퓨팅: 여러 조직이나 위치에 퍼져 있는 '독립적인' 컴퓨터 자원을 연결해 하나의 큰 시스템처럼 사용한다.

분산 시스템은 이렇게 여러 노드가 협업하여 효율적인 작업을 할 수 있도록 도와준다. 그러나 이로 인해 발생하는 동기화, 데이터 일관성, 네트워크 지연 등의 문제를 해결해야 하는 복잡성도 존재한다.


코드 상세 분석

이제 서버 구현 부분부터 본격적으로 코드를 분석해보자.

Server side

defmodule ArithServer do
  use GenServer

  @name __MODULE__

  def start do
    {:ok, pid} = GenServer.start_link(__MODULE__, [], name: @name)
    pids = for _ <- 1..4, do: elem(ArithWorker.start_link([]), 1)
    GenServer.cast(@name, {:set_workers, pids})
    IO.puts("Started workers: " <> Enum.join(Enum.map(pids, &inspect(&1)), ", "))
    {:ok, pid}
  end
  • use GenServer: GenServer 모듈을 사용하겠다고 선언한다. 이를 통해 GenServer의 동작과 상태 관리 기능을 현재 모듈에서 사용할 수 있다.

  • @name __MODULE__: @name은 모듈 속성으로, __MODULE__ 매크로를 통해 현재 모듈의 이름을 저장한다. 이 이름은 서버 인스턴스의 고유 이름을 설정할 때 사용된다.

모듈 속성(Module Attribute)

모듈 속성은 Elixir에서 모듈 레벨의 상수나 설정 같은 것을 정의할 때 사용된다. 이런 속성은 컴파일 타임에 정의되기 때문에 런타임에 변경할 수 없다. 예를 들어, @name은 그냥 일종의 상수로 생각하면 된다. 이 상수는 __MODULE__이라는 값으로 초기화되고, 이 값은 이 모듈의 이름을 나타낸다.

매크로(Macro)

매크로는 코드에서 코드를 생성하거나 변형할 수 있게 해주는 기능이다. Elixir에서 __MODULE__ 같은 것은 매크로다. 이 매크로는 현재 모듈의 이름을 반환한다. 컴파일 타임에 __MODULE__이라는 매크로는 현재 코드가 있는 모듈의 이름으로 치환된다.

@name __MODULE__

이제 이 두 개념을 합쳐서 설명하면, @name __MODULE__는 현재 모듈의 이름을 @name이라는 모듈 속성에 저장한다. 이렇게 하면 @name을 어디에서든 사용할 수 있고, 그 값은 이 모듈의 이름이 될 것이다. 예를 들어, 모듈의 이름이 ArithServer라면 @nameArithServer가 될 것이다.

@name 속성은 나중에 GenServer.start_link(__MODULE__, [], name: @name)에서 name: 옵션으로 사용된다. 이렇게 해서 GenServer 인스턴스가 고유한 이름을 갖게 된다. 이 이름은 다른 곳에서 이 서버를 찾을 때나 메시지를 보낼 때 사용될 수 있다.

한마디로, @name 모듈 속성에 현재 모듈의 이름을 저장해서 나중에 다른 부분에서 @name을 통해 그 값을 쉽게 참조할 수 있게 하는 것이다. 이렇게 해서 코드가 더 간결하고 관리하기 쉬워진다. 그리고 @name으로 한 것은 프로그래머의 선택이다. @module로 해도 전혀 문제 없다. 모듈 속성의 이름은 프로그래머가 자유롭게 정할 수 있으며, 그 이름이 코드 내에서 어떻게 사용될지도 프로그래머가 결정한다.

@name이라고 한 이유는 일반적으로 이 변수가 GenServer의 이름을 저장하기 위해 사용되므로, name이라는 단어가 이 변수의 목적을 좀 더 명확하게 표현해주기 때문일 것이다.

간단하게 말하면, @name이라고 한 것은 이 변수의 역할을 더 명확하게 하기 위한 프로그래머의 선택일 것이다. 만약에 내가 그것을 조금 더 명확하게 표현하고자 한다면 @module_name으로 사용하면 되겠다. Elixir에서는 일반적으로 snake_case를 사용한다. 따라서 @module_name처럼 snake_case를 사용하는 것이 Elixir 커뮤니티에서 더 일반적이다. 하지만 이것은 정해진 규칙은 아니라서 @ModuleName처럼 camel case를 사용할 수도 있다.

결국 선택은 프로그래머가 하는 것이며, 중요한 것은 일관성을 유지하는 것이다. 프로젝트 또는 팀에서 이미 정한 코드 스타일 가이드가 있다면 그것을 따르는 것이 좋다. 없다면 일반적인 Elixir 커뮤니티의 스타일 가이드를 따르는 것도 좋은 방법이다.

def start do

이 함수는 서버와 워커를 시작하는 함수다.

  • {:ok, pid} = GenServer.start_link(__MODULE__, [], name: @name): 현재 모듈을 사용해서 GenServer를 시작하고, 그 프로세스 ID(pid)를 가져온다. name: @name 부분은 서버에 고유한 이름을 부여한다. 이 이름은 나중에 이 서버 인스턴스를 찾을 때 사용한다.

start_link 함수와 ArithServer의 관계는 Elixir의 OTP (Open Telecom Platform) 프레임워크에서 GenServer를 사용할 때 매우 흔히 볼 수 있다. 여기서 start_link 함수는 ArithServer GenServer 프로세스를 시작하기 위한 역할을 한다.

start_link 함수

이 함수는 GenServer.start_link/3를 호출하여 새로운 GenServer 프로세스를 시작한다. 이 때 첫 번째 인자로 __MODULE__을 넘기는데, 이는 현재 모듈 (ArithServer)이 GenServer 콜백을 구현하고 있다는 것을 의미한다.

ArithServer는 GenServer를 구현한 모듈이다. 콜백 함수 (init/1, handle_call/3 등)을 포함하고 있어서 GenServer가 어떤 식으로 동작할지 정의한다.

start_link가 호출되면, 실제로는 ArithServer.init/1 콜백 함수가 호출된다. 그리고 이 함수의 반환 값으로 GenServer의 초기 상태가 설정된다. 이렇게 start_linkArithServer는 서로 밀접한 관계를 가지며, start_linkArithServer를 실제로 구동시키는 역할을 한다.

더 구체적으로, ArithServer.start 함수에서 ArithWorker.start_link([])를 호출하여 4개의 작업자 (worker) 프로세스를 생성하고, 그 PID (Process ID)들을 GenServer.cast를 통해 ArithServer의 상태에 저장한다. 이렇게 ArithServer는 여러 ArithWorker 프로세스를 관리하고, 산술 연산 요청이 들어오면 순차적으로 이들 작업자에게 분배한다.

  • pids = for _ <- 1..4, do: elem(ArithWorker.start_link([]), 1): 4개의 ArithWorker 워커를 시작하고, 각각의 프로세스 ID를 리스트로 모은다.

elem 함수는 튜플의 특정 위치에 있는 요소를 가져올 때 사용한다. 예를 들어, elem({:ok, pid}, 1)은 튜플의 두 번째 요소인 pid를 반환한다. Elixir에서 튜플의 인덱스는 0부터 시작한다. ArithWorker.start_link([])의 반환 값은 {:ok, pid} 형태의 튜플이므로, elem(ArithWorker.start_link([]), 1)pid를 가져오는 것이다.

for _ <- 1..4 문장은 Elixir의 컴프리헨션 문법을 사용한 것이다. 이것은 1부터 4까지의 숫자를 순회하면서 루프를 돌게 하는 것을 의미한다. _ 기호는 여기서는 "변수를 사용하지 않겠다"라는 뜻이다. 즉, 1부터 4까지의 숫자를 각각 가져와서 무언가를 하겠지만, 숫자 자체는 중요하지 않다는 뜻이다.

결과적으로, pids = for _ <- 1..4, do: elem(ArithWorker.start_link([]), 1) 코드는 1부터 4까지 숫자를 순회하면서 ArithWorker.start_link([])를 호출하고, 그 결과로 나온 pid들을 리스트에 모은다. 이렇게 생성된 pid 리스트는 ArithServer가 관리할 작업자 프로세스의 ID들이 된다.

  • GenServer.cast(@name, {:set_workers, pids}): 초기화된 워커의 pid 리스트를 서버의 상태에 설정한다. cast는 비동기 메시지를 보내서, 서버 상태를 업데이트한다.

GenServer.cast/2 함수는 GenServer에게 비동기적으로 메시지를 보내는 역할을 한다. 이 함수는 호출자가 응답을 기다리지 않고 즉시 다음 작업으로 넘어간다. 즉, 이 함수를 사용하면 메시지를 보낸 후에 바로 다음 작업을 수행할 수 있다.

메시지는 handle_cast/2 콜백 함수에서 처리되며, 이 함수는 서버의 내부 상태를 업데이트 할 수 있다. GenServer.cast(@name, {:set_workers, pids})에서 @name은 GenServer의 이름이고, {:set_workers, pids}는 보낼 메시지다. 이 메시지는 ArithServer 모듈 내의 handle_cast/2 함수에서 받아서 처리된다.

def handle_cast({:set_workers, pids}, state) do
  {:noreply, %{state | workers: pids}}
end

여기에서 {:set_workers, pids} 메시지를 받으면, 서버의 상태를 업데이트한다. 상태의 workers 키에 pids 리스트를 설정한다. {:noreply, %{state | workers: pids}}를 반환함으로써, 상태가 업데이트되고 다른 메시지를 기다린다.

이렇게 cast를 사용하면, 상태 업데이트를 비동기적으로 처리할 수 있어서 전체 시스템의 효율성을 높일 수 있다.

서버 상태를 업데이트하는 이유는 여러 가지다. 이 예에서는 ArithWorkerpid들을 관리하기 위해 상태를 업데이트한다. 이렇게 상태를 관리하면, 후에 다른 연산을 처리할 요청이 들어올 때 해당 워커들을 이용할 수 있다.

{:noreply, %{state | workers: pids}}에 대한 설명:

  • :noreply: 응답이 필요 없다는 것을 나타낸다. cast는 비동기적으로 동작하기 때문에, 호출자는 응답을 기다리지 않는다.
  • %{state | workers: pids}: Elixir에서 맵(map)의 값을 수정하는 문법이다. state 맵에 있는 workers 키의 값을 pids로 변경한다.

%{state | workers: pids} 문법을 사용하는 이유는 state라는 맵(map)에서 workers라는 키의 값을 pids로 바꿀 때 편리하고 명시적으로 변경할 수 있기 때문이다. Elixir에서 맵은 불변(immutable)하기 때문에, 이런 방식으로 특정 키의 값을 업데이트하면 새로운 맵이 생성된다. 기존의 state 맵은 그대로 유지되고, 새로운 맵이 반환되어 이후의 작업에서 사용된다.

이러한 방식은 함수형 프로그래밍 패러다임에서 자주 볼 수 있는 패턴으로, 불변성을 유지하면서도 상태 변경이 필요한 경우에 이런 식으로 처리한다. 불변성을 유지하게 되면 병렬 처리나 디버깅, 코드의 예측 가능성 등에서 이점이 있다.

간단히 말해서, workers 키의 값을 pids로 업데이트하기 위해 이런 문법을 사용한다. 이 새로운 맵은 handle_cast 함수에서 반환되어, GenServer의 새로운 상태로 설정된다. 이 상태는 다음에 들어오는 요청을 처리할 때 사용된다.

| 기호는 맵을 업데이트할 때 사용하는 문법이다. 기존의 state 맵에서 workers라는 키만 pids로 업데이트하고, 나머지는 그대로 둔 새로운 맵을 생성한다. 이 새로운 맵이 handle_cast/2에서 반환되고, 이후로 이 상태가 서버의 새로운 상태가 된다.

상태를 업데이트하는 이유는, 서버가 상태를 가지고 있으면 서버가 할 수 있는 작업이 더 다양해지고, 서버의 동작을 좀 더 유연하게 관리할 수 있기 때문이다. 예를 들어, 어떤 워커가 바쁜지, 어떤 데이터를 미리 캐싱해 둘 것인지 등을 상태를 통해 관리할 수 있다.

  • IO.puts("Started workers: " <> Enum.join(Enum.map(pids, &inspect(&1)), ", ")): 시작된 워커의 pid를 콘솔에 출력한다. Enum.mapEnum.join을 사용해서 pid 리스트를 문자열로 변환하고 출력한다.

Enum.join(Enum.map(pids, &inspect(&1)), ", ") 이 코드는 Elixir에서 컬렉션에 대한 여러 작업을 한 번에 처리하는 예시다. 여기서 pids는 Process ID들의 리스트다. 이 코드는 크게 두 부분으로 나눠볼 수 있다.

  1. Enum.map(pids, &inspect(&1)):

    • Enum.map 함수는 첫 번째 인자로 주어진 리스트 (pids)의 각 요소에 대해서 두 번째 인자로 주어진 함수 (&inspect(&1))를 실행한다.
    • &inspect(&1)은 익명 함수다. 여기서 &1은 함수의 첫 번째 인자를 의미하며, inspect 함수는 이 인자를 문자열 형태로 변환한다.
    • 결국, Enum.map(pids, &inspect(&1))pids 리스트의 각 요소 (각 Process ID)를 문자열로 변환한 새로운 리스트를 생성한다.
  2. Enum.join( ..., ", "):

    • 이 부분은 Enum.join 함수를 사용해서 앞서 만든 문자열 리스트를 하나의 문자열로 합친다. 각 문자열 사이에는 쉼표와 공백(,)이 들어간다.

결과적으로, Enum.join(Enum.map(pids, &inspect(&1)), ", ")는 Process ID 리스트를 받아서 그것을 문자열로 변환하고, 그 문자열들을 쉼표와 공백으로 구분한 하나의 문자열을 만든다. 이 문자열은 IO.puts로 콘솔에 출력된다.

&inspect(&1)에 대한 이해를 도와주기 위해, Elixir의 익명 함수와 'capture operator' (&)에 대해서 알아보자.

  1. 익명 함수 (Anonymous Functions)

    • 이름이 없는 함수를 익명 함수라고 한다. 이런 함수는 다음과 같이 정의할 수 있다.
    fn x -> x * 2 end
    

    이 함수는 인자 x를 받아 x * 2를 반환한다.

  2. Capture Operator (&)

    • Elixir에서 &는 capture operator라고 하며, 이를 사용해 익명 함수를 더 간결하게 표현할 수 있다.
    • &1, &2, &3 등은 capture operator와 함께 사용되어 익명 함수의 첫 번째, 두 번째, 세 번째 인자를 각각 나타낸다.

&inspect(&1)를 뜯어보면:

  • &: Capture operator. 이걸 사용해서 익명 함수를 만든다는 것을 나타낸다.
  • inspect: 내장 함수로, 어떤 값을 문자열로 변환한다.
  • (&1): 익명 함수의 첫 번째 인자를 의미한다.

이렇게 조합하면 &inspect(&1)는 "첫 번째 인자를 받아 inspect 함수를 적용한 결과를 반환하는 익명 함수"가 된다.

예를 들어, 리스트 [1, 2, 3]이 있을 때, Enum.map([1, 2, 3], &inspect(&1))를 실행하면 ["1", "2", "3"]이라는 새 리스트가 반환된다. 여기서 &inspect(&1) 은 리스트의 각 요소를 문자열로 변환한다.


& Operator

Elixir에서 & 연산자는 여러 가지 용도로 사용되는데, 주로 다음과 같은 경우들이 있다.

  1. Capture Operator: 이 경우가 가장 흔하다. 익명 함수를 간결하게 표현할 때 사용된다.
    add = &(&1 + &2)
    add.(1, 2)  # 3
    

Elixir에서 & 연산자는 익명 함수(anonymous function)를 간결하게 표현하는 데 사용된다. 일반적으로 익명 함수는 fn ... -> ... end 형태로 정의한다. 그러나 이런 형태는 간단한 연산을 할 때 번거롭게 느껴질 수 있다. 이 때 & 연산자를 사용하면 코드를 좀 더 간결하게 만들 수 있다.

예를 들어, 리스트의 각 원소를 제곱하는 작업을 하는 함수가 있다고 하자.

일반적인 익명 함수 표현:

squares = Enum.map([1, 2, 3, 4], fn x -> x * x end)

& 연산자를 사용한 간결한 표현:

squares = Enum.map([1, 2, 3, 4], &(&1 * &1))

여기서 &1은 익명 함수의 첫 번째 인자를 나타낸다. 이런 식으로 & 연산자를 사용하면 코드가 더 간결해지고, 특히 함수형 프로그래밍 패러다임에서 유용하게 쓰인다.

인자가 3개인 익명 함수를 사용하는 간단한 예시를 들면 다음과 같다.

일반적인 익명 함수 표현을 사용하여 세 수를 더하는 함수:

add = fn a, b, c -> a + b + c end
result = add.(1, 2, 3)  # 결과는 6

& 연산자를 사용한 간결한 표현:

add = &(&1 + &2 + &3)
result = add.(1, 2, 3)  # 결과는 6

여기서 &1, &2, &3는 각각 첫 번째, 두 번째, 세 번째 인자를 나타낸다. 이런 식으로 & 연산자와 인덱스를 함께 사용하면 인자가 여러 개인 함수도 간결하게 표현할 수 있다.

  1. Function Reference: 함수에 대한 참조를 만들 때 사용한다.

    fun = &Enum.join/1
    fun.(["a", "b", "c"])  # "abc"
    
  2. Capture Function: 이미 정의된 함수를 익명 함수처럼 캡쳐해서 사용할 수 있다.

    Enum.map([1, 2, 3], &(&1 * 2))  # [2, 4, 6]
    
  3. Capture Module Function: 모듈의 함수를 캡쳐할 수도 있다.

    Enum.map([1, 2, 3], &Integer.to_string/1)  # ["1", "2", "3"]
    
  4. Bitwise AND: 숫자 연산에서 &는 bitwise AND 연산을 의미할 수 있다. 하지만 이 경우는 함수와 혼동이 불가능한 문맥에서만 발생한다.

    1 & 2  # bitwise AND, 결과는 0
    
  5. 표현식에서의 활용: 특정 표현식에 적용될 함수를 익명 함수로 캡쳐해서 전달하는 경우도 있다.

    Enum.reduce([1, 2, 3], 0, &(&1 + &2))  # 6
    
  6. 몇 번째 인자를 참조하는지 나타낼 때 Elixir에서 & 연산자와 숫자 조합은 익명 함수에서 몇 번째 인자를 참조하는지를 나타낸다. &1, &2, &3 등의 형태로 사용하며, 이것은 첫 번째, 두 번째, 세 번째 인자를 참조하게 된다.

예를 들어, 두 수를 더하는 익명 함수는 다음과 같이 표현할 수 있다:

sum = &(&1 + &2)
sum.(1, 2)  # 결과는 3

여기서 &1은 첫 번째 인자를, &2는 두 번째 인자를 의미한다. 이런 방식으로 익명 함수에서 인자를 간단하게 참조할 수 있다.

이런 다양한 용도로 & 연산자가 사용된다. 문맥에 따라 의미가 달라질 수 있으니 주의해서 살펴보는 것이 중요하다.


  • {:ok, pid}: 함수의 마지막에서 서버의 pid를 반환한다. 이 pid는 서버를 조작할 때 사용될 수 있다.

이 함수를 통해서 GenServer 인스턴스가 초기화되고, 필요한 워커도 함께 시작된다. 이렇게 초기화하고 설정을 마친 후, 실제로 산술 계산 요청을 처리할 준비가 된다.


  def init(_) do
    {:ok, %{workers: [], next_worker: 1}}
  end

init/1 함수는 GenServer 프로세스가 시작될 때 호출되는 초기화 함수다. 이 함수는 서버의 초기 상태를 설정하고, 해당 상태를 반환해야 한다.

{:ok, %{workers: [], next_worker: 1}}는 함수의 반환 값이며, 이것은 두 부분으로 나뉜다.

  • :ok: 함수가 성공적으로 완료됨을 나타내는 애텀(atom)이다.

  • %{workers: [], next_worker: 1}: 초기 상태를 나타내는 Elixir의 맵(map) 데이터 구조다. workers 키에는 빈 리스트가 할당되어 있고, next_worker 키에는 1이 할당되어 있다.

이 초기 상태는 GenServer 프로세스가 동작하는 동안 유지되며, 여기서는 두 가지 정보를 추적한다.

  1. workers: 작업을 처리할 워커 프로세스의 pid (프로세스 ID) 리스트를 저장한다. 초기에는 빈 리스트 []로 설정되어 있다.

  2. next_worker: 다음에 작업을 할당할 워커의 인덱스를 나타낸다. 초기값은 1이다. 이 값은 각 요청이 들어올 때마다 업데이트되며, 워커 리스트를 순회하면서 작업을 분배한다.

이러한 초기 상태 설정은 GenServer 프로세스가 후에 다른 함수를 호출할 때나, 상태를 업데이트할 때 사용된다.

Elixir에서 % 기호는 맵(map) 데이터 구조를 생성할 때 사용한다. 맵은 키와 값의 쌍으로 이루어진 데이터 구조로, %{} 안에 키: 값 형태로 요소들이 들어간다.

예를 들어, %{workers: [], next_worker: 1}workers라는 키에 빈 리스트 []를, next_worker라는 키에 1이라는 값을 각각 할당한 맵을 생성한다.

이런 식으로 % 기호는 Elixir에서 맵을 명시적으로 표현할 때 사용한다. 이는 언어 설계에서의 선택으로, 다른 데이터 구조와 맵을 쉽게 구분할 수 있게 해준다.

Elixir에서 맵(map) 구조를 생성하는 기본 문법은 %{키 => 값, 키 => 값, ...} 또는 %{키: 값, 키: 값, ...} 형태다. => 기호를 사용하면 어떤 데이터 타입도 키로 사용할 수 있지만, : 기호를 사용할 경우 키는 반드시 애터믹(atom)이어야 한다.

예시:

  • 애터믹을 키로 사용하는 경우:

    %{name: "Alice", age: 30, city: "Seoul"}
    
  • 문자열이나 다른 데이터 타입을 키로 사용하는 경우:

    %{"name" => "Alice", :age => 30, 3 => "Seoul"}
    

이런 식으로, %{} 안에 키-값 쌍을 넣어 맵을 생성할 수 있다.

age:age는 Elixir에서 다른 타입의 데이터다.

  • age: 이것은 변수다. 이 변수에 어떤 값이 할당되어 있어야 하며, 그 값이 맵(map)에서 사용될 것이다.

  • :age: 이것은 애터믹(atom)이다. 애터믹은 불변의 상수와 같은 것으로, 그 자체로 값이다. 애터믹은 주로 식별자나 키 같은 것으로 사용된다.

예를 들어,

age = 30
map1 = %{age: 25}  # 여기서 :age는 애터믹, 값은 25
map2 = %{age => 25}  # 여기서 age는 변수, 값은 25

map1에서 :age는 애터믹으로, 키 이름 자체가 :age이다. map2에서 age는 변수로, 그 변수에 저장된 값이 30이므로, 실제로 이 맵은 %{30 => 25}와 동일하다.

따라서, age는 변수의 값에 따라 달라질 수 있고, :age는 항상 :age라는 애터믹 값으로 고정되어 있다.

조금 햇갈리는 부분인데, Elixir에서 애터믹(atom)을 키로 사용할 때 %{:age => 25} 또는 %{age: 25} 형태로 표현할 수 있다. :age 앞에 콜론(:)을 붙여야 정확하게 애터믹으로 인식된다.

애터믹을 키로 사용하고 값을 할당하는 두 가지 방법이 있다:

  1. %{:age => 25}: 확실하게 애터믹과 값을 나타내는 '테트로'(=>)를 사용.
  2. %{age: 25}: Elixir에서는 이렇게 간단히 쓸 수도 있는데, 이것은 :age => 25와 동일하다.

둘 다 :age 애터믹을 키로 가지고 값으로 25를 가진 맵을 생성한다.

  def handle_cast({:set_workers, pids}, state) do
    {:noreply, %{state | workers: pids}}
  end

handle_cast/2 함수는 GenServer 프로세스에서 비동기 메시지를 처리하는 역할을 한다. 이 함수는 메시지를 받고 상태를 업데이트하며, 끝난 후에 호출한 쪽에게 응답을 주지 않는다(:noreply). 이 함수는 주로 상태 업데이트와 같이 즉시 응답이 필요하지 않은 작업에 사용된다.

이 코드에서 handle_cast({:set_workers, pids}, state) 함수는 :set_workers 메시지와 함께 pids(프로세스 ID들의 리스트)를 받는다. 이 정보를 사용하여 현재 서버의 상태인 state 맵에 있는 workers 키의 값을 pids로 업데이트한다.

  • {:set_workers, pids}: 메시지의 패턴. 이 패턴에 맞는 메시지가 GenServer에게 전송되면 이 handle_cast 함수가 호출된다.

  • state: 현재 GenServer의 상태. 이 상태는 이전 호출에서 반환된 상태 또는 init/1 함수에서 설정한 초기 상태다.

  • {:noreply, %{state | workers: pids}}: 반환값. :noreply는 응답이 필요 없음을 나타내고, %{state | workers: pids}는 상태를 업데이트하는 부분이다. workers 키의 값이 pids로 변경된 새로운 상태를 만든다.

이렇게 하면 서버 상태에 있는 workers 리스트가 새로운 pids 리스트로 업데이트된다. 이 상태 업데이트는 비동기적으로 이루어진다.

  def handle_call({:square, x}, _from, %{workers: workers, next_worker: next_worker} = state) do
    worker_pid = Enum.at(workers, next_worker - 1)
    {pid_res, result} = GenServer.call(worker_pid, {:square, x})
    {:reply, {pid_res, result}, %{state | next_worker: rotate(next_worker, workers)}}
  end

handle_call/3 함수는 GenServer에서 동기 메시지를 처리하는 역할을 한다. 이 함수는 메시지를 받고 처리한 뒤 호출한 쪽에게 결과를 돌려준다.

  • {:square, x}: 받은 메시지의 패턴. 클라이언트가 GenServer.call/3:square 메시지와 함께 숫자 x를 보낼 때 이 함수가 호출된다.

  • _from: 이 메시지를 보낸 프로세스의 정보다. 이 예시에서는 사용되지 않고 있어서 언더스코어(_)가 붙어 있다.

  • %{workers: workers, next_worker: next_worker} = state: 현재 GenServer의 상태를 디스트럭쳐링한다. 상태에는 workers 리스트와 next_worker 인덱스가 있다.

디스트럭쳐링이라는 것은 GenServer의 상태(state) 안에 있는 정보를 각각의 변수에 할당하는 것이다. 상태에는 여러 정보가 담겨있는데, 이 코드에서는 workersnext_worker 두 가지 정보를 가져오고 싶다.

코드에서 %{workers: workers, next_worker: next_worker} = state라고 쓰면, state라는 맵(map)에서 workers라는 키에 해당하는 값을 workers라는 변수에, next_worker라는 키에 해당하는 값을 next_worker라는 변수에 각각 저장한다.

예를 들어, state%{workers: [pid1, pid2, pid3], next_worker: 2}라면, workers 변수에는 [pid1, pid2, pid3]가 들어가고, next_worker 변수에는 2가 들어간다. 이렇게 해서 각 변수에 필요한 정보를 할당한 다음, 이 변수들을 함수 안에서 사용할 수 있다.

  • worker_pid = Enum.at(workers, next_worker - 1): workers 리스트에서 next_worker 인덱스에 해당하는 워커의 PID를 가져온다.

next_worker - 1을 하는 이유는 Elixir에서 리스트의 인덱스가 0부터 시작하기 때문이다. 예를 들어, workers 리스트에 [pid1, pid2, pid3]이 저장되어 있다면, pid1의 인덱스는 0, pid2의 인덱스는 1, pid3의 인덱스는 2로 되어 있다.

만약 next_worker 값이 1이라면, 원하는 것은 리스트의 첫 번째 원소인 pid1일 것이다. 그런데 next_worker는 1이므로, next_worker - 1을 하면 0이 되어, 0번째 인덱스를 가진 pid1Enum.at 함수로 가져올 수 있다.

즉, next_worker - 1을 하는 이유는 next_worker 값과 실제 리스트의 인덱스 사이의 차이를 보정하기 위함이다.

  • {pid_res, result} = GenServer.call(worker_pid, {:square, x}): 선택한 워커에게 :square 메시지를 보내고 결과를 받는다. 결과는 {pid_res, result} 형태다.

이 코드는 GenServer.call(worker_pid, {:square, x})를 통해 특정 워커 프로세스에 {:square, x} 메시지를 보내고, 그 워커로부터 결과를 받는 역할을 한다. GenServer.call은 요청을 보낸 후 응답을 기다리는 동기적인 함수다. 워커가 처리를 완료하면 그 결과를 반환해준다.

여기서 worker_pid는 워커 프로세스의 Process ID(PID)이며, {:square, x}는 워커에게 전달하는 명령이다. 명령을 처리한 후에 워커는 GenServer.call로부터 결과를 반환한다.

{pid_res, result} = GenServer.call(worker_pid, {:square, x}) 코드는 워커로부터 반환된 결과를 {pid_res, result} 튜플에 저장한다. 이 튜플의 첫 번째 원소 pid_res는 워커의 PID가 될 수 있고, 두 번째 원소 result는 실제 계산 결과가 된다. (이는 워커 구현에 따라 달라질 수 있음)

즉, 이 코드는 특정 워커에게 x의 제곱을 계산하라는 명령을 내리고, 그 결과를 받아와서 {pid_res, result}에 저장하는 역할을 한다. 이렇게 하면 워커 프로세스의 계산 능력을 활용하여 작업을 병렬적으로 처리할 수 있다.

  • {:reply, {pid_res, result}, %{state | next_worker: rotate(next_worker, workers)}}: 이 함수의 반환값이다.
    • :reply: 응답을 준다는 의미다.
    • {pid_res, result}: 응답 내용. 워커가 처리한 결과를 돌려준다.
    • %{state | next_worker: rotate(next_worker, workers)}: 상태 업데이트. next_worker 값을 rotate 함수를 통해 변경한다. rotate 함수는 아마도 다음 사용할 워커의 인덱스를 계산해주는 함수일 것이다.

이렇게 하면, 클라이언트가 :square 메시지를 보냈을 때, 해당 워커가 숫자의 제곱을 계산하고 그 결과를 돌려주며, 다음에 사용할 워커의 인덱스를 업데이트한다.

  defp rotate(val, list) do
    rem(val, length(list)) + 1
  end

rotate 함수는 리스트의 길이를 벗어나지 않는 새로운 인덱스를 계산한다. 이 함수는 현재 인덱스 값 val과 리스트 list를 인자로 받는다. 함수의 목적은 리스트의 다음 워커 인덱스를 계산하는 것이다.

rem(val, length(list))vallength(list)로 나눈 나머지를 계산한다. rem 함수는 Elixir에서 나머지를 계산할 때 사용하는 내장 함수다. 예를 들어, rem(5, 3)은 2를 반환한다.

+ 1이 붙은 이유는 Elixir의 리스트 인덱스가 0부터 시작하기 때문이다. 따라서 0을 벗어나게 하고 싶으면 1을 더해준다.

예를 들어, 리스트의 길이가 4이고 현재 val이 4라면, rem(4, 4)는 0이 되고 여기에 1을 더해 1이 반환된다. 이렇게 하면 리스트의 처음으로 다시 돌아가게 된다. 이러한 로직은 워커 리스트가 끝에 도달했을 때 처음으로 돌아가게 해준다.


Client side

defmodule ArithWorker do
  use GenServer

  def start_link(_) do
    GenServer.start_link(__MODULE__, nil)
  end

  def init(_) do
    {:ok, nil}
  end

  def handle_call({:square, x}, _from, state) do
    {:reply, {self(), x * x}, state}
  end

  def handle_call({:sqrt, x}, _from, state) do
    {:reply, {self(), :math.sqrt(x)}, state}
  end
end

client side는 server side에 비하면 매우 단순하다.

start_link 함수는 현재 모듈(__MODULE__)을 GenServer로 시작하라는 명령을 내린다. GenServer.start_link 함수는 GenServer 프로세스를 생성하고, 이 프로세스를 관리할 수 있는 PID(Process Identifier)를 반환한다.

GenServer.start_link의 첫 번째 인자는 GenServer의 콜백을 구현한 모듈이고, 두 번째 인자는 초기 상태값이다. 여기서는 nil을 사용했다.

nil을 사용한 이유는 특별한 초기 상태가 필요하지 않거나, init 함수에서 상태를 설정할 때 nil을 사용하는 것이 괜찮을 경우에 사용한다. 예를 들어, 초기 상태를 복잡한 구조로 설정할 필요가 없거나 init 함수 내에서 어차피 다른 값으로 상태를 변경할 계획일 때 nil을 사용할 수 있다.

결과적으로, start_link 함수는 새로운 GenServer 프로세스를 시작하고, 그 프로세스가 작동하기 위한 초기 상태를 설정한다. 여기서 초기 상태는 nil이지만, init 함수에서 이 값을 어떻게든 변경할 수 있다.

  def handle_call({:square, x}, _from, state) do
    {:reply, {self(), x * x}, state}
  end

handle_call 함수는 GenServer에서 동기 메시지를 처리하는 역할을 한다. 이 함수의 명세는 다음과 같다:

  • 첫 번째 인자 {:square, x}: 클라이언트로부터 받은 메시지. 여기서는 :square라는 액션과 그에 따른 데이터 x를 받는다.
  • 두 번째 인자 _from: 메시지를 보낸 프로세스의 정보. 여기서는 사용하지 않아서 _from으로 표시했다.
  • 세 번째 인자 state: 현재 GenServer의 상태. 변경할 필요가 없다면 그대로 반환한다.

함수의 내용에서 {:reply, {self(), x * x}, state}를 반환하고 있다. 이것은 세 가지 부분으로 이루어져 있다:

  1. :reply: 클라이언트에게 응답할 것임을 나타낸다.
  2. {self(), x * x}: 응답의 내용. self()는 현재 GenServer 프로세스의 PID, x * x는 입력값 x의 제곱이다.
  3. state: 업데이트된 상태. 여기서는 상태가 변하지 않았으므로 그대로 state를 반환한다.

결론적으로, 이 handle_call 함수는 :square 메시지를 받으면 제곱을 계산해서 결과와 함께 현재 GenServer 프로세스의 PID를 클라이언트에게 돌려주는 역할을 한다.

self()@name은 서로 다른 목적으로 사용된다.

  1. self(): 이 함수는 현재 실행 중인 프로세스의 PID(Process Identifier)를 반환한다. handle_call 함수가 실행되는 프로세스의 PID를 클라이언트에게 알려줄 때 사용한다.

  2. @name: 이것은 모듈 속성으로, GenServer를 등록할 때 사용하는 이름이다. 여기서는 __MODULE__ 즉, 현재 모듈의 이름을 @name에 저장하여 사용한다. 이 이름은 GenServer 프로세스가 시작될 때 등록되어, 다른 프로세스들이 이 이름으로 해당 GenServer를 찾을 수 있다.

self()를 사용하면 현재 작동 중인 GenServer 프로세스의 실제 PID를 얻을 수 있다. 반면 @name은 GenServer 프로세스가 어떤 이름으로 등록되었는지를 나타내기 때문에, 실제 PID와는 다를 수 있다.

따라서, self() 대신 @name을 사용하면 목적에 따라 문제가 발생할 수 있다. 예를 들어, @name으로 응답을 보내면, 클라이언트는 GenServer의 등록된 이름을 받게 되므로, 실제로 어떤 프로세스에서 응답이 왔는지를 정확히 알 수 없다.


이번 코드는 전체적으로 좀 복잡하고 어렵다. 그래서 이렇게 정리를 했지만, 한번에 이해하기가 쉽지 않기도 하다. 하지만 잘 익혀두면 서버에서 효율적으로 분산처리를 하는 원리를 이해하는데 도움이 될 것이라 생각한다.

댓글 없음:

댓글 쓰기

관련 포스팅