- Published on
Kubernetes MSA에서 멱등키로 중복결제 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡해도 결제는 종종 두 번 일어납니다. 특히 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회”를 범위로 잡습니다.
- 예:
orderId하나당 결제는 한 번만 가능하게 할지, “같은 주문에서 부분취소/재결제” 같은 예외를 허용할지 정책화해야 합니다.
- 처리 결과를 저장하고 재요청 시 반환하기
- 단순히 “중복이면 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_attemptsididempotency_key(unique)order_idamountstatus(PROCESSING, SUCCEEDED, FAILED)response_payload(승인 결과 JSON)created_at,updated_at
동일 키로 들어오면 response_payload를 그대로 반환합니다.
처리 플로우(핵심)
- 요청 수신:
Idempotency-Key필수 - DB에
idempotency_key로 레코드INSERT- 성공하면 “내가 최초 처리자”
- 유니크 충돌이면 “이미 처리 중이거나 완료됨”
- 최초 처리자는 결제사 호출
- 결과를 DB에 업데이트하고 응답 반환
- 중복 요청자는 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에서도 안정적으로 구현할 수 있습니다.