트랜잭셔널 아웃박스
주문을 저장한 뒤 알림을 보내려는데 두 호출 사이에 프로세스가 종료되면, 주문은 남고 알림은 빠진다. 순서를 뒤집으면 저장에 실패한 주문의 알림을 먼저 보낼 수 있다. 트랜잭셔널 아웃박스는 업무 데이터와 “보내야 할 사건”을 같은 DB 트랜잭션에 기록하고, 커밋된 사건을 별도의 전달자가 읽어 보내는 패턴이다.1
흐름은 주문 접수 → [주문 + outbox 기록을 함께 커밋] → 전달자 → 수신자다. 원자적으로 묶이는 것은 대괄호 안의 기록이다. 수신자까지 처리한 결과는 이후의 전달과 수신 계약으로 확인한다.
트랜잭션 경계가 서비스 밖으로 나갈 때
Gray의 1981년 트랜잭션 논문은 복구 가능한 데이터 작업과 외부 세계의 실제 행동을 구별했다. 하나의 DB에서 작업을 되돌리는 규칙을 세워도 다른 시스템에 이미 전달한 행동까지 같은 방식으로 복구하기는 어렵다.2
메시징에서도 트랜잭션 경계는 중요한 설계 대상이었다. Hohpe와 Woolf의 Transactional Client는 메시지를 채널에 넣거나 꺼내는 일을 클라이언트 트랜잭션과 연결해 설명한다. 저자들의 현재 설명은 SQS의 visibility timeout처럼, 받은 메시지를 바로 제거하는 대신 잠시 숨기고 처리 뒤 명시적으로 삭제하는 방식도 별도로 구별한다.3
Chris Richardson의 Microservices Patterns는 2018년 출판된 패턴 정리이며, 저자의 공개 패턴 목록은 아웃박스를 DB 갱신과 메시지 발행을 연결하는 방법으로 설명한다.41 이 기록들은 문제와 해법을 따라가는 자료다. 아웃박스라는 이름의 최초 사용 연도나 발명자를 확정할 근거로 확대하지 않는다.
같은 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 연결을 사용한다.
각주
- Chris Richardson, “Transactional Outbox”, Context·Solution·Result context. 저자 설명. ↩
- Jim Gray, The Transaction Concept: Virtues and Limitations, VLDB, 1981, “A General Model of Transactions”. 저자 자료. ↩
- Gregor Hohpe, Bobby Woolf, “Transactional Client”. 저자 패턴 설명. 웹 공개 설명과 SQS 보충 예제를 참조했다. ↩
- Chris Richardson, Microservices Patterns, Manning, October 2018. 출판사 서지. ↩
- AWS Prescriptive Guidance, “Transactional outbox pattern”, 관계형 outbox 구현 및 Issues and considerations. 공식 문서. ↩
- Debezium, “Outbox Event Router”, Basic outbox table. 공식 문서. ↩