Published on

MSA Saga 중복결제 방지 - Outbox+Debezium+Kafka

Authors

서로 다른 서비스가 결제라는 단일 비즈니스 결과를 만들어내는 MSA에서는, 사가(Saga) 자체보다 중복결제(duplicate charge) 를 어떻게 막느냐가 진짜 난제입니다. 네트워크 재시도, 컨슈머 리밸런스, 프로듀서 재전송, 타임아웃 후 재호출 같은 “정상적인” 이벤트들이 결제 도메인에서는 곧바로 사고로 이어질 수 있습니다.

이 글에서는 Outbox 패턴 + Debezium CDC + Kafka 조합으로

  • 결제 요청을 정확히 한 번만 처리한 것처럼 보이게 만들고
  • 이벤트 발행과 DB 커밋의 원자성을 확보하며
  • 사가 오케스트레이션/코레오그래피 어느 쪽에도 적용 가능한

실전 설계를 정리합니다.

또한 “Kafka EOS를 켜면 끝 아닌가” 같은 흔한 오해를 풀고, 실제 운영에서 필요한 idempotency 키 설계, 데이터 모델링, 컨슈머 중복처리 방어, 관측/재처리 전략까지 다룹니다.

왜 사가에서 중복결제가 발생하는가

사가에서 중복결제는 대개 아래 조합으로 발생합니다.

1) 클라이언트 재시도와 결제 API의 비멱등성

  • 모바일 앱이 타임아웃으로 동일 결제 요청을 재전송
  • API Gateway가 502를 받고 자동 재시도
  • 결제 서비스가 “요청을 받았는지” 상태를 잃고 다시 승인 호출

2) 메시지 시스템의 at-least-once 특성

Kafka는 기본적으로 컨슈머 관점에서 at-least-once에 가깝습니다.

  • 컨슈머가 처리 후 커밋 전에 죽으면 재처리
  • 리밸런스 중 동일 레코드가 다시 할당
  • 프로듀서 재시도로 동일 메시지가 중복 발행

3) DB 커밋과 이벤트 발행의 분리

결제 서비스가 다음 순서로 동작하면 문제가 커집니다.

  1. DB에 결제 상태 업데이트
  2. Kafka에 PaymentApproved 발행

여기서 1은 성공, 2는 실패하면 다운스트림은 결제 승인 사실을 모릅니다. 반대로 2는 성공, 1은 롤백이면 “승인 이벤트는 있는데 DB는 미승인” 같은 유령 상태가 생깁니다.

이 간극을 메우는 대표적인 답이 트랜잭셔널 아웃박스(Outbox) 입니다.

목표: “중복을 허용하되 결과는 한 번만”

현실적인 목표는 “진짜 exactly-once”가 아니라 다음입니다.

  • 입력(명령/이벤트)은 중복될 수 있다
  • 하지만 결제 승인(외부 PG 호출)과 내부 상태 전이는 한 번만 일어나야 한다
  • 다운스트림은 중복 이벤트를 받아도 항상 같은 최종 상태로 수렴해야 한다

즉, 시스템 전체를 멱등(idempotent)하게 만드는 것이 핵심입니다.

핵심 구성요소 개요

Outbox 패턴

업무 트랜잭션 안에서

  • 도메인 상태 변경(예: payments 테이블 업데이트)
  • 이벤트 레코드 저장(예: outbox_events 테이블 insert)

같은 DB 트랜잭션으로 묶습니다.

Debezium CDC

DB의 binlog/WAL을 읽어 outbox_events 테이블의 insert를 감지하고 Kafka로 내보냅니다.

  • 애플리케이션이 Kafka 발행을 직접 하지 않습니다.
  • “DB 커밋이 되면 이벤트는 언젠가 반드시 Kafka에 나타난다”를 보장합니다.

Kafka

사가의 이벤트 버스 역할.

  • 다운스트림 서비스는 이벤트를 구독하고 로컬 트랜잭션으로 상태를 갱신
  • 중복 이벤트는 컨슈머 멱등 처리로 흡수

데이터 모델: 결제 멱등성의 기준은 무엇인가

중복결제를 막으려면 먼저 “같은 결제”를 정의해야 합니다.

권장 기준은 다음 중 하나입니다.

  • payment_attempt_id: 클라이언트가 생성하는 UUID (가장 흔함)
  • order_id + payment_method + amount 조합 (비추천: 금액 변경/부분결제 등 확장에 취약)
  • PG에서 제공하는 idempotency 키 (가능하면 적극 활용)

이 글에서는 payment_attempt_id를 기준으로 설명합니다.

예시: payments 테이블

CREATE TABLE payments (
  id BIGSERIAL PRIMARY KEY,
  payment_attempt_id UUID NOT NULL,
  order_id BIGINT NOT NULL,
  amount NUMERIC(18,2) NOT NULL,
  status VARCHAR(32) NOT NULL,
  pg_transaction_id VARCHAR(128),
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (payment_attempt_id)
);

UNIQUE (payment_attempt_id)가 1차 방어선입니다. 동일 시도 ID로 결제 생성 자체가 두 번 일어나지 못합니다.

예시: outbox_events 테이블

CREATE TABLE outbox_events (
  id BIGSERIAL PRIMARY KEY,
  aggregate_type VARCHAR(64) NOT NULL,
  aggregate_id VARCHAR(128) NOT NULL,
  event_type VARCHAR(128) NOT NULL,
  event_key VARCHAR(128) NOT NULL,
  payload JSONB NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  published_at TIMESTAMP,
  UNIQUE (event_type, event_key)
);

CREATE INDEX idx_outbox_created_at ON outbox_events(created_at);
  • event_key는 보통 payment_attempt_id 또는 order_id를 넣습니다.
  • UNIQUE (event_type, event_key)는 동일 이벤트의 중복 insert를 방지합니다.

결제 서비스: “승인 호출”을 한 번만 수행하는 방법

결제 서비스는 외부 PG 호출이 포함되므로, 내부 DB만 멱등하다고 끝이 아닙니다.

권장 흐름

  1. payment_attempt_id로 결제 레코드 생성 또는 조회
  2. 상태가 이미 APPROVED면 즉시 동일 결과 반환
  3. 상태가 PENDING이면 “이미 처리 중” 응답 또는 동일 처리 흐름 합류
  4. PG 승인 호출은 상태 전이를 이용해 단 한 번만 트리거

아래는 의사코드 예시입니다.

// TypeScript pseudo
async function approvePayment(cmd: {
  paymentAttemptId: string
  orderId: number
  amount: string
}) {
  return db.transaction(async (tx) => {
    // 1) 결제 레코드 upsert
    const payment = await tx.payments.upsert({
      where: { payment_attempt_id: cmd.paymentAttemptId },
      create: {
        payment_attempt_id: cmd.paymentAttemptId,
        order_id: cmd.orderId,
        amount: cmd.amount,
        status: "PENDING",
      },
      update: {},
    })

    // 2) 이미 승인된 경우 멱등 반환
    if (payment.status === "APPROVED") {
      return { status: "APPROVED", pgTransactionId: payment.pg_transaction_id }
    }

    // 3) 여기서 중요한 점: PG 호출을 트랜잭션 안에서 직접 하면 락/타임아웃이 커짐
    //    대신 '승인 필요' 상태를 저장하고, 워커/사가 단계에서 처리하거나
    //    상태 전이를 CAS로 수행해 중복 호출을 막는다.

    // 4) outbox에 'PaymentApprovalRequested' 이벤트 적재
    await tx.outbox_events.insert({
      aggregate_type: "payment",
      aggregate_id: String(payment.id),
      event_type: "PaymentApprovalRequested",
      event_key: cmd.paymentAttemptId,
      payload: {
        paymentAttemptId: cmd.paymentAttemptId,
        orderId: cmd.orderId,
        amount: cmd.amount,
      },
    })

    return { status: "PENDING" }
  })
}

여기서 포인트는 결제 승인(PG 호출)을 동기 HTTP 요청 처리 경로에서 분리하는 것입니다.

  • 동기 경로에서 PG까지 호출하면 “클라이언트 타임아웃 후 재시도”가 더 쉽게 발생합니다.
  • 승인 호출은 이벤트 기반 워커가 담당하고, 워커는 멱등하게 설계합니다.

Debezium: Outbox 테이블을 Kafka 이벤트로 바꾸기

Debezium은 DB 변경 스트림을 Kafka로 내보냅니다. Outbox 패턴에서는 보통 다음 중 하나를 사용합니다.

  • Debezium Outbox Event Router SMT
  • 또는 단순히 outbox_events 토픽으로 내보낸 뒤 Kafka Streams/커스텀 컨슈머로 라우팅

Debezium 커넥터 설정 예시(개념)

아래는 “어떤 필드로 토픽을 라우팅하고 키를 무엇으로 할지”를 보여주는 예시입니다. 실제 운영 값은 DB/커넥터에 맞게 조정하세요.

{
  "name": "payments-outbox-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "******",
    "database.dbname": "payments",
    "topic.prefix": "cdc",

    "table.include.list": "public.outbox_events",

    "transforms": "outbox",
    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
    "transforms.outbox.route.by.field": "event_type",
    "transforms.outbox.route.topic.replacement": "events.${routedByValue}",
    "transforms.outbox.table.field.event.key": "event_key",
    "transforms.outbox.table.field.event.payload": "payload",
    "transforms.outbox.table.field.event.id": "id"
  }
}

이렇게 하면 outbox_events의 레코드가 예를 들어 events.PaymentApprovalRequested 같은 토픽으로 라우팅됩니다.

Kafka 컨슈머: 중복 이벤트를 안전하게 흡수하기

이제 중복은 “일어날 수 있다”가 전제입니다. 컨슈머는 반드시 멱등해야 합니다.

1) 컨슈머 멱등 처리의 정석: inbox 테이블

컨슈머가 처리한 이벤트 ID를 저장하는 inbox 테이블을 둡니다.

CREATE TABLE inbox_events (
  id BIGSERIAL PRIMARY KEY,
  consumer_name VARCHAR(64) NOT NULL,
  event_id VARCHAR(128) NOT NULL,
  processed_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE (consumer_name, event_id)
);

컨슈머 로직은 다음 순서로 구성합니다.

  1. 로컬 트랜잭션 시작
  2. inbox_events(consumer_name, event_id) insert 시도
  3. 유니크 충돌이면 이미 처리한 이벤트이므로 즉시 ack
  4. 충돌이 아니면 비즈니스 처리 수행 후 커밋

2) 예시: 승인 요청 이벤트를 받는 워커

async function onPaymentApprovalRequested(evt: {
  eventId: string
  paymentAttemptId: string
  orderId: number
  amount: string
}) {
  await db.transaction(async (tx) => {
    // 1) inbox로 중복 방지
    try {
      await tx.inbox_events.insert({
        consumer_name: "payment-approver",
        event_id: evt.eventId,
      })
    } catch (e) {
      // unique violation -> already processed
      return
    }

    // 2) 결제 레코드 조회
    const payment = await tx.payments.findUnique({
      where: { payment_attempt_id: evt.paymentAttemptId },
    })
    if (!payment) throw new Error("payment not found")

    // 3) 이미 승인된 경우 멱등 종료
    if (payment.status === "APPROVED") return

    // 4) 여기서 외부 PG 호출을 어떻게 안전하게 할까?
    //    - 가능하면 PG idempotency key로 paymentAttemptId를 전달
    //    - 또는 PG transaction id를 저장하고 재시도 시 조회
  })

  // 주의: 외부 호출은 DB 트랜잭션 밖에서 수행하는 경우가 많다.
  // 그 경우에도 중복 호출 방지를 위해 상태 전이를 CAS로 잡아야 한다.
}

외부 호출을 트랜잭션 밖으로 빼면 “inbox는 커밋됐는데 PG 호출은 실패” 같은 상황이 생깁니다. 이는 중복결제보다는 “미승인/지연” 문제이므로, 재시도 워커로 풀 수 있습니다.

중복결제를 막는 핵심은 PG 호출 자체도 멱등이어야 한다는 점입니다.

PG 호출 멱등성: 가장 강력한 방어선

가능하면 PG가 제공하는 idempotency 기능을 사용하세요.

  • 헤더로 idempotency 키를 받는 PG라면 payment_attempt_id를 그대로 사용
  • PG가 없으면 내부적으로 “승인 요청 1회만” 되도록 CAS 업데이트를 사용

CAS(Compare-And-Set) 업데이트 예시

PENDING인 결제만 PROCESSING으로 바꾸는 업데이트를 먼저 수행하고, 성공한 워커만 PG 호출을 진행합니다.

UPDATE payments
SET status = 'PROCESSING', updated_at = NOW()
WHERE payment_attempt_id = $1
  AND status = 'PENDING';

영향 받은 row 수가 1이면 내가 락을 잡은 것이고, 0이면 이미 다른 워커가 처리 중이거나 완료입니다.

PG 호출 성공 후:

UPDATE payments
SET status = 'APPROVED', pg_transaction_id = $2, updated_at = NOW()
WHERE payment_attempt_id = $1
  AND status = 'PROCESSING';

실패 시에는 FAILED로 바꾸고, 사가 보상(주문 취소 등)을 트리거하는 outbox 이벤트를 남깁니다.

사가 관점에서의 연결: 주문 서비스와 재고 서비스는 어떻게 안전해지나

결제 서비스가 PaymentApproved 이벤트를 발행하면 주문 서비스는 OrderPaid로 전이합니다. 여기서도 중복 이벤트가 들어올 수 있으므로 주문 서비스 역시 inbox 패턴으로 방어합니다.

주문 서비스 컨슈머 예시

async function onPaymentApproved(evt: {
  eventId: string
  orderId: number
  paymentAttemptId: string
}) {
  await db.transaction(async (tx) => {
    // inbox
    await tx.inbox_events.insert({
      consumer_name: "order-service",
      event_id: evt.eventId,
    })

    // 멱등 상태 전이
    const order = await tx.orders.findUnique({ where: { id: evt.orderId } })
    if (!order) throw new Error("order not found")

    if (order.status === "PAID") return

    await tx.orders.update({
      where: { id: evt.orderId },
      data: { status: "PAID" },
    })

    // 필요하면 outbox로 다음 이벤트 발행
    await tx.outbox_events.insert({
      aggregate_type: "order",
      aggregate_id: String(evt.orderId),
      event_type: "OrderPaid",
      event_key: String(evt.orderId),
      payload: { orderId: evt.orderId },
    })
  })
}

이렇게 하면 결제 승인 이벤트가 중복으로 와도 주문 상태는 한 번만 PAID로 전이합니다.

“Kafka Exactly-Once”만으로는 부족한 이유

Kafka의 EOS(Exactly Once Semantics)는 특정 조건에서 유용하지만, 중복결제 문제를 단독으로 해결하지는 못합니다.

  • DB 커밋과 Kafka 트랜잭션을 하나로 묶기 어렵습니다(서로 다른 시스템)
  • 외부 PG 호출은 Kafka 트랜잭션 범위 밖입니다
  • 컨슈머의 비즈니스 부작용(메일 발송, 포인트 적립, PG 승인)은 재처리 시 중복될 수 있습니다

따라서 업무 멱등성(도메인 레벨)Outbox/Inbox(데이터 레벨) 가 함께 필요합니다.

운영에서 꼭 챙겨야 할 디테일

1) 이벤트 스키마에 반드시 넣을 것

  • eventId: outbox의 PK 또는 UUID
  • eventType
  • occurredAt
  • aggregateId
  • idempotencyKey 또는 paymentAttemptId

이 중 eventId는 inbox 중복 제거의 기준입니다.

2) 리플레이(재처리) 전략

  • Debezium/Kafka 장애로 이벤트가 지연되면 “결제가 됐는데 주문이 안 바뀜”이 생깁니다.
  • 이때 운영자가 토픽을 리플레이하면 중복이 폭발할 수 있으니, inbox로 안전하게 흡수해야 합니다.

3) 관측성: 지연과 적체를 수치로 보기

  • outbox 테이블의 “미발행” 적체량
  • Debezium 커넥터 lag
  • Kafka consumer lag

이 세 가지는 사가의 체감 지연(결제 완료 후 주문 반영까지)을 결정합니다.

4) 쿠버네티스 환경에서의 종료/리밸런스 이슈

컨슈머가 SIGTERM을 받았을 때

  • 폴링 중단
  • 처리 중인 메시지 마무리
  • 오프셋 커밋

순서가 꼬이면 중복 처리가 급증합니다. 특히 사이드카나 프록시가 붙은 파드에서 종료 순서가 비정상적이면 재처리가 늘어납니다. 관련해서는 Kubernetes 사이드카 종료 순서 버그 해결 가이드도 함께 참고하면 좋습니다.

또한 클러스터 이벤트로 파드가 자주 재시작되면 컨슈머 리밸런스가 잦아지고, 결과적으로 at-least-once 특성이 더 자주 드러납니다. 이미지 풀 실패로 재기동이 반복되는 경우는 Kubernetes ImagePullBackOff·ErrImagePull 해결 체크리스트로 먼저 안정화하세요.

정리: 중복결제 방지 체크리스트

아래를 모두 만족하면 “중복이 와도 결제는 한 번만”에 가까워집니다.

  1. 결제 시도 식별자 payment_attempt_id를 만들고 DB 유니크로 강제
  2. 결제 상태 전이를 PENDING에서 PROCESSING으로 CAS 업데이트로 선점
  3. Outbox 테이블에 이벤트를 업무 트랜잭션과 함께 커밋
  4. Debezium으로 Outbox를 Kafka에 반영해 발행 누락을 제거
  5. 모든 컨슈머는 inbox 테이블로 이벤트 중복을 제거
  6. PG 호출은 가능하면 idempotency 키를 사용하고, 불가능하면 내부 상태 전이로 호출 1회를 보장
  7. outbox 적체, 커넥터 lag, consumer lag를 모니터링해 “지연으로 인한 재시도 폭증”을 예방

Outbox+Debezium+Kafka는 사가에서 데이터 일관성과 이벤트 전달 신뢰성을 크게 올려주지만, 결제 같은 외부 부작용은 결국 도메인 멱등성 설계가 마무리합니다. 이 조합을 “중복이 필연인 분산 시스템에서, 중복이 사고로 번지지 않게 만드는 표준 패턴”으로 이해하고 적용하는 것이 가장 안전합니다.