−주문을 저장한 뒤 알림을 보내려는데 두 호출 사이에 프로세스가 종료되면, 주문은 남고 알림은 빠진다. 순서를 뒤집으면 저장에 실패한 주문의 알림을 먼저 보낼 수 있다. 트랜잭셔널 아웃박스는 업무 데이터와 “보내야 할 사건”을 같은 DB 트랜잭션에 기록하고, 커밋된 사건을 별도의 전달자가 읽어 보내는 패턴이다.[각주 1]
흐름은 주문 접수 → [주문 + outbox 기록을 함께 커밋] → 전달자 → 수신자다. 원자적으로 묶이는 것은 대괄호 안의 기록이다. 수신자까지 처리한 결과는 이후의 전달과 수신 계약으로 확인한다.
[SVG 다이어그램]
트랜잭션 경계가 서비스 밖으로 나갈 때
Gray의 1981년 트랜잭션 논문은 복구 가능한 데이터 작업과 외부 세계의 실제 행동을 구별했다. 하나의 DB에서 작업을 되돌리는 규칙을 세워도 다른 시스템에 이미 전달한 행동까지 같은 방식으로 복구하기는 어렵다.[각주 2]
메시징에서도 트랜잭션 경계는 중요한 설계 대상이었다. Hohpe와 Woolf의 Transactional Client는 메시지를 채널에 넣거나 꺼내는 일을 클라이언트 트랜잭션과 연결해 설명한다. 저자들의 현재 설명은 SQS의 visibility timeout처럼, 받은 메시지를 바로 제거하는 대신 잠시 숨기고 처리 뒤 명시적으로 삭제하는 방식도 별도로 구별한다.[각주 3]
Chris Richardson의 Microservices Patterns는 2018년 출판된 패턴 정리이며, 저자의 공개 패턴 목록은 아웃박스를 DB 갱신과 메시지 발행을 연결하는 방법으로 설명한다.[각주 4][각주 1] 이 기록들은 문제와 해법을 따라가는 자료다. 아웃박스라는 이름의 최초 사용 연도나 발명자를 확정할 근거로 확대하지 않는다.
같은 DB에 발행할 일을 남기기
아래 두 Python 블록을 한 파일에 순서대로 이어서 실행한다. 첫 블록은 주문과 사건을 함께 기록한다. 메모리 SQLite는 원자성 관계를 실행해 보기 위한 저장소이며 프로세스 재시작 뒤의 복구를 시험하는 환경은 아니다.
import json
import sqlite3
producer = sqlite3.connect(":memory:", isolation_level=None)
producer.executescript("""
CREATE TABLE orders(id INTEGER PRIMARY KEY, item TEXT NOT NULL);
CREATE TABLE outbox(
event_id TEXT PRIMARY KEY, payload TEXT NOT NULL,
sent INTEGER NOT NULL DEFAULT 0
);
""")
def place_order(item):
producer.execute("BEGIN IMMEDIATE")
try:
order_id = producer.execute(
"INSERT INTO orders(item) VALUES (?)", (item,)
).lastrowid
event_id = f"order-created:{order_id}"
payload = json.dumps({"order_id": order_id, "item": item})
producer.execute(
"INSERT INTO outbox(event_id, payload) VALUES (?, ?)",
(event_id, payload),
)
producer.commit()
return event_id
except Exception:
producer.rollback()
raise
event_id = place_order("book")
assert producer.execute("SELECT COUNT(*) FROM orders").fetchone()[0] == 1
assert producer.execute("SELECT COUNT(*) FROM outbox").fetchone()[0] == 1
outbox 삽입이 실패하면 주문도 롤백된다. 성공적으로 커밋한 뒤 전달이 실패하면 sent=0인 사건이 남는데, 이를 재시작 뒤에도 읽으려면 실제 저장소의 내구성과 보존 정책이 필요하다.[각주 5]
전달은 두 번, 수신자의 DB 효과는 한 번
전달자가 전송한 뒤 sent를 표시하기 전에 종료되면 같은 사건이 다시 전송될 수 있다. 다음 수신자는 처리한 사건의 ID와 자신의 업무 효과인 영수증 기록을 같은 트랜잭션에 넣는다. 네트워크와 브로커는 함수 호출로 대신하며, 정상 입력은 위 생산자가 만든 사건 형식으로 한정한다.
consumer = sqlite3.connect(":memory:", isolation_level=None)
consumer.executescript("""
CREATE TABLE processed(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL);
CREATE TABLE receipts(order_id INTEGER PRIMARY KEY, item TEXT NOT NULL);
""")
def handle_event(event_id, payload):
consumer.execute("BEGIN IMMEDIATE")
try:
row = consumer.execute(
"SELECT payload FROM processed WHERE event_id=?", (event_id,)
).fetchone()
if row is not None:
if row[0] != payload:
raise ValueError("EVENT_ID_REUSED_WITH_DIFFERENT_PAYLOAD")
else:
event = json.loads(payload)
consumer.execute(
"INSERT INTO receipts VALUES (?, ?)",
(event["order_id"], event["item"]),
)
consumer.execute(
"INSERT INTO processed VALUES (?, ?)", (event_id, payload)
)
consumer.commit()
except Exception:
consumer.rollback()
raise
event_id, payload = producer.execute(
"SELECT event_id, payload FROM outbox WHERE sent=0"
).fetchone()
handle_event(event_id, payload)
handle_event(event_id, payload)
assert consumer.execute("SELECT COUNT(*) FROM receipts").fetchone()[0] == 1
producer.execute("UPDATE outbox SET sent=1 WHERE event_id=?", (event_id,))
print("two deliveries, one receipt")
producer.close()
consumer.close()
같은 사건을 두 번 전달해도 receipts에는 한 행만 남는다. 같은 ID에 다른 본문이 오면 입력이 뒤바뀐 것으로 거절한다. 이 수신자의 원자적 효과는 자신의 DB에 넣는 영수증이며, 여기에 외부 결제나 메일 호출을 추가하면 그 외부 효과의 중복 계약도 따로 필요하다.
순서와 재전송은 누가 책임지는가
전송 완료를 먼저 표시하면 표시 뒤 실제 전송 전에 종료되는 경우 사건을 잃는다. 반대로 전송 뒤 표시하면 중복 창이 생기므로 수신자가 중복을 처리해야 한다.[각주 1] “나중에 반드시 전송된다”는 진행도 전달자의 재가동, 실패 처리, 저장소와 기록의 보존에 달려 있다.
여러 전달자가 일하면 ORDER BY로 앞 행을 먼저 집어도 발행 완료 순서가 뒤집힐 수 있다. 앞 사건을 맡은 전달자가 느려지는 동안 다른 전달자가 다음 사건을 보낼 수 있기 때문이다. 주문별 순서가 필요하면 주문 식별자와 순번, 전달자 담당 규칙, 브로커의 파티션 또는 그룹, 수신자의 적용 순서를 함께 설계한다. Debezium의 Outbox Event Router도 사건 고유 ID와 Kafka 키에 사용하는 aggregateid의 역할을 구별한다.[각주 6]
place_order("book")를 두 번 호출하면 이 예제는 주문을 두 개 만든다. 주문 요청의 중복은 멱등성으로, 이미 만들어진 사건의 중복은 수신자의 처리 기록으로 다룬다. 기록 보존 기간도 재전송이 가능한 기간과 맞춰야 한다.
예제 환경: Python 3의 표준 sqlite3. 두 블록은 생산자와 수신자의 서로 다른 DB 연결을 사용한다.
+개요
트랜잭셔널 아웃박스(transactional outbox)는 업무 데이터의 변경과 나중에 전달할 메시지를 같은 데이터베이스 트랜잭션에 기록하는 패턴이다. 전달자는 커밋된 메시지를 별도로 읽어 브로커나 수신 서비스에 보낸다.
한 업무의 결과를 두 시스템에 나눠 남기면 그 사이에 실패 지점이 생긴다. 주문 DB를 커밋한 뒤 메시지를 보내기 전에 프로세스가 종료되면, 주문은 있지만 다른 서비스는 주문이 생겼다는 사실을 모른다. 반대로 메시지를 먼저 보내면 확정되지 않은 주문을 다른 서비스가 처리할 수 있다.
아웃박스는 그 사이에 “보낼 일이 있다”는 영속적인 기록을 둔다. 주문과 전송할 사건을 함께 커밋하면, 주문이 확정된 뒤 전달자가 잠시 멈춰도 전송할 대상은 DB에 남아 있다. 업무 요청을 처리하는 코드의 실행 수명과 메시지 전달의 수명이 여기서 나뉜다.
그래서 아웃박스를 이해하려면 큐 테이블의 이름이나 주기 실행기의 문법보다 트랜잭션 경계부터 봐야 한다. 어떤 두 기록이 함께 확정되는지, 전달은 누가 맡는지, 중복 전달을 수신자가 어떻게 받아들이는지가 모두 그 경계에서 나오는 질문이다.
연혁
1981년: Jim Gray가 VLDB에 발표한 「The Transaction Concept: Virtues and Limitations」에서 트랜잭션이 보호하는 데이터와 외부 행동의 경계를 다뤘다.[각주 1]
2003년: Gregor Hohpe가 기업 통합 발표 자료에서 통합 방식을 파일 전송, 공유 데이터베이스, 원격 호출, 메시징으로 나눠 비교했다. 연도는 발표 자료의 저작권 표시이며 책의 발행연도와는 다르다.[각주 2]
2018년 10월: Richardson의 『Microservices Patterns』가 Manning에서 출판됐다. 서비스별 DB 갱신과 사건 발행의 문제를 책으로 정리한 출판 기록이다.[각주 3]
보호되는 데이터와 외부 행동의 경계 (1981)
Gray는 1981년 논문에서 동작을 세 가지로 나눴다. 중단이나 복구 때 되돌리거나 다시 할 필요가 없는 비보호(unprotected) 동작, 되돌리거나 다시 할 수 있고 그래야 하는 보호(protected) 동작, 한 번 하면 되돌릴 수 없는 실제(real) 동작이다. 현금 지급기 같은 장치의 실제 동작은 트랜잭션이 커밋될 때까지 미뤘다가 실행하고, 순번 같은 이름을 붙여 중복된 실행을 버린다.[각주 1]
같은 트랜잭션 시스템 안에서 처리하는 메시지 동작은 Gray의 분류에서 데이터베이스 동작과 함께 보호 동작에 들어간다. 업무 DB의 트랜잭션에 참여하지 않는 브로커로 보내는 메시지는 그 로컬 복구 범위 밖에 있어서, 주문 DB를 커밋한 뒤 메시지를 보내기 전에 프로세스가 종료되는 문제도 여기서 생긴다. 두 작업을 순서대로 호출한다고 두 작업이 함께 확정되지는 않는다. 분산 트랜잭션으로 참여자들의 커밋을 조정하는 접근도 있지만, 아웃박스는 로컬에 전송 의도를 남기는 쪽으로 문제를 나눈다.[각주 1][각주 4]
기업 통합과 메시징의 트랜잭션 경계 (2003)
Gregor Hohpe의 2003년 발표 자료에는 애플리케이션을 연결할 때 데이터를 언제 전달하는지와 시스템끼리 얼마나 결합되는지를 함께 다뤄야 했던 배경이 드러난다. Hohpe와 Bobby Woolf의 Transactional Client 패턴은 클라이언트와 메시징 시스템의 세션을 트랜잭션으로 만들어 클라이언트가 트랜잭션 경계를 정하게 한다. 보낸 메시지는 커밋해야 채널에 들어가고, 받은 메시지는 커밋해야 채널에서 빠진다.[각주 2][각주 5]
이 웹 설명이 다루는 범위는 세션의 메시징 동작까지다. 별도 DB의 업무 변경까지 같은 원자성에 넣으려면 그 자원과 트랜잭션 관리자가 참여하는지를 따로 확인해야 한다. 메시징 안의 트랜잭션과 서비스 DB의 트랜잭션은 서로 다른 경계일 수 있고, 그 사이에 놓인 아웃박스는 업무를 확정했다는 사실을 나중에 전달할 기록으로 남긴다.
서비스별 DB와 사건 발행의 패턴화 (2018)
Chris Richardson은 2018년 10월 출판된 『Microservices Patterns』에서 서비스별 DB 갱신과 사건 발행의 문제를 다뤘다. 그의 현재 패턴 카탈로그는 업무 데이터와 발행할 사건을 같은 로컬 트랜잭션에 기록하는 방식을 Transactional Outbox라는 이름으로 설명하고, 다른 이름으로 Application events를 병기한다. 전달자는 커밋된 사건을 따로 읽어 브로커에 보낸다. 업무 요청의 실행이 끝나도 전송할 일은 DB에 남아 있으므로 전달을 나중에 이어갈 수 있다.[각주 4][각주 3]
이 구조에서 함께 확정되는 것은 생산자 DB의 두 기록뿐이고, 한 번의 커밋이 묶는 범위도 거기까지다. 수신자는 자기 DB의 상태를 별도로 확정하며, 브로커는 그 사이에서 메시지를 옮긴다.
테이블 폴링과 로그 기반 전달
Richardson의 카탈로그는 전달자를 구현하는 방식으로 폴링과 트랜잭션 로그 추적을 구분한다. 폴링은 커밋된 outbox 행을 조회해서, 로그 추적은 DB가 남긴 변경 기록을 읽어서 전송 대상을 찾는다.[각주 4]
Debezium의 Outbox Event Router는 변경 데이터 캡처(CDC)로 얻은 outbox 레코드를 이벤트로 라우팅하는 구체적인 도구다. 기본 설정에서 사건 ID(id)는 메시지 헤더에, 집계 대상 식별자(aggregateid)는 메시지 키에 들어가고, 집계 대상 타입(aggregatetype)은 토픽 이름에 쓰이며, 페이로드는 메시지 값이 된다. 사건 타입 같은 다른 열은 table.fields.additional.placement 설정으로 헤더나 엔벨로프에 추가한다.[각주 6]
폴링은 조회 주기·인덱스·작업 선점의 영향을 받고, 로그 기반 전달은 로그 보존·체크포인트·커넥터의 수명을 함께 관리한다. 어느 쪽이든 전달자가 중간에 멈췄을 때 다시 읽기 시작할 위치와 기록이 필요하다.
중복 전달과 멱등한 수신
전달자가 메시지를 보낸 뒤 “보냈다”는 상태를 기록하기 전에 종료되면 같은 사건을 다시 보낼 수 있다. AWS의 패턴 설명도 중복 전달 가능성과 수신자의 멱등성 문제를 함께 다룬다.[각주 7]
수신자는 사건 ID를 보고 이미 처리한 사건인지 구별할 수 있다. 처리한 ID와 업무 효과를 같은 로컬 트랜잭션에 넣어 두면, 재전달된 사건이 업무 효과를 다시 만드는 일을 막을 수 있다. 사건 ID의 범위와 보존 기간도 이 중복 판별 계약에 포함된다.
이 중복 방지가 미치는 범위는 같은 DB에 함께 기록한 효과까지다. 외부 결제나 메일처럼 다른 시스템에 남는 효과가 있다면, 그 시스템과도 요청을 식별하고 처리 결과를 주고받는 계약이 필요하다.
순서, 보존, 다른 패턴과의 경계
같은 주문의 생성과 취소처럼 순서에 따라 의미가 달라지는 사건은 전달 순서를 설계해야 한다. 수신자를 병렬화할 때도 어느 집계 대상 안에서 순서를 지킬지 정해야 하는데, 메시지의 키와 파티션이 이를 구현하는 수단이다.[각주 4][각주 6]
사건 기록을 언제 지우는지가 전달자의 진행 위치와 복구 가능성을 좌우한다. 커밋된 사건을 전달 전에 지우면 이어갈 기록이 사라지고, 전달한 뒤에도 계속 쌓아 두면 저장 비용이 커진다. 삭제 조건은 확인 지점과 복구 기간에 맞춰 정한다.
아웃박스는 상태 변경의 전달을 맡고, 사가는 여러 서비스의 업무 단계를 조정한다. 이벤트 소싱에서는 사건 자체가 업무 상태의 원본이다. 이 구분은 메시지를 보관하는 테이블의 유무가 아니라, 어느 기록을 상태의 근거로 삼는지에 달려 있다.
두 저장 경계 예제
그림은 생산자와 수신자의 로컬 트랜잭션을 각각 묶어 보여준다. 브로커 없이 전달 코드가 수신자를 직접 호출하는 경우라서 sent는 수신자가 처리한 뒤에 표시되며, 브로커를 두면 전달자가 브로커의 수신 확인을 받은 뒤 sent를 표시한다. 아래 PostgreSQL 코드는 그중 생산자의 주문과 사건 기록만 실행한다. 전달자와 수신자 코드는 이 예제의 범위 밖이다.
[SVG 다이어그램]
CREATE TEMP TABLE orders (id integer PRIMARY KEY, item text NOT NULL);
CREATE TEMP TABLE outbox (
event_id text PRIMARY KEY,
order_id integer NOT NULL,
event_type text NOT NULL
);
BEGIN;
INSERT INTO orders VALUES (1, 'book');
INSERT INTO outbox VALUES ('event-1', 1, 'OrderPlaced');
COMMIT;
SELECT id, item FROM orders;
SELECT event_id, order_id, event_type FROM outbox;
두 SELECT에는 주문 1건과 event-1 사건 1건이 나온다. BEGIN과 COMMIT은 이 두 기록을 함께 확정한다. 그림의 handle_event와 수신자 DB는 그 뒤의 별도 처리 경계다. 예제의 TEMP 테이블은 만든 세션에서만 보이는 실습용이며, 실제로는 전달자 세션도 읽을 수 있는 일반 테이블이어야 한다.