Published on

Saga+Outbox로 중복결제 막는 멱등키·Kafka 패턴

Authors

서버가 결제 요청을 받는 순간부터 “정확히 한 번만 결제된다”를 보장하기는 생각보다 어렵습니다. 네트워크 타임아웃으로 클라이언트가 재시도하고, 게이트웨이 응답이 지연되고, Kafka가 at-least-once로 메시지를 중복 전달하고, 컨슈머가 처리 후 커밋 전에 죽는 순간까지 모두 중복결제 트리거가 됩니다.

이 글은 Saga + Outbox를 중심으로, 멱등키(idempotency key)Kafka 패턴을 결제 흐름에 결합해 중복결제를 막는 방법을 “어디에 어떤 유니크 제약을 두고”, “어떤 트랜잭션 경계를 잡고”, “Kafka에서 어떤 키로 파티셔닝하고 어떤 테이블로 중복을 제거할지”까지 한 번에 정리합니다.


중복결제가 생기는 대표 시나리오

결제는 보통 다음 구성 요소를 거칩니다.

  • API 서버: 결제 생성, 결제 승인 요청
  • DB: 주문/결제 상태 저장
  • 결제 게이트웨이(PG): 실제 승인
  • 메시지 브로커(Kafka): 후속 처리(정산, 포인트, 영수증, 알림)

중복결제는 아래 조합에서 자주 발생합니다.

  1. 클라이언트 재시도
    • 모바일 네트워크에서 응답이 늦으면 동일 요청을 다시 보냄
  2. 서버 재시도
    • PG 호출 타임아웃 후 “실패로 간주”하고 재호출했는데, PG는 이미 승인 완료
  3. Kafka 중복 전달
    • 컨슈머가 처리했지만 오프셋 커밋 전에 장애가 나면 같은 메시지를 다시 받음
  4. 분산 트랜잭션 부재
    • DB 업데이트는 성공했는데 이벤트 발행이 실패(또는 반대)하여 상태 불일치

해결의 핵심은 “정확히 한 번”을 네트워크/브로커에 기대지 않고, DB의 유니크 제약과 상태 머신으로 강제하는 것입니다.


큰 그림: Saga + Outbox + 멱등키 + Kafka 멱등 컨슈머

권장 아키텍처를 한 줄로 요약하면 다음입니다.

  • API 계층: 멱등키로 “결제 생성/승인 요청”을 단 한 번만 처리
  • DB 트랜잭션: 결제 상태 변경과 Outbox 이벤트 기록을 원자적으로 커밋
  • Outbox 퍼블리셔: Outbox 테이블을 폴링/CDC로 Kafka에 발행(중복 발행 가능)
  • Kafka 컨슈머: 이벤트 event_id 기반으로 멱등 처리(중복 소비 가능)
  • Saga 오케스트레이션: 결제 성공/실패/보상(취소) 흐름을 상태 머신으로 관리

여기서 중요한 포인트는 “중복이 발생할 수 있다”를 전제로 설계한다는 점입니다.

  • Kafka는 기본적으로 at-least-once를 쉽게 달성하지만, exactly-once는 운영 난이도가 높습니다.
  • 따라서 발행 중복, 소비 중복을 허용하고, 처리 결과만 단 한 번 반영되게 만들면 됩니다.

1) 멱등키 설계: 무엇을 키로 삼을 것인가

멱등키는 “같은 의미의 요청”을 식별하는 키입니다. 결제에서는 보통 두 종류가 필요합니다.

결제 생성 멱등키

  • 예: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
  • 의미: 동일 주문에 대해 결제 시도를 “한 번만” 생성

DB에서는 다음처럼 강제합니다.

  • payments 테이블에 idempotency_key 유니크
  • 또는 (order_id, attempt_no) 같은 비즈니스 키 유니크

결제 승인 멱등키

승인은 더 까다롭습니다. PG가 제공하는 idempotency 기능(예: “merchant_uid”, “request_id”)이 있다면 반드시 사용하고, 없다면 내부에서 승인 요청 레코드를 만들어 중복 호출을 차단합니다.

  • payment_authorizations 테이블에 authorization_key 유니크
  • 승인 호출 전에 해당 키로 “이미 승인 요청이 진행/완료인지” 확인

2) DB 스키마 예시: 유니크 제약으로 중복을 물리적으로 봉쇄

아래는 단순화한 예시입니다. 포인트는 “중복을 애플리케이션 로직이 아니라 DB 제약으로 막는다”입니다.

-- 결제 엔티티
create table payments (
  payment_id bigint primary key,
  order_id bigint not null,
  idempotency_key varchar(64) not null,
  status varchar(32) not null, -- PENDING, AUTHORIZING, AUTHORIZED, CAPTURED, FAILED, CANCELED
  amount bigint not null,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (idempotency_key),
  unique (order_id) -- 주문당 결제 1회 정책이라면
);

-- 승인 요청(외부 PG 호출 단위)
create table payment_authorizations (
  authorization_id bigint primary key,
  payment_id bigint not null,
  authorization_key varchar(64) not null,
  status varchar(32) not null, -- REQUESTED, SUCCEEDED, FAILED
  pg_transaction_id varchar(128),
  created_at timestamptz not null default now(),
  unique (authorization_key),
  unique (pg_transaction_id)
);

-- Outbox
create table outbox_events (
  event_id uuid primary key,
  aggregate_type varchar(32) not null, -- PAYMENT
  aggregate_id bigint not null,        -- payment_id
  event_type varchar(64) not null,     -- PaymentAuthorized, PaymentFailed...
  payload jsonb not null,
  status varchar(16) not null default 'NEW', -- NEW, PUBLISHED
  created_at timestamptz not null default now(),
  published_at timestamptz
);

create index on outbox_events (status, created_at);

유니크 제약이 많아 보이지만, 결제에서는 이 정도가 “보험료”입니다. 특히 동시성에서 유니크 충돌이 발생할 수 있으므로, 애플리케이션은 이를 정상 케이스로 처리해야 합니다. 관련해서 DB 락/데드락이 엮이면 장애로 이어질 수 있으니, 트랜잭션 범위와 인덱스 설계를 점검하는 것이 좋습니다. 데드락 재현과 해결 접근은 MySQL InnoDB 데드락(1213) 재현과 해결 가이드도 함께 참고하면 도움이 됩니다.


3) API 처리: “먼저 저장, 그 다음 외부 호출”로 바꾸기

중복결제를 막는 핵심은 다음 순서를 지키는 것입니다.

  1. 멱등키로 결제 레코드를 먼저 생성/조회
  2. 결제 상태를 AUTHORIZING으로 전환(조건부 업데이트)
  3. 그 다음에야 PG 승인 호출

이렇게 하면 “동일 멱등키로 들어온 요청”은 항상 같은 payment_id를 얻고, 동시에 두 요청이 들어와도 상태 전이가 한 번만 일어나게 만들 수 있습니다.

아래는 Node.js(의사코드) 예시입니다.

// Express/Fastify 스타일 의사코드
async function createOrGetPayment(req) {
  const idemKey = req.headers['idempotency-key'];
  const { orderId, amount } = req.body;

  // 1) 멱등키로 upsert 시도
  // 충돌 시 기존 payment를 조회해서 반환
  const payment = await db.tx(async (tx) => {
    try {
      return await tx.payments.insert({
        order_id: orderId,
        idempotency_key: idemKey,
        status: 'PENDING',
        amount
      });
    } catch (e) {
      if (isUniqueViolation(e)) {
        return await tx.payments.findByIdempotencyKey(idemKey);
      }
      throw e;
    }
  });

  return payment;
}

async function authorizePayment(req) {
  const { paymentId } = req.params;
  const authKey = req.headers['authorization-key'];

  // 2) 승인 요청 레코드부터 멱등하게 만들기
  const authorization = await db.tx(async (tx) => {
    try {
      return await tx.payment_authorizations.insert({
        payment_id: paymentId,
        authorization_key: authKey,
        status: 'REQUESTED'
      });
    } catch (e) {
      if (isUniqueViolation(e)) {
        return await tx.payment_authorizations.findByAuthorizationKey(authKey);
      }
      throw e;
    }
  });

  // 이미 성공한 승인이라면 그대로 반환
  if (authorization.status === 'SUCCEEDED') {
    return { ok: true, paymentId };
  }

  // 3) 상태 전이 가드: PENDING/FAILED 등에서만 AUTHORIZING으로
  const updated = await db.payments.updateStatusIf(paymentId, {
    from: ['PENDING', 'FAILED'],
    to: 'AUTHORIZING'
  });

  if (!updated) {
    // 누군가 이미 진행 중이거나 완료
    return { ok: true, paymentId };
  }

  // 4) 외부 PG 호출(타임아웃/재시도 설계 필요)
  const pgRes = await pg.authorize({ paymentId, amount: req.body.amount, authKey });

  // 5) 결과를 DB+Outbox에 원자적으로 기록
  await db.tx(async (tx) => {
    if (pgRes.approved) {
      await tx.payments.update(paymentId, { status: 'AUTHORIZED' });
      await tx.payment_authorizations.markSucceeded(authorization.authorization_id, pgRes.txId);

      await tx.outbox_events.insert({
        event_id: uuidv4(),
        aggregate_type: 'PAYMENT',
        aggregate_id: paymentId,
        event_type: 'PaymentAuthorized',
        payload: { paymentId, pgTxId: pgRes.txId }
      });
    } else {
      await tx.payments.update(paymentId, { status: 'FAILED' });
      await tx.payment_authorizations.markFailed(authorization.authorization_id);

      await tx.outbox_events.insert({
        event_id: uuidv4(),
        aggregate_type: 'PAYMENT',
        aggregate_id: paymentId,
        event_type: 'PaymentAuthorizationFailed',
        payload: { paymentId, reason: pgRes.reason }
      });
    }
  });

  return { ok: pgRes.approved, paymentId };
}

여기서 외부 호출은 타임아웃이 핵심이며, 타임아웃 후 재시도 시에도 authorization_key와 PG 측 멱등키가 동일해야 “중복 승인”이 아니라 “같은 승인 요청의 재조회/재전송”이 됩니다. 네트워크/스트림 레벨에서 업로드/요청이 멈추는 문제를 추적해야 하는 경우, 디버깅 관점은 Node.js fetch 업로드 멈춤 디버깅 - 스트림과 AbortController도 같이 보면 좋습니다.


4) Outbox 패턴: DB 커밋과 이벤트 발행의 원자성

결제에서 가장 위험한 순간은 “DB는 성공했는데 이벤트 발행이 실패”하는 경우입니다.

  • 결제는 AUTHORIZED로 바뀌었지만
  • Kafka에 PaymentAuthorized가 안 나가서
  • 포인트 적립/영수증 발행/정산이 누락

Outbox는 이를 이렇게 해결합니다.

  • 같은 DB 트랜잭션에서
    • 도메인 상태 변경
    • outbox 이벤트 insert
  • 커밋 후 별도 프로세스가 outbox를 읽어 Kafka로 발행

즉 “이벤트 발행”은 재시도 가능하고, “이벤트 존재”는 DB가 보장합니다.

Outbox 퍼블리셔(폴링) 예시

async function publishOutboxLoop() {
  while (true) {
    const batch = await db.outbox_events.findNew({ limit: 100 });

    for (const ev of batch) {
      try {
        // Kafka key는 aggregate_id로 두는 경우가 많음(같은 payment 순서 보장)
        await kafkaProducer.send({
          topic: 'payment-events',
          messages: [{
            key: String(ev.aggregate_id),
            value: JSON.stringify({
              eventId: ev.event_id,
              type: ev.event_type,
              payload: ev.payload,
              occurredAt: ev.created_at
            })
          }]
        });

        await db.outbox_events.markPublished(ev.event_id);
      } catch (e) {
        // 발행 실패 시 그대로 두고 다음 루프에서 재시도
        // 관측성(로그/메트릭/알람) 필수
      }
    }

    await sleep(200);
  }
}

주의할 점은 Outbox 퍼블리셔가 장애/재시작되면 같은 이벤트를 중복 발행할 수 있다는 것입니다. 따라서 컨슈머는 반드시 멱등해야 합니다.


5) Kafka 컨슈머 멱등 처리: “중복 소비”를 정상으로 만들기

Kafka에서 흔한 장애 시나리오:

  • 메시지를 처리하고 DB에 반영
  • 오프셋 커밋 전에 프로세스 다운
  • 재시작 후 같은 메시지를 다시 소비

따라서 컨슈머는 다음 원칙이 필요합니다.

  • 메시지에 eventId(UUID)를 포함
  • 컨슈머 DB에 consumed_events 같은 테이블을 두고 event_id 유니크로 중복 제거
  • “도메인 반영”과 “consumed_events insert”를 같은 트랜잭션으로 묶기

컨슈머 멱등 테이블 예시

create table consumed_events (
  consumer_group varchar(128) not null,
  event_id uuid not null,
  consumed_at timestamptz not null default now(),
  primary key (consumer_group, event_id)
);

컨슈머 처리 예시

async function handlePaymentEvent(message) {
  const { eventId, type, payload } = JSON.parse(message.value.toString());

  await db.tx(async (tx) => {
    // 1) 중복 체크를 유니크 제약으로
    try {
      await tx.consumed_events.insert({
        consumer_group: 'ledger-service',
        event_id: eventId
      });
    } catch (e) {
      if (isUniqueViolation(e)) {
        return; // 이미 처리함
      }
      throw e;
    }

    // 2) 도메인 반영(예: 원장 기록)
    if (type === 'PaymentAuthorized') {
      await tx.ledger_entries.insert({
        payment_id: payload.paymentId,
        pg_tx_id: payload.pgTxId,
        amount: payload.amount
      });
    }

    if (type === 'PaymentCanceled') {
      await tx.ledger_entries.insert({
        payment_id: payload.paymentId,
        amount: -payload.amount
      });
    }
  });

  // 3) 트랜잭션 성공 후 오프셋 커밋
}

이 방식은 “중복 메시지”뿐 아니라 “Outbox 중복 발행”까지 함께 흡수합니다.


6) Saga로 결제 상태 머신을 운영하기

Saga는 분산 트랜잭션 대신 “단계별 상태 전이 + 실패 시 보상”으로 일관성을 맞춥니다.

결제에서 흔한 Saga 단계는 다음입니다.

  1. PENDING (결제 생성)
  2. AUTHORIZING (승인 진행)
  3. AUTHORIZED (승인 완료)
  4. CAPTURED (매입/정산 단계까지 완료, 선택)
  5. 실패 시 FAILED
  6. 보상 트랜잭션으로 CANCELED

중요한 것은 각 단계 전이를 조건부 업데이트로 만들고, 이벤트로 다음 단계를 트리거하는 것입니다.

  • 승인 성공 이벤트 PaymentAuthorized 수신
  • 후속 서비스(재고, 쿠폰, 포인트) 처리 중 실패
  • 오케스트레이터가 PaymentCancelRequested 발행
  • 결제 서비스가 PG 취소 수행 후 PaymentCanceled 발행

이때도 모든 요청은 멱등해야 합니다.

  • 취소 요청도 cancel_key 유니크
  • PG 취소도 PG가 제공하는 idempotency 키 사용

7) 파티셔닝 키 전략: 순서 보장 vs 처리량

Kafka에서 결제 이벤트는 순서가 중요할 때가 많습니다.

  • 같은 payment_id에 대해
  • PaymentAuthorized 다음에 PaymentCanceled가 오면 안 됨

따라서 보통 메시지 키를 payment_id로 둬서 같은 결제는 같은 파티션으로 흘러가게 합니다.

  • 장점: 결제 단위 순서 보장
  • 단점: 특정 결제에 이벤트가 몰리면 핫 파티션 가능

대안으로는 order_id 또는 user_id를 키로 두기도 하지만, “결제 단위 순서”가 깨질 수 있으니 상태 전이 가드(조건부 업데이트)와 멱등 처리가 더 중요해집니다.


8) 운영 관점 체크리스트

설계가 좋아도 운영에서 놓치면 중복결제는 다시 생깁니다.

반드시 수집할 메트릭/로그

  • 멱등키 유니크 충돌 횟수(정상 트래픽인지, 공격/버그인지)
  • Outbox 적체량(NEW 이벤트 개수)
  • 컨슈머 중복 처리 비율(중복 eventId 비율)
  • PG 승인/취소 타임아웃 비율

장애 시 재처리 전략

  • Outbox는 재발행해도 됨(컨슈머가 멱등)
  • 컨슈머는 재시작해도 됨(중복 eventId 흡수)
  • 결제 API는 동일 멱등키로 재호출해도 동일 결과 반환

DB 경합/데드락 대비

  • 결제 테이블의 “상태 업데이트”는 짧은 트랜잭션으로
  • 인덱스/유니크 키를 명확히 설계
  • 대량 동시 승인 시 데드락이 보이면 쿼리 순서/인덱스/격리수준 점검

9) 자주 하는 실수

Outbox 없이 “DB 커밋 후 Kafka 발행”

  • 발행 실패 시 누락
  • 재시도 로직이 API에 섞여 복잡해짐

멱등키를 “요청 바디 해시”로 대충 만들기

  • 필드 순서/공백/소수점 표현 차이로 다른 해시가 나올 수 있음
  • 클라이언트 재시도에서 동일 키가 보장되지 않음

컨슈머가 UPSERT로만 멱등을 해결하려고 함

  • 비즈니스적으로는 “중복 이벤트”와 “업데이트 이벤트”를 구분해야 함
  • 원장/정산처럼 append-only가 필요한 곳은 consumed_events 같은 별도 중복 제거가 안전

결론: “중복을 허용하고, 반영만 한 번”이 답이다

Saga+Outbox 조합은 결제처럼 실패가 일상인 도메인에서 특히 강력합니다.

  • 멱등키로 API 재시도/중복 요청을 차단하고
  • DB 유니크 제약으로 동시성에서도 물리적으로 막고
  • Outbox로 상태 변경과 이벤트 기록을 원자화하고
  • Kafka 컨슈머 멱등 처리로 중복 발행/중복 소비를 흡수하며
  • Saga 상태 머신으로 실패 시 보상까지 일관되게 운영

이 다섯 가지가 함께 맞물리면, 네트워크 타임아웃과 재시도가 난무하는 환경에서도 “중복결제 0”에 가까운 시스템을 만들 수 있습니다.