TechFeedTechFeed
Backend

트랜잭션 아웃박스, 이중 쓰기, CDC, 디비지움 | 주문과 큐를 어떻게 같이 맞출까?

주문 저장과 큐 발행이 다른 커밋이면 없는 주문의 알림이 나가거나 재고 이벤트가 빠진다. 트랜잭션 아웃박스 테이블 칸, 폴링 릴레이와 디비지움 변경 캡처, 파티션 키 순서, 소비 멱등, 논리 복제 슬롯 적체 알람을 백엔드, API, 데이터, PostgreSQL, 클라우드, 개발자, 데브옵스, TypeScript 서비스 기준으로 풀어 쓴 심층 가이드.

by

주문 행을 커밋한 뒤 큐에 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.createdorder.paid가 연속으로 나가면, 소비자는 생성보다 결제를 먼저 받으면 안 된다. 카프카는 파티션 안에서만 순서를 보장한다. 그래서 브로커 키를 aggregate_id(주문 아이디)로 둔다. 같은 주문의 이벤트는 같은 파티션에 들어가고, 다른 주문은 병렬로 흐른다.


폴링 릴레이가 한 번에 100행을 집어 병렬로 발행하면, 같은 주문의 두 행이 역전될 수 있다. 배치 안에서도 집계 아이디별로 직렬화하거나, 한 워커가 한 집계만 맡게 샤딩한다. 레디스 스트림이나 래빗엠큐를 쓸 때도 “키 단위 순서”를 큐 선택이 받쳐 주는지 먼저 본다. 노드 쪽 작업 큐만 필요할 때는 불엠큐 튜토리얼의 잡 아이디·반복 제한이 릴레이 구현에 가깝다.


게시가 성공한 뒤에만 상태를 PUBLISHED로 바꾼다. 브로커가 ACK를 주기 전에 프로세스가 죽으면 행은 PENDING으로 남고, 재시작 후 다시 나간다. 그래서 소비자는 같은 id를 두 번 받을 수 있다고 전제한다. “정확히 한 번 발행”을 브로커만으로 약속하지 말고, “적어도 한 번 발행 + 소비 멱등”으로 계약을 적는다.


헤더에 이벤트 아이디, 타입, 발생 시각, 스키마 버전을 넣는다. 본문만 보면 구 버전 소비자가 새 필드를 필수로 알고 죽는다. 버전을 헤더로 빼고, 필드는 추가만 허용하는 편이 롤백에 덜 깨진다. 한글 필드명을 페이로드에 쓸지는 팀 계약이다. 검색·로그를 영어 키로 통일하고 값만 한글을 넣는 팀이 많다.


소비 멱등 | 같은 이벤트가 두 번 와도

릴레이가 재시도하면 중복은 정상이 된다. 소비 쪽은 이벤트 id를 처리 로그 테이블에 유니크로 넣고, 이미 있으면 본 작업을 건너뛴다. 본 작업(재고 차감, 포인트 적립, 메일 발송)과 로그 인서트를 같은 트랜잭션에 두면, 차감만 되고 로그가 없는 구멍이 줄어든다. 메일은 트랜잭션 밖이므로 “발송 예약 행”을 같은 트랜잭션에 만들고 워커가 보낸다. 또 아웃박스다.


중복 방어어디에 두나한계
이벤트 id 유니크소비자 DB다른 이벤트가 같은 효과를 내면 못 막음
업무 유니크 (주문당 적립 1회)포인트·쿠폰 테이블이벤트 타입이 늘면 제약이 늘어남
상태 머신 전이주문.status이미 PAID인데 PAID를 다시 받아도 no-op
브로커 오프셋 커밋카프카 컨슈머 그룹처리 후 커밋 전에 죽으면 재처리

상태 머신과 이벤트 아이디를 같이 쓰는 편이 안전하다. 아이디만 있으면 “다른 타입인데 같은 효과를 내는 이벤트”를 놓친다. 상태만 있으면 재처리 로그가 없어 디버깅이 어렵다. 처리 로그는 보관 주기를 정한다. 90일이면 그 안에 재전송이 끝난다는 운영 약속을 브로커 Retention과 맞춘다.


실패는 재시도 가능한 것(타임아웃, 429, 5xx)과 불가능한 것(스키마 불명, 필수 필드 없음)으로 나눈다. 후자는 데드 레터로 보내고 알람을 켠다. 전자는 지수 백오프와 상한을 둔다. 상한 없이 재시도하면 잘못된 페이로드가 파티션을 막는다. 속도 제한이 있는 외부 API 앞에는 레이트 리밋 설계회로 차단기를 소비자 앞에 둔다.


메시지 큐 컨슈머가 중복 이벤트를 걸러 내는 처리 흐름
적어도 한 번 전달을 전제로 두면 소비 쪽 유니크·상태 전이가 마지막 방어가 된다

실패 모드 | 릴레이가 멈추거나 두 번 보낼 때

패턴을 도입한 뒤에도 장애는 형태만 바뀐다. 이중 쓰기가 커밋 불일치에서, 대기열 적체·슬롯 팽창·중복 소비로 옮긴다. 관측 항목을 미리 정해 두지 않으면 “이벤트가 안 간다”는 증상만 남는다.


릴레이 중단. 폴링 워커가 죽거나 커넥터가 오프되면 업무 트랜잭션은 이미 커밋되고 큐만 멈춘다. 사용자 화면은 정상이다. outboxPENDING 수와 now() - created_at의 p95를 알람으로 건다. 5분 이상 밀리면 호출보다 슬롯·워커부터 본다.


부분 배치 성공. 100건 중 80건 ACK를 받고 프로세스가 죽으면, 남은 20건은 PENDING이다. 이미 ACK 받은 80건은 재시작 후 다시 나가지 않게 상태를 건별로 갱신한다. 배치 전체를 한 플래그로 바꾸면 80건이 중복 발행된다.


논리 복제 슬롯 적체. 디비지움이 따라가지 못하면 WAL이 안 지워진다. 디스크 알람이 디스크 자체보다 슬롯 지연에서 먼저 울려야 한다. 테스트 클러스터에서 커넥터를 끄고 슬롯을 방치한 채 부하를 넣어 한 번 재현해 보면, 프로덕션에서 같은 실수를 덜 한다.


페이로드가 너무 큼. 주문 스냅샷에 이미지 URL 목록·이력 전체를 넣으면 브로커 메시지 한도를 넘긴다. 소비자가 다시 조회할 아이디와 버전만 넣는 “얇은 이벤트”와, 조회 없이 처리하는 “두꺼운 이벤트”를 이벤트 타입별로 고른다. 재고 차감은 두꺼운 편이 편하고, 검색 인덱스 갱신은 아이디만으로 충분할 때가 많다.


시계와 정렬. created_at을 애플리케이션 시계로 넣으면 서버 간 수 밀리초 역전이 난다. 데이터베이스 now()를 쓰거나, 단조 증가 시퀀스를 같이 둔다. 글로벌 순서가 필요하면 아웃박스만으로 부족하고, 단일 집계 안 순서만 약속하는 편이 맞다.


스키마 변경. 컬럼을 먼저 늘리고 나중에 줄이는 순서는 이벤트에도 적용한다. 생산자가 필드를 먼저 넣고, 소비자가 읽게 한 다음, 옛 필드를 뺀다. 한 PR에서 생산·소비 계약을 동시에 깨면 재처리 창 동안 데드 레터가 쌓인다.


멀티 리전 쓰기. 액티브-액티브로 같은 주문 아이디를 두 리전에 쓰면 아웃박스도 두 줄이 생긴다. 집계의 홈 리전을 고정하거나, 아이디에 리전 접두를 둔다. 이 문제는 패턴이 아니라 데이터 거주 설계다.


쓰지 말아야 할 때 | 동기 응답이면 이벤트가 과하다

같은 프로세스, 같은 데이터베이스 안에서 끝나는 일이면 아웃박스가 필요 없다. 주문 저장과 주문 이력 저장이 한 스키마에 있으면 그냥 한 트랜잭션이다. 이벤트를 꺼내는 이유는 다른 서비스, 다른 저장소, 다른 실패 단위가 있을 때다.


호출자가 즉시 결과를 받아야 하는 결제 승인 응답을 이벤트로 바꾸면 UX가 끊긴다. 승인 API는 동기 트랜잭션으로 주문·결제 행을 남기고, 영수증 메일·추천 갱신·분석 파이프만 아웃박스로 뺀다. “모든 부수 효과를 이벤트로”는 디버깅 경로가 길어진다.


초당 한두 건이고 소비자가 하나뿐이면, 트랜잭션 후 동기 발행에 재시도 큐를 얹는 짧은 설계도 있다. 실패 시 보상 작업이 사람이 봐도 될 정도면 패턴의 운영 비용이 더 클 수 있다. 반대로 소비자가 셋 이상이고 재처리 창이 길면, 처음부터 대기 테이블을 두는 편이 싸다.


읽기 모델 갱신만 필요하면 아웃박스 대신 같은 DB의 프로젝션 테이블을 같은 트랜잭션에 쓸 수 있다. 검색 엔진·웨어하우스처럼 다른 저장소로 나갈 때만 대기 행이 이득이다. 쿼리 지연이 걱정이면 대기 행 조회 계획부터 본다. 인덱스와 부분 인덱스는 포스트그레 쿼리 성능 가이드의 설명 계획 절차와 같다.


보안 쪽에서는 페이로드에 카드 번호·주민등록번호·접근 토큰을 넣지 않는다. 이벤트는 로그와 토픽에 오래 남는다. 식별자는 내부 아이디로 두고, 민감 값은 소비자가 권한이 있는 API로 다시 읽는다. 토픽 ACL과 스키마 레지스트리 접근은 애플리케이션 시크릿과 같은 등급으로 취급한다.


참고 자료


자주 묻는 질문

아웃박스와 메시지 큐는 무엇이 다른가?

큐는 서비스 사이에 이벤트를 나르는 파이프다. 아웃박스는 그 파이프에 넣기 전에, 업무 데이터와 같은 커밋에 대기 행을 남기는 테이블이다. 큐만 있으면 이중 쓰기가 남고, 테이블만 있으면 다른 서비스가 구독하지 못한다.


폴링과 디비지움 중 처음부터 무엇을 고르면 되나?

대기 지연 5초가 허용되고 초당 발행이 수백 이하면 폴링으로 시작한다. 슬롯·커넥터 온콜이 없는 팀이 디비지움부터 켜면 디스크 알람을 이벤트로 착각하기 쉽다. 지연과 처리량이 숫자로 부족해지면 캡처로 옮긴다.


게시한 행은 지워도 되나?

재처리 창과 감사 기간이 끝난 뒤에 지운다. 바로 삭제하면 “이 아이디를 이미 보냈는지”를 릴레이가 알 수 없다. 상태 컬럼으로 제외하고, 주기 배치로 아카이브하는 편이 안전하다. 캡처 방식은 인서트만 보고 삭제해도 이미 토픽에 있다.


사가 오케스트레이션이 있으면 아웃박스가 필요 없나?

사가는 여러 서비스의 보상 흐름이다. 각 서비스가 자기 커밋과 발행을 맞추는 일은 그대로 남는다. 오케스트레이터에 명령을 보내는 그 한 줄도 대기 테이블을 거치는 편이 안전하다.


몽고디비처럼 문서 저장소에도 같은 패턴을 쓰나?

같은 트랜잭션(또는 다중 문서 트랜잭션) 안에 업무 문서와 아웃박스 문서를 넣으면 된다. 캡처는 체인지 스트림으로 대체한다. 트랜잭션을 지원하지 않는 엔진이면 패턴의 전제가 무너지므로, 먼저 원자 쓰기가 되는지 확인한다.


이벤트 아이디는 UUID면 충분한가?

유니크와 분산 생성에는 충분하다. 정렬이 필요하면 ULID나 DB 시퀀스를 같이 둔다. 아이디를 주문번호 해시로 만들면 같은 주문의 다른 이벤트 타입이 충돌한다. 타입을 아이디에 섞거나 타입+주문을 복합 유니크로 둔다.


트랜잭션 아웃박스이중 쓰기CDC디비지움백엔드API데이터PostgreSQL클라우드개발자메시지 큐멱등

함께 보면 좋은 문제 해결

EXPLORE / Backend

이어서 읽어보기

전체 토픽 둘러보기