- Published on
MSA 사가 패턴 - Outbox+CDC로 중복결제 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 타임아웃 났는데 결제는 이미 승인됐고, 클라이언트는 재시도해서 중복결제가 발생했다. MSA에서 결제 플로우를 운영하다 보면 가장 무서운 케이스 중 하나입니다. 단일 DB 트랜잭션으로 주문 생성 + 결제 승인 + 재고 차감을 한 번에 묶을 수 없기 때문에, 결국 분산 트랜잭션을 애플리케이션 레벨에서 안전하게 흉내 내야 합니다.
이 글은 사가(Saga) 패턴을 기반으로, 이벤트 발행의 신뢰성을 Outbox 패턴으로 보장하고, 브로커/프로듀서 장애나 이중 발행 문제를 CDC(Change Data Capture) 로 단순화해 중복결제를 구조적으로 막는 방법을 다룹니다.
운영 관점에서 특히 중요한 키워드는 아래 4가지입니다.
- 멱등성(Idempotency): 동일 요청이 여러 번 와도 결과가 한 번만 반영
- Exactly-once 환상 버리기: 현실은 at-least-once, 따라서 컨슈머 멱등 처리
- Outbox로 원자성 확보: 비즈니스 상태 변경과 이벤트 기록을 같은 DB 트랜잭션에
- CDC로 전파 책임 분리: 앱이 “브로커에 보냈다”를 보장하려 하지 말고 DB 변경을 스트리밍
관련해서 DB 운영 안정성(특히 WAL/테이블 팽창)은 CDC 품질에 직결됩니다. Postgres를 쓴다면 PostgreSQL VACUUM 안됨? bloat·wraparound 10분 진단도 함께 참고하면 좋습니다.
왜 사가에서 중복결제가 생기는가
사가 패턴은 긴 비즈니스 트랜잭션을 여러 로컬 트랜잭션으로 분해하고, 실패 시 보상(Compensation)으로 되돌립니다. 문제는 결제가 외부 PG(결제대행)와 연동되는 순간부터입니다.
대표적인 중복결제 시나리오는 다음과 같습니다.
- 주문 서비스가 결제 서비스에
AuthorizePayment요청 - 결제 서비스가 PG에 승인 요청을 보내고 성공 응답을 받음
- 결제 서비스가 DB에
PAID로 저장하기 전에 장애(프로세스 크래시, DB 타임아웃) - 주문 서비스는 타임아웃으로 실패로 간주하고 재시도
- 결제 서비스는 같은 승인 요청을 다시 보내고 PG가 또 승인 → 중복결제
즉, “승인 요청을 한 번만 보내기”가 아니라 승인 요청이 여러 번 나가도 결과가 한 번만 반영되도록 만들어야 합니다.
여기서 필요한 게 (1) 결제 요청 멱등성 키, (2) 결제 상태 머신, (3) 이벤트 발행 신뢰성(Outbox), 그리고 (4) 이벤트 전파의 안정성(CDC) 입니다.
목표 아키텍처: Outbox + CDC + 사가 오케스트레이션
구성은 크게 3가지 선택지가 있습니다.
- 코레오그래피(이벤트 기반) 사가: 서비스들이 이벤트를 구독/발행하며 진행
- 오케스트레이션 사가: 중앙 오케스트레이터(주문 서비스 등)가 단계별 명령을 내림
- 하이브리드: 핵심 단계는 오케스트레이터, 주변은 이벤트
중복결제 방지 관점에서 핵심은 결제 서비스의 멱등 처리와 상태 전이, 그리고 이벤트 발행의 신뢰성입니다. 이를 위해 결제 서비스는 다음을 갖습니다.
- 결제 DB에
payments테이블(상태/멱등키/PG 트랜잭션 키) - 같은 트랜잭션에
outbox_events테이블 기록 - CDC(예: Debezium)가
outbox_events변경을 Kafka 등으로 스트리밍 - 컨슈머는 멱등하게 처리(중복 이벤트 허용)
왜 Outbox만으로는 부족하고 CDC가 필요한가
Outbox 패턴은 “DB 트랜잭션으로 상태 변경과 이벤트 기록을 묶는다”는 점에서 강력합니다. 하지만 Outbox만 쓰면 보통 애플리케이션이 아래 역할까지 떠안습니다.
- Outbox 폴링/락/배치/재시도
- 브로커 전송 실패 처리
- 중복 전송 방지
CDC를 붙이면 전송 책임을 앱에서 분리할 수 있습니다.
- 앱은
outbox_events에만 쓰면 됨 - CDC가 WAL 기반으로 변경을 읽어 브로커에 전달
- 앱 크래시/재배포/일시 중단과 무관하게 전파가 지속
물론 CDC도 만능은 아닙니다. 운영 포인트가 DB 쪽으로 이동합니다(Replication slot, WAL retention, vacuum 등). 그래서 DB 운영 체크가 중요합니다.
결제 서비스 데이터 모델: 멱등성과 상태 머신
중복결제를 막는 가장 확실한 방법은 멱등성 키를 결제의 유일성 제약으로 강제하는 것입니다.
테이블 예시(PostgreSQL)
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
idempotency_key TEXT NOT NULL,
amount BIGINT NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL, -- INIT, AUTHORIZED, CAPTURED, FAILED, CANCELED
pg_payment_id TEXT, -- PG가 발급한 결제 식별자
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 같은 주문에 같은 멱등키로는 단 1건만 생성되도록 강제
CREATE UNIQUE INDEX ux_payments_order_id_idempotency
ON payments(order_id, idempotency_key);
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL, -- Payment
aggregate_id BIGINT NOT NULL, -- payments.id
event_type TEXT NOT NULL, -- PaymentAuthorized, PaymentFailed...
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX ix_outbox_unpublished
ON outbox_events(created_at)
WHERE published_at IS NULL;
핵심은 ux_payments_order_id_idempotency 입니다. 애플리케이션 버그/재시도/네트워크 흔들림이 있어도, DB가 중복 결제 엔티티 생성 자체를 차단합니다.
상태 머신 설계 팁
결제는 보통 다음 상태를 가집니다.
INIT: 결제 생성됨(아직 PG 승인 전)AUTHORIZED: 승인 완료(캡처 전)CAPTURED: 매입/캡처 완료(실제 과금 확정)FAILED: 실패CANCELED: 취소/보상 완료
중요한 규칙은 “같은 멱등키로 들어온 요청은 기존 상태를 반환”하는 것입니다.
결제 API: 멱등성 처리 로직(중복 호출 방어)
아래는 Node.js(Express) + PostgreSQL 예시입니다. 포인트는 다음입니다.
INSERT ... ON CONFLICT ... DO UPDATE로 멱등 엔티티 확보- 이미
AUTHORIZED이상이면 PG 재호출 금지 - PG 호출 결과를 저장하는 트랜잭션 안에서 Outbox 이벤트까지 기록
import { Pool } from "pg";
type PaymentStatus = "INIT" | "AUTHORIZED" | "CAPTURED" | "FAILED" | "CANCELED";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function authorizePayment(params: {
orderId: number;
idempotencyKey: string;
amount: number;
currency: string;
}) {
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1) 멱등 엔티티 확보
const upsert = await client.query(
`
INSERT INTO payments(order_id, idempotency_key, amount, currency, status)
VALUES ($1, $2, $3, $4, 'INIT')
ON CONFLICT (order_id, idempotency_key)
DO UPDATE SET updated_at = now()
RETURNING id, status, pg_payment_id, amount, currency;
`,
[params.orderId, params.idempotencyKey, params.amount, params.currency]
);
const payment = upsert.rows[0] as {
id: number;
status: PaymentStatus;
pg_payment_id: string | null;
amount: number;
currency: string;
};
// 2) 이미 승인/캡처된 결제면 그대로 반환 (중복 PG 호출 방지)
if (payment.status === "AUTHORIZED" || payment.status === "CAPTURED") {
await client.query("COMMIT");
return { paymentId: payment.id, status: payment.status, reused: true };
}
// 3) PG 승인 호출 (네트워크/타임아웃으로 중복 호출될 수 있으므로
// 반드시 PG에도 idempotencyKey를 전달하는 게 이상적)
const pgResult = await callPgAuthorize({
orderId: params.orderId,
amount: params.amount,
currency: params.currency,
idempotencyKey: params.idempotencyKey,
});
// 4) 승인 결과 저장 + Outbox 기록을 같은 트랜잭션으로
await client.query(
`
UPDATE payments
SET status = 'AUTHORIZED', pg_payment_id = $2, updated_at = now()
WHERE id = $1;
`,
[payment.id, pgResult.pgPaymentId]
);
await client.query(
`
INSERT INTO outbox_events(aggregate_type, aggregate_id, event_type, payload)
VALUES ('Payment', $1, 'PaymentAuthorized', $2::jsonb);
`,
[payment.id, JSON.stringify({ orderId: params.orderId, paymentId: payment.id, pgPaymentId: pgResult.pgPaymentId })]
);
await client.query("COMMIT");
return { paymentId: payment.id, status: "AUTHORIZED" as const, reused: false };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
async function callPgAuthorize(input: {
orderId: number;
amount: number;
currency: string;
idempotencyKey: string;
}): Promise<{ pgPaymentId: string }> {
// 예시: 실제로는 PG SDK/HTTP 호출
// 중요한 점: 가능하면 PG API에도 idempotency key를 전달
return { pgPaymentId: `pg_${input.orderId}_${Date.now()}` };
}
여기서 “PG에도 멱등키를 전달”이 가능하면 최강입니다. 하지만 PG가 멱등키를 지원하지 않거나, 지원하더라도 내부 정책이 불명확한 경우가 있습니다. 그럴수록 우리 DB의 유니크 제약 + 상태 머신이 더 중요해집니다.
Outbox 이벤트를 CDC로 발행하기
이제 outbox_events에 쌓인 이벤트를 브로커로 내보내야 합니다.
- Outbox 폴러(애플리케이션/잡)가 직접 Kafka로 발행
- 또는 Debezium 같은 CDC가 DB WAL을 읽어 Kafka로 발행
여기서는 CDC 방식을 가정합니다.
CDC 토픽 설계
CDC가 outbox_events 변경을 Kafka로 보내면, 컨슈머는 보통 다음 중 하나로 처리합니다.
- Debezium 원본 메시지 포맷을 그대로 소비
- Kafka Connect SMT로 메시지 형태를 변환해
payload만 전달 - 별도 스트림 프로세싱으로
event_type기준 라우팅
실전에서는 “outbox row 전체”를 보내되, 컨슈머에서 event_type과 aggregate_id를 기준으로 분기하는 방식이 단순합니다.
컨슈머 멱등 처리(중복 이벤트 허용)
CDC는 기본적으로 at-least-once입니다. 동일 이벤트가 재전송될 수 있으므로, 컨슈머는 처리 로그 테이블 또는 업서트로 멱등하게 만들어야 합니다.
예: 주문 서비스가 PaymentAuthorized를 받아 주문 상태를 PAID로 바꾸는 로직.
CREATE TABLE inbox_processed (
consumer_name TEXT NOT NULL,
event_id BIGINT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.ORDER_DB_URL });
export async function handlePaymentAuthorized(event: {
eventId: number; // outbox_events.id
payload: { orderId: number; paymentId: number; pgPaymentId: string };
}) {
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1) 이미 처리한 이벤트면 스킵
const ins = await client.query(
`
INSERT INTO inbox_processed(consumer_name, event_id)
VALUES ('order-service', $1)
ON CONFLICT DO NOTHING
RETURNING event_id;
`,
[event.eventId]
);
if (ins.rowCount === 0) {
await client.query("COMMIT");
return { skipped: true };
}
// 2) 주문 상태 전이 (이미 PAID여도 문제 없게)
await client.query(
`
UPDATE orders
SET status = 'PAID', updated_at = now()
WHERE id = $1 AND status IN ('CREATED', 'PAYMENT_PENDING');
`,
[event.payload.orderId]
);
await client.query("COMMIT");
return { skipped: false };
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
이 패턴을 흔히 Inbox(consumer-side dedup) 라고도 부릅니다. Outbox와 짝을 이루면, 생산자/소비자 모두에서 중복을 견딜 수 있습니다.
사가 오케스트레이션: 결제 실패와 보상 트랜잭션
중복결제 방지는 “결제 승인 요청을 한 번만”이 아니라 “전체 플로우가 실패해도 돈이 잘못 빠지지 않게”까지 포함합니다.
오케스트레이션 사가에서 주문 서비스는 대략 이런 단계를 가질 수 있습니다.
- 주문 생성(
CREATED) - 결제 승인 요청 →
PAYMENT_PENDING PaymentAuthorized수신 →PAID- 재고 차감 요청
- 배송 요청
실패 시 보상은 반대 방향입니다.
- 재고 차감 실패 시: 결제 취소(VOID/REFUND) 요청
- 배송 생성 실패 시: 재고 복구 + 결제 취소
여기서도 Outbox+CDC를 동일하게 적용합니다.
- 주문 서비스가
CancelPayment커맨드를 결제 서비스로 보내는 방식이 HTTP라면, 재시도 시 커맨드 멱등키가 필요 - 이벤트 기반이라면
PaymentCancelRequested같은 이벤트를 Outbox에 기록하고 CDC로 전파
결제 취소 또한 승인과 마찬가지로 멱등해야 합니다.
- 동일
paymentId에 대해 취소는 한 번만 수행 - 이미 취소된 상태면 성공으로 응답
운영 체크리스트: Outbox+CDC에서 자주 터지는 지점
1) DB 부하와 WAL 증가
CDC는 WAL을 읽습니다. 트래픽이 많거나 Outbox가 과도하게 쌓이면 WAL이 급증하고 디스크 압박이 생깁니다.
- Outbox payload 크기 제한(예: 10KB 이하)
- 이벤트는 “사실”만 담고, 상세는 조회로 보완(또는 별도 스냅샷 토픽)
- 파티션 키를
aggregate_id로 잡아 순서 보장
Postgres라면 vacuum/테이블 팽창 이슈가 CDC 지연으로 이어질 수 있습니다. PostgreSQL VACUUM 안됨? bloat·wraparound 10분 진단을 같이 보면 원인 파악이 빨라집니다.
2) Replication slot 적체
Debezium이 장애로 멈추면 replication slot이 WAL을 계속 붙잡아 디스크가 찰 수 있습니다.
- slot 모니터링(지연 바이트)
- Debezium 재시작 자동화
- 최악의 경우 slot drop/recreate 절차 마련(데이터 재동기화 전략 포함)
3) 이벤트 스키마 진화
Outbox payload는 결국 계약입니다.
event_type버저닝(PaymentAuthorizedV2같은 방식 또는 payload에version필드)- 컨슈머의 하위 호환 파싱
4) 보안/인증 문제로 인한 재시도 폭증
서비스 간 인증이 꼬이면(예: JWT aud/iss 불일치) 결제 요청이 실패하고 재시도가 폭증해 중복 위험이 커집니다. 인증 계층 이슈는 먼저 제거하세요: OAuth 로그인 후 401? JWT aud/iss 불일치 점검
테스트 전략: “중복결제”를 재현 가능한 케이스로 만들기
중복결제는 재현이 어려워 보이지만, 아래 테스트를 자동화하면 상당 부분 잡을 수 있습니다.
- 결제 승인 직후 프로세스 강제 종료(승인 성공, DB 저장 실패)
- 네트워크 타임아웃 시뮬레이션(클라이언트 재시도)
- 동일
idempotencyKey로 10개 동시 요청(경합) - CDC 지연/중단 후 재개(이벤트 중복/재전송)
특히 “동시 요청 경합”은 DB 유니크 인덱스로 깔끔하게 정리되는지 확인해야 합니다.
결론: 중복결제 방지는 패턴 조합 문제다
사가 패턴만으로는 중복결제를 막지 못합니다. 사가는 분산 트랜잭션의 진행/보상을 정의할 뿐, 재시도와 중복은 별도의 메커니즘이 필요합니다.
실전에서 가장 효과적인 조합은 다음입니다.
- 결제/취소 API에 멱등성 키를 도입하고 DB 유니크 제약으로 강제
- 결제 상태를 명확한 상태 머신으로 관리하고, 특정 상태에서는 외부 PG를 재호출하지 않기
- 상태 변경과 이벤트 기록을 Outbox 로 같은 트랜잭션에 묶기
- 이벤트 전파는 CDC 로 위임하고, 컨슈머는 Inbox(처리 로그) 로 멱등 처리
이 4가지를 갖추면, 네트워크 타임아웃/프로세스 크래시/브로커 재전송 같은 현실적인 장애에서도 “돈은 한 번만 빠지는” 시스템에 가까워집니다.