Published on

MSA 사가 패턴 - Outbox+CDC로 중복결제 막기

Authors

서버가 타임아웃 났는데 결제는 이미 승인됐고, 클라이언트는 재시도해서 중복결제가 발생했다. 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(결제대행)와 연동되는 순간부터입니다.

대표적인 중복결제 시나리오는 다음과 같습니다.

  1. 주문 서비스가 결제 서비스에 AuthorizePayment 요청
  2. 결제 서비스가 PG에 승인 요청을 보내고 성공 응답을 받음
  3. 결제 서비스가 DB에 PAID로 저장하기 전에 장애(프로세스 크래시, DB 타임아웃)
  4. 주문 서비스는 타임아웃으로 실패로 간주하고 재시도
  5. 결제 서비스는 같은 승인 요청을 다시 보내고 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_typeaggregate_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와 짝을 이루면, 생산자/소비자 모두에서 중복을 견딜 수 있습니다.

사가 오케스트레이션: 결제 실패와 보상 트랜잭션

중복결제 방지는 “결제 승인 요청을 한 번만”이 아니라 “전체 플로우가 실패해도 돈이 잘못 빠지지 않게”까지 포함합니다.

오케스트레이션 사가에서 주문 서비스는 대략 이런 단계를 가질 수 있습니다.

  1. 주문 생성(CREATED)
  2. 결제 승인 요청 → PAYMENT_PENDING
  3. PaymentAuthorized 수신 → PAID
  4. 재고 차감 요청
  5. 배송 요청

실패 시 보상은 반대 방향입니다.

  • 재고 차감 실패 시: 결제 취소(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가지를 갖추면, 네트워크 타임아웃/프로세스 크래시/브로커 재전송 같은 현실적인 장애에서도 “돈은 한 번만 빠지는” 시스템에 가까워집니다.