들어가며: 2백만 연결의 기적
WhatsApp의 전설
2012년, WhatsApp의 한 엔지니어가 벤치마크를 공개했다:
"FreeBSD + Erlang으로 단일 서버에서 2,000,000개의 TCP 연결."
이건 전설로 남았다. 그때까지 C10K(동시 10,000 연결)도 어렵다고 여겨졌는데, 200만? 어떻게 가능했는가?
답은 Erlang의 Actor 모델이었다. Erlang이라는 언어는 1986년 Ericsson의 전화 교환기를 위해 만들어졌고, 처음부터 동시성과 분산, 내결함성을 위해 설계되었다.
Actor 모델이란
1973년 Carl Hewitt이 제안한 Actor 모델은 전통적 프로그래밍과 근본적으로 다르다:
"모든 것은 actor다. Actor는 메시지를 받고, 메시지를 보내고, 새 actor를 만든다."
- 공유 메모리 없음: 모든 통신은 메시지로.
- 락 없음: 상태가 actor 내부에만 있음.
- Asynchronous: 메시지는 비동기로 전달.
- Fault isolation: 한 actor의 실패가 다른 actor에 번지지 않음.
이 단순한 모델에서 놀라운 시스템이 만들어진다.
이 글에서 다룰 것
- Actor 모델의 수학적 정의.
- Erlang/OTP: 원조이자 레퍼런스.
- Akka: JVM의 actor 구현.
- Elixir: Erlang의 현대적 문법.
- Orleans: Microsoft의 .NET Actor.
- Let it crash 철학.
- Supervision tree 패턴.
- 분산 Actor 시스템.
왜 이 지식이 중요한가?
- 동시성: 수백만 연결 처리. 현대 백엔드의 필수.
- 내결함성: 복구가 아닌 "죽게 두기" 철학.
- 분산 시스템: 로컬과 원격 actor가 같은 코드.
- Elixir: Phoenix LiveView 같은 현대 프레임워크.
- WhatsApp, Discord, Riot: 대기업들의 선택.
1. Actor 모델의 이론
세 가지 기본 작업
Carl Hewitt의 원래 정의:
- Create: 새 actor를 생성한다.
- Send: 다른 actor에게 메시지를 보낸다.
- Become: 다음 메시지에 대한 동작을 결정한다 (상태 변경).
이게 전부다. 이 세 가지로 모든 계산을 표현할 수 있다.
Actor의 구성 요소
각 Actor는:
- Address (PID): 고유 식별자.
- Mailbox: 받은 메시지 큐.
- Behavior: 메시지를 어떻게 처리할지 정의하는 함수.
- State: Actor 내부 상태 (외부에서 접근 불가).
Actor A
┌─────────────┐
│ Mailbox │ ← msg3 ← msg2 ← msg1
├─────────────┤
│ Behavior │ (함수: msg → action)
├─────────────┤
│ State │ (내부 데이터)
└─────────────┘
메시지 처리 모델
Actor는 하나의 메시지를 한 번에 처리한다:
while True:
msg = mailbox.pop() # 다음 메시지
action = behavior(msg) # 현재 동작으로 처리
apply(action) # 상태 변경, 메시지 송신 등
핵심: 한 actor 내부는 단일 스레드로 동작. 그래서 actor 내부의 상태는 동시 수정 걱정 없음. 락 불필요.
공유 메모리가 없다
Actor 간 통신은 오직 메시지로만:
// 잘못된 접근
actor_B.state.counter += 1 // 불법!
// 올바른 접근
actor_B.send(IncrementMessage())
이 제약이 모든 동시성 문제를 사라지게 한다. Race condition, deadlock, locks — 없다. 대신 메시지 전달의 복잡성으로 전환된다.
Asynchronous Message Passing
메시지 송신은 비동기다:
actor_A.send(actor_B, msg)
// 즉시 반환. B가 실제로 처리하는 건 나중.
이는:
- Sender를 block하지 않음: 송신자는 다른 일 계속.
- Backpressure: Mailbox가 넘칠 수 있음 (구현에 따라).
- 순서 보장: 대부분의 구현이 "한 A에서 한 B로 보낸 메시지는 순서대로"를 보장.
- 전달 보장: 로컬은 at-most-once, 원격은 구현에 따라.
Location Transparency
Actor의 PID는 로컬/원격을 구분하지 않는다:
% 같은 코드
Pid ! Message
Pid가 같은 노드든 다른 노드든 코드는 동일. 이는 투명한 분산을 가능하게 한다. Erlang이 먼저 이 원칙을 구현했다.
Actor vs Thread
| 항목 | Thread | Actor |
|---|---|---|
| 공유 메모리 | 있음 | 없음 |
| 동기화 | 락, 세마포어 | 메시지 |
| 무게 | 무거움 (수 MB) | 가벼움 (수 KB) |
| 수 | 수천 | 수백만 |
| 에러 전파 | 전체 프로세스 다운 | 개별 격리 |
| 분산 | 어려움 | 내장 |
| 디버깅 | 어려움 | 상대적 쉬움 |
2. Erlang: 원조의 힘
역사
- 1986: Ericsson의 전화 교환기용.
- 1998: 오픈소스.
- 1989: OTP (Open Telecom Platform) 라이브러리.
- 2010년대: WhatsApp, Ericsson, Heroku 등에서 대규모 사용.
언어 철학
Erlang은 의도적으로 제한된 언어다:
- Immutable: 변수는 한 번만 할당.
- Pattern matching: 주요 제어 흐름.
- Functional: 부작용 최소화.
- Dynamic typing: 런타임 타입.
- Single assignment: Prolog 영향.
이 제약들이 동시성을 안전하게 만든다.
경량 프로세스
Erlang의 actor는 "process" 라고 부른다. 그러나 OS process가 아닌 VM 내부 스케줄링 단위:
- 크기: 약 300 바이트로 시작.
- Spawn 비용: 1 μs 미만.
- Context switch: OS thread보다 빠름.
- 동시 프로세스 수: 수백만 가능.
% 100만 프로세스 생성
lists:foreach(
fun(_) -> spawn(fun() -> loop() end) end,
lists:seq(1, 1000000)
).
% 수 초 만에 완료.
OS thread로는 불가능. Erlang의 가벼움이 비밀.
Send와 Receive
% Actor(프로세스) 생성
Pid = spawn(fun() -> counter(0) end).
% 메시지 송신
Pid ! {increment, 5}.
Pid ! {get, self()}.
% 메시지 수신
counter(N) ->
receive
{increment, Delta} ->
counter(N + Delta);
{get, From} ->
From ! {count, N},
counter(N);
stop ->
ok
end.
!는 send, receive는 블로킹 메시지 수신. self()는 자기 PID.
Pattern Matching in Receive
receive는 mailbox를 스캔해 패턴에 맞는 첫 메시지를 가져온다:
receive
{urgent, Msg} -> handle_urgent(Msg);
{normal, Msg} -> handle_normal(Msg)
after
5000 -> timeout()
end.
- 선택적 매칭 (특정 메시지만 골라 처리).
- 타임아웃 지원.
- 메시지 큐를 재정렬하지 않고 매칭만.
Selective Receive의 함정
Erlang은 mailbox를 앞에서부터 스캔한다. 매칭되지 않으면 보류 큐에 남음:
receive
{critical, X} -> ...
end.
만약 mailbox에 {critical, _}가 없으면 영원히 기다림. 다른 메시지들이 쌓여 메모리 폭발. 이는 Erlang의 유명한 함정. 해결: 항상 catch-all을 두거나 after 타임아웃.
Erlang/OTP
OTP (Open Telecom Platform) 는 Erlang의 표준 라이브러리 + 디자인 패턴 모음:
- gen_server: 일반 서버 behavior.
- gen_statem: 상태 기계.
- gen_event: 이벤트 핸들러.
- supervisor: 감독 트리.
- application: 애플리케이션 구조.
OTP는 actor 모델의 재사용 가능한 디자인 패턴. Erlang으로 뭔가 만들면 거의 항상 OTP 위에서 만든다.
gen_server 예시
-module(counter).
-behaviour(gen_server).
-export([start_link/0, increment/1, get/0]).
-export([init/1, handle_call/3, handle_cast/2]).
start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, 0, []).
increment(N) -> gen_server:cast(?MODULE, {increment, N}).
get() -> gen_server:call(?MODULE, get).
init(State) -> {ok, State}.
handle_cast({increment, N}, State) -> {noreply, State + N}.
handle_call(get, _From, State) -> {reply, State, State}.
구조적이고 익숙한 패턴. 작성과 유지 보수가 쉬움.
3. Let It Crash: 혁명적 철학
전통적 에러 처리
대부분의 언어에선:
try:
result = risky_operation()
except Exception as e:
log_error(e)
# 복구 시도, 또는 실패 반환
return None
- 가능한 모든 에러를 예측.
- Defensive programming.
- 문제: 예상 못한 에러는 여전히 크래시.
Erlang의 접근
"Let it crash."
- 에러를 예상하려 하지 마라.
- Actor가 실패하면 그냥 죽게 하라.
- Supervisor가 재시작한다.
코드는 happy path에 집중하고, 실패는 외부(supervisor)가 관리한다.
왜 이게 작동하는가?
전통적 가정: "에러는 예외적". Erlang의 가정: "에러는 정상적".
네트워크가 끊기고, 디스크가 꽉 차고, 메모리가 부족한다. 이는 예상해야 할 일상이다. 그러나 각 에러 케이스를 다루려 하면 코드가 복잡해진다.
Erlang의 해결: 오류가 나면 재시작. 재시작 후에는 깨끗한 상태에서 시작. 이것이 대부분의 문제를 해결한다 ("have you tried turning it off and on again?").
Supervision Tree
Supervisor는 자식 actor들을 감시하는 특수 actor:
Main Supervisor
/ | \
Worker Worker Sub-Supervisor
/ | \
Worker Worker Worker
각 supervisor는 restart strategy를 가진다:
- one_for_one: 실패한 자식만 재시작.
- one_for_all: 하나 실패하면 모든 자식 재시작.
- rest_for_one: 실패한 자식과 그 뒤의 모든 자식 재시작.
- simple_one_for_one: 동적으로 생성되는 자식들.
재시작 한계
"무한 재시작"은 위험 (같은 에러가 반복되면 CPU 낭비). Supervisor는 재시작 한계를 가진다:
max_restarts: 시간당 최대 재시작 횟수.max_seconds: 측정 기간.
초과하면 Supervisor 자체가 죽고, 상위 supervisor에게 전파. 이것이 에러의 계층적 확산.
예시: WhatsApp 구조
Application Supervisor
├── Cluster Supervisor
│ ├── Node Manager
│ └── Distribution Manager
├── Connection Supervisor
│ ├── Connection 1
│ ├── Connection 2
│ └── ... (수백만)
├── Message Supervisor
│ ├── Message Router
│ └── Storage Manager
└── Monitoring Supervisor
├── Metrics Collector
└── Health Checker
각 레벨에서 장애가 격리된다. 한 연결의 에러가 다른 연결에 영향 없음. 한 하위 시스템의 문제가 전체를 무너뜨리지 않음.
교훈: 실패를 받아들이는 소프트웨어
Let it crash는 소프트웨어 설계의 패러다임 전환이다:
- 전통: "내 코드는 완벽해야 한다".
- Erlang: "내 코드는 실패할 것이다. 그래도 시스템은 돌아간다".
이는 분산 시스템의 본질에 맞다. 분산 시스템에서 어떤 것도 100% 신뢰할 수 없다. 네트워크, 하드웨어, 소프트웨어 모두 때때로 실패. 받아들이고 설계하자.
4. Akka: JVM의 Actor
동기
2009년 Jonas Bonér가 시작. JVM에 Erlang 스타일 actor를 가져오려 했다. Scala와 통합.
2012년 Typesafe(현 Lightbend)가 상업 지원. Akka는 Java/Scala 생태계에서 actor의 대명사가 되었다.
Akka vs Erlang의 차이
언어 차이:
- Erlang: 동적 타입, immutable, 함수형.
- Akka: JVM 언어의 타입 시스템을 그대로 사용.
Actor 경량성:
- Erlang: 프로세스 200~300 바이트, 완벽한 격리.
- Akka: Akka actor 몇 KB, JVM thread pool 기반.
Selective receive:
- Erlang: 네이티브 지원.
- Akka: Stash로 유사하게 구현 가능.
문화:
- Erlang: "언어가 곧 철학". 언어 전체가 actor 중심.
- Akka: "라이브러리". JVM 코드와 섞어 쓰기 가능.
기본 Akka 예시 (Scala)
import akka.actor.{Actor, ActorSystem, Props}
class Counter extends Actor {
var count = 0
def receive = {
case Increment(n) => count += n
case Get => sender() ! count
}
}
val system = ActorSystem("MySystem")
val counter = system.actorOf(Props[Counter], "counter")
counter ! Increment(5)
counter ! Get // 응답이 sender에 전달
- Props: Actor 생성 설정.
- receive: 메시지 처리 함수.
- sender(): 현재 메시지의 송신자 ref.
Typed Actor (Akka 2.6+)
초기 Akka는 메시지가 Any 타입이었다 (Erlang 스타일). 이는 타입 안전성을 포기한 것. Akka 2.6부터 Typed actors가 기본:
sealed trait CounterMessage
case class Increment(n: Int) extends CounterMessage
case class Get(replyTo: ActorRef[Int]) extends CounterMessage
val counterBehavior: Behavior[CounterMessage] = Behaviors.setup { context =>
var count = 0
Behaviors.receiveMessage {
case Increment(n) =>
count += n
Behaviors.same
case Get(replyTo) =>
replyTo ! count
Behaviors.same
}
}
컴파일 타임에 메시지 타입 검증. JVM의 장점 활용.
Akka Cluster
Akka Cluster는 여러 노드를 하나의 actor system으로 묶는다:
- Gossip 프로토콜: 노드 멤버십.
- 분산 actor: 로컬처럼 사용.
- Cluster Sharding: 상태 자동 분산.
- Cluster Singleton: 클러스터 전체에 단일 actor.
- Distributed Data: CRDT 기반 공유 상태.
Cluster Sharding 예시:
val shardRegion = ClusterSharding(system).start(
typeName = "User",
entityProps = Props[UserActor],
settings = ClusterShardingSettings(system),
extractEntityId = extractEntityId,
extractShardId = extractShardId
)
// userId=42로 메시지 보내기
shardRegion ! Envelope(42, SomeMessage)
// 42라는 ID의 actor가 어느 노드에 있든 자동으로 라우팅
이는 수백만 개의 actor를 수십 개 노드에 자동 분산한다. 노드 추가/제거 시 rebalance.
Akka Persistence
Event Sourcing 내장 지원. Actor의 상태 변경을 이벤트 로그로 저장:
val behavior: Behavior[Command] = EventSourcedBehavior[Command, Event, State](
persistenceId = PersistenceId("counter", "1"),
emptyState = State(0),
commandHandler = (state, cmd) => cmd match {
case Increment(n) => Effect.persist(Incremented(n))
},
eventHandler = (state, evt) => evt match {
case Incremented(n) => state.copy(count = state.count + n)
}
)
Actor 재시작 시 이벤트 로그를 재생해서 상태 복구. 분산 시스템의 failure recovery에 강력.
Akka의 함정
- "Props로 자식 생성": Erlang보다 복잡.
- Untyped actor 레거시: 많은 튜토리얼이 여전히 구식.
- JVM 튜닝: GC, heap, thread pool.
- 라이선스 변경: 2022년 BSL (Business Source License)로 변경. 대규모 상업 사용은 유료.
라이선스 이슈 이후 Pekko (Apache fork)가 등장. Akka와 100% 호환. 오픈소스.
5. Elixir: Erlang의 현대적 얼굴
탄생
- 2012: Ruby 개발자 José Valim이 시작.
- Erlang VM (BEAM) 위에서 실행.
- Ruby/Python 스타일 문법 + Erlang의 강력함.
왜 Elixir인가?
Erlang은 강력하지만 문법이 이질적이다 (Prolog 영향). 많은 개발자가 진입 장벽을 느꼈다. Elixir는:
- 친숙한 문법: Ruby/Python 사용자에게 친화.
- 현대적 도구: Mix (build), ExUnit (test), Hex (package manager).
- 100% Erlang 호환: 모든 OTP, 모든 라이브러리 사용 가능.
- Metaprogramming: 매크로로 DSL 구축.
예시: Counter
defmodule Counter do
use GenServer
# Client API
def start_link(initial \\ 0) do
GenServer.start_link(__MODULE__, initial, name: __MODULE__)
end
def increment(n) do
GenServer.cast(__MODULE__, {:increment, n})
end
def get do
GenServer.call(__MODULE__, :get)
end
# Server callbacks
def init(initial), do: {:ok, initial}
def handle_cast({:increment, n}, state) do
{:noreply, state + n}
end
def handle_call(:get, _from, state) do
{:reply, state, state}
end
end
Erlang의 gen_server와 구조는 같지만 훨씬 읽기 쉽다.
Phoenix와 LiveView
Elixir의 킬러 앱: Phoenix 프레임워크. Web development의 Rails급 생산성 + Erlang의 동시성.
Phoenix LiveView: 서버 렌더링 + 실시간 업데이트를 JavaScript 없이. 내부적으로 WebSocket + actor로 구현.
성능: 하나의 Phoenix 서버가 수백만 동시 연결 처리 가능 (WhatsApp 수준).
Discord의 사용
Discord는 Elixir + Erlang을 대규모로 사용한다:
- 메시지 라우팅: Elixir 클러스터.
- 동시 음성 채팅: 수백만 연결.
- 5백만+ 동시 사용자: 2019년 블로그에서 밝힘.
"Erlang은 죽은 언어 아닌가?"라는 질문에 대한 답이 여기 있다. Discord, WhatsApp, Riot (League of Legends 채팅) 등 수백만 사용자 서비스가 BEAM 위에서 돌아간다.
6. Orleans: Microsoft의 Virtual Actors
역사
- 2010: Microsoft Research에서 시작.
- Project Orleans의 목표: "클라우드 스케일 .NET actor".
- Halo 4+: 플레이어 매칭, 통계, 리더보드.
Virtual Actors
Orleans의 혁신: virtual actor. Actor의 생명주기 관리를 런타임이 자동으로:
전통 actor:
- 개발자가 명시적으로 생성/소멸.
- PID 관리 필요.
Virtual actor:
- Actor는 항상 존재한다고 가정.
- 메시지 수신 시 자동 활성화.
- 유휴 상태면 자동 비활성화.
- 재시작 시 자동 복원.
public interface ICounterGrain : IGrainWithIntegerKey
{
Task Increment(int n);
Task<int> Get();
}
public class CounterGrain : Grain, ICounterGrain
{
private int count = 0;
public Task Increment(int n)
{
count += n;
return Task.CompletedTask;
}
public Task<int> Get() => Task.FromResult(count);
}
사용:
var counter = grainFactory.GetGrain<ICounterGrain>(42);
await counter.Increment(5);
int value = await counter.Get();
ID 42의 counter grain이 처음 호출되면 자동 생성, 이후 유휴 상태가 길어지면 자동 비활성. 개발자는 생명주기 신경 쓸 필요 없음.
장점
- 단순화: 생성/소멸 코드 없음.
- 자동 failover: 노드 장애 시 다른 노드에서 재생성.
- 상태 지속: Grain의 상태는 storage provider로 자동 저장.
- .NET 네이티브: C# 타입 시스템 활용.
단점
- 런타임 오버헤드: 자동화의 비용.
- 에러 모델: let it crash와 다른 접근.
- 학습 곡선: virtual actor 개념이 처음엔 낯섦.
사용처
- Halo 시리즈 게임 서비스.
- Azure 일부 서비스 내부.
- 금융 거래 (FIS 등 파트너).
- IoT: 디바이스마다 grain.
.NET의 유일한 Actor 시스템?
Orleans는 .NET 생태계에서 가장 유명한 actor 프레임워크지만 유일하진 않다. Akka.NET (Akka의 .NET 포팅), Proto.Actor 등도 있다. 그러나 Microsoft 공식 + Azure 통합으로 Orleans가 주류.
7. 다른 Actor 시스템들
Dapr Actors
Microsoft의 Dapr (Distributed Application Runtime)은 sidecar 기반 분산 런타임. Dapr Actors는 언어 중립적 virtual actor:
# Python
from dapr.actor import Actor
class CounterActor(Actor):
async def increment(self, n):
state = await self._state_manager.get_state("count")
await self._state_manager.set_state("count", state + n)
HTTP/gRPC 기반이라 어떤 언어든 사용 가능. Kubernetes 친화.
Akka.NET
Akka의 .NET 포팅. F#/C#에서 사용. Pekko와 함께 Akka 철학의 연장선.
Pykka
Python의 간단한 actor 라이브러리. Thread 기반.
Ray
Python의 분산 컴퓨팅 프레임워크. Actor 추상화 포함:
import ray
@ray.remote
class Counter:
def __init__(self):
self.count = 0
def increment(self, n):
self.count += n
def get(self):
return self.count
counter = Counter.remote()
counter.increment.remote(5)
count = ray.get(counter.get.remote())
ML 워크로드에 인기. Ray Tune, Ray Serve 등과 통합.
CAF (C++ Actor Framework)
C++용 고성능 actor 프레임워크. 게임 엔진, HFT에서 사용.
8. 수백만 동시성의 비밀
Erlang의 비결
왜 Erlang은 수백만 동시성이 가능한가?
1. 경량 프로세스:
- 시작 크기 ~300 바이트.
- Spawn 비용 ~1 μs.
- JVM thread보다 1000배 가벼움.
2. 선점형 스케줄링:
- BEAM VM의 preemptive scheduling.
- Function 호출 후 "reduction" 카운트.
- 프로세스가 CPU를 "오래" 잡으면 강제 교체.
3. 독립 힙:
- 각 프로세스마다 별도 힙.
- Per-process GC: 프로세스 하나를 GC해도 다른 것에 영향 없음.
- "Stop the world" 없음.
4. Share-nothing:
- 공유 메모리 없으니 락 경합 없음.
- 메시지 전달 시 복사 (로컬 포인터 아님).
5. 간단한 스케줄러:
- CPU당 스케줄러 스레드 1개.
- 각 스레드가 자기 run queue.
- Work stealing으로 균형.
벤치마크
Erlang의 The Road to 2 Million Websocket Connections in Phoenix (2015년 블로그):
- 테스트: Phoenix Channels with WebSocket.
- 목표: 2백만 연결.
- 결과: 서버 한 대에서 달성.
- 메모리: ~40 GB (연결당 20 KB).
- CPU: 거의 idle.
이는 단일 서버에서다. Node.js나 Java로는 매우 어렵다.
비교: Go goroutine
Go의 goroutine도 경량 동시성으로 유명:
- 크기: 2 KB 시작 (동적 확장).
- 수: 수백만 가능.
- 성능: Erlang과 비슷하거나 약간 우위.
그러나 Go는 actor 모델이 아니다. Share-memory + channel. 에러 처리도 전통적 (recover/defer).
Erlang의 강점은 내결함성 패턴과 분산 투명성이다. 순수 성능만 보면 Go와 비슷.
실전 디자인: State Management
수백만 actor가 있으면 상태 관리가 중요:
- In-memory: 각 actor가 자기 상태. 가장 빠름.
- Persistent: Event sourcing으로 이벤트 로그 저장.
- Distributed: CRDT, 또는 별도 DB.
WhatsApp은 주로 in-memory를 썼다. 메시지 라우팅은 stateless에 가까우니 가능. 더 복잡한 상태가 필요한 경우 Cassandra/DB와 조합.
9. 실전 패턴
패턴 1: Request-Reply
가장 기본적인 패턴:
# 동기 call
reply = GenServer.call(server, :request)
# 비동기 cast (응답 없음)
GenServer.cast(server, :fire_and_forget)
call은 내부적으로 타임아웃을 가진다 (기본 5초). 응답 없으면 exception.
패턴 2: Pub-Sub
# Subscriber
Phoenix.PubSub.subscribe(MyApp.PubSub, "chat:lobby")
# Publisher
Phoenix.PubSub.broadcast(MyApp.PubSub, "chat:lobby", {:new_message, msg})
# Subscriber receives
def handle_info({:new_message, msg}, state), do: ...
Actor 시스템에서 자연스럽게 구현.
패턴 3: Worker Pool
여러 worker actor에게 작업 분배:
# poolboy 사용
{:ok, pool_pid} = :poolboy.start_link(
[worker_module: MyWorker, size: 10],
init_args
)
worker = :poolboy.checkout(pool_pid)
result = MyWorker.process(worker, task)
:poolboy.checkin(pool_pid, worker)
패턴 4: State Machine
gen_statem으로 상태 기계:
defmodule TrafficLight do
use GenStateMachine
def init(_), do: {:ok, :red, %{}}
def handle_event(:state_timeout, :tick, :red, data) do
{:next_state, :green, data, [{:state_timeout, 3000, :tick}]}
end
def handle_event(:state_timeout, :tick, :green, data) do
{:next_state, :yellow, data, [{:state_timeout, 1000, :tick}]}
end
def handle_event(:state_timeout, :tick, :yellow, data) do
{:next_state, :red, data, [{:state_timeout, 3000, :tick}]}
end
end
상태 기반 로직이 명확.
패턴 5: Circuit Breaker
Actor로 구현하기 쉬움:
defmodule CircuitBreaker do
use GenServer
def call(name, func) do
case GenServer.call(name, :state) do
:closed -> try_call(name, func)
:open -> {:error, :circuit_open}
:half_open -> try_call(name, func)
end
end
# ... handle failures and state transitions
end
10. 제한과 함정
함정 1: Actor가 만능은 아니다
계산 집약적 작업엔 불리:
- 메시지 전달 오버헤드.
- 단일 actor는 단일 스레드.
- GPU 같은 병렬 하드웨어와 안 맞음.
해결: 계산은 다른 언어/시스템으로, actor는 조율에.
함정 2: Mailbox 폭발
빠른 생산자 + 느린 소비자:
Producer: 초당 100만 메시지 송신
Consumer: 초당 1만 메시지 처리
→ 매 초 99만 개가 mailbox에 쌓임
→ 메모리 폭발, OOM
해결:
- Backpressure: 소비자가 "slow down" 신호.
- Sampling: 넘치면 drop.
- Bounded mailbox: Akka에서 설정 가능. Erlang은 기본 무한.
- Flow control: GenStage, Broadway (Elixir).
함정 3: Selective Receive의 성능
Erlang의 selective receive는 mailbox 스캔:
receive
{specific, Msg} -> handle(Msg)
end
Mailbox에 10만 개 메시지가 있고, 원하는 패턴이 뒷쪽에 있으면 10만 개를 스캔. 느림.
해결:
receive없이 모든 메시지를 순차 처리.spawn으로 새 actor에게 특정 메시지 분기.
함정 4: 분산 환경의 메시지 순서
로컬에선 "A → B 메시지는 순서 유지"가 보장되지만, 분산에선:
- 메시지가 네트워크로 감.
- 재시도, 재정렬 가능.
- Eventual consistency.
해결:
- Causal ordering이 필요하면 명시적으로 구현.
- Idempotent 설계.
- Event sourcing.
함정 5: Global state 유혹
Actor 모델의 철학에 반하지만 편의를 위해:
# :ets 테이블은 프로세스 외부에 공유 가능
:ets.new(:shared, [:public, :named_table])
이런 공유 상태는 편하지만 락 경합, 디버깅 어려움을 만든다. 필요할 때만 신중히.
함정 6: 원격 호출의 타임아웃
GenServer.call(remote_pid, :get, 5000)
5초 타임아웃. 네트워크 문제로 타임아웃이면:
- 실제로 완료되었는지 알 수 없음.
- At-least-once를 원하면 재시도 필요.
- 하지만 재시도하면 중복 가능.
해결: Idempotent 설계. 고유 request ID.
11. Actor 모델 vs 다른 동시성 모델
vs Thread + Lock
- Thread: 공유 메모리, 락, 복잡.
- Actor: 메시지, 락 없음, 단순.
Actor가 대부분의 경우 더 안전하고 단순. 그러나 계산 집약적 작업엔 thread가 여전히 빠름.
vs CSP (Go channel)
CSP (Communicating Sequential Processes): Go의 goroutine + channel. Actor와 유사하지만:
- Actor: 메시지는 actor의 mailbox로.
- CSP: 메시지는 channel로 (actor에 매이지 않음).
Go의 채널은 더 유연하지만 actor의 "오너십" 모델이 없어 고려가 복잡할 수 있다.
vs Reactive Streams
Reactive Streams (RxJS, Reactor): 비동기 데이터 스트림 + backpressure.
Actor는 "개체" 중심, Reactive Streams는 "데이터 흐름" 중심. 서로 보완적이며 실제로 자주 조합된다.
vs Software Transactional Memory (STM)
STM (Clojure, Haskell): 메모리 접근을 트랜잭션처럼.
Actor는 STM이 필요 없다 (공유 메모리 없음). 둘 다 락 기반 동시성의 대안이지만 접근이 다르다.
vs Event-driven (Node.js)
Node.js는 단일 스레드 이벤트 루프. 한 번에 한 작업만.
Actor는 다중 actor가 병렬 실행 가능. 각 actor 내부는 순차적.
Node는 단순하고 이해하기 쉽지만 CPU 활용에 제한. Actor는 복잡도가 있지만 멀티코어 자유롭게.
12. 배우기와 실전
어디서 시작할까
Elixir + Phoenix 추천:
- Ruby/Python과 비슷한 친숙한 문법.
- 즉시 Erlang OTP의 모든 힘.
- Phoenix로 실전 프로젝트.
- "Programming Elixir" (Dave Thomas) 책.
- Phoenix LiveView로 실시간 웹.
Erlang 추천:
- "Learn You Some Erlang" (무료 온라인).
- "Programming Erlang" (Joe Armstrong).
- 더 이상 구식 튜토리얼 걱정 없이 OTP 전반.
Akka 추천:
- JVM 환경이면 자연스러운 선택.
- 단, 라이선스 이슈 (Pekko 고려).
- "Reactive Messaging Patterns with Akka" (자료 풍부).
Orleans 추천:
- .NET 환경.
- Microsoft 공식 지원.
- Azure와 통합.
실전 프로젝트 아이디어
- Chat application: WebSocket + actor. Phoenix LiveView 좋음.
- Game server: 플레이어마다 actor.
- Stock ticker: 심볼마다 actor.
- IoT device manager: 디바이스마다 actor.
- Task scheduler: 워커 풀.
- Metrics collector: 여러 소스에서 수집, 집계.
작게 시작해서 actor의 힘을 느껴보라. 공유 메모리 방식보다 얼마나 단순하고 안전한지 보일 것이다.
실전 사례
- WhatsApp: 2백만+ 연결 per server.
- Discord: 5백만+ 동시 사용자.
- Riot Games: League of Legends 채팅.
- Heroku: 라우팅 계층.
- Klarna: 금융 거래.
- Pinterest: 알림 시스템.
- Bleacher Report: 실시간 피드.
모두 Erlang 또는 Elixir를 사용한다. "niche 언어"가 아니다.
퀴즈로 복습하기
Q1. Actor 모델의 세 가지 기본 작업은 무엇이고, 왜 이것으로 "모든 계산"이 표현 가능한가?
A. Carl Hewitt의 원래 정의:
- Create: 새 actor 생성.
- Send: 다른 actor에게 메시지 전송.
- Become: 다음 메시지에 대한 동작 결정 (상태 변경).
왜 이것으로 충분한가:
"Become"이 핵심이다. Actor는 다음 메시지를 받기 전에 자신의 동작을 바꿀 수 있다. 이는 상태 기계와 동일하다.
예시: Counter
behavior_0 = 메시지 increment 받으면 behavior_1로
behavior_1 = 메시지 increment 받으면 behavior_2로
...
behavior_N = 메시지 get 받으면 송신자에게 N 전송
"상태가 N인 counter"는 "다음 메시지에 대해 특정 동작을 하는 actor"와 동등하다. Stateful computation이 actor로 표현된다.
Turing-completeness:
- Create로 재귀 가능 (자기 복제 actor).
- Send로 입출력 가능.
- Become으로 상태 가능.
- 조합으로 모든 λ-calculus 연산 가능.
따라서 actor 모델은 Turing-complete다. 어떤 계산도 표현 가능.
실전적 의미: Actor 모델의 단순함은 동시성을 관리 가능하게 만든다. 전통 프로그래밍의 락, 세마포어, 모니터는 모두 공유 메모리 문제를 풀려는 것. Actor는 공유 메모리 자체를 금지해서 문제를 없앤다.
"복잡한 문제를 어떻게 풀지" 대신 "문제를 아예 없애자"의 접근. 이것이 Hewitt의 철학적 기여다.
Q2. Erlang의 "Let it crash" 철학이 전통적 에러 처리보다 나은 이유는?
A. 전통적 에러 처리는 "에러를 예상하고 처리" 하려 한다:
try:
result = db.query(...)
except ConnectionError:
retry(3)
except Timeout:
return cached_value()
except DataCorruption:
log_and_skip()
except UnknownError: # 예상 못한 건?
raise # 프로세스 죽음
문제:
- 모든 에러를 예측 못함: 실제로 일어나는 에러의 30%가 "예상 못한" 것.
- 코드 복잡도 폭발: 행복 경로 20줄, 에러 처리 200줄.
- 일관성 잃음: 에러 후 상태가 어중간. 부분적으로 완료된 트랜잭션.
- 연쇄 실패: 한 에러가 다른 곳으로 전파.
- 재현 어려움: 특정 에러 시퀀스는 테스트 못함.
Let it crash 접근:
handle_request(Req) ->
Result = do_work(Req), % 에러 체크 없이
reply(Result).
에러가 나면 프로세스가 죽는다. Supervisor가 재시작. 새 프로세스는 깨끗한 상태.
이 접근의 이점:
- 코드가 단순: 행복 경로에 집중.
- 일관성: 죽은 프로세스는 어떤 상태도 남기지 않음 (격리된 메모리).
- 자연스러운 복구: 대부분의 에러가 "일시적". 재시작으로 해결.
- 가시성: 재시작 로그로 문제 패턴 파악.
- 내결함성 기본값: 모든 actor가 자동으로 "resilient".
"왜 재시작이 해결책인가":
실제 데이터:
- 네트워크 장애의 95%는 일시적 (수 초 내 복구).
- 디스크 쓰기 실패의 90%는 재시도로 해결.
- 외부 서비스 장애의 80%는 몇 초 후 복구.
"일단 재시작"은 실전에서 놀랍도록 잘 작동한다. Google SRE 책의 유명한 조언: "Have you tried turning it off and on again?".
전제 조건:
Let it crash가 작동하려면:
- 격리된 상태: 한 actor의 상태가 다른 것에 영향 없어야.
- Supervisor 계층: 자동 재시작 메커니즘.
- 재시작 한계: 무한 재시작 방지.
- Idempotent 설계: 재시작 후 같은 결과.
- Event sourcing (선택): 재시작 후 상태 복구.
Erlang은 언어 레벨에서 이 모두를 지원한다. 그래서 let it crash가 철학이 아니라 엔지니어링 원칙으로 작동한다.
다른 언어의 시도:
- Kubernetes: 컨테이너 레벨의 let it crash. Pod 실패 → 재시작.
- Erlang의 이념이 클라우드 네이티브로 진화. Kubernetes의 "self-healing" 개념이 정확히 이것.
교훈: 완벽을 포기하면 견고함이 생긴다. 모든 에러를 예방하려는 시도 자체가 복잡도를 낳고, 복잡도는 새로운 버그를 낳는다. 실패를 받아들이고 회복 메커니즘을 설계하는 것이 더 실용적이다.
Q3. Erlang 프로세스가 OS thread보다 1000배 가벼운 이유는?
A. 여러 요인이 결합되어 극도의 경량성을 달성한다:
1. 스택 크기:
- OS thread: 기본 스택 2~8 MB (변경 가능하지만 실제로 거의 안 함).
- Erlang process: 시작 시 ~233 words (약 1.8 KB). 필요하면 동적 확장.
1000개 OS thread = 8 GB 메모리. 1000개 Erlang process = 2 MB. 4000배 차이.
2. Context switch:
- OS thread: 커널 호출 필요. TLB flush, 레지스터 save/restore. 수 μs.
- Erlang process: BEAM VM 내부에서. 커널 호출 없음. 수십 ns.
3. 스케줄링 단위:
- OS: Kernel이 스케줄링. 수천 thread에서 오버헤드 급증.
- BEAM: User-space 스케줄러. 수백만 process 지원.
4. Independent Heap: 각 Erlang process가 자기 힙을 가진다:
- Per-process GC: 한 프로세스 GC는 다른 것 영향 없음.
- Small heap: 대부분 프로세스는 작은 데이터만. GC 빠름.
- No stop-the-world: JVM의 악몽 없음.
5. Share-nothing:
- 락 없음 (접근할 공유 상태 없음).
- 캐시 경합 없음.
6. Reduction-based scheduling:
- BEAM은 function call을 "reduction"으로 계산.
- 2000 reductions마다 preempt.
- Fair하면서 매우 빠른 스케줄링.
7. NIF 제외하면 순수 Erlang:
- FFI가 없으니 외부 복잡도 없음.
- 모든 것이 VM 내부에서 관리.
실측:
- WhatsApp: 단일 서버에서 2,000,000 connection.
- Phoenix: 2백만 WebSocket 연결 벤치마크.
- 100만 actor spawn: 수 초.
JVM과 비교:
- JVM thread: OS thread 기반. 비슷하게 무거움.
- JVM + Akka actor: 단일 JVM thread를 여러 actor가 공유. 중간 수준 경량.
- Goroutine (Go): 2 KB 시작. 비슷한 수준의 경량성.
언제 이 경량성이 중요한가:
- 많은 연결: WebSocket, 채팅, 게임 서버.
- 많은 독립 작업: IoT 디바이스 관리.
- Actor 기반 모델링: 각 entity를 actor로.
- 병렬 작업: 수만 개의 독립 계산.
왜 다른 VM은 이걸 못하는가:
JVM, V8 등은 OS thread 기반 모델을 선택했다. 다음과 호환되어야 했기 때문:
- 기존 라이브러리 (OS API 호출).
- 다른 언어와의 FFI.
- 복잡한 메모리 모델.
Erlang은 깨끗한 판에서 시작해서 경량 프로세스를 언어 레벨에 내장할 수 있었다. 이것이 Erlang의 독특한 역사적 유산이다.
교훈: 제약이 때론 해방이다. Erlang의 "많은 것을 못함" (공유 메모리, 직접 시스템 호출 등)이 "다른 것을 할 수 있게" 만들었다. 경량 프로세스는 자유의 대가가 아니라 제약의 결과다.
Q4. Supervision Tree가 분산 시스템의 내결함성을 어떻게 제공하는가?
A. Supervision tree는 장애를 계층적으로 격리하고 복구하는 메커니즘이다.
기본 구조:
Application
│
Main Supervisor
/ | \
Child Child Sub-Supervisor
/ | \
Child Child Child
각 supervisor는:
- 자식 actor들의 목록 유지.
- 자식의 실패 감지.
- 설정된 strategy에 따라 재시작.
Restart Strategies:
one_for_one: 실패한 자식만 재시작.
[A, B, C] 중 B 실패
→ B만 재시작
→ [A, B', C]
용도: 자식들이 독립적일 때.
one_for_all: 하나 실패하면 모두 재시작.
[A, B, C] 중 B 실패
→ 모두 재시작
→ [A', B', C']
용도: 자식들이 공유 상태에 의존할 때.
rest_for_one: 실패한 것부터 뒤의 모든 자식 재시작.
[A, B, C, D] 중 B 실패
→ B, C, D 재시작
→ [A, B', C', D']
용도: 순차적 의존성이 있을 때.
simple_one_for_one: 동적으로 생성되는 자식들.
- 미리 정의된 template으로 자식 생성.
- 각 자식은 독립.
- 용도: 수천 개의 비슷한 actor (예: 연결마다 하나).
재시작 한계:
{intensity, 10}, % 시간당 최대 10번 재시작
{period, 60} % 60초 동안
초과하면:
- Supervisor 자체가 죽음.
- 상위 supervisor에게 장애 전파.
- 연쇄적으로 올라가며 더 큰 재시작.
계층적 장애 복구의 힘:
작은 장애 → 작은 재시작 (하위 actor). 중간 장애 → 중간 재시작 (sub-supervisor 전체). 큰 장애 → 상위 재시작 (전체 서브 시스템).
치명적 장애 → 전체 애플리케이션 재시작 (최악 경우).
각 레벨에서 격리가 이뤄지므로 작은 에러는 작은 범위에서 처리된다.
예시: Chat 애플리케이션
ChatApp Supervisor
├── AuthService Supervisor
│ ├── User1 Session
│ ├── User2 Session
│ └── UserN Session ← 한 세션 실패 = 다른 세션 영향 없음
├── MessageRouter Supervisor
│ ├── RoomA Router ← RoomA 실패 = 전체 영향
│ ├── RoomB Router
│ └── RoomC Router
└── Storage Supervisor
├── Primary DB
├── Replica DB
└── Cache
- User1의 세션이 버그로 크래시 → User1 재연결. 다른 사용자 영향 없음.
- RoomA가 크래시 → RoomA 메시지 임시 실패. 다른 방 정상.
- Storage 전체 장애 → 애플리케이션 재시작 필요할 수 있음.
교훈: Let it crash + Supervisor = Fault Tolerance:
개별 actor: 죽는 걸 두려워하지 않음. Supervisor: 항상 준비된 복구 메커니즘. 계층 구조: 장애 영향 범위 제한. 재시작 한계: 무한 루프 방지.
이 조합이 실제로 작동하는 내결함성 시스템을 만든다. Erlang 시스템이 9 나인(99.9999999%) 가용성을 달성했다는 Ericsson의 주장 (AXD301 교환기)은 이 철학의 증명이다.
현대 적용:
- Kubernetes Pod restart: 간단한 supervisor 개념.
- Docker restart policy: 컨테이너 수준.
- Systemd: Linux 서비스 supervisor.
- PM2 (Node.js): 프로세스 supervisor.
Erlang이 1980년대에 해결한 문제를 클라우드 네이티브 세계가 재발견하고 있다. 아이디어는 변하지 않았다.
실전 설계 원칙:
- 얕게 계층화: 3~4 레벨이 적당. 너무 깊으면 복잡.
- 일시적 vs 영구적 실패 구분: 영구적 실패는 재시작으로 해결 안 됨.
- 초기화 로직 단순: Supervisor가 재시작할 때 빨라야.
- State 복구: 중요한 상태는 event sourcing 또는 DB로.
- Metric 수집: 재시작 빈도 모니터링 → 이상 감지.
Supervision tree는 완벽한 시스템을 만드는 방법이 아니라 불완벽한 시스템이 견고하게 동작하는 방법이다. 이것이 Erlang이 우리에게 준 가장 큰 선물 중 하나다.
Q5. Actor 모델과 Go의 goroutine+channel의 근본적 차이는?
A. 둘 다 "경량 동시성 + 메시지 기반 통신"이지만 철학이 다르다.
Actor 모델:
- Entity 중심: Actor가 "무엇"을 나타냄 (user, order, device).
- Actor 소유: 메시지는 특정 actor의 mailbox로.
- 주소 지정:
actorRef.send(msg)— 누구에게 보내는지 명시. - 공유 없음: Actor 외부에서 actor 내부 상태 접근 불가.
CSP (Go channel):
- Process 중심: Goroutine이 "작업"을 나타냄.
- Channel 소유: 메시지는 채널로 (누가 받을지 불분명).
- 간접 통신:
ch <- msg— 채널에 보내고 누가 읽든 상관없음. - 공유 가능: Go는 공유 메모리도 허용 (select와 함께).
철학 비교:
Erlang: "Do not share."
Actor ! Message % 특정 actor에게
Go: "Do not communicate by sharing memory; share memory by communicating."
ch <- message // 채널로
구체적 차이들:
1. 주소 모델
- Actor: 각 actor가 PID/ref를 가짐. "User #42"에게 메시지.
- Go: Channel이 "파이프" 역할. 누가 쓰고 누가 읽는지는 런타임에 결정.
2. 라이프사이클
- Actor: 명시적 생성/종료. Supervisor가 관리.
- Goroutine:
go f()로 시작, 함수 종료 시 자동 소멸. Supervisor 없음.
3. 에러 처리
- Actor: Let it crash + supervision.
- Go:
recover()+ defer. 또는 goroutine 죽으면 전체 프로세스 죽음.
4. 분산
- Actor: 로컬/원격 투명 (Erlang, Akka).
- Go: 로컬만. 분산은 별도 (gRPC 등).
5. 상태 관리
- Actor: 내부 상태 은닉.
- Go: 공유 상태 사용 가능 (sync.Mutex 등). 혼합 모델.
6. 타입 안전성
- Erlang: Dynamic typing.
- Akka: Typed actor (Akka 2.6+).
- Go: Static typing. 채널도 타입 있음 (
chan int).
7. 통신 패턴
Actor - Request-Reply:
response = GenServer.call(user_server, :get_profile)
- Call은 특정 actor에게.
- 응답은 caller에게 돌아옴.
Go - Fan-out:
results := make(chan Result, 10)
for _, job := range jobs {
go func(j Job) {
results <- process(j)
}(job)
}
- Channel로 여러 goroutine 결과 수집.
- "누가 처리할지" 자동.
어떤 것이 더 나은가:
Actor 선호:
- Entity 기반 도메인: 각 user, order, session이 actor.
- 내결함성 중요: Supervision 덕분.
- 분산: Location transparency.
- 장기 상태: 오래 사는 actor의 상태.
Go channel 선호:
- Pipeline 처리: 데이터 흐름.
- 단순한 동시성: 짧은 작업 여러 개.
- Fan-in/Fan-out: 여러 producer/consumer.
- 빠른 구현: 단순한 경우 코드가 더 짧음.
실전에서는 혼합:
많은 시스템이 두 패턴을 조합한다:
- Phoenix (Elixir): Actor가 기본, 하지만 내부엔 pipeline.
- Go 시스템: 채널이 기본, 필요시 actor 스타일 구조체.
학문적 관계:
- Actor 모델: 1973 Hewitt.
- CSP: 1978 Hoare.
- 같은 시대, 독립 개발.
- 둘 다 공유 메모리 대신 메시지를 강조.
- 차이: Actor는 identity 중심, CSP는 process 중심.
한 줄 요약:
- Actor: "이 entity(actor)에게 이 요청(message)을 보내".
- CSP: "이 데이터(msg)를 이 파이프(channel)에 넣어. 누가 받든".
두 관점 모두 유효하고 각자의 강점이 있다. 문제가 entity 관리라면 actor, 데이터 흐름이라면 channel이 자연스럽다. 현대 시스템은 두 개념을 모두 이해하고 적절히 사용하는 것이 이상적이다.
마치며: 메시지가 모든 것
핵심 정리
- Actor model: Create, Send, Become — 세 가지 작업.
- 공유 없음: 메시지만으로 통신. 락 없음.
- Erlang/OTP: 원조. 수백만 경량 프로세스.
- Let it crash: 실패를 받아들이고 재시작.
- Supervision tree: 계층적 내결함성.
- Akka: JVM의 actor. Lightbend 주도.
- Elixir: Erlang의 현대적 얼굴. Phoenix + LiveView.
- Orleans: .NET의 virtual actor.
- Location transparency: 로컬과 원격이 같은 코드.
언제 Actor 모델을 선택할 것인가
선택 시:
- 수천~수백만 동시 연결.
- 분산 시스템.
- 내결함성이 중요.
- Entity 기반 도메인 (user, game player, IoT device).
- Stateful 서비스.
피할 시:
- 단순 CRUD API.
- 계산 집약적 작업.
- 소규모 시스템.
- 팀 경험 없음.
마지막 교훈
Erlang은 1986년에 시작되어 40년 가까이 진화해 왔다. 그 동안 수많은 동시성 모델이 나왔다 사라졌다. Erlang은 여전히 현역이다.
이유? Joe Armstrong(Erlang 공동 창시자, 2019년 작고)의 말:
"너가 만드는 시스템이 10만 개의 연결을 처리해야 한다면, 넌 잘못된 언어를 쓰고 있을 수 있다."
WhatsApp은 2014년 $19 billion에 Facebook에 인수되었다. 단 55명의 엔지니어로 4억 5천만 사용자를 서비스했다. Erlang의 힘이 그 비율을 가능하게 했다.
Actor 모델은 패러다임 전환이다. "공유 메모리로 어떻게 동기화할까"에서 "메시지로 어떻게 협업할까"로. 이 전환이 어렵지만, 한 번 받아들이면 많은 문제가 단순해진다.
다음에 "많은 동시 사용자를 어떻게 처리?", "분산 시스템을 어떻게 신뢰성 있게 만들까?"라는 질문이 생기면, Actor 모델을 떠올려 보자. 1973년의 이 아이디어가 여전히 최고의 답 중 하나다.
참고 자료
- Erlang/OTP Official Documentation
- Learn You Some Erlang for Great Good! - 무료 온라인
- Programming Erlang (Joe Armstrong)
- Programming Elixir (Dave Thomas)
- Designing for Scalability with Erlang/OTP - Francesco Cesarini
- Akka Documentation
- Reactive Messaging Patterns with Akka
- Orleans Documentation
- Phoenix Framework
- Phoenix LiveView
- The Road to 2 Million Websocket Connections in Phoenix
- How Discord Scaled Elixir to 5M Concurrent Users
- Hewitt: Actor Model of Computation (1973 original)
- Joe Armstrong: Why Erlang Is Safe
현재 단락 (1/870)
2012년, WhatsApp의 한 엔지니어가 벤치마크를 공개했다: