행위자 모델
더 많은 작업
- Actor model; 액터 모델; 행위자 모델
- 상태를 공유하지 않는 행위자들이 비동기 메시지만으로 협력하는 동시 계산 모델
행위자 모델(actor model, 액터 모델)은 행위자(actor)를 동시 계산의 기본 단위로 삼는 병행(concurrent) 계산의 수학적 모델이다. 행위자는 메시지를 받으면 그에 반응해 스스로 결정을 내리고, 다른 행위자에게 메시지를 보내고, 새 행위자를 만들고, 다음 메시지를 어떻게 처리할지 정한다. 행위자는 자기 상태만 바꿀 수 있고, 다른 행위자에게는 메시지를 통해서만 간접적으로 영향을 준다. 여기서 병행(concurrent, 동시) 계산은 여러 계산이 겹치는 시간 동안 함께 진행되는 것을 말하며, 여러 계산이 물리적으로 같은 순간에 실행되는 병렬(parallel) 계산과 구별된다. 공유 상태가 없으므로 공유 메모리와 잠금(lock)에 기대는 동시성 모델에서 고질적으로 생기던 교착상태나 경쟁 상태를 구조적으로 줄인다.
칼 휴잇(Carl Hewitt), 피터 비숍(Peter Bishop), 리처드 스타이거(Richard Steiger)가 1973년 MIT 인공지능 연구소에서 발표한 논문에서 처음 정식화했다.[1] 이론 연구의 틀이자 실제 시스템의 설계 원리로 쓰이며, Erlang·Elixir의 프로세스, Akka와 Apache Pekko, Microsoft Orleans, Swift의 actor, Ray의 액터 등 여러 언어와 프레임워크가 이 모델을 따른다.
행위자 모델의 실용적 의의는 개발자가 동시 처리 단위를 직접 스레드에 배치하지 않아도 된다는 데 있다. 개발자는 코드를 행위자라는 논리적 단위로 나누기만 하고, 런타임이 그 행위자들을 스레드와 코어, 나아가 여러 기계에 알맞게 배치해 실행한다. 한국어 번역어로는 "행위자 모델"이 쓰이지만, 업계에서는 "액터 모델"이라는 말이 더 흔하다.
행위자 모델은 "모든 것은 행위자다"라는 철학을 따른다. 일부 객체지향 언어의 "모든 것은 객체다"와 닮았다. 객체 지향 언어에서 행위자 모델을 구현할 때는 객체 하나를 행위자 하나로 보는 경우가 많다.
행위자는 받은 메시지에 반응해 다음 일을 할 수 있다. 세 가지 일 사이에 정해진 순서는 없고 동시에 일어나도 된다.[1]
- 유한한 수의 메시지를 다른 행위자(자기 자신 포함)에게 보낸다.
- 유한한 수의 새 행위자를 만든다.
- 다음에 받을 메시지에 쓸 행동(behavior)을 정한다.
세 번째 규칙이 상태를 표현하는 방법이다. 행위자는 변수를 덮어쓰는 대신 "다음 메시지는 이 상태로 처리한다"는 새 행동을 지정한다. 예컨대 잔액이 100인 계좌 행위자가 입금 50 메시지를 받으면, 다음 행동을 "잔액이 150인 계좌"로 바꾼다.
| 구성 요소 | 설명 |
|---|---|
| 행위자 | 자기만의 상태와 행동을 가진 계산 단위. 내부는 한 번에 메시지 하나씩 순차 처리한다 |
| 메시지 | 행위자 사이에 오가는 불변 데이터. 보내는 쪽은 받는 쪽의 응답을 기다리지 않고 보낸다(비동기) |
| 주소 | 메시지를 받을 행위자를 가리키는 식별자(메일 주소에 비유). 주소를 아는 행위자에게만 메시지를 보낼 수 있다 |
| 메일박스 | 도착한 메시지가 처리되기를 기다리는 곳. 대부분의 구현에서 행위자마다 하나씩 둔다 |
| 행동 | 메시지를 받았을 때 할 일을 정한 함수. 다음 메시지를 위한 새 행동을 돌려준다 |
- 공유 상태가 없다: 행위자의 상태는 그 행위자만 읽고 쓴다. 다른 행위자가 상태를 알고 싶으면 메시지로 물어봐야 한다. 행위자 내부는 한 번에 메시지 하나만 처리하므로 뮤텍스 같은 잠금이 필요 없다.
- 비동기 직접 통신: 보내는 쪽과 받는 쪽이 동시에 준비되어 있을 필요가 없다. IP 패킷처럼 메시지는 그냥 보내지며, 받는 쪽과 동기식으로 악수할 필요가 없다.
- 메시지 도착 순서를 보장하지 않는다: 휴잇은 보낸 순서대로 도착해야 한다는 조건을 모델에 넣는 데 반대했다. 행위자 X가 Y에게 M1, M2를 차례로 보내도 M1이 먼저 도착한다는 보장이 없다. 순서가 필요하면 큐 역할의 행위자로 따로 모델링한다. 패킷 교환망이 순서를 보장하지 않는 대신 버퍼링, 다중 경로, 재전송 같은 최적화를 할 수 있는 것과 같은 이치다. 실제 구현은 대개 같은 송신자·수신자 쌍 사이의 순서는 보장한다.
- 국소성: 메시지를 처리하는 동안 행위자가 메시지를 보낼 수 있는 곳은 받은 메시지에 들어 있던 주소, 원래 알고 있던 주소, 방금 만든 행위자의 주소뿐이다.
- 가변 위상: 새 행위자를 만들고 메시지에 주소를 담아 보낼 수 있으므로, 행위자 사이의 연결 구조가 실행 중에 자유롭게 바뀐다. 예를 들어 응답을 제3의 행위자가 처리하게 하려면 요청 메시지에 그 행위자의 주소(재개 지점, resumption)를 함께 넣으면 된다.
- 본래부터 동시적: 순차 프로세스를 조합하는 기존 방식과 달리, 행위자 모델에서는 동시성이 기본이고 순차 실행이 특수한 경우다.
- 위치 투명성: 주소만 알면 같은 프로세스 안의 행위자든 다른 기계의 행위자든 같은 방식으로 메시지를 보낸다. 이 덕분에 한 기계용 코드를 분산 시스템으로 넓히기 쉽다.
구현에서는 대개 다음처럼 동작한다.
- 보내는 행위자가 받는 행위자의 주소(참조)로 메시지를 보낸다. 보내기는 즉시 끝난다(tell, fire-and-forget).
- 메시지가 받는 행위자의 메일박스에 들어간다.
- 런타임 스케줄러가 메일박스에 메시지가 있는 행위자를 스레드에 올려 메시지를 하나씩 처리하게 한다. 수많은 행위자가 적은 수의 스레드를 나눠 쓴다.
- 행위자는 메시지를 처리하며 상태를 바꾸고, 다른 행위자에게 메시지를 보내거나 새 행위자를 만든다.
- 응답이 필요하면 요청 메시지에 자기 주소를 넣어 보내고, 응답도 메시지로 받는다(ask 패턴은 이를 퓨처로 감싼 것이다).
- 1973년: 휴잇, 비숍, 스타이거가 IJCAI에서 "A Universal Modular ACTOR Formalism for Artificial Intelligence"를 발표했다. 인공지능 언어 Planner의 제어 구조 문제를 다루다 나온 것으로, LISP, Simula, 초기 Smalltalk, 능력 기반(capability) 시스템, 패킷 교환에서 영향을 받았다.[1] 휴잇은 수십~수천 개의 독립 마이크로프로세서가 저마다 메모리를 가지고 고속 통신망으로 이어진 대규모 병렬 기계를 염두에 두었다.
- 이후 아이린 그라이프(Irene Greif)가 박사 논문에서 행위자 모델의 조작적 의미론(operational semantics)을 만들었고, 헨리 베이커(Henry Baker)와 휴잇이 행위자 시스템의 공리(법칙)를 발표했다. 윌리엄 클링어(William Clinger)의 1981년 논문이 멱영역(power domain)을 쓴 표시적 의미론을[2], 굴 아그하(Gul Agha)의 1985년 논문(1986년 MIT Press 단행본)이 전이 기반 의미론을 내놓으며 행위자 모델 이론이 틀을 갖추었다.[3]
- 무한 비결정성 논쟁: 에츠허르 데이크스트라(Edsger Dijkstra)의 전역 상태 기반 모델은 한정된 비결정성만 가지므로 "요청이 결국 처리된다"는 보장과 처리 지연이 무한할 수 있다는 성질을 함께 표현하지 못했다. 데이크스트라는 무한 비결정성을 구현할 수 없다고 보았지만, 휴잇은 중재 회로(arbiter)가 안정되는 데 걸리는 시간에 상한이 없다는 점을 들어 반박하고 행위자 모델에 서비스 보장을 넣었다. 클링어가 영역 이론으로 이를 수학적으로 정리했다.
- 메시지 버퍼링: 행위자 모델은 메시지가 반드시 버퍼에 쌓인다고 가정하지 않았다. 이 점은 당시 많은 오해를 낳았고 지금도 논쟁거리다.
- 이론적 영향: 로빈 밀너(Robin Milner)는 튜링상 강연에서 값, 값에 대한 연산자, 프로세스를 모두 같은 종류인 행위자로 보자는 휴잇의 착상이 π-계산법 설계에 영향을 주었다고 밝혔다.
- 1986년: 에릭슨 컴퓨터 과학 연구소에서 조 암스트롱(Joe Armstrong), 로버트 비르딩(Robert Virding), 마이크 윌리엄스(Mike Williams)가 전화 교환기용 언어 Erlang 개발을 시작했다. 암스트롱은 Erlang이 행위자 모델을 구현하려고 만든 것이 아니라 전화 교환 소프트웨어라는 구체적 문제를 풀다 나온 것이며, 영향은 Prolog(패턴 매칭), Smalltalk(메시지 전달), 데이크스트라의 보호 명령에서 받았다고 밝혔다. 비르딩도 당시 행위자 모델을 들어 보지 못한 것이 오히려 다행이었다고 말했다.[4] 그럼에도 Erlang은 결과적으로 가장 널리 쓰이는 행위자 방식 언어가 되었고, 1998년 오픈 소스로 공개되었다.[5]
- 2000년대 후반 이후: 멀티코어·매니코어 프로세서와 클라우드 분산 시스템이 퍼지며 행위자 모델이 다시 주목받았다. Scala 액터와 Akka(2009년 무렵 시작), Microsoft Orleans, Swift 5.5의 actor(2021) 등이 나왔다.
Erlang, Elixir, Scala 같은 언어가 행위자 모델에 기초한 동시성 기능을 제공한다. Scala는 한때 표준 라이브러리에 scala.actors를 두었으나 2.10 무렵부터 Akka를 쓰도록 바뀌었고, 이후 표준 라이브러리에서 빠졌다.
토니 호어(Tony Hoare)의 CSP(Communicating Sequential Processes, 1978)는 행위자 모델과 함께 메시지 전달 동시성의 양대 모델로 꼽힌다.[6] 초기 CSP는 고정된 수의 순차 프로세스가 고정된 위상으로 연결되어, 프로세스 이름을 대상으로 동기식 메시지를 주고받는 모델이었다. 이후 CSP와 밀너의 CCS·π-계산법은 이름 대신 익명 채널을 쓰는 방향으로 바뀌었고, Go의 고루틴과 채널이 이 계열이다.
| 구분 | 행위자 모델 | CSP(현대적 채널 방식) |
|---|---|---|
| 통신 대상 | 이름(주소)이 있는 행위자에게 직접 | 이름 없는 채널을 거쳐 |
| 전송 방식 | 비동기: 보내고 곧바로 계속 | 기본은 동기(랑데부): 받는 쪽이 받을 때까지 대기. 버퍼 채널도 있음 |
| 메시지 보관 | 행위자마다 메일박스 | 채널(버퍼 크기 지정) |
| 연결 구조 | 행위자 생성과 주소 전달로 동적으로 변함 | 초기 CSP는 고정, 현대 구현은 채널을 값으로 넘겨 동적 |
| 비결정성 | 무한 비결정성 | 초기 모델은 한정 비결정성, 이후 이론은 무한 비결정성도 다룸 |
| 분산 | 위치 투명성으로 여러 기계로 확장하기 쉬움 | 주로 한 프로세스 안의 동시성 |
| 예 | Erlang, Akka, Orleans | Go 채널, Clojure core.async, Occam |
페트리 넷(Petri net)은 비동기 메시지 전달과 무한 비결정성이라는 점에서 행위자와 닮았지만, 기본 처리 단계와 메시지 저장소의 위상이 고정되어 있다는 점에서는 초기 CSP와 닮았다.
Erlang의 프로세스가 곧 행위자다. 운영체제 프로세스나 스레드가 아니라 BEAM 가상 머신이 관리하는 가벼운 실행 단위로, 필요에 따라 크기가 늘고 줄며 만들고 없애는 비용과 스케줄링 부담이 작아 수십만~수백만 개를 동시에 돌릴 수 있다. 프로세스마다 힙이 따로 있어 한 프로세스의 가비지 컬렉션이 다른 프로세스를 멈추지 않는다. 한 프로세스가 같은 대상에게 보낸 신호(메시지)는 보낸 순서대로 도착한다는 것이 보장된다.[7]
-module(counter).
-export([start/0, loop/1]).
start() -> spawn(counter, loop, [0]). % 새 행위자(프로세스) 생성
loop(N) ->
receive % 메일박스에서 메시지 하나를 꺼냄
{inc, From} ->
From ! {ok, N + 1}, % 보낸 쪽에 응답 메시지
loop(N + 1); % 다음 행동: 상태가 N+1인 loop
stop ->
ok
end.
%% 사용: Pid = counter:start(), Pid ! {inc, self()}, receive {ok, V} -> V end.
Erlang의 신뢰성은 오류 처리 방식에서 나온다. 링크(link)로 이어진 프로세스는 한쪽이 죽으면 다른 쪽에 종료 신호가 가고, 모니터(monitor)는 한쪽 방향으로만 상대의 종료를 지켜본다.[7] OTP(Open Telecom Platform)는 이를 바탕으로 한 미들웨어와 라이브러리 모음이며, 에릭슨의 AXD301 ATM 교환기 같은 제품에서 검증되었다.[8]
OTP의 핵심은 감독자(supervisor)다. 감독자는 자식 프로세스를 시작·중지·감시하며, 자식이 죽으면 다시 시작해 살려 둔다. 감독자 밑에 다시 감독자를 두어 만든 계층을 감독 트리(supervision tree)라고 한다.[9]
| 재시작 전략 | 자식 하나가 죽으면 |
|---|---|
| one_for_one | 그 자식만 다시 시작 |
| one_for_all | 나머지 자식을 모두 끝내고 전부 다시 시작 |
| rest_for_one | 그 자식보다 뒤에 시작한 자식들을 끝내고, 그 자식과 함께 다시 시작 |
| simple_one_for_one | 같은 종류의 자식을 동적으로 추가하는 단순화된 one_for_one |
자식마다 permanent(항상 재시작), transient(비정상 종료일 때만), temporary(재시작 안 함)를 정하고, 정해진 시간 안에 재시작이 너무 잦으면 감독자 자신도 죽어 위 감독자에게 판단을 넘긴다.[9] 이런 구조 때문에 Erlang에서는 오류를 방어 코드로 모두 처리하려 하기보다 프로세스를 그냥 죽게 두고 감독자가 깨끗한 상태로 다시 시작하게 하는 설계가 권장되며, 이를 "죽게 내버려 둬라(let it crash)"라는 표어로 부른다. 같은 BEAM 위의 Elixir(GenServer, Supervisor), Gleam도 이 모델을 그대로 쓴다.
Akka는 JVM(Scala·Java)용 행위자 툴킷으로, 2009년 무렵 요나스 보네르(Jonas Bonér)가 시작한 것으로 알려져 있으며 Lightbend(옛 Typesafe)가 개발했다. 행위자 계층과 감독, 원격·클러스터 행위자, 영속성(이벤트 소싱), 스트림, HTTP 모듈을 제공한다. 최신 API인 Akka Typed는 행위자가 받을 메시지 타입을 컴파일 시점에 검사한다.
import akka.actor.typed.{ActorRef, Behavior}
import akka.actor.typed.scaladsl.Behaviors
object Counter {
sealed trait Command
case object Increment extends Command
final case class Get(replyTo: ActorRef[Int]) extends Command
def apply(n: Int = 0): Behavior[Command] =
Behaviors.receiveMessage {
case Increment => apply(n + 1) // 다음 행동: 상태 n+1
case Get(replyTo) => replyTo ! n; Behaviors.same // 응답 메시지를 보내고 행동 유지
}
}
2022년 9월 Lightbend는 Akka 2.7부터 라이선스를 Apache License 2.0에서 Business Source License(BSL) 1.1로 바꾼다고 발표했다. BSL은 개발·테스트에는 자유롭게 쓸 수 있지만 운영 환경에서 쓰려면 상용 라이선스가 필요하며(연 매출 2500만 달러 미만 기업은 면제), 각 버전은 3년이 지나면 Apache 2.0으로 돌아간다.[10] 이에 대응해 커뮤니티는 BSL 전환 이전의 Akka 2.6.x를 포크한 Apache Pekko를 만들었고, Pekko는 아파치 인큐베이터를 거쳐 2024년 5월 아파치 최상위 프로젝트가 되었다.[11] .NET용 이식판으로는 Akka.NET이 있다.
Microsoft Orleans는 행위자 모델에 기반한 .NET 분산 프레임워크로, 가상 행위자(virtual actor)라는 추상을 처음 내놓았다. 가상 행위자는 논리적으로 언제나 존재하는 개체다. 명시적으로 만들거나 없앨 수 없고, 실행 중이던 서버가 죽어도 가상의 존재는 영향을 받지 않으므로 언제나 주소로 부를 수 있다. Orleans에서는 가상 행위자를 그레인(grain)이라 부르며, 그레인은 사용자가 정한 식별자·행동·상태로 이루어진다. 런타임이 호출이 오면 그레인을 자동으로 활성화하고, 한동안 쓰이지 않으면 메모리에서 내리며, 그레인을 올리는 서버를 사일로(silo)라고 한다. Microsoft는 Azure, Xbox, Skype, Halo, Gears of War 등의 서비스에 Orleans를 쓴다.[12] Dapr도 가상 행위자 패턴을 구현해, 처음 요청이 오면 행위자를 활성화하고 쓰지 않는 행위자는 메모리에서 정리하며, 행위자 메서드에 한 번에 한 호출씩 접근하는 턴 기반(turn-based) 동시성을 제공한다.[13]
분산 컴퓨팅 프레임워크 Ray에서 액터는 상태를 가진 작업자(서비스)다. @ray.remote를 붙인 클래스를 인스턴스로 만들면 전용 작업자 프로세스가 생기고, 서로 다른 액터의 메서드는 병렬로 돌지만 같은 액터에 부른 메서드는 호출한 순서대로 하나씩 실행된다.[14] 자세한 내용은 레이 리모트 액터 문서를 참고한다.
import ray
@ray.remote
class Counter:
def __init__(self):
self.n = 0
def inc(self):
self.n += 1
return self.n
c = Counter.remote() # 원격 작업자 프로세스에 액터 생성
print(ray.get(c.inc.remote())) # 1 (메서드 호출은 ObjectRef를 돌려준다)
- Swift: 5.5(2021)에서 async/await, 구조적 동시성과 함께 actor 타입이 들어왔다.[15] actor 안의 변경 가능한 상태는 행위자 격리(actor isolation) 규칙으로 보호되며, 바깥에서는 await를 붙여 비동기로만 접근할 수 있다.
actor BankAccount {
private var balance = 0
func deposit(_ amount: Int) { balance += amount }
func current() -> Int { balance }
}
let account = BankAccount()
await account.deposit(100) // 바깥에서는 await로만 접근
print(await account.current()) // 100
- Pony: 행위자와 참조 능력(reference capability) 타입 시스템으로 데이터 경쟁이 없음을 컴파일 시점에 보장한다.
- 그 밖에 Dart의 isolate(메모리를 공유하지 않고 메시지로 통신), C++ Actor Framework(CAF), Proto.Actor(Go·.NET), Microsoft의 비동기 에이전트 라이브러리(Asynchronous Agents Library) 등이 있다. 초기 행위자 언어로는 Act 1·2·3, Acttalk, ABCL, Rosette, SALSA 등이 있었다.
| 장점 | 단점 |
|---|---|
| 공유 가변 상태와 잠금이 없어 데이터 경쟁과 교착상태를 구조적으로 줄인다 | 행위자끼리 서로 응답을 기다리면 메시지 수준의 교착상태는 여전히 생길 수 있다 |
| 행위자 하나는 순차 코드라 이해하기 쉽다 | 전체 흐름이 여러 행위자의 비동기 메시지로 흩어져 추적·디버깅이 어렵다 |
| 위치 투명성으로 한 기계에서 클러스터로 확장하기 쉽다 | 메시지 유실, 중복, 순서 뒤바뀜을 설계에서 고려해야 한다(대개 최대 한 번 전달) |
| 감독 트리로 장애를 격리하고 자동 복구한다(결함 허용) | 메시지 복사·직렬화 비용이 있고, 공유 메모리 방식보다 지연이 크다 |
| 수백만 개의 가벼운 행위자로 연결·세션·기기마다 행위자를 둘 수 있다 | 느린 행위자의 메일박스가 무한히 쌓이면 메모리가 고갈되므로 배압(back-pressure)이나 제한이 필요하다 |
| 이벤트 소싱과 잘 맞아 상태 복원이 쉽다 | 여러 행위자에 걸친 트랜잭션·일관성은 사가(saga) 같은 별도 패턴이 필요하다 |
행위자 하나는 한 번에 메시지 하나만 처리하므로, 요청이 몰리는 행위자는 병목이 된다. 또 이론상 행위자 각각은 단순하고(상태 기계, 메시지와 행동, 내부 값) 행위자끼리는 위계 없이 대등하지만, 실제 시스템을 쓸모 있는 전체로 만들려면 비동기성과 비결정성을 조율할 구조가 필요하다. 감독 트리, 라우터, 클러스터 샤딩 같은 구현상의 조직화 장치가 그 역할을 한다.
- 통신 교환기, 메시징·채팅 서버처럼 연결이 많고 멈추면 안 되는 시스템(Erlang/OTP).
- 온라인 게임 서버: 플레이어, 방, 아이템마다 행위자를 둔다(Orleans).
- 사물 인터넷: 기기마다 디지털 트윈 행위자를 둔다.
- 금융 거래, 주문 처리, 장바구니처럼 개체별 상태를 가진 서비스.
- 분산 머신 러닝: 매개변수 서버나 시뮬레이터를 상태 있는 액터로 둔다(Ray).
- 모델링: 전자 우편은 계정을 행위자, 메일 주소를 행위자 주소로 보는 행위자 시스템으로 모델링할 수 있고, SOAP 끝점을 주소로 보면 웹 서비스도 행위자로 볼 수 있다. 시험 표기법 TTCN-3의 시험 구성 요소도 행위자 모델을 가깝게 따른다.
- ↑ 1.0 1.1 1.2 Carl Hewitt, Peter Bishop, Richard Steiger, "A Universal Modular ACTOR Formalism for Artificial Intelligence", IJCAI 1973
- ↑ William D. Clinger, "Foundations of Actor Semantics", MIT AI-TR-633, 1981
- ↑ Gul Agha, "Actors: A Model of Concurrent Computation in Distributed Systems", MIT Press, 1986
- ↑ Joe Armstrong, "Erlang is *not* a implementation of the Actor model", erlang-questions 메일링 리스트, 2014
- ↑ Joe Armstrong, "A History of Erlang", HOPL III, 2007
- ↑ C. A. R. Hoare, "Communicating Sequential Processes", CACM 21(8), 1978
- ↑ 7.0 7.1 Processes, Erlang System Documentation
- ↑ About Erlang/OTP
- ↑ 9.0 9.1 Supervisor Behaviour, Erlang System Documentation
- ↑ Why We Are Changing the License for Akka, Akka 블로그
- ↑ The Apache Software Foundation Announces New Top-Level Project Apache Pekko
- ↑ Orleans overview, Microsoft Learn
- ↑ Actors overview, Dapr Docs
- ↑ Actors, Ray Documentation
- ↑ Swift 5.5 Released!