멱등성 — 두 번 눌러도 한 번만 결제되게 · 트랜잭션 아웃박스로 주문과 이벤트를 지킨다 · 이론
트랜잭션 아웃박스로 주문과 이벤트를 지킨다의 설계 원리
한 줄 요약
주문과 발행할 이벤트를 같은 DB 트랜잭션에 남기고, 전달의 중복은 소비자가 처리한다.
왜 이게 필요했나
주문을 DB에 저장한 직후 프로세스가 죽어 이벤트를 발행하지 못하면 다른 서비스는
주문이 생겼다는 사실을 영영 모를 수 있다. 반대로 이벤트부터 보내면 주문 저장이
실패해도 다른 서비스가 존재하지 않는 주문을 처리할 수 있다. 두 시스템에 쓰는 일을
함수 두 줄의 순서로 해결할 수는 없다. 같은 DB 안에서 주문과 outbox 행을 원자적으로
저장한 뒤 별도 발행기가 미전송 이벤트를 읽는 패턴이 이 틈을 줄인다.
어떻게 동작하나
BEGIN → orders INSERT → 장애 지점 → outbox INSERT → COMMIT ↓발행기: pending → publish → sent 표시소비자: consumed 확인 + 효과 반영 → 같은 트랜잭션으로 COMMIT주문 저장과 이벤트 저장 사이 예외가 나면 둘 다 없어야 한다. 발행이 실패하면 sent를
표시하면 안 된다. 그런데 발행은 성공했고 sent 표시 전에 죽었다면 다음 실행이 같은
이벤트를 다시 보낸다. 따라서 이것은 exactly-once 전송이 아니다. 최소 한 번 전달될
수 있는 설계에서 효과의 중복을 막기 위해 소비자도 이벤트 id 기록과 합계 반영을
하나의 트랜잭션으로 묶는다.
현장에서 만나는 모습
멱등 키를 재사용하면서 금액을 바꾸면 기존 성공을 반환하는 대신 충돌을 알려야 한다.
이 실습에서는 주문 id가 이벤트 id이며 한 주문당 생성 이벤트 하나만 만든다. 일반적인
수정·취소 이벤트까지 포함하려면 별도 이벤트 id와 버전·순서 정책이 필요하다.
발행기는 하나라는 전제로 구성한다. 다중 발행기의 claim, lease, 독성 메시지 격리와
outbox 정리는 이 실습이 구현하지 않는 운영 과제다.
다음 실습에서 할 것
DB 파일을 열어 행 수와 payload를 확인하고 중간 예외를 주입한다. 마지막에는
소비자가 이미 처리한 직후 발행기가 예외를 받는 상황을 만든 뒤 재전송한다.
전달 기록은 두 번이지만 매출 합계는 한 번이어야 한다. 보고서에 ‘중복 제거’라는
문구를 적는 것으로 통과하지 않는다. 별도 연결에서 읽은 실제 저장 상태로 판정한다.
참고: [SQLite 트랜잭션](https://docs.python.org/3/library/sqlite3.html#transaction-control)