결제, 주문 생성, 포인트 적립 같은 API를 만들 때 가장 불편한 실패는 "서버가 처리했는지 클라이언트가 모르는 실패"다. 예를 들어 클라이언트가 POST /payments를 보냈고 서버는 결제를 생성했는데, 응답이 돌아오기 전에 네트워크가 끊겼다고 하자. 클라이언트 입장에서는 타임아웃만 보인다. 다시 요청해야 할까, 말아야 할까.
그냥 다시 보내면 같은 결제가 두 번 생길 수 있다. 반대로 재시도를 포기하면 실제로는 실패했던 요청을 놓칠 수 있다. 이 애매한 구간을 줄이기 위해 많이 쓰는 패턴이 Idempotency Key다. Stripe API docs의 Idempotent requests 문서도 같은 문제의식에서 출발한다. Stripe는 객체를 만들거나 갱신하는 요청에서 idempotency key를 쓰면, 연결 오류가 발생해도 같은 작업을 안전하게 재시도할 수 있다고 설명한다.
Idempotency Key의 핵심은 "같은 요청을 다시 보내도 같은 비즈니스 작업으로 취급한다"는 약속이다.
이 글에서는 Stripe 사례를 기준으로 Idempotency Key가 어떤 데이터를 저장하고, 어떤 경계에서 조심해야 하는지 정리했다.
HTTP 메서드 관점에서 GET은 보통 안전하게 재시도할 수 있다. 같은 리소스를 조회하는 요청이기 때문이다. DELETE도 스펙과 구현에 따라 여러 번 호출해도 최종 상태가 같게 설계할 수 있다. 문제는 POST다. POST /orders는 호출할 때마다 새 주문을 만들 수 있고, POST /payments는 호출할 때마다 새 결제 시도를 만들 수 있다.
Idempotency Key는 클라이언트가 "이 요청은 특정 작업을 대표한다"는 식별자를 서버에 같이 보내는 방식이다.
POST /payments
Idempotency-Key: order-20260611-0001-pay
{
"orderId": "order-20260611-0001",
"amount": 39000
}
서버는 이 키를 보고 처음 보는 요청이면 실제 처리를 수행하고 결과를 저장한다. 같은 키가 다시 오면 새 작업을 만들지 않고, 저장된 결과를 돌려준다. Stripe API v1 문서에 따르면 Stripe는 첫 요청의 상태 코드와 본문을 저장하고, 이후 같은 키 요청에는 성공뿐 아니라 500 오류까지 같은 결과를 반환한다. 이 지점이 중요하다. 단순히 "성공했으면 중복 생성만 막는다"가 아니라, 클라이언트가 같은 재시도에 대해 일관된 응답을 받도록 만드는 쪽에 가깝다.
가장 단순한 구조는 요청 처리 앞단에 idempotency 저장소를 두는 것이다. 저장소는 보통 키, 요청 파라미터의 해시, 처리 상태, 응답 상태 코드, 응답 본문, 만료 시각을 가진다.
Client
|
| POST /payments + Idempotency-Key
v
API Server
|
| 1. key 조회
v
Idempotency Store
|
| 2-a. 없음: 처리 예약 후 비즈니스 로직 실행
| 2-b. 있음: 저장된 응답 또는 진행 중 상태 처리
v
Business Logic / DB / External API
흐름을 단계로 풀면 이렇다.
여기서 중요한 설계 포인트는 "키만 같으면 무조건 같은 요청"으로 보면 안 된다는 점이다. Stripe 문서는 idempotency layer가 들어온 파라미터를 원래 요청의 파라미터와 비교하고, 같지 않으면 accidental misuse를 막기 위해 오류를 낸다고 설명한다. 즉 같은 키로 금액이나 대상 주문이 바뀐 요청을 보내면, 서버는 그것을 재시도가 아니라 클라이언트 버그로 보는 편이 안전하다.
만료 정책도 필요하다. Stripe API v1 문서에서는 idempotency key를 최소 24시간 뒤 자동 제거할 수 있고, 제거된 뒤 같은 키를 재사용하면 새 요청을 생성한다고 설명한다. 그래서 키는 영원한 전역 식별자라기보다, 정해진 재시도 창 안에서 중복 실행을 막는 장치로 이해하는 편이 맞다.
Idempotency Key 구현은 "키를 Redis에 넣는다" 정도로 끝나지 않는다. 실패와 동시성을 먼저 생각해야 한다.
첫째, 원자성이 필요하다. 같은 키를 가진 요청 두 개가 거의 동시에 들어오면 둘 다 "기록 없음"을 보고 비즈니스 로직을 실행할 수 있다. 그래서 저장소에는 SET NX, unique constraint, advisory lock처럼 한 요청만 처리권을 얻도록 하는 장치가 있어야 한다. RDB를 쓴다면 (scope, idempotency_key)에 unique index를 두고, insert 성공 여부로 최초 요청을 판단하는 방식이 흔하다.
둘째, scope를 정해야 한다. idempotency key가 모든 사용자에게 전역으로 유일해야 하는지, 특정 계정이나 상점 안에서만 유일하면 되는지 결정해야 한다. Stripe API v2 문서는 같은 API, 같은 account 또는 sandbox 범위, 일정 기간 안에서 같은 key를 idempotent replay로 본다고 설명한다. 일반 서비스에서도 tenant_id, user_id, endpoint 같은 범위를 키와 함께 묶어야 충돌과 정보 노출 가능성을 줄일 수 있다.
셋째, 저장할 응답의 범위를 정해야 한다. Stripe API v1처럼 첫 요청의 status code와 body를 저장하면 클라이언트는 같은 키에 대해 같은 결과를 받는다. 다만 내부 서비스에서는 실패 응답을 모두 저장할지 고민이 필요하다. 유효성 검증 실패처럼 비즈니스 실행이 시작되지 않은 요청은 저장하지 않고 재시도 가능하게 둘 수 있다. Stripe 문서도 파라미터 검증 실패나 동시 실행 충돌처럼 endpoint execution이 시작되지 않은 경우에는 idempotent result를 저장하지 않는다고 설명한다.
아래는 RDB를 기준으로 한 간단한 의사 코드다.
PaymentResponse createPayment(CreatePaymentCommand command, String key) {
String scope = command.userId() + ":POST:/payments";
String requestHash = sha256(command.normalizedJson());
IdempotencyRecord record = idempotencyRepository.find(scope, key);
if (record != null) {
if (!record.requestHash().equals(requestHash)) {
throw new BadRequestException("same idempotency key with different payload");
}
if (record.completed()) {
return record.savedResponse(); // 같은 작업의 재시도이므로 저장된 응답 반환
}
throw new ConflictException("request is still processing");
}
idempotencyRepository.insertProcessing(scope, key, requestHash);
try {
Payment payment = paymentService.create(command);
PaymentResponse response = PaymentResponse.from(payment);
idempotencyRepository.markCompleted(scope, key, 200, response);
return response;
} catch (Exception e) {
idempotencyRepository.markCompleted(scope, key, 500, errorBody(e));
throw e;
}
}
실제 구현에서는 insertProcessing과 비즈니스 트랜잭션의 경계를 더 신중히 잡아야 한다. 결제처럼 외부 API를 호출하는 작업은 DB 트랜잭션만으로 전체 원자성을 만들 수 없기 때문이다. 이 경우 idempotency record, payment 상태, 외부 결제사의 idempotency 기능을 함께 엮어야 한다.
키는 충분히 유일해야 하고, 민감정보를 담지 않는 편이 좋다. Stripe API docs는 V4 UUID 또는 충돌을 피할 만큼 엔트로피가 충분한 랜덤 문자열을 제안하고, idempotency key에 이메일 주소나 개인 식별자 같은 민감 데이터를 넣지 말라고 안내한다. 또한 Stripe API v1 기준 key 길이는 최대 255자라고 설명한다.
서비스 내부에서는 완전 랜덤 UUID와 비즈니스 식별자 기반 키 사이에서 선택하게 된다.
| 방식 | 장점 | 주의점 |
|---|---|---|
| UUID 기반 | 충돌 가능성이 낮고 구현이 단순하다 | 같은 비즈니스 작업에서 같은 UUID를 재사용하도록 클라이언트가 보관해야 한다 |
| 주문 ID 기반 | "주문 1건당 결제 1건" 같은 의도가 드러난다 | 주문 ID만 쓰면 다른 작업과 충돌할 수 있어 endpoint나 action을 함께 넣는 편이 안전하다 |
| 서버 발급 토큰 | 클라이언트 실수를 줄일 수 있다 | 토큰 발급 단계가 추가되고 만료 정책이 필요하다 |
나는 API를 설계할 때 "클라이언트가 재시도해야 하는 작업 단위"를 먼저 정하고, 그 단위가 키에 반영되는지 확인하는 방식이 이해하기 쉬웠다. 예를 들어 장바구니에서 PaymentIntent를 하나만 만들어야 한다면 cartId 또는 checkoutSessionId를 기반으로 키를 만들 수 있다. Stripe Payment Intents 문서도 같은 purchase에 대해 중복 PaymentIntent 생성을 막기 위해 idempotency key를 제공하라고 안내한다.
Idempotency Key는 재시도를 없애는 기술이 아니라, 재시도를 해도 같은 작업으로 수렴하게 만드는 API 설계 패턴이다. 클라이언트는 같은 작업에 같은 키를 붙이고, 서버는 그 키와 요청 파라미터, 응답을 저장해 중복 실행을 막는다.
핵심은 세 가지로 정리할 수 있다.
다음에 더 파고들 주제는 idempotency record와 비즈니스 트랜잭션의 경계, 그리고 외부 결제 API와 내부 주문 DB를 함께 다룰 때의 Outbox/Saga 조합이다. Idempotency Key 하나로 모든 분산 실패가 사라지지는 않지만, "응답을 못 받았으니 한 번 더 보낸다"는 현실적인 클라이언트 동작을 서버가 감당할 수 있게 해준다.