Published on

MSA 사가 패턴 중복결제 버그, Outbox로 해결

Authors

서로 다른 마이크로서비스가 결제 플로우를 나눠 갖는 MSA에서 사가(Saga) 패턴을 도입하면, 분산 트랜잭션 없이도 비즈니스 프로세스를 이어갈 수 있습니다. 하지만 운영에서 가장 자주 터지는 사고 중 하나가 바로 중복결제입니다. 특히 네트워크 타임아웃, 메시지 재전송, 컨슈머 재시작 같은 “정상적인 장애”가 겹치면, 결제 서비스가 같은 주문을 두 번 결제해버리는 상황이 쉽게 만들어집니다.

이 글에서는 사가 패턴에서 중복결제가 발생하는 전형적인 원인을 짚고, Outbox 패턴을 중심으로 “한 번만 결제되도록” 만드는 설계를 단계적으로 정리합니다. 예시는 Spring Boot + PostgreSQL + Kafka 조합으로 설명하지만, 핵심은 기술보다 원자성(atomicity)과 멱등성(idempotency)을 어디에 두느냐입니다.

사가에서 중복결제가 생기는 전형적인 시나리오

1) 최소 한 번(at-least-once) 전달의 함정

대부분의 메시지 브로커/컨슈머는 기본적으로 at-least-once 전달을 제공합니다. 즉, 컨슈머가 메시지를 처리했지만 ack 이전에 죽으면 브로커는 같은 메시지를 다시 보냅니다. 이때 결제 API가 “같은 주문인데 또 결제”를 허용하면 바로 중복결제로 이어집니다.

2) 오케스트레이터 재시도와 타임아웃

오케스트레이션 사가에서 오케스트레이터가 결제 서비스 호출 후 타임아웃이 나면 재시도합니다. 실제로 결제는 성공했지만 응답이 늦었을 뿐이라면, 재시도 호출이 두 번째 결제를 만들 수 있습니다.

3) DB 커밋과 이벤트 발행의 불일치(dual write)

가장 위험한 케이스는 이겁니다.

  • 결제 서비스가 DB에 PAYMENT_APPROVED를 저장
  • 그 다음 Kafka에 PaymentApproved 이벤트 발행
  • 그런데 이벤트 발행 직전에 프로세스가 죽음

그러면 DB는 승인됐는데 이벤트가 안 나가서 다운스트림(주문/배송)이 멈춥니다. 반대로 이벤트를 먼저 발행하고 DB 커밋이 실패하면 “승인 이벤트는 나갔는데 DB에는 결제 없음” 같은 유령 상태가 생깁니다.

이 dual write 문제는 중복결제의 직접 원인이라기보다, 재처리/보정 작업을 유발해서 중복을 더 쉽게 만듭니다.

단순한 해결책이 왜 자주 실패하는가

“컨슈머에서 중복 체크하면 되지 않나?”

가능하지만 조건이 있습니다.

  • 중복을 판단할 수 있는 고유 키(idempotency key) 가 있어야 함
  • 그 키에 대해 DB 수준 유니크 제약 같은 강한 보장이 있어야 함
  • 이벤트 처리와 중복 체크가 분리되면 레이스 컨디션이 생김

즉, 애플리케이션 코드에서 existsBy...로 확인하고 insert하는 식은 동시성에서 깨집니다.

“결제 PG가 멱등을 지원하니 괜찮다?”

PG가 idempotency를 지원해도 내부적으로는

  • 우리 서비스 DB 상태
  • PG 결제 상태
  • 메시지 이벤트 상태

가 서로 엇갈릴 수 있습니다. 결국 우리 시스템의 상태 전파(이벤트 발행) 까지 포함해 멱등을 설계해야 합니다.

Outbox 패턴이 중복결제에 강한 이유

Outbox 패턴의 핵심은 간단합니다.

  • 비즈니스 상태 변경(예: 결제 승인 저장)
  • 이벤트 발행 대상 기록(Outbox 테이블 insert)

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

그 다음 별도 퍼블리셔가 Outbox 테이블을 읽어 브로커로 발행하고, 발행 완료를 마킹합니다. 이렇게 하면

  • DB 커밋이 되면 이벤트도 “발행될 예정”으로 반드시 남고
  • 프로세스가 죽어도 Outbox가 남아 재발행 가능하며
  • 발행 중복이 발생해도 이벤트에 고유 ID를 부여해 다운스트림에서 멱등 처리 가능

이 구조가 됩니다.

설계 목표: 결제는 한 번만, 이벤트는 결국 발행

중복결제를 막으려면 크게 두 축이 필요합니다.

  1. 결제 자체의 멱등성: 같은 주문/시도에 대해 결제 승인 레코드가 1개만 생기게
  2. 이벤트 발행의 신뢰성: 승인 레코드가 생기면 승인 이벤트가 결국 발행되게

Outbox는 (2)를 강하게 보장하고, (1)은 DB 유니크 제약 + idempotency key로 보완합니다.

데이터 모델: Payment와 Outbox

PostgreSQL 기준 예시입니다.

-- 결제는 idempotency_key 단위로 1번만 승인되게
create table payments (
  id bigserial primary key,
  order_id varchar(64) not null,
  idempotency_key varchar(128) not null,
  amount numeric(18,2) not null,
  status varchar(32) not null,
  pg_tx_id varchar(128),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (idempotency_key)
);

-- Outbox는 이벤트 단위 고유 ID를 갖고 발행 상태를 관리
create table outbox_events (
  event_id uuid primary key,
  aggregate_type varchar(64) not null,
  aggregate_id varchar(64) not null,
  event_type varchar(128) not null,
  payload jsonb not null,
  status varchar(32) not null, -- PENDING, PUBLISHED, FAILED
  created_at timestamptz not null default now(),
  published_at timestamptz
);

create index idx_outbox_pending on outbox_events (status, created_at);

여기서 포인트는 다음입니다.

  • payments.idempotency_key 유니크로 “결제 승인 레코드 중복 생성”을 원천 차단
  • Outbox는 event_id로 이벤트 중복을 식별 가능

결제 승인 처리: 하나의 트랜잭션으로 Payment + Outbox 저장

Spring Boot(JPA) 예시입니다.

@Service
public class PaymentService {

  private final PaymentRepository paymentRepository;
  private final OutboxRepository outboxRepository;
  private final PgClient pgClient;

  @Transactional
  public PaymentResult approve(PaymentCommand cmd) {
    // 1) 멱등 키로 기존 결제 확인 (유니크 제약이 최종 안전장치)
    var existing = paymentRepository.findByIdempotencyKey(cmd.idempotencyKey());
    if (existing.isPresent()) {
      return PaymentResult.alreadyProcessed(existing.get().getId());
    }

    // 2) PG 결제 승인 호출 (여기서도 idempotency_key를 PG에 전달하는 편이 좋음)
    var pg = pgClient.approve(cmd.orderId(), cmd.amount(), cmd.idempotencyKey());

    // 3) 결제 레코드 저장
    var payment = new Payment(cmd.orderId(), cmd.idempotencyKey(), cmd.amount());
    payment.approve(pg.transactionId());
    paymentRepository.save(payment);

    // 4) Outbox 이벤트 저장 (같은 트랜잭션)
    var event = OutboxEvent.pending(
        java.util.UUID.randomUUID(),
        "Payment",
        payment.getId().toString(),
        "PaymentApproved",
        java.util.Map.of(
            "paymentId", payment.getId(),
            "orderId", cmd.orderId(),
            "amount", cmd.amount(),
            "pgTxId", pg.transactionId()
        )
    );
    outboxRepository.save(event);

    return PaymentResult.approved(payment.getId());
  }
}

동시성 레이스는 어떻게 막나

위 코드만으로는 동시에 같은 idempotency_key로 요청이 2개 들어오면 둘 다 find에서 빈 값 보고 진행할 수 있습니다. 하지만 마지막에 unique (idempotency_key)가 걸려 있어서 둘 중 하나는 insert에서 실패합니다.

이때 중요한 것은 유니크 위반을 “정상 멱등 결과”로 처리하는 것입니다.

try {
  paymentRepository.save(payment);
} catch (DataIntegrityViolationException e) {
  var p = paymentRepository.findByIdempotencyKey(cmd.idempotencyKey())
      .orElseThrow();
  return PaymentResult.alreadyProcessed(p.getId());
}

Outbox 퍼블리셔: 폴링 + 배치 발행 + 상태 전이

Outbox 발행은 크게 두 방식이 있습니다.

  • 폴링 방식: 주기적으로 DB에서 PENDING을 읽어 발행
  • CDC 방식: Debezium 같은 CDC로 Outbox insert를 스트리밍

여기서는 구현 난이도가 낮은 폴링을 예로 듭니다.

@Component
public class OutboxPublisher {

  private final OutboxRepository outboxRepository;
  private final KafkaTemplate<String, String> kafkaTemplate;
  private final ObjectMapper objectMapper;

  @Scheduled(fixedDelayString = "${outbox.publish.delay-ms:1000}")
  @Transactional
  public void publishBatch() {
    // SKIP LOCKED로 다중 인스턴스 경쟁 시 중복 발행을 줄임
    var events = outboxRepository.findPendingForUpdateSkipLocked(100);

    for (var e : events) {
      try {
        var payload = objectMapper.writeValueAsString(e.getPayload());
        // key를 aggregateId로 두면 파티션 순서 보장에 유리
        kafkaTemplate.send("payment-events", e.getAggregateId(), payload);
        e.markPublished();
      } catch (Exception ex) {
        e.markFailed(ex.getMessage());
      }
    }
  }
}

리포지토리 쿼리 예시입니다.

select *
from outbox_events
where status = 'PENDING'
order by created_at
for update skip locked
limit 100;

“발행 성공”의 기준을 어디까지 볼 것인가

Kafka send는 비동기이며, 진짜 브로커 ack까지 기다리려면 get()을 호출하거나 producer 설정을 조정해야 합니다. 운영에서는

  • acks=all
  • enable.idempotence=true

같은 프로듀서 설정을 권장합니다. 그래도 애플리케이션 장애로 “발행했는데 마킹 못함”이 발생할 수 있어, 중복 발행 가능성은 남습니다.

따라서 다운스트림은 반드시 event_id 또는 paymentId 기준 멱등 처리를 해야 합니다.

다운스트림(주문 서비스) 멱등 처리: Inbox 또는 Processed Event 테이블

주문 서비스가 PaymentApproved를 받아 주문 상태를 PAID로 바꾼다고 합시다. 중복 이벤트가 오면 업데이트가 여러 번 일어나도 결과는 같을 수 있지만, 부수효과(쿠폰 사용 처리, 포인트 적립 등)가 있으면 문제가 됩니다.

가장 흔한 패턴은 “처리한 이벤트 ID를 저장”하는 것입니다.

create table processed_events (
  event_id uuid primary key,
  processed_at timestamptz not null default now()
);

컨슈머 처리 예시입니다.

@Transactional
public void onMessage(PaymentApprovedEvent evt) {
  if (processedEventRepository.existsById(evt.eventId())) {
    return; // 이미 처리됨
  }

  orderRepository.markPaid(evt.orderId(), evt.paymentId());

  processedEventRepository.save(new ProcessedEvent(evt.eventId()));
}

여기서도 existsinsert 레이스가 있으니, processed_events.event_id를 PK로 두고 유니크 위반을 정상 처리로 보는 방식이 더 안전합니다.

사가 관점에서의 정리: 보상 트랜잭션과 Outbox의 역할

사가에서 결제는 보통 “되돌리기 어려운” 단계입니다. 결제가 승인된 뒤 다음 단계(재고 차감, 배송 생성)가 실패하면 보상 트랜잭션으로 환불을 실행해야 합니다.

Outbox는 이때도 중요합니다.

  • 결제 승인 이벤트가 유실되면 오케스트레이터는 결제가 안 된 줄 알고 재시도하며 중복 위험이 커짐
  • 결제 승인 이벤트가 반드시 발행되면, 오케스트레이터는 그 이벤트를 기준으로 다음 단계를 진행하거나 실패 시 환불로 넘어갈 수 있음

즉 Outbox는 “중복결제 방지”뿐 아니라 사가의 상태 전파 신뢰성을 만들어줍니다.

운영에서 자주 놓치는 체크리스트

1) DB 성능이 Outbox를 망친다

Outbox는 테이블에 쓰기/읽기가 꾸준히 발생합니다. 인덱스가 부실하거나 autovacuum이 밀리면 지연이 커지고, 지연은 재시도 폭증으로 이어져 중복/부하 문제가 커집니다.

PostgreSQL 운영 이슈가 의심되면 아래 글도 함께 참고하는 게 좋습니다.

2) 컨슈머 장애가 “결제 중복”으로 관측된다

결제는 한 번만 됐는데 주문이 PAID로 안 바뀌면, 운영자는 결제가 실패한 줄 알고 수동 재처리/재시도를 합니다. 이때 idempotency key가 제대로 설계되지 않으면 진짜 중복결제가 발생합니다.

클러스터 환경에서 503이나 타임아웃이 자주 보이면 인프라 레벨 진단도 병행하세요.

3) 커넥션 풀 고갈이 재시도를 부른다

결제/주문 서비스가 DB 커넥션 풀 고갈로 지연되면 타임아웃과 재시도가 연쇄적으로 발생합니다. 그 결과 “같은 요청이 여러 번” 들어오고, 멱등이 약하면 중복결제로 직결됩니다.

결론: Outbox는 중복결제 방지의 ‘중심축’이다

사가 패턴에서 중복결제는 단순 버그가 아니라 전달 보장, 재시도, 타임아웃, dual write가 합쳐져 나타나는 구조적 문제입니다. 이를 해결하려면

  • 결제 생성은 idempotency_key + DB 유니크 제약으로 한 번만
  • 결제 승인 이벤트는 Outbox로 “DB 커밋과 함께” 기록
  • Outbox 퍼블리셔는 재시도 가능하게 만들고, 중복 발행 가능성은 전제로 둠
  • 다운스트림은 event_id 기반 멱등 처리로 부수효과 중복을 차단

이 네 가지가 세트로 들어가야 합니다.

Outbox를 도입하면 “결제는 됐는데 주문이 멈춤” 같은 상태 불일치를 크게 줄이고, 그 과정에서 발생하던 재처리/재시도로 인한 중복결제 위험도 함께 낮출 수 있습니다. 결국 목표는 하나입니다. 결제는 한 번만, 상태 전파는 결국 도달입니다.