사전 액터 모델
패턴

액터 모델

gabury1고친 사람 github-actions[bot]

액터 모델은 여러 일을 동시에 돌릴 때 같은 값을 여럿이 건드리지 않게 해 줍니다. 프로그램을 액터라는 작은 단위로 나눠 메시지만 주고받게 합니다. 각 액터는 자기 값을 혼자 쥔 채 받은 메시지를 하나씩 처리합니다. 그래서 잠금 없이도 값이 엉키지 않습니다.

쉽고 빠른 이해

액터 모델은 동시에 도는 코드끼리 값을 나눠 쓰지 않고 메시지로만 일을 부탁하게 하는 설계입니다. 계좌 하나를 액터 하나가 맡으면 입금과 출금은 그 액터에게 보내는 메시지가 됩니다.

여러 스레드가 같은 변수를 동시에 고치면 값이 엉킵니다. 막으려고 잠금을 걸면 서로 기다리다 멈추는 일이 생깁니다. 액터 모델은 값을 한 액터만 만지게 해서 이 문제를 처음부터 없앱니다.

어떻게 도는가:

  1. 액터마다 메시지가 쌓이는 우편함이 있습니다
  2. 액터는 우편함에서 메시지를 하나 꺼내 끝까지 처리한 뒤 다음 것을 꺼냅니다
  3. 다른 액터의 도움이 필요하면 메시지를 보내 두고 답을 기다리지 않습니다

채팅방, 게임 캐릭터, 접속한 기기처럼 제각각 바뀌는 상태가 많을 때 잘 맞습니다. 하나마다 액터 하나를 두면 서로 잠금을 걸 일이 없습니다.

대가가 있습니다. 한 요청의 흐름이 여러 메시지로 흩어져 따라가기 어렵습니다. 한 액터에 메시지가 몰리면 우편함이 쌓여 느려집니다. 여러 액터의 값을 한 번에 바꾸는 일은 직접 설계해야 합니다.

상세

큰 사무실에 담당자가 여럿 있습니다. 담당자마다 자기 서류함을 둡니다. 남의 서류함은 열 수 없습니다. 일을 부탁하려면 그 담당자의 우편함에 쪽지를 넣고 돌아갑니다. 담당자는 쪽지를 한 장씩 꺼내 처리합니다.

이 모델에서 담당자가 액터(actor)입니다. 서류함은 액터가 혼자 쥔 값이고, 쪽지는 메시지입니다. 액터는 메시지를 받아 처리하는 가장 작은 계산 단위입니다. 액터 모델은 프로그램 전체를 이런 액터들이 메시지를 주고받는 모습으로 짭니다.

공유 메모리가 부르는 문제

서버는 요청 여러 개를 겹쳐 처리합니다. 여러 일을 겹쳐 진행하는 성질을 동시성이라고 합니다. 동시성을 얻는 흔한 방법은 스레드를 여럿 띄우는 것입니다. 스레드는 한 프로그램 안에서 따로 진행되는 실행 흐름입니다.

스레드들은 같은 메모리를 함께 봅니다. 이렇게 값을 여럿이 나눠 쓰는 방식을 공유 메모리 방식이라고 합니다. 두 스레드가 같은 변수를 동시에 고치면 한쪽이 고친 값이 사라질 수 있습니다. 실행 순서에 따라 결과가 달라지는 이 상황을 경쟁 상태라고 부릅니다.

흔한 처방은 상호 배제입니다. 한 번에 한 스레드만 그 변수를 고치도록 뮤텍스 같은 잠금을 겁니다. 잠금은 빠뜨리기 쉽습니다. 두 스레드가 서로 상대의 잠금이 풀리기를 기다리며 영영 멈추는 데드락도 생깁니다.

액터 모델은 방향을 바꿉니다. 값을 나눠 쓰지 않습니다. 값마다 주인 액터가 하나 있습니다. 다른 쪽은 그 주인에게 메시지로 부탁만 합니다. 누구도 남의 값을 직접 만지지 않으니 잠금이 필요 없습니다.

액터를 이루는 세 부분

액터 하나는 세 부분으로 이루어집니다. 그중 메시지가 쌓이는 우편함을 메일박스(mailbox)라고 부릅니다. 메일박스는 큐입니다. 큐는 먼저 들어온 것이 먼저 나가는 줄입니다.

부분 무엇인가 왜 필요한가
상태 액터가 혼자 쥔 값 남이 못 만지게 가둬 둔다
메일박스 들어온 메시지가 줄을 서는 큐 메시지가 한꺼번에 몰려도 하나씩 처리하게 한다
행동 메시지를 받았을 때 할 일 같은 메시지라도 지금 상태에 따라 다르게 처리한다

상태는 계좌 액터라면 잔액입니다. 메일박스에는 「100원 입금」 같은 메시지가 줄을 섭니다. 행동은 「입금 메시지를 받으면 잔액에 더한다」 같은 처리 규칙입니다.

메시지를 받은 액터가 할 수 있는 일

메시지 하나를 처리하는 동안 액터가 할 수 있는 일은 셋입니다. 이 셋만으로 모든 계산을 짜는 것이 액터 모델의 뼈대입니다.

주소는 메시지를 보낼 대상을 가리키는 값입니다. 액터를 만든 쪽이 새 액터의 주소를 받습니다. 주소는 메시지에 실어 다른 액터에게 건넬 수도 있습니다.

  1. 주소를 아는 다른 액터에게 메시지를 보냅니다
  2. 새 액터를 만듭니다
  3. 다음 메시지를 받을 때의 행동을 정합니다. 앞의 표에서 본 그 행동입니다

3번에는 자기 상태를 바꾸는 일이 듭니다. 계좌 액터가 잔액을 100원에서 200원으로 바꾸면 다음 출금 메시지가 다르게 처리됩니다. 그래서 상태를 바꾸는 것도 다음 행동을 정하는 일입니다.

남의 상태를 읽거나 고치는 동작은 이 목록에 없습니다. 남에게 무언가를 시키려면 언제나 메시지를 보내야 합니다.

한 번에 메시지 하나

여러 액터가 한 액터에게 동시에 메시지를 보내도 메시지는 메일박스에 줄을 섭니다. 액터는 하나를 끝까지 처리한 뒤 다음을 꺼냅니다. 아래 그림은 주문 액터와 결제 액터가 계좌 액터 하나에 메시지를 보내는 모습입니다.

flowchart TD
    subgraph 보내는쪽["보내는 쪽"]
        A["주문 액터"]
        B["결제 액터"]
    end
    subgraph 메일박스["계좌 액터의 메일박스"]
        M1["메시지 1 · 맨 앞"] ~~~ M2["메시지 2"] ~~~ M3["메시지 3 · 맨 뒤"]
    end
    A -->|보낸다| 메일박스
    B -->|보낸다| 메일박스
    메일박스 -->|하나씩 꺼낸다| C["계좌 액터 · 잔액"]

메시지는 동시에 들어와도 처리는 한 줄로 이어집니다. 그래서 액터 안의 코드는 한 흐름으로만 돕니다. 잔액을 고치는 코드에 잠금이 필요 없는 까닭입니다.

동시성은 액터 사이에서 생깁니다. 서로 다른 액터들은 나란히 돌 수 있습니다. 한 액터 안에서는 순서대로, 여러 액터 사이에서는 동시에 돕니다.

액터 하나가 스레드 하나를 차지하지는 않습니다. 대개 런타임이 적은 수의 스레드로 많은 액터를 돌아가며 실행합니다. 런타임은 프로그램 안에서 실행을 관리하는 코드입니다. 메시지가 없는 액터는 스레드를 쓰지 않고 기다립니다.

카운터 액터 한 벌

파이썬 표준 라이브러리만으로 흉내 낸 카운터 액터입니다. 스레드 하나가 큐에서 메시지를 꺼내 처리합니다. 뒤에서 볼 Erlang·Akka 같은 도구는 실패한 액터를 다시 띄우거나 다른 서버로 메시지를 보내는 일까지 합니다. 뼈대는 이 코드와 같습니다.

Python
import queue, threading

class Counter:
    def __init__(self):
        self.count = 0
        self.mailbox = queue.Queue()
        threading.Thread(target=self.run, daemon=True).start()

    def send(self, msg, reply=None):
        self.mailbox.put((msg, reply))

    def run(self):
        while True:
            msg, reply = self.mailbox.get()
            if msg == "inc":
                self.count += 1
            elif msg == "get":
                reply.put(self.count)

count 가 이 액터의 상태입니다. 이 값을 고치는 코드는 run 안에만 있습니다. 밖에서는 send 로 메일박스에 메시지를 넣을 수만 있습니다.

Python
c = Counter()
for _ in range(3):
    c.send("inc")
r = queue.Queue()
c.send("get", r)
print(r.get())  # 3

inc 세 개를 보낸 뒤 get 을 보냅니다. 같은 쪽이 보낸 메시지는 보낸 순서대로 큐에 섭니다. 그래서 get 은 셋을 다 센 뒤에 처리됩니다. 답은 메시지와 함께 넘긴 큐 r 로 돌아옵니다.

여기서 r.get() 으로 답을 기다리는 쪽은 액터가 아니라 예제를 부르는 메인 코드입니다. 큐 r 은 그 코드가 답을 받는 임시 우편함 구실을 합니다.

보내고 기다리지 않는다

메시지를 보내는 쪽은 답을 기다리지 않습니다. 보내자마자 다음 일로 넘어갑니다. 이런 전송을 비동기 전송이라고 합니다.

기다리지 않으면 보내는 액터가 그동안 다른 메시지를 처리할 수 있습니다. 답이 필요하면 받는 쪽이 답을 새 메시지로 돌려보냅니다. 아래 그림에서 주문 액터는 재고 액터에게 묻고 바로 다음 일을 합니다.

sequenceDiagram
    participant 주문 as 주문 액터
    participant 재고 as 재고 액터
    주문->>재고: 재고 확인 요청
    Note over 주문: 기다리지 않고 다음 메시지를 처리한다
    재고-->>주문: 재고 있음
    Note over 주문: 답도 메일박스에서 하나의 메시지로 꺼낸다

메시지가 도착하는 순서

여러 액터가 한 액터에게 메시지를 보내면 메일박스에 서는 순서를 따져야 합니다. 서로 다른 액터가 보낸 메시지 사이의 순서는 약속되지 않습니다. 같은 쪽이 보낸 메시지끼리는 많은 구현이 보낸 순서를 지켜 줍니다. 앞의 카운터 예제에서 get 이 inc 셋 뒤에 처리된 것도 같은 쪽이 보낸 메시지였기 때문입니다.

주소만 알면 어디로든 보낸다

보내는 쪽은 받는 액터의 주소만 압니다. 받는 액터가 같은 프로그램 안에 있든 다른 서버에 있든 보내는 코드는 같습니다. 이 성질을 위치 투명성이라고 합니다.

액터 모델이 분산 시스템으로 쉽게 번지는 까닭이 이것입니다. 액터를 여러 서버에 흩어 놓아도 코드 모양이 안 바뀝니다. 대신 네트워크를 건너는 메시지는 사라질 수 있습니다. 메시지가 안 올 수 있다는 가정을 코드에 담아야 합니다.

액터 모델을 따르는 언어와 도구

Erlang 은 언어 자체가 이 방식으로 짜여 있습니다. Erlang 에서는 액터를 프로세스라고 부릅니다. 운영체제가 만드는 프로세스와는 다릅니다. 언어 런타임이 돌리는 작은 실행 단위입니다. 이 프로세스들은 메시지로만 서로 말합니다.

Akka 는 JVM(Java Virtual Machine, 자바 가상 머신) 위에서 액터를 쓰게 해 주는 도구 모음입니다. 자바나 스칼라 코드로 액터를 만들고 메시지를 보냅니다.

앞에서 본 대로 액터는 메시지를 처리하다 새 액터를 만들 수 있습니다. 새 액터를 만든 액터를 부모, 만들어진 액터를 자식이라고 부릅니다.

두 도구 모두 자식 액터가 실패하면 부모 액터가 그 소식을 받게 합니다. 부모는 실패한 자식을 다시 띄우거나 멈춥니다. 이런 부모와 자식의 계층을 흔히 감독 트리라고 부릅니다.

쓰면 나빠지는 것

첫째는 흐름을 따라가기 어렵다는 것입니다. 한 요청이 여러 액터 사이의 메시지로 쪼개집니다. 함수 호출처럼 한 줄기 호출 기록이 남지 않습니다. 오류가 났을 때 어느 메시지에서 시작됐는지 찾기 어렵습니다.

둘째는 메일박스가 쌓인다는 것입니다. 받는 액터가 보내는 쪽보다 느리면 메시지가 계속 쌓입니다. 보내는 쪽은 기다리지 않으니 스스로 멈추지 않습니다. 보내는 속도를 받는 쪽에 맞춰 늦추는 장치를 둬야 합니다. 이 장치를 백프레셔라고 합니다.

셋째는 여러 액터의 상태를 한 번에 바꾸기 어렵다는 것입니다. 계좌 A 액터에서 계좌 B 액터로 송금하면 두 액터가 따로 메시지를 처리합니다. 중간에 실패하면 한쪽만 바뀐 채 남습니다. 두 값을 함께 바꾸거나 함께 안 바꾸는 트랜잭션이 액터 사이에는 없습니다. 되돌리는 메시지를 직접 설계해야 합니다.

넷째는 기다림의 고리가 여전히 생길 수 있다는 것입니다. 두 액터가 서로의 답을 받아야만 다음으로 넘어가게 짜면 둘 다 멈춥니다. 잠금은 없어도 데드락과 같은 모양이 됩니다.

다섯째는 메시지에 담는 값입니다. 메시지로 고칠 수 있는 객체를 넘기면 두 액터가 같은 객체를 만지게 됩니다. 공유 메모리의 문제가 그대로 돌아옵니다. 그래서 메시지에는 만든 뒤 못 고치는 불변 객체를 흔히 씁니다.

CSP 와 다른 점

메시지로 동시성을 다루는 방식이 하나 더 있습니다. 바로 CSP(Communicating Sequential Processes)입니다. 우리말로는 통신하는 순차 프로세스라고 옮깁니다.

CSP 를 따르는 대표 언어가 Go 입니다. Go 에서는 런타임이 고루틴이라는 실행 흐름을 적은 수의 스레드 위에서 돌립니다. 고루틴끼리 값을 넘기는 통로가 채널입니다.

둘은 메시지를 어디로 보내느냐에서 갈립니다. 액터 모델은 받는 액터를 주소로 지목합니다. CSP 는 받는 쪽이 누구인지 모른 채 채널에 값을 넣습니다.

액터 모델 CSP
보내는 대상 받는 액터의 주소 이름 붙은 채널
우편함 액터마다 하나씩 딸려 있다 채널이 따로 서 있다. 여럿이 한 채널을 쓴다
보낼 때 기다리나 기다리지 않는다 기본 형태에서는 받는 쪽이 꺼낼 때까지 기다린다

표의 마지막 줄이 쓰는 느낌을 가장 크게 가릅니다. CSP 는 주고받는 순간에 두 쪽이 만나므로 속도가 저절로 맞춰집니다. 액터 모델은 보내는 쪽이 앞서 나가므로 메일박스가 쌓일 수 있습니다.

쓰는 곳과 안 쓰는 곳

제각각 바뀌는 상태가 많을 때 잘 맞습니다. 채팅방 하나, 게임 캐릭터 하나, 접속한 기기 하나를 액터 하나씩으로 둡니다. 각자의 상태를 각자의 액터가 쥐니 서로 잠금을 걸 일이 없습니다.

계산 하나를 잘게 쪼개 동시에 돌려 빨리 끝내는 일에는 잘 안 씁니다. 액터의 이점은 나눠 쓰는 값을 지키는 데 있습니다. 행렬 곱처럼 조각끼리 나눠 쓰는 값이 없으면 지킬 것이 없어 메시지를 주고받는 비용만 남습니다.

여러 값을 한꺼번에 맞춰 바꿔야 하는 일도 액터보다는 데이터베이스 트랜잭션이 흔히 맡습니다. 트랜잭션은 여러 값을 함께 바꾸거나 함께 안 바꾸게 묶어 주기 때문입니다.

관련 항목

액터 모델과 맞세워지는 동시성 모델

CSP · 공유 메모리 · 이벤트 루프 · async-await · 소프트웨어 트랜잭셔널 메모리 · 스레드 풀

액터 모델을 이루는 구성 요소

메일박스 · 메시지 전달 · 큐 · 위치 투명성 · 불변 객체 · 비동기

액터 모델이 피하려는 공유 상태 문제

경쟁 상태 · 상호 배제 · 뮤텍스 · 임계 구역 · 데드락 · 잠금

액터 모델을 구현한 언어와 프레임워크

Erlang · Elixir · Akka · Orleans

액터 사이 메시지 전달의 보장 수준

최대 한 번 전달 · 최소 한 번 전달 · 정확히 한 번 전달 · 메시지 순서 보장

액터 시스템이 실패와 과부하를 다루는 방식

감독 트리 · let it crash · 백프레셔 · 서킷 브레이커 · 보상 트랜잭션

액터 모델이 속하는 상위 분류

동시성 · 병렬성 · 분산 시스템 · 동시성 프로그래밍 · 계산 모델

액터와 맞세워지는 실행 단위

스레드 · 프로세스 · 고루틴 · 코루틴 · 그린 스레드 · 런타임

다른 이름: actor model · Actor Model · 액터 · 행위자 모델