주문 행을 커밋한 뒤 큐에 publish를 한 번 더 치면, 그 두 쓰기는 같은 트랜잭션이 아니다. 저장은 됐는데 브로커가 타임아웃되면 재고는 빠지고 차감 이벤트는 없다. 반대로 메시지를 먼저 보내고 저장이 롤백되면, 없는 주문의 알림이 나간다. 트랜잭션 아웃박스는 업무 행과 발행 대기 행을 같은 데이터베이스 트랜잭션에 넣고, 커밋 뒤에 릴레이만 큐로 옮긴다.
아래는 이중 쓰기가 깨지는 지점, 테이블 칸, 폴링과 변경 데이터 캡처, 소비 멱등, 쓰지 말아야 할 경우까지 한 주제로 깊게 본다. 같은 행을 동시에 고치는 락은 낙관적·비관적·분산 락, 큐 종류는 카프카·래빗엠큐·레디스 스트림, 중복 웹훅은 멱등 키 케이스와 맞춰 보면 빈칸이 줄어든다.
이중 쓰기가 깨지는 순간 | 커밋과 발행이 갈라질 때
서비스가 한 요청에서 데이터베이스와 메시지 브로커를 같이 건드리면, 둘 중 하나만 성공하는 구간이 생긴다. 네트워크 한 번, 프로세스 재시작 한 번이면 충분하다. 분산 트랜잭션(2단계 커밋)으로 묶는 방법도 문서에는 있지만, 브로커와 관계형 DB를 한 코디네이터에 올리는 운영 비용이 크고 장애 면적이 넓다. 실무에서 많이 고르는 우회는 “한 저장소에만 원자적으로 쓰고, 두 번째 저장소는 나중에 따라가게”다.
순서
실패 지점
남는 상태
현장에서 보이는 결과
DB 먼저, 큐 나중
발행 타임아웃·프로세스 종료
주문은 있고 이벤트 없음
재고·정산·알림이 안 움직임
큐 먼저, DB 나중
저장 롤백·유니크 충돌
이벤트는 있고 주문 없음
유령 주문, 중복 차감 시도
둘 다 재시도
타임아웃 후 실제 성공
같은 이벤트 2번
소비 쪽 멱등이 없으면 이중 반영
캐시와 DB
캐시 선기록 후 DB 실패
목록과 원장이 다름
이중 쓰기의 다른 얼굴
결제 승인, 쿠폰 사용, 포인트 적립, 국내 PG 웹훅 반영처럼 “한 번만 일어나야 하는 일”이 여기에 걸린다. 락으로 같은 행을 직렬화해도, 행을 고친 뒤 브로커로 나가는 순간은 락 밖이다. 트랜잭션 안에서 외부 API를 호출하면 커넥션과 행 잠금이 네트워크를 기다린다. 그 함정은 락 글에서 이미 짚었고, 여기서는 커밋 경계를 어떻게 옮길지에 둔다.
저장과 발행을 다른 시스템에 나누면 하나만 성공하는 구간이 생긴다
아웃박스 테이블 칸 | 업무 행과 같이 커밋하는 대기열
패턴의 뼈대는 단순하다. orders를 넣는 그 트랜잭션 안에서 outbox에도 한 줄을 넣는다. 커밋이 되면 둘 다 있고, 롤백이면 둘 다 없다. 큐는 아직 모른다. 릴레이(폴러 또는 변경 캡처)가 나중에 대기 행을 읽어 브로커로 보낸다. 크리스 리처드슨의 마이크로서비스 패턴 카탈로그가 이 구조를 트랜잭션 아웃박스(Transactional outbox)로 정리했고, 아마존 웹 서비스 권장 가이드도 같은 이중 쓰기 문제를 이 패턴으로 푼다.
칸은 팀마다 조금씩 다르지만, 빠지면 운영이 막히는 항목이 있다. 이벤트 고유키, 집계 아이디, 토픽 또는 이벤트 타입, 페이로드, 생성 시각, 게시 시각, 상태. 페이로드는 소비자가 필요한 필드만 넣는다. 주문 전체 스냅샷을 넣으면 스키마가 바뀔 때마다 구 이벤트가 깨진다. 한글 주문자명·도로명 주소가 있으면 JSON 문자셋과 컬럼 인코딩을 UTF-8로 맞춘다.
컬럼
역할
빠지면
id (UUID)
이벤트 고유키, 브로커 키·소비 멱등 키
재발행과 재처리를 구분 못 함
aggregate_id
주문·결제 같은 집계 아이디, 파티션 키
같은 주문의 순서가 섞임
event_type
소비 라우팅
토픽이 늘 때 분기 불가
payload
소비자 계약 JSON
소비자가 다시 조회해야 함
status / published_at
폴링 대상 선별, 재시도
이미 보낸 행을 또 읽음
created_at
지연 메트릭, 보관 주기
밀린 시간을 못 잰다
아웃박스 테이블 예시 (PostgreSQL)
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'PENDING',
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz,
attempt_count int NOT NULL DEFAULT 0
);
CREATE INDEX outbox_pending_idx
ON outbox (created_at)
WHERE status = 'PENDING';
-- 업무 쓰기와 반드시 같은 트랜잭션
BEGIN;
INSERT INTO orders (id, status, amount_krw) VALUES ($1, 'PAID', $2);
INSERT INTO outbox (id, aggregate_id, event_type, payload)
VALUES ($3, $1, 'order.paid', $4);
COMMIT;
부분 인덱스(WHERE status = 'PENDING')는 대기 행만 얇게 유지한다. 게시한 행을 같은 테이블에 영원히 두면 인덱스가 커진다. 보관 주기가 지난 행은 아카이브 테이블로 옮기거나 파티션을 자른다. 금액은 원 단위 정수(amount_krw)로 두고 부동소수점을 피한다. 부가세 10%를 이벤트에 넣을 거면 세전·세후를 칸으로 나눠 소비자가 다시 계산하지 않게 한다.
ORM으로 넣어도 규칙은 같다. 주문 엔티티 저장과 아웃박스 엔티티 저장이 같은 커밋 묶음·같은 커넥션이어야 한다. 주문만 커밋하고 이벤트를 별도 요청으로 넣으면 패턴 이름이 붙어도 이중 쓰기다. 트랜잭션 전파 설정을 코드 리뷰 체크리스트에 한 줄 적어 둔다.
폴링 릴레이와 변경 캡처 | 대기 행을 누가 꺼내는가
대기 행을 큐로 옮기는 길은 크게 둘이다. 애플리케이션 워커가 PENDING을 주기적으로 읽는 폴링, 그리고 데이터베이스 트랜잭션 로그를 구독하는 변경 데이터 캡처(CDC)다. 디비지움(Debezium)은 후자의 대표 구현이고, 아웃박스 전용 이벤트 라우터 변환을 공식 문서에서 제공한다. 포스트그레는 논리 복제·논리 디코딩으로 WAL을 흘려 보낸다.
방식
잘 맞는 규모
지연
운영 부담
폴링 릴레이
초당 수십~수백, 팀 인원 적음
폴링 주기만큼 (0.5~5초)
워커·락·인덱스만
LISTEN/NOTIFY + 폴링
대기 시간을 줄이고 싶을 때
알림 후 즉시, 유실 시 폴링 보완
알림 유실을 전제로 설계
디비지움 CDC
초당 수천, 여러 서비스 구독
보통 수백 ms 이하
커넥터·슬롯·오프셋·권한
클라우드 관리형 CDC
매니지드 카프카·DMS 쓰는 팀
제품 따라 다름
벤더 한도·리전·비용
폴링은 구현이 짧다. SELECT … FOR UPDATE SKIP LOCKED로 여러 워커가 대기 행을 나눠 가져가고, 발행에 성공하면 status를 바꾼다. 스킵 락은 작업 큐 테이블에 잘 맞고, 결제 한 줄을 이 요청이 반드시 처리해야 하는 재고 차감과는 역할이 다르다. 배치 크론이 겹치면 데드 레터 체크리스트의 단일 실행 규칙을 같이 본다.
디비지움 경로는 애플리케이션이 상태 컬럼을 안 고쳐도 된다. 커넥터가 인서트만 보고 카프카 토픽으로 흘린다. 대신 논리 복제 슬롯이 쌓이면 WAL이 디스크를 채운다. 커넥터를 잠시 꺼도 슬롯은 살아 있다. 복제 권한, 하트비트, 슬롯 모니터링은 커넥터를 켠 첫날에 대시보드에 넣는다. 공식 커넥터 문서의 슬롯·출판 설정은 버전마다 이름이 조금 다르니, 설치한 메이저 기준으로 다시 확인한다.
변경 캡처는 테이블을 다시 훑지 않고 트랜잭션 로그에서 대기 행을 읽는다
게시 순서와 키 | 같은 주문은 한 파티션으로
한 주문에 order.created와 order.paid가 연속으로 나가면, 소비자는 생성보다 결제를 먼저 받으면 안 된다. 카프카는 파티션 안에서만 순서를 보장한다. 그래서 브로커 키를 aggregate_id(주문 아이디)로 둔다. 같은 주문의 이벤트는 같은 파티션에 들어가고, 다른 주문은 병렬로 흐른다.
폴링 릴레이가 한 번에 100행을 집어 병렬로 발행하면, 같은 주문의 두 행이 역전될 수 있다. 배치 안에서도 집계 아이디별로 직렬화하거나, 한 워커가 한 집계만 맡게 샤딩한다. 레디스 스트림이나 래빗엠큐를 쓸 때도 “키 단위 순서”를 큐 선택이 받쳐 주는지 먼저 본다. 노드 쪽 작업 큐만 필요할 때는 불엠큐 튜토리얼의 잡 아이디·반복 제한이 릴레이 구현에 가깝다.
게시가 성공한 뒤에만 상태를 PUBLISHED로 바꾼다. 브로커가 ACK를 주기 전에 프로세스가 죽으면 행은 PENDING으로 남고, 재시작 후 다시 나간다. 그래서 소비자는 같은 id를 두 번 받을 수 있다고 전제한다. “정확히 한 번 발행”을 브로커만으로 약속하지 말고, “적어도 한 번 발행 + 소비 멱등”으로 계약을 적는다.
헤더에 이벤트 아이디, 타입, 발생 시각, 스키마 버전을 넣는다. 본문만 보면 구 버전 소비자가 새 필드를 필수로 알고 죽는다. 버전을 헤더로 빼고, 필드는 추가만 허용하는 편이 롤백에 덜 깨진다. 한글 필드명을 페이로드에 쓸지는 팀 계약이다. 검색·로그를 영어 키로 통일하고 값만 한글을 넣는 팀이 많다.
소비 멱등 | 같은 이벤트가 두 번 와도
릴레이가 재시도하면 중복은 정상이 된다. 소비 쪽은 이벤트 id를 처리 로그 테이블에 유니크로 넣고, 이미 있으면 본 작업을 건너뛴다. 본 작업(재고 차감, 포인트 적립, 메일 발송)과 로그 인서트를 같은 트랜잭션에 두면, 차감만 되고 로그가 없는 구멍이 줄어든다. 메일은 트랜잭션 밖이므로 “발송 예약 행”을 같은 트랜잭션에 만들고 워커가 보낸다. 또 아웃박스다.
중복 방어
어디에 두나
한계
이벤트 id 유니크
소비자 DB
다른 이벤트가 같은 효과를 내면 못 막음
업무 유니크 (주문당 적립 1회)
포인트·쿠폰 테이블
이벤트 타입이 늘면 제약이 늘어남
상태 머신 전이
주문.status
이미 PAID인데 PAID를 다시 받아도 no-op
브로커 오프셋 커밋
카프카 컨슈머 그룹
처리 후 커밋 전에 죽으면 재처리
상태 머신과 이벤트 아이디를 같이 쓰는 편이 안전하다. 아이디만 있으면 “다른 타입인데 같은 효과를 내는 이벤트”를 놓친다. 상태만 있으면 재처리 로그가 없어 디버깅이 어렵다. 처리 로그는 보관 주기를 정한다. 90일이면 그 안에 재전송이 끝난다는 운영 약속을 브로커 Retention과 맞춘다.
실패는 재시도 가능한 것(타임아웃, 429, 5xx)과 불가능한 것(스키마 불명, 필수 필드 없음)으로 나눈다. 후자는 데드 레터로 보내고 알람을 켠다. 전자는 지수 백오프와 상한을 둔다. 상한 없이 재시도하면 잘못된 페이로드가 파티션을 막는다. 속도 제한이 있는 외부 API 앞에는 레이트 리밋 설계와 회로 차단기를 소비자 앞에 둔다.
적어도 한 번 전달을 전제로 두면 소비 쪽 유니크·상태 전이가 마지막 방어가 된다
실패 모드 | 릴레이가 멈추거나 두 번 보낼 때
패턴을 도입한 뒤에도 장애는 형태만 바뀐다. 이중 쓰기가 커밋 불일치에서, 대기열 적체·슬롯 팽창·중복 소비로 옮긴다. 관측 항목을 미리 정해 두지 않으면 “이벤트가 안 간다”는 증상만 남는다.
릴레이 중단. 폴링 워커가 죽거나 커넥터가 오프되면 업무 트랜잭션은 이미 커밋되고 큐만 멈춘다. 사용자 화면은 정상이다. outbox의 PENDING 수와 now() - created_at의 p95를 알람으로 건다. 5분 이상 밀리면 호출보다 슬롯·워커부터 본다.
부분 배치 성공. 100건 중 80건 ACK를 받고 프로세스가 죽으면, 남은 20건은 PENDING이다. 이미 ACK 받은 80건은 재시작 후 다시 나가지 않게 상태를 건별로 갱신한다. 배치 전체를 한 플래그로 바꾸면 80건이 중복 발행된다.
논리 복제 슬롯 적체. 디비지움이 따라가지 못하면 WAL이 안 지워진다. 디스크 알람이 디스크 자체보다 슬롯 지연에서 먼저 울려야 한다. 테스트 클러스터에서 커넥터를 끄고 슬롯을 방치한 채 부하를 넣어 한 번 재현해 보면, 프로덕션에서 같은 실수를 덜 한다.
페이로드가 너무 큼. 주문 스냅샷에 이미지 URL 목록·이력 전체를 넣으면 브로커 메시지 한도를 넘긴다. 소비자가 다시 조회할 아이디와 버전만 넣는 “얇은 이벤트”와, 조회 없이 처리하는 “두꺼운 이벤트”를 이벤트 타입별로 고른다. 재고 차감은 두꺼운 편이 편하고, 검색 인덱스 갱신은 아이디만으로 충분할 때가 많다.
시계와 정렬.created_at을 애플리케이션 시계로 넣으면 서버 간 수 밀리초 역전이 난다. 데이터베이스 now()를 쓰거나, 단조 증가 시퀀스를 같이 둔다. 글로벌 순서가 필요하면 아웃박스만으로 부족하고, 단일 집계 안 순서만 약속하는 편이 맞다.
스키마 변경. 컬럼을 먼저 늘리고 나중에 줄이는 순서는 이벤트에도 적용한다. 생산자가 필드를 먼저 넣고, 소비자가 읽게 한 다음, 옛 필드를 뺀다. 한 PR에서 생산·소비 계약을 동시에 깨면 재처리 창 동안 데드 레터가 쌓인다.
멀티 리전 쓰기. 액티브-액티브로 같은 주문 아이디를 두 리전에 쓰면 아웃박스도 두 줄이 생긴다. 집계의 홈 리전을 고정하거나, 아이디에 리전 접두를 둔다. 이 문제는 패턴이 아니라 데이터 거주 설계다.
쓰지 말아야 할 때 | 동기 응답이면 이벤트가 과하다
같은 프로세스, 같은 데이터베이스 안에서 끝나는 일이면 아웃박스가 필요 없다. 주문 저장과 주문 이력 저장이 한 스키마에 있으면 그냥 한 트랜잭션이다. 이벤트를 꺼내는 이유는 다른 서비스, 다른 저장소, 다른 실패 단위가 있을 때다.
호출자가 즉시 결과를 받아야 하는 결제 승인 응답을 이벤트로 바꾸면 UX가 끊긴다. 승인 API는 동기 트랜잭션으로 주문·결제 행을 남기고, 영수증 메일·추천 갱신·분석 파이프만 아웃박스로 뺀다. “모든 부수 효과를 이벤트로”는 디버깅 경로가 길어진다.
초당 한두 건이고 소비자가 하나뿐이면, 트랜잭션 후 동기 발행에 재시도 큐를 얹는 짧은 설계도 있다. 실패 시 보상 작업이 사람이 봐도 될 정도면 패턴의 운영 비용이 더 클 수 있다. 반대로 소비자가 셋 이상이고 재처리 창이 길면, 처음부터 대기 테이블을 두는 편이 싸다.
읽기 모델 갱신만 필요하면 아웃박스 대신 같은 DB의 프로젝션 테이블을 같은 트랜잭션에 쓸 수 있다. 검색 엔진·웨어하우스처럼 다른 저장소로 나갈 때만 대기 행이 이득이다. 쿼리 지연이 걱정이면 대기 행 조회 계획부터 본다. 인덱스와 부분 인덱스는 포스트그레 쿼리 성능 가이드의 설명 계획 절차와 같다.
보안 쪽에서는 페이로드에 카드 번호·주민등록번호·접근 토큰을 넣지 않는다. 이벤트는 로그와 토픽에 오래 남는다. 식별자는 내부 아이디로 두고, 민감 값은 소비자가 권한이 있는 API로 다시 읽는다. 토픽 ACL과 스키마 레지스트리 접근은 애플리케이션 시크릿과 같은 등급으로 취급한다.