트랜잭셔널 아웃박스는 업무 데이터와 “보내야 할 이벤트”를 같은 DB 트랜잭션에 기록한다. 외부 전송은 나중에 별도 전달기가 맡는다. 이 방식이 묶는 것은 업무 변경과 전송 의도의 저장이며, 외부 시스템에서의 단 한 번 실행까지 자동 보장하지는 않는다. 중복·순서·지연·복구를 나누어 설계해야 효과가 있다.
DB에는 성공인데 왜 다음 단계는 사라질까
관리자가 상품 설명을 수정했다. DB에는 새 설명이 들어갔고 화면에도 “저장 완료”가 나왔다. 그런데 검색 결과는 옛 설명을 보여 준다. 검색 색인 갱신을 알리는 이벤트를 보내기 직전에 서버가 멈췄기 때문이다. 저장 API와 검색 엔진이 각각 정상이어도 두 작업 사이에는 빈틈이 생긴다.
AI 에이전트가 고객지원 건의 처리를 기록하고 담당자에게 알리는 경우에도 같은 질문이 생긴다. 처리 기록을 남긴 뒤 알림 호출이 실패하면 무엇을 다시 실행할 것인가. 전체 업무를 재시도하면 이미 끝낸 변경이 반복될 수 있고, 아무것도 하지 않으면 알림이 빠진 상태가 남는다. 에이전트의 판단 능력과 별개로 시스템이 관리해야 하는 문제다.
이 글은 업무 상태를 바꾸는 DB와 이벤트를 받는 외부 시스템 사이의 이중 쓰기를 다룬다. 특정 메시지 제품을 고르는 안내보다, 장애가 발생해도 어디에서 작업을 다시 이어 갈지 정하는 설계 가이드다. 아래 상품·지원·색인 사례는 설명을 위해 구성했으며 실제 고객 사고를 보고한 것이 아니다.
AWS의 아웃박스 가이드는 DB 변경과 이벤트 발송 중 하나만 성공하는 불일치를 문제의 출발점으로 잡는다. 발송을 먼저 하고 DB를 나중에 바꿔도 틈은 사라지지 않는다. 이벤트를 받은 쪽은 일을 시작했는데 DB가 롤백될 수 있다.
아웃박스가 한 번에 묶는 것은 전송 의도다
업무 테이블과 별도로 outbox 테이블을 두고, 같은 트랜잭션 안에 둘을 기록한다. 업무 변경이 롤백되면 이벤트 행도 사라지고, 커밋되면 둘 다 남는다. 전달기는 커밋된 이벤트를 읽어 외부로 보낸다. 패턴 원문의 핵심도 업무 저장과 메시지 전달을 이 경계로 나누는 것이다.
[같은 DB 트랜잭션]
상품 설명 변경 + ProductDescriptionChanged 이벤트 저장
↓ COMMIT
[별도 전달기] outbox 읽기 → 브로커에 발송 → 전송 확인 기록
↓
[소비자] 처리한 이벤트인지 확인 + 검색용 상태 변경
outbox는 단순한 성공 로그와 다르다. 로그를 읽지 않아도 서비스가 계속 돌아갈 수 있다면 관측 자료에 가깝다. 반면 여기서는 이 행이 아직 끝내야 하는 작업의 입력이다. 삭제 정책, 재시도, 접근 권한과 관측 지표를 업무 데이터처럼 관리해야 한다.
업무가 다른 DB에 있고 outbox만 별도의 저장소에 있다면, 둘을 평범한 호출 두 번으로 저장하는 순간 같은 문제가 돌아온다. “아웃박스 테이블이 존재한다”보다 “해당 업무 변경과 같은 원자적 커밋에 포함되는가”가 중요하다.
다음 SQL은 구조를 설명하는 PostgreSQL 형태의 의사 예제다. 실행 가능한 전체 스키마·동시성 제어를 제공하는 코드는 아니며, 뒤의 다운로드 예제는 별도의 SQLite 구현이다.
BEGIN;
UPDATE products SET description = :text, version = :next_version
WHERE id = :product_id;
INSERT INTO outbox(event_id, aggregate_id, version, event_type, payload)
VALUES (:event_id, :product_id, :next_version,
'ProductDescriptionChanged', :payload);
COMMIT;
실제로는 존재하지 않는 상품 때문에 UPDATE가 0행이거나, 동시 수정으로 버전이 충돌한 경우도 검사해야 한다. 위 예제에서 그 검사를 생략했다고 이벤트를 무조건 생성해도 된다는 뜻은 아니다. 무슨 업무 변경을 확정했는지와 이벤트 내용이 일치해야 한다.
장애 지점을 나누면 중복이 남는 이유가 보인다
| 멈춘 지점 | 남아 있는 상태 | 다시 시작할 행동 |
|---|---|---|
| 업무·outbox 커밋 전 | 트랜잭션이 롤백되면 둘 다 없음 | 요청의 업무 식별자로 재처리 여부 판단 |
| 커밋 후, 전송 전 | 업무와 미전송 이벤트가 있음 | 전달기가 이벤트 전송을 재개 |
| 외부 수신 후, 전송 확인 기록 전 | 외부에는 도착했지만 송신 측은 미확인 | 같은 이벤트 ID로 재전송될 수 있음 |
| 소비자의 DB 커밋 후, 메시지 확인 응답 전 | 소비자 업무는 끝났지만 메시지가 재전달될 수 있음 | 소비자가 이미 처리한 이벤트인지 확인 |
세 번째 행이 중요하다. 브로커가 받았는지 확실하지 않은 상황에서 전송 완료로 표시하면 누락 위험이 생기고, 다시 보내면 중복 가능성이 생긴다. 아웃박스는 이 딜레마를 지워 주지 않는다. 대신 미확인 이벤트를 남겨 두고 중복을 처리할 수 있는 수신 측과 함께 복구하는 구조를 만든다.
같은 이벤트를 다시 보낼 때마다 새 event_id를 만들면 소비자는 다른 사건으로 본다. 전달 시도 번호는 늘릴 수 있지만 이벤트의 정체성은 유지해야 한다. 반대로 사용자가 주문을 두 번 실제로 생성했다면 서로 다른 사건이다. 문장이 같다는 이유만으로 동일 이벤트로 합쳐서는 안 된다.
소비자에게도 작은 트랜잭션이 필요하다
Idempotent Consumer 패턴은 처리한 메시지 ID를 기록해 반복 전달의 효과를 제한하는 방법을 설명한다. 로컬 DB를 바꾸는 소비자라면 처리 기록과 업무 변경을 같은 트랜잭션에 넣을 수 있다.
BEGIN
(consumer_name, event_id)를 처리 기록에 삽입
이미 존재하면 이번 전달은 중복으로 종료
존재하지 않으면 해당 업무의 DB 변경 수행
COMMIT
메시지 처리 확인 응답
확인 응답은 DB 커밋 이후에 보낸다. 업무 처리가 실패하면 처리 기록도 롤백돼 다음 전달이 다시 시도할 수 있어야 한다. 기록만 먼저 커밋하고 업무를 실행하면, 실패한 작업을 다음 시도에서 “이미 처리함”으로 버릴 수 있다.
여러 소비자가 같은 사건으로 서로 다른 일을 한다면 처리 기록의 키에는 소비자 식별자도 필요하다. 배송 소비자가 처리했다는 이유로 통계 소비자가 건너뛰면 안 된다. 처리 기록의 보관 기간은 가능한 재전달·재처리 기간과 맞춰 정한다. 영구 보관을 기본 답으로 삼을 필요는 없지만, 기록을 지운 뒤 오래된 이벤트를 재생하면 중복 효과가 돌아올 수 있다.
그렇다면 소비자 코드 안에서 메일을 보내면 될까. 메일 API는 보통 이 DB 트랜잭션의 일부가 아니다. 메일은 발송됐는데 DB 커밋이 실패할 수 있다. 이런 외부 부수 효과는 제공자가 지원하는 멱등 키, 발송 결과 조회, 별도 발송 의도 저장과 대사 절차 등을 검토해야 한다. 수신 시스템이 지원하지 않는 보장을 로컬 처리 기록만으로 만들어낼 수는 없다.
Kafka의 전달 의미 설명도 Kafka 내부 읽기·처리·쓰기의 트랜잭션과 외부 시스템 출력을 구분한다. “exactly-once 지원”이라는 제품 표현을 보는 대신, 어느 저장소와 어느 효과까지 함께 확정되는지를 질문해야 한다.
직접 실행: 전송 확인을 잃어도 배송 행은 한 개로
이 글을 위해 Python 표준 라이브러리와 임시 SQLite 파일 두 개로 작은 재현 프로그램을 만들었다. 하나는 주문과 outbox, 다른 하나는 처리 기록과 배송 행을 보관한다. 브로커 대신 함수 호출로 전달하고, 지정된 지점에 예외를 넣거나 전송 확인을 생략한다. DB 연결을 다시 열어 커밋된 전송 의도를 읽는 단계도 포함했다.
코드를 저장한 디렉터리에서 다음과 같이 실행한다. 이번 검증 환경은 Python 3.11.12와 SQLite 3.51.0이다. 추가 패키지는 필요 없고 네트워크 요청도 하지 않는다. 임시 DB는 실행을 마치면 정리된다.
python3 outbox-replay-demo.py
| 확인한 논리 장애 | 이번 로컬 실행에서 확인한 값 |
|---|---|
| 주문·outbox 기록 후 커밋 전 예외 | 주문 0행, outbox 0행 |
| 커밋 후 연결을 다시 열고 전송 전 상태 확인 | 주문 1행, outbox 1행, 배송 0행 |
| 소비자 업무 변경 후 커밋 전 예외 | 처리 기록 0행, 배송 0행 |
| 첫 처리 뒤 전송 확인을 생략하고 동일 이벤트 재전달 | 호출 2회, 첫 처리 applied, 재전달 duplicate, 배송 1행 |
마지막 행은 두 번 호출해도 동일 이벤트의 로컬 배송 행이 하나로 유지된다는 확인이다. 네트워크 장애, 프로세스 강제 종료, 전원 장애, 동시 소비자, 실제 메일·결제, Kafka 운영을 검증한 것은 아니다. 처리량이나 운영 신뢰도를 측정한 결과로 사용해서는 안 된다.
이 예제의 멱등 처리 부분은 다음과 같다. 충돌을 무시한 INSERT의 결과로 새 이벤트인지 구분하고, 같은 트랜잭션 안에서 배송 행을 만든다. SQLite의 트랜잭션 규칙이 적용되는 로컬 DB 범위의 동작이다.
with consumer:
cursor = consumer.execute(
'INSERT INTO processed VALUES (?,?) ON CONFLICT DO NOTHING',
('shipping', event_id),
)
if cursor.rowcount == 0:
return 'duplicate'
consumer.execute('INSERT INTO shipping VALUES (?)', (order_id,))
독자가 다음으로 바꿔 볼 지점은 “중복이면 무조건 버리기” 자체보다 업무 의미다. 배송 생성과 배송 주소 변경은 같은 주문에서도 다른 사건이다. 이벤트 종류와 주문의 버전을 함께 설계해야 수정 요청을 생성 요청의 중복으로 오해하지 않는다.
폴링과 CDC: 무엇을 운영할 수 있는가
전달기가 outbox를 주기적으로 조회하는 폴링은 동작을 SQL과 작업 로그로 따라가기 쉽다. 대신 조회 주기·인덱스·배치 크기·여러 작업자의 점유 방식과 실패한 행의 재시도 정책을 정해야 한다. 전송 실패를 같은 행에서 무한 반복하면 뒤의 정상 이벤트까지 밀릴 수 있다.
CDC는 DB 변경 로그를 읽는 방식이다. Debezium Outbox Event Router 문서는 outbox 변경을 이벤트로 변환하고 id와 aggregateid를 각각 이벤트 식별과 메시지 키에 사용하는 구성을 설명한다. 2026년 10월 9일 확인한 stable 문서는 3.7로 표시됐다. 이 글에서는 커넥터를 설치·운영하지 않았다.
| 판단 기준 | 폴링을 먼저 검토할 조건 | CDC를 먼저 검토할 조건 |
|---|---|---|
| 운영 기반 | DB와 주기 작업 중심으로 운영 중 | 이미 CDC·브로커를 관측하고 복구하는 체계가 있음 |
| 지연 요구 | 조회 주기에서 생기는 지연을 허용 | 변경 로그 전달 지연과 커넥터 상태를 관리할 수 있음 |
| 장애 조사 | 미전송 행·재시도 상태를 직접 조회하고 싶음 | 커넥터 위치·로그 보존·재시작을 함께 추적할 수 있음 |
| 추가 책임 | 잠금·점유 만료·조회 부하·정리 | 커넥터 권한·로그 보존·스키마 변경·재동기화 |
이 표는 편집상 선택 기준이며 어느 쪽이 항상 더 빠르거나 저렴하다는 벤치마크가 아니다. 작은 서비스에서 CDC 운영 부담이 이익보다 클 수 있고, 이미 갖춘 CDC 기반에서는 별도 폴링 코드를 줄일 수 있다. 중요한 것은 고른 경로의 미처리 작업과 복구 위치를 실제로 알 수 있는가다.
SKIP LOCKED와 순서를 혼동하지 않는다
여러 전달기가 같은 테이블을 읽을 때 PostgreSQL의 FOR UPDATE SKIP LOCKED는 이미 잠긴 행을 건너뛰는 데 쓸 수 있다. PostgreSQL 18 공식 SELECT 문서는 큐 같은 테이블의 경합을 줄이는 용도를 설명하면서, 건너뛴 결과가 일관된 전체 데이터 뷰가 아니라는 점도 명시한다.
여기서 “중복 점유를 줄임”과 “업무 사건의 순서를 지킴”은 다르다. 상품 P의 버전 7 이벤트가 잠겨 있을 때 다른 작업자가 버전 8을 먼저 보낼 수 있다. 둘 다 ORDER BY로 읽는다는 이유만으로 외부 전송 순서까지 보장되지 않는다.
예를 들어 이벤트가 상품의 전체 최신 상태를 담고 소비자가 더 높은 버전만 반영하는 설계라면 늦게 온 버전 7을 무시할 수 있다. 하지만 이벤트가 “재고에서 1개 차감” 같은 변화량이라면 오래된 번호를 단순히 버리면 계산이 틀린다. 업무 대상별 직렬 처리, 누락 버전 대기, 원본 재조회 같은 전략 중 사건의 의미에 맞는 것을 선택해야 한다.
브로커 메시지 키를 상품 ID로 맞추는 것은 관련 메시지를 같은 파티션에 모으는 데 도움이 된다. 그러나 그 앞의 전달기가 순서를 뒤집어 보낸 문제까지 자동으로 복원하지는 않는다. 순서가 필요하다면 업무 대상별 버전 생성·전달·소비까지 이어서 검토한다.
또한 DB 잠금을 잡은 채 느린 네트워크 전송이 끝나기를 기다리면 트랜잭션이 길어진다. 짧은 트랜잭션으로 작업을 점유하고 만료 시각을 두는 방식은 대안이지만, 점유가 만료된 동안 옛 작업자가 늦게 완료하는 경우까지 설계해야 한다. 점유 방식 변경이 소비자의 멱등성을 대체하지 않는다.
운영 화면은 성공 개수보다 미완료의 나이를 보여 준다
전송 성공이 계속 쌓여도 한 고객의 이벤트가 오래 멈춰 있을 수 있다. 다음은 운영 화면을 설계한다면 넣을 항목에 대한 제안이다. 실제 제품 화면의 캡처나 측정값은 아니다.
| 위치 | 보여 줄 정보 | 담당자가 결정할 일 |
|---|---|---|
| 상단 상태 | 가장 오래된 미전송 이벤트의 대기 시간, 적체 수 | 처리량 감소인지 일부 고착인지 확인 |
| 실패 목록 | 이벤트 ID·업무 대상·유형·시도 횟수·마지막 오류 | 재시도·격리·원인 수정 중 선택 |
| 사건 상세 | 커밋 → 발송 시도 → 수신 확인 → 소비 완료의 관측 기록 | 어느 경계까지 실제로 끝났는지 확인 |
| 재처리 화면 | 같은 ID 유지 여부, 대상 소비자, 예상 부수 효과 | 중복 효과와 순서 영향을 검토하고 재생 |
색인 갱신 지연이라면 화면의 “저장 완료”를 “검색 반영 완료”와 구분하는 것도 필요하다. 이벤트를 outbox에 기록했다는 사실만 알고 있을 때 독자나 고객에게 후속 작업까지 끝났다고 안내하면, 시스템의 상태와 제품의 약속이 달라진다.
실패 이벤트를 별도 보관했다고 업무가 끝난 것은 아니다. 담당자, 허용 지연, 재처리 조건을 정하고, 민감한 payload 전체를 로그 화면에 노출하지 않는다. 최소한의 식별자로 원래 업무 기록에 접근하도록 설계하는 편이 낫다.
도입하지 않아도 되는 경우와 설계 확인표
단일 DB 안에서 끝나는 작업이라면 트랜잭션만으로 충분할 수 있다. 원본을 주기적으로 다시 읽어 완전히 복원할 수 있는 파생 데이터라면, 누락 이벤트를 모두 저장하는 대신 재동기화 작업이 더 단순한 선택일 수도 있다. 이때는 허용 지연과 복원 비용을 확인한다.
반대로 외부 시스템까지 즉시 성공해야만 업무를 수락할 수 있다면, 아웃박스를 넣는 것만으로 요구를 충족하지 못한다. 대기 상태를 제품에 노출하거나 보상 가능한 단계로 나누는 등 업무 흐름의 설계가 필요하다. 에이전트 도입 평가에서 말한 완료 기준도 이 경계와 맞아야 한다. MCP 연결 규약이 있어도 외부 변경의 원자성이 생기는 것은 아니다.
설계 검토에서는 다음을 자신의 업무 이름으로 채워 보자.
- 어떤 업무 변경과 이벤트를 같은 저장소에서 함께 커밋하는가?
- 전송 확인을 잃으면 무엇을 같은 ID로 재시도하는가?
- 소비자의 처리 기록과 어느 부수 효과까지 함께 커밋할 수 있는가?
- 순서는 전체가 필요한가, 주문·상품 같은 대상별로 필요한가?
- 가장 오래된 미완료 사건을 찾고 안전하게 재처리할 수 있는가?
- 사용자에게 보여 주는 “완료”는 저장·발송·후속 처리 중 무엇인가?
이 질문에 답할 수 있으면 아웃박스는 테이블 하나가 아니라 복구 가능한 작업 경로가 된다. 답이 비어 있는 부분은 메시지 제품을 바꿔도 그대로 남을 수 있다.
- 업무 변경과 보낼 이벤트는 같은 로컬 트랜잭션에 기록한다. 외부 호출까지 원자적으로 묶이는 것은 아니다.
- 재전송은 같은 event_id로 하고, 소비자의 처리 기록과 로컬 부수 효과도 한 트랜잭션에 넣는다.
- 순서는 업무 대상별로 정의하고, 지연·재처리·실패 격리 정책을 함께 운영한다.
- 후속 처리가 지연돼도 되는지부터 판단한다. 아웃박스만으로 결제·메일 같은 외부 효과의 중복을 해결할 수는 없다.
근거와 원자료
자료의 발행 시점과 이번 글에서 참고한 범위를 함께 기록합니다.
- Transactional outbox pattern ↗AWS · 갱신형 문서
이중 쓰기 문제와 같은 트랜잭션의 outbox 기록. 예제의 특정 큐 보장을 외부 부수 효과에 확대하지 않는다.
- Pattern: Transactional outbox ↗Chris Richardson · Microservices.io · 갱신형 문서
로컬 업무 저장과 메시지 릴레이 분리, 중복 가능성.
- Pattern: Idempotent Consumer ↗Chris Richardson · Microservices.io · 갱신형 문서
소비자별 처리 ID 기록과 로컬 업무 변경의 트랜잭션.
- Outbox Event Router ↗Debezium · 3.7 문서
event id, aggregateid 메시지 키와 변경 이벤트 라우팅. 설치·운영 성능 미검증.
- PostgreSQL 18 SELECT — The Locking Clause ↗PostgreSQL · 18 문서
SKIP LOCKED는 잠긴 행을 건너뛰며 전체 순서·일관된 뷰를 보장하지 않는다.
- Apache Kafka Design — Message Delivery Semantics ↗Apache Kafka · 4.1 문서
Kafka 트랜잭션과 외부 출력 시스템의 보장 경계를 구분한다.
- SQLite Transaction ↗SQLite · 갱신형 문서
로컬 SQL 트랜잭션 경계. 다운로드 예제의 실제 버전은 결과 JSON 참조.
갱신 기록
첫 발행. 원자료 기반 설계 해설과 자체 SQLite 논리 장애 재현을 구분해 제공한다.
오류를 발견했다면 →