−주문을 저장한 뒤 알림을 보내려는데 두 호출 사이에 프로세스가 종료되면, 주문은 남고 알림은 빠진다. 순서를 뒤집으면 저장에 실패한 주문의 알림을 먼저 보낼 수 있다. 트랜잭셔널 아웃박스는 업무 데이터와 “보내야 할 사건”을 같은 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에 남아 있다. 업무 요청을 처리하는 코드의 실행 수명과 메시지 전달의 수명을 나누는 것이다.
따라서 아웃박스를 이해하는 출발점은 큐 테이블의 이름이나 주기 실행기의 문법보다 트랜잭션 경계다. 어떤 두 기록이 함께 확정되는지, 전달은 누가 맡는지, 중복 전달은 수신자가 어떻게 받아들이는지를 이어서 살펴봐야 한다.
1. 트랜잭션이 시스템 경계를 만나다
DB 내부에서 함께 확정할 수 있는 변경도 외부 메시지나 실제 행동까지 이어지면 같은 방식으로 되돌리기 어렵다. Gray의 1981년 트랜잭션 논문은 보호되는 데이터 동작과 외부 세계의 동작을 구별하며 이 경계를 논의했다.[각주 1]
여러 참여자가 커밋 결정을 함께 내리는 분산 트랜잭션은 한 접근이다. 하지만 DB와 메시지 브로커가 같은 프로토콜을 지원해야 하고, 장애·대기·운영 결합도 함께 다뤄야 한다. 아웃박스는 로컬 DB에 남기는 기록을 이용해 업무 변경과 전달 사이를 연결하는 별도의 구조를 택한다.[각주 2]
이 문제의 계보에는 메시징의 트랜잭션 처리도 있다. Hohpe와 Woolf의 Transactional Client 패턴은 메시지 송수신과 자원 변경의 트랜잭션 경계를 다룬다. 이런 논의가 서비스별로 데이터와 배포를 나누는 아키텍처에서 다시 구체적인 구현 문제로 나타난다.[각주 3]
2. 서비스 아키텍처에서 정리된 패턴
Chris Richardson의 Transactional Outbox 설명은 서비스의 데이터 변경과 사건 발행이라는 문제, 로컬 기록과 별도 전달이라는 해법을 패턴으로 정리한다. 그의 『Microservices Patterns』는 2018년에 출판됐다.[각주 2][각주 4]
앞선 트랜잭션·메시징 계보에서 현재의 패턴 설명으로 이어지는 공통 질문은 서비스가 DB와 브로커에 쓰는 두 작업을 어떻게 연결할 것인가다. 최초 사용 시점은 이 자료만으로 확정하기 어렵다.
3. 기본 구조와 두 개의 저장 경계
생산자는 업무 데이터와 outbox 기록을 같은 트랜잭션에 넣는다. 커밋이 성공하면 두 기록이 함께 남고, 실패하면 둘 다 확정되지 않는다. 메시지에는 수신자가 같은 사건인지 식별할 고유 ID를 넣는다.
전달자는 커밋된 outbox 기록을 읽어 전송한다. 수신자는 자기 DB에서 업무 효과를 적용하며, 필요하면 처리한 사건 ID도 같은 트랜잭션에 기록한다. 생산자의 커밋과 수신자의 커밋 사이에는 메시지 전달이 놓여 있다.
[SVG 다이어그램]
그림의 place_order는 생산자의 주문 작업, handle_event는 수신자의 처리다. 두 DB 안의 묶음은 각자의 로컬 원자성 범위이며, 가운데 전달 동작은 별도의 단계다.
4. 전송 의도를 함께 남기는 최소 예제
아래 PostgreSQL 코드는 업무 레코드와 사건 레코드의 확정 범위만 보여준다. 전달 루프와 수신자의 상세 구현은 생략하고, 우선 어떤 두 기록을 묶는지 확인한다.
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;
결과에는 주문 1과 그 주문을 가리키는 사건 event-1이 함께 남는다. 이 예제는 원자적 기록의 핵심만 나타낸다. 두 레코드를 서로 다른 DB나 별도 트랜잭션에 넣으면 같은 구조의 보장을 얻지 못한다.
5. 폴링과 로그 추적
Polling Publisher는 아직 전달하지 않은 기록을 주기적으로 조회한다. 저장소의 일반 조회로 동작을 이해하기 쉽고, 조회 주기와 배치가 전달 지연과 부하에 영향을 준다.
Transaction Log Tailing은 DB의 변경 로그를 따라 커밋된 사건을 가져간다. CDC 도구가 이 전달 경로에 쓰일 수 있다. Debezium의 Outbox Event Router는 outbox 레코드의 사건 ID, aggregate ID, payload 같은 필드를 메시지의 식별·키·본문에 연결한다.[각주 5]
두 방식은 전송 대상을 발견하는 경로가 다르다. 이후 브로커에 보내고 진행 위치를 기록하는 사이의 장애는 각각의 구현과 전달 계약에서 다뤄야 한다.
6. 중복 전달 창과 수신자의 멱등성
전달자가 메시지를 보낸 직후, 전송 완료 기록을 남기기 전에 종료될 수 있다. 재시작한 전달자는 같은 사건을 다시 보낼 수 있다. 전송 성공 여부를 모르는 상황에서 기록을 먼저 완료 처리하면 메시지를 잃을 위험이 있다.[각주 6]
수신자가 같은 사건 ID를 이미 처리했는지 기록하고, 그 기록과 자기 DB의 업무 효과를 함께 확정하면 중복 메시지에 대한 효과를 조절할 수 있다. 이 처리는 수신자 DB 안의 원자성에 기대며, 그 밖의 메일·결제 API에는 각자의 중복 처리 계약이 필요하다.
한 사건의 중복 전달과 한 주문 요청의 중복 접수도 다르다. 사용자가 같은 주문을 두 번 제출해 서로 다른 사건 두 개가 생겼다면, 사건 ID 중복 제거만으로 주문이 하나가 되지는 않는다.
7. 순서와 보존이 만드는 의미
같은 주문의 변경 사건은 앞뒤 순서가 중요할 수 있다. 여러 전달자가 기록을 가져갈 때 읽은 순서와 전송 완료 순서가 달라질 수 있으므로, 주문별 식별자·순번·브로커의 키·수신자 적용 규칙을 연결해야 한다.[각주 2][각주 5]
outbox와 수신 기록의 보존도 이 계약의 일부다. 미전송 기록을 지우면 전달할 근거가 사라지고, 처리 기록을 지운 뒤 옛 사건을 재전송하면 다시 적용될 수 있다. 보관 기간은 재전송 가능 기간과 복구 경로를 기준으로 정한다.
8. 사가·이벤트 소싱과의 관계
사가는 여러 서비스에 걸친 업무 단계를 이어가고 실패 시 보상하는 방식을 다룬다. 아웃박스는 그 단계의 결과를 사건으로 전달하는 데 쓰일 수 있다. 이벤트 소싱은 사건을 상태의 원본으로 저장하는 모델이며, outbox가 있다고 해서 업무 상태의 원본까지 사건으로 바뀌는 것은 아니다.
세 구조는 함께 사용할 수 있지만 책임은 각각 다르다. 아웃박스의 중심은 로컬 업무 변경과 전달할 기록을 함께 확정하는 데 있다.