액터 모델(Actor Model): 메시지로 소통하는 동시성 패러다임
멀티스레드 코드를 디버깅하다가 머리를 쥐어뜯어 본 적 있으신가요? 락이 어디서 잡혔다가 풀리는지, 어떤 스레드가 어떤 변수를 먼저 건드렸는지 추적하다 보면 어느새 날이 밝아 있곤 하죠. 사실 동시성 코드가 어려운 건 우리가 못 짠 게 아니라 공유 메모리 모델 자체가 사람의 머리로 추론하기 까다롭기 때문입니다.
그래서 오래전부터 “공유하지 말고 메시지로만 대화하자”는 다른 결의 모델을 제안한 사람들이 있었습니다. 그중에서도 1973년에 제안되어 Erlang과 함께 산업 현장에서 검증된 액터 모델(Actor Model)을 이번 글에서 살펴보겠습니다. 개념을 차근차근 짚어본 뒤, 마지막에는 Rust의 Tokio로 작은 액터를 직접 만들어보면서 감을 잡아보죠.
액터 모델이란 무엇인가
액터 모델은 1973년 Carl Hewitt가 MIT에서 제안한 동시성 계산 모델입니다. 당시 인공지능 연구를 하던 Hewitt는 “지능적인 시스템은 수많은 작은 행위자들이 메시지를 주고받으며 동작한다”는 직관에서 출발했는데요. 그 결과 태어난 것이 바로 “모든 것은 액터다”라는 단순하면서도 강력한 아이디어입니다.
액터(actor)는 동시성 시스템의 가장 작은 단위입니다. 객체 지향 언어의 “객체”와 비슷하게 들리지만 결정적인 차이가 하나 있어요. 객체는 다른 객체의 메서드를 직접 호출할 수 있지만, 액터는 오직 메시지로만 다른 액터와 소통합니다. 메서드 호출이 아니라 우편함에 편지를 던져 넣는 것에 가깝죠.
Hewitt의 정의에 따르면 액터는 메시지를 하나 받았을 때 정확히 세 가지 일을 할 수 있습니다. 우선 자기 내부 상태를 바꾸면서 다음 메시지를 처리할 동작을 결정할 수 있고, 다른 액터에게 메시지를 보낼 수 있으며, 새로운 액터를 만들어낼 수도 있습니다. 이 세 가지 원시 동작만으로 어떤 동시성 시스템도 표현할 수 있다는 게 액터 모델의 주장입니다.
이론으로만 머물 뻔한 이 모델은 1980년대 에릭슨에서 통신 장비를 만들기 위해 설계한 Erlang 언어를 통해 실전 검증을 받습니다. WhatsApp이 단 50명의 엔지니어로 9억 명의 사용자를 감당할 수 있었던 비밀 중 하나도 Erlang의 액터 모델이고, 디스코드가 1,200만 명 동시 접속을 처리하는 채널 서버도 Erlang의 후예인 Elixir로 짜여 있습니다.
메시지로 소통하는 격리된 단위
액터 모델의 가장 강력한 속성은 격리(isolation) 입니다. 모든 액터는 자기만의 상태(state)와 자기만의 메일박스(mailbox)를 가지고 있어요. 다른 액터의 상태에는 직접 접근할 수 없고, 메일박스에 메시지를 떨어뜨리는 것이 유일한 통신 수단입니다.
┌─────────────────────────┐
│ Actor A │
│ ┌─────┐ ┌──────────┐ │
│ │상태 │ │ 메일박스 │◀─┼── 메시지
│ └─────┘ └──────────┘ │
│ │ │ │
│ └──처리──┘ │
└─────────────────────────┘
메시지는 비동기로 전달되고, 보낸 쪽은 응답을 기다리지 않고 곧바로 다음 일을 합니다. 받은 쪽은 메일박스에 쌓인 메시지를 한 번에 하나씩만 꺼내서 처리하고요. 이 “한 번에 하나씩”이라는 규칙이 굉장히 중요한데요, 액터 내부 코드는 절대로 동시에 두 개의 메시지를 처리하지 않기 때문에 우리가 락이나 원자 연산을 쓸 일이 없어집니다. 동시성 문제의 절반은 여기서 사라지는 셈이죠.
기존 멀티스레드 코드에서는 여러 스레드가 같은 변수를 동시에 읽고 쓰지 못하도록 우리가 직접 가드를 세웠어야 했죠. 액터 모델에서는 그 가드가 모델 자체에 박혀 있습니다. 상태는 액터 안에 갇혀 있고, 외부에서 그 상태에 영향을 주려면 메시지라는 정해진 통로를 거쳐야만 합니다. 마치 잘 캡슐화된 객체에 멀티스레드 안전성이 무료로 따라오는 것과 비슷합니다.
위치 투명성과 슈퍼바이저 트리
액터에게 메시지를 보낼 때 우리는 그 액터의 주소(address) 만 알면 됩니다. 같은 프로세스 안에 있든, 다른 컴퓨터에 있든, 심지어 다른 데이터센터에 있든 우리 코드는 똑같아요. 이걸 위치 투명성(location transparency)이라고 부릅니다.
위치 투명성이 있으면 단일 머신에서 잘 도는 시스템을 클러스터로 확장할 때 큰 변경 없이 옮겨갈 수 있습니다. 액터 사이의 호출이 함수 호출이었다면 분산 환경으로 옮기는 순간 코드를 다시 짰어야 했겠지만, 처음부터 메시지로 대화했다면 그냥 메시지 라우터 뒤에 네트워크가 끼어들 뿐이죠. Erlang으로 짠 통신 장비가 수십 년간 다운타임 없이 돌아온 비결도 여기에 있습니다.
여기에 더해 액터 모델 진영에는 “Let it crash” 라는 독특한 철학이 있습니다. 보통의 코드는 모든 예외 상황을 미리 처리하려고 애쓰지만, Erlang/OTP는 정반대로 “예상하지 못한 상태에 빠지면 그냥 죽어라, 깨끗한 상태로 다시 띄워줄게”라고 말합니다. 그리고 이 약속을 지키는 것이 슈퍼바이저(supervisor)입니다.
슈퍼바이저는 다른 액터를 자식으로 두고 자식이 죽으면 미리 정의된 전략에 따라 재시작하는 특별한 액터인데요, 이런 슈퍼바이저들이 트리를 이루어 시스템 전체를 떠받칩니다. 한 액터가 잘못된 메시지를 받아 죽어도 그 영향은 형제 액터들에게 거의 미치지 않고, 슈퍼바이저가 깨끗하게 새 액터를 띄워주죠. “방어적 프로그래밍” 대신 “고립과 회복”으로 안정성을 얻는 발상의 전환입니다.
다른 동시성 모델과 비교
액터 모델이 어떤 자리에 있는지 다른 모델과 견주어보면 더 또렷해집니다. 우선 가장 익숙한 공유 메모리 + 락 모델은 같은 메모리를 여러 스레드가 들여다보면서 락으로 보호하는 방식이죠. 단일 머신에서 성능은 좋지만, 데드락과 레이스 컨디션이 끊임없이 발목을 잡고, 분산 환경으로 확장하기도 까다롭습니다.
Go로 대표되는 CSP(Communicating Sequential Processes) 모델은 액터 모델과 닮았으면서도 다릅니다. 둘 다 메시지 패싱을 쓰지만, CSP의 채널은 익명이고 액터는 식별자가 있습니다. Go에서는 채널을 만들고 그 채널을 아는 고루틴이라면 누구나 메시지를 넣을 수 있는 반면, 액터 모델에서는 액터 A의 주소를 가진 쪽이 A에게 메시지를 보냅니다. 그래서 CSP가 데이터 흐름 중심이라면 액터 모델은 실체(entity) 중심이라 객체 지향과 자연스럽게 어울립니다.
async/await는 또 다른 결의 도구입니다. async/await는 단일 스레드 안에서 I/O 대기 시간을 효율적으로 활용하는 데에 초점이 있고, 동시 실행되는 단위들이 어떻게 상태를 나눠 가질지에 대해서는 직접 답하지 않습니다. 액터 모델은 그 빈자리를 채우는 답 중 하나죠. 실제로 Rust에서는 async/await로 액터의 메시지 루프를 구현하는 게 자연스러운 패턴인데요, 이어지는 절에서 직접 만들어보겠습니다.
| 모델 | 통신 방식 | 단위의 정체성 | 분산 친화성 |
|---|---|---|---|
| 공유 메모리 + 락 | 메모리 직접 접근 | 스레드(익명) | 낮음 |
| CSP(채널) | 익명 채널 | 채널(데이터 흐름) | 보통 |
| 액터 모델 | 주소로 메시지 | 액터(엔티티) | 높음 |
| async/await | 보완 도구 | 태스크 | 중립 |
Rust로 미니 액터 구현하기
Rust 표준 라이브러리에는 액터 추상이 따로 없지만, Tokio의 mpsc와 oneshot 채널만 있으면 충분히 액터처럼 동작하는 단위를 만들 수 있습니다. mpsc와 oneshot이 각각 어떤 상황에 맞는지는 tokio::sync 도구 고르기에서 더 넓게 다뤘으니, 여기서는 카운터 액터를 하나 만들어보겠습니다. 이 액터는 Increment와 Get이라는 두 가지 메시지를 받습니다.
use tokio::sync::{mpsc, oneshot};
enum CounterMessage {
Increment,
Get(oneshot::Sender<u64>),
}
struct CounterActor {
receiver: mpsc::Receiver<CounterMessage>,
count: u64,
}
impl CounterActor {
fn new(receiver: mpsc::Receiver<CounterMessage>) -> Self {
Self { receiver, count: 0 }
}
async fn run(mut self) {
while let Some(msg) = self.receiver.recv().await {
match msg {
CounterMessage::Increment => self.count += 1,
CounterMessage::Get(reply) => {
let _ = reply.send(self.count);
}
}
}
}
}
#[derive(Clone)]
struct CounterHandle {
sender: mpsc::Sender<CounterMessage>,
}
impl CounterHandle {
fn new() -> Self {
let (tx, rx) = mpsc::channel(32);
tokio::spawn(CounterActor::new(rx).run());
Self { sender: tx }
}
async fn increment(&self) {
let _ = self.sender.send(CounterMessage::Increment).await;
}
async fn get(&self) -> u64 {
let (tx, rx) = oneshot::channel();
let _ = self.sender.send(CounterMessage::Get(tx)).await;
rx.await.unwrap_or(0)
}
}
#[tokio::main]
async fn main() {
let counter = CounterHandle::new();
let mut handles = vec![];
for _ in 0..100 {
let c = counter.clone();
handles.push(tokio::spawn(async move {
c.increment().await;
}));
}
for h in handles {
h.await.unwrap();
}
println!("최종 카운트: {}", counter.get().await);
}
최종 카운트: 100
이 코드의 핵심은 액터 본체와 핸들의 분리 입니다. CounterActor는 자신의 상태(count)와 메시지를 받을 mpsc 수신자를 들고 있는 비공개 타입이고, 외부에는 CounterHandle만 노출됩니다. 핸들은 Sender를 감싸고 있어서 마음껏 clone해서 여러 태스크에 나눠줄 수 있어요.
핵심 패턴 몇 가지를 짚어볼게요. 우선 액터의 run 메서드는 메시지를 하나씩 꺼내 처리하는 무한 루프인데, &mut self를 사용해도 안전합니다. mpsc::Receiver는 액터 본인만 가지고 있고 동시에 두 메시지가 처리될 수 없으므로 Mutex 같은 동기화가 전혀 필요 없는 거죠. 그리고 Get 메시지처럼 응답이 필요한 경우에는 oneshot 채널을 메시지 안에 함께 실어 보내서, 받은 쪽이 그 채널로 답장을 보내게 합니다. 이 요청-응답 패턴은 rust-tokio 글에서 살펴봤던 워커-요청자 구조의 일반화이기도 합니다.
100개의 태스크가 동시에 increment()를 호출했지만 락 없이도 결과는 정확히 100이죠. 액터가 한 번에 하나의 메시지만 꺼내 처리하기 때문에 그렇습니다. 직접 락을 거는 코드보다 보일러플레이트는 좀 더 들어가지만, 동시성 추론은 훨씬 단순해집니다.
본격적인 액터 프레임워크가 필요하면 Rust에는 actix가 있어서 슈퍼바이저나 비동기 응답, 라이프사이클 훅 같은 기능을 추가로 제공합니다. 다만 단순한 패턴이라면 위처럼 mpsc + oneshot만으로도 충분히 잘 동작합니다.
액터 모델이 만능은 아니다
여기까지 읽고 “그럼 모든 코드를 액터로 짜야겠네!”라고 생각하실 수 있는데요, 액터 모델에도 분명한 한계가 있습니다.
우선 디버깅이 까다롭습니다. 동기 함수 호출은 호출 스택만 따라가면 되지만, 액터 시스템에서는 메시지가 여러 메일박스를 거치며 비동기적으로 흘러가기 때문에 “이 메시지가 어디서 왔지?”를 추적하는 일이 만만치 않아요. 그래서 분산 트레이싱(distributed tracing) 같은 도구가 필수가 되는 경우가 많습니다.
메시지 순서도 신경 써야 합니다. 같은 액터에서 보낸 메시지들 사이의 순서는 대부분의 구현에서 보장되지만, 서로 다른 액터에서 같은 수신자로 보낸 메시지들은 순서가 뒤섞일 수 있어요. “A에게 X를 보내고 그게 처리된 다음에 Y를 보낸다”는 트랜잭션적 보장은 추가 설계 없이 그냥 얻어지지 않습니다.
또 하나 신경 쓸 부분은 백프레셔(back-pressure)입니다. 메시지를 받는 속도보다 보내는 속도가 빠르면 메일박스가 끝없이 부풀어 메모리를 다 잡아먹기도 하죠. Tokio의 mpsc처럼 버퍼 크기를 정해두는 채널을 쓰거나, 응답 기반의 흐름 제어를 명시적으로 설계해야 합니다.
마지막으로 단일 머신에서의 절대 성능은 잘 짜인 락 기반 코드보다 떨어질 수 있습니다. 메시지를 큐에 넣고 빼는 비용, 채널을 통한 컨텍스트 스위칭 비용이 무시할 수 없거든요. 액터 모델이 진가를 발휘하는 건 단일 성능보다 동시성 추론의 단순성, 장애 격리, 분산 확장성 이 더 중요한 영역입니다.
마치며
이번 글에서는 액터 모델이 무엇이고 왜 만들어졌는지, 다른 동시성 모델과 어떤 점에서 다른지를 쭉 따라가 봤습니다. 핵심은 상태를 격리하고 메시지로만 대화한다는 단순한 규칙이고요, 이 규칙 하나로 락이 사라지고 분산이 자연스러워지며 장애 격리까지 따라옵니다. 대신 디버깅이나 절대 성능에서는 비용을 치른다는 점도 함께 살펴봤죠.
Rust로 작은 액터를 만들어보긴 했지만, 액터 모델의 진짜 매력을 느끼고 싶다면 결국 Erlang이나 Elixir를 한 번쯤 만져보시는 걸 추천드립니다. 슈퍼바이저 트리와 hot code reload를 직접 써보면 “왜 통신 장비 회사들이 30년 동안 이걸 놓지 못했는지” 자연스럽게 와닿을 거예요.
더 깊은 이론적 배경이 궁금하시다면 Carl Hewitt의 정의가 정리된 Erlang 공식 사이트의 Getting Started를 참고해보시기 바랍니다.
This work is licensed under
CC BY 4.0