Published on

Kubernetes MSA에서 멱등키로 중복결제 막기

Authors

서버가 멀쩡해도 결제는 종종 두 번 일어납니다. 특히 Kubernetes 위의 MSA 환경에서는 네트워크 타임아웃, 클라이언트 재시도, Ingress 레벨의 연결 종료, Pod 재시작으로 인한 중복 처리 등이 겹치면서 “사용자는 한 번 눌렀는데 두 번 결제됨” 같은 사고가 현실이 됩니다.

중복결제를 막는 가장 보편적인 방법이 멱등키(idempotency key) 입니다. 같은 멱등키로 들어온 요청은 처음 한 번만 처리하고, 그 이후에는 동일한 결과를 재사용하도록 만드는 방식입니다.

이 글에서는 Kubernetes MSA에서 멱등키를 어디에 두고, 어떤 저장소에 기록하며, 결제처럼 “정확히 한 번”이 어려운 문제를 어떻게 “실질적으로 한 번”에 가깝게 만드는지 설계와 코드로 정리합니다.

왜 Kubernetes MSA에서 중복결제가 더 자주 터지나

MSA와 Kubernetes 조합에서는 중복 요청이 만들어지는 경로가 많습니다.

1) 타임아웃과 재시도

  • 모바일 네트워크에서 응답이 늦으면 앱이 재시도합니다.
  • 프론트엔드가 fetch 재시도 로직을 갖고 있거나, API Gateway가 5xx에 대해 재시도할 수도 있습니다.

2) Ingress 499, 연결 끊김

클라이언트가 먼저 연결을 끊으면(사용자 뒤로가기, 브라우저 탭 종료, 타임아웃) NGINX Ingress에서 499가 급증할 수 있습니다. 이때 서버는 이미 결제를 처리했는데 클라이언트는 실패로 보고 재시도해 중복결제로 이어집니다.

관련해서 Ingress 레벨에서 어떤 상황에 499가 늘어나는지 먼저 감을 잡아두면 좋습니다.

3) Pod 재시작과 요청 재전송

Pod가 처리 중 재시작되면(예: OOMKilled) 클라이언트는 실패로 인지하고 재시도합니다. 재시작 루프가 반복되면 동일 요청이 여러 번 들어올 수 있습니다.

4) 비동기 처리와 중복 소비

결제 승인 이벤트를 Kafka 등으로 흘리고 후속 서비스가 소비할 때, at-least-once 전달 특성 때문에 동일 메시지가 두 번 소비될 수 있습니다. 결제 “승인” 자체는 결제사에 한 번만 나가야 하고, 내부 후속 처리도 한 번만 반영되어야 합니다.

이 부분은 SAGA나 보상 트랜잭션과도 맞물립니다.

멱등키의 목표: “동일 요청”을 정의하고 결과를 재사용

멱등키 설계에서 핵심은 두 가지입니다.

  1. 동일 요청의 범위를 명확히 정하기
  • 보통 “사용자 결제 시도 1회”를 범위로 잡습니다.
  • 예: orderId 하나당 결제는 한 번만 가능하게 할지, “같은 주문에서 부분취소/재결제” 같은 예외를 허용할지 정책화해야 합니다.
  1. 처리 결과를 저장하고 재요청 시 반환하기
  • 단순히 “중복이면 409”로 끝내면 클라이언트는 또 재시도합니다.
  • 이상적인 동작은 “이미 처리된 결과(승인번호, 상태)를 그대로 반환”하는 것입니다.

멱등키는 어디서 오나: 클라이언트 생성 vs 서버 발급

클라이언트가 생성하는 방식

  • 결제 버튼 클릭 시 UUID를 생성해 Idempotency-Key 헤더로 보냅니다.
  • 장점: 구현 간단, 네트워크 재시도에 강함
  • 단점: 악의적 재사용, 키 관리 어려움

서버가 발급하는 방식(권장: 결제 생성 단계 분리)

  • POST /payments/intents 같은 API로 서버가 paymentIntentId를 발급
  • 실제 결제 승인 요청은 POST /payments/{intentId}/confirm처럼 진행
  • 장점: 서버가 키의 수명과 범위를 강하게 통제
  • 단점: API가 한 단계 늘어남

실무에서는 “서버 발급 intent” 패턴이 결제 UX와도 잘 맞습니다.

저장소 선택: Redis만으로 충분할까, DB가 필요할까

멱등키 저장은 크게 두 레이어로 나뉩니다.

  • 짧은 시간 중복 방어(초 단위): Redis SET NX 같은 락/세마포어
  • 결제 결과의 영속 보관(분~일 단위): RDB의 유니크 제약 + 결과 저장

결제는 금전 거래라서 “Redis에만 기록하고 끝”은 위험합니다. Redis 장애나 데이터 유실 시 중복 방어가 깨질 수 있기 때문입니다. 보통은 다음 조합이 안정적입니다.

  • RDB에 idempotency_key 유니크 인덱스
  • 처리 중 동시성 제어는 RDB 트랜잭션 또는 Redis 락

권장 아키텍처: Payment API에서 멱등성 보장

데이터 모델 예시

  • payment_attempts
    • id
    • idempotency_key (unique)
    • order_id
    • amount
    • status (PROCESSING, SUCCEEDED, FAILED)
    • response_payload (승인 결과 JSON)
    • created_at, updated_at

동일 키로 들어오면 response_payload를 그대로 반환합니다.

처리 플로우(핵심)

  1. 요청 수신: Idempotency-Key 필수
  2. DB에 idempotency_key로 레코드 INSERT
    • 성공하면 “내가 최초 처리자”
    • 유니크 충돌이면 “이미 처리 중이거나 완료됨”
  3. 최초 처리자는 결제사 호출
  4. 결과를 DB에 업데이트하고 응답 반환
  5. 중복 요청자는 DB에서 결과를 읽어 동일 응답 반환

여기서 중요한 포인트는 “중복 요청을 막는 것”이 아니라 “중복 요청을 흡수해서 같은 결과를 주는 것”입니다.

Spring Boot + JPA 예시 코드

아래 코드는 개념을 설명하기 위한 예시이며, 실제로는 예외 처리, 재시도 정책, 결제사 오류 매핑을 더 촘촘히 해야 합니다.

엔티티

@Entity
@Table(
  name = "payment_attempts",
  uniqueConstraints = {
    @UniqueConstraint(name = "uk_idempotency_key", columnNames = "idempotencyKey")
  }
)
public class PaymentAttempt {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(nullable = false, length = 80)
  private String idempotencyKey;

  @Column(nullable = false)
  private String orderId;

  @Column(nullable = false)
  private long amount;

  @Enumerated(EnumType.STRING)
  @Column(nullable = false)
  private Status status;

  @Lob
  private String responsePayload;

  public enum Status {
    PROCESSING, SUCCEEDED, FAILED
  }

  // getters/setters
}

서비스 로직

@Service
public class PaymentService {

  private final PaymentAttemptRepository repo;
  private final PaymentGatewayClient gateway;

  public PaymentService(PaymentAttemptRepository repo, PaymentGatewayClient gateway) {
    this.repo = repo;
    this.gateway = gateway;
  }

  @Transactional
  public PaymentResult pay(String idempotencyKey, String orderId, long amount) {
    // 1) 먼저 기존 시도를 조회
    var existing = repo.findByIdempotencyKey(idempotencyKey);
    if (existing.isPresent()) {
      return toResult(existing.get());
    }

    // 2) 최초 처리자 되기: 유니크 키로 INSERT
    PaymentAttempt attempt = new PaymentAttempt();
    attempt.setIdempotencyKey(idempotencyKey);
    attempt.setOrderId(orderId);
    attempt.setAmount(amount);
    attempt.setStatus(PaymentAttempt.Status.PROCESSING);

    try {
      repo.saveAndFlush(attempt);
    } catch (DataIntegrityViolationException dup) {
      // 동시 요청 경쟁에서 졌음: 다시 조회해서 결과 재사용
      var raced = repo.findByIdempotencyKey(idempotencyKey)
        .orElseThrow();
      return toResult(raced);
    }

    // 3) 결제사 호출(네트워크 타임아웃/재시도 정책 중요)
    PaymentGatewayResponse resp = gateway.charge(orderId, amount, idempotencyKey);

    // 4) 결과 저장
    attempt.setStatus(resp.approved()
      ? PaymentAttempt.Status.SUCCEEDED
      : PaymentAttempt.Status.FAILED);
    attempt.setResponsePayload(resp.rawJson());
    repo.save(attempt);

    return new PaymentResult(resp.approved(), resp.approvalId(), resp.message());
  }

  private PaymentResult toResult(PaymentAttempt attempt) {
    if (attempt.getStatus() == PaymentAttempt.Status.PROCESSING) {
      // 처리 중인 요청에 대해선 202로 유도하거나, 짧게 폴링하도록 설계
      throw new PaymentInProgressException();
    }
    // responsePayload를 역직렬화해서 동일 응답 구성
    return PaymentResult.fromPayload(attempt.getResponsePayload());
  }
}

포인트는 saveAndFlush로 유니크 제약을 즉시 평가하게 만들어 경쟁 상황에서 한 쪽만 “최초 처리자”가 되게 하는 것입니다.

결제사도 멱등키를 지원하면 반드시 함께 사용

내부에서 멱등키로 중복 호출을 막아도, 네트워크 문제로 “결제사에 요청이 갔는지 안 갔는지 모르는 상태”가 생깁니다. 이때 결제사가 멱등키를 지원한다면, 외부 결제사 호출에도 같은 키를 전달해야 합니다.

  • 내부 멱등키: 우리 시스템의 중복 처리 방지
  • 외부 멱등키: 결제사 측 중복 승인 방지

둘 중 하나만 해서는 사고가 남습니다.

Kubernetes 환경에서 자주 놓치는 함정 6가지

1) 멱등키 TTL을 너무 짧게 잡음

결제는 사용자가 앱을 다시 열고 재시도할 수 있습니다. 최소 수 시간, 보통은 24시간 이상을 고려합니다. “키 재사용” 정책이 있다면 주문 단위로 스코프를 묶는 편이 안전합니다.

2) “같은 키 + 다른 바디” 처리

동일 Idempotency-Key로 금액이 다르게 들어오면 어떻게 할지 정의해야 합니다.

  • 권장: 409 또는 422로 “키 재사용 불가”를 명확히 반환
  • 서버는 최초 요청의 orderId, amount를 저장해두고 이후 요청과 비교해야 합니다.

3) 처리 중(PROCESSING) 상태 응답

경쟁 요청이 들어오면 하나는 처리 중일 수 있습니다.

  • 단순 500으로 떨어뜨리면 클라이언트는 또 재시도합니다.
  • 202 Accepted + 폴링 엔드포인트 제공, 또는 짧은 서버 대기 후 결과 반환 같은 UX 설계가 필요합니다.

4) DB 트랜잭션과 외부 호출을 한 트랜잭션에 묶음

외부 결제사 호출을 DB 트랜잭션 안에서 오래 붙잡으면 커넥션 고갈, 락 경합이 생깁니다. “PROCESSING 레코드 생성”까지만 짧게 트랜잭션으로 묶고, 외부 호출은 트랜잭션 밖에서 수행한 뒤 결과 업데이트를 별도 트랜잭션으로 하는 방식도 고려하세요.

5) 메시지 기반 후속 처리의 중복 반영

결제가 성공한 뒤 PaymentSucceeded 이벤트를 발행하면, 소비자는 중복 소비에 대비해야 합니다.

  • 소비자 측에서도 eventId 또는 paymentAttemptId로 멱등 처리
  • DB에 “이미 반영됨” 테이블을 두고 유니크 제약으로 막기

6) Ingress 타임아웃과 애플리케이션 타임아웃 불일치

Ingress proxy_read_timeout이 짧고 애플리케이션은 더 오래 기다리면, 클라이언트는 실패로 보고 재시도하지만 서버는 뒤늦게 결제를 승인해버립니다. 이때 멱등키가 없으면 그대로 중복결제가 됩니다.

Redis 락을 추가하는 패턴(선택)

RDB 유니크 제약만으로도 대부분 막을 수 있지만, 초당 트래픽이 높거나 “PROCESSING 상태에서의 폴링”이 많으면 Redis로 짧은 락을 걸어 DB 경합을 줄일 수 있습니다.

예: SET lock:pay:{idempotencyKey} value NX PX 10000

redis-cli SET lock:pay:abc123 "1" NX PX 10000
  • 성공이면 처리 진행
  • 실패면 이미 누군가 처리 중이므로 DB 결과를 조회하거나 202를 반환

주의: Redis 락은 만능이 아니며, 락 만료와 네트워크 분리 상황을 고려해야 합니다. 결제처럼 중요한 영역은 최종적으로 RDB 유니크 제약이 “마지막 안전장치”가 되는 구성이 안전합니다.

운영 관점 체크리스트: 중복결제 징후를 빨리 잡는 법

  • 동일 orderId에 대해 결제 승인 레코드가 2개 이상 생성되는지 모니터링
  • Idempotency-Key 누락 요청 비율(클라이언트 버전 이슈) 대시보드화
  • Ingress 499, upstream timeout, 결제 API p95 지연을 함께 관찰
  • Pod 재시작(OOMKilled)과 결제 실패/재시도 상관관계 확인

프론트엔드에서 중복 제출 자체를 줄이면 서버 부담이 크게 내려갑니다.

정리: “중복 차단”이 아니라 “중복 흡수”로 설계하라

Kubernetes MSA에서 중복결제는 “버그”라기보다 “분산 시스템의 기본값”에 가깝습니다. 멱등키는 그 기본값을 바꾸는 가장 실용적인 장치입니다.

  • 멱등키는 헤더로 받고, 서버는 결과를 저장해 재사용
  • RDB 유니크 제약으로 최종 안전장치를 만들고, 필요하면 Redis로 경합 완화
  • 결제사도 멱등키를 지원하면 반드시 함께 전달
  • 처리 중 상태, 키 재사용 정책, TTL, 타임아웃 정합성을 명확히

이 원칙을 지키면 “재시도는 마음껏 하되 결제는 한 번만”이라는 사용자 경험을 Kubernetes MSA에서도 안정적으로 구현할 수 있습니다.