결제 API 에 POST 보냈는데 응답이 안 옴. 504 timeout. 재시도? 안 하면 결제 못 한 것 같고, 하면 중복 결제 위험. 네트워크가 그 사이 끊겼는지 응답만 늦었는지 클라이언트는 알 수 없다. 이게 모든 POST 의 본질적 문제고, idempotency key 가 답이다. 이 가이드는 Stripe/PayPal 의 실제 동작, 서버 측 상태 머신, 키 생성 전략을 정리한다.
왜 GET 은 안전하고 POST 는 위험한가
HTTP 메서드의 idempotency 정의: 같은 요청을 N 회 보내도 결과가 1 회 보낸 것과 같음.
- GET, PUT, DELETE — 정의상 idempotent. 같은 PUT 을 100 번 보내도 최종 상태 1 번과 같음.
- POST — idempotent 아님. 같은 POST 100 번 = 100 개 리소 스 생성.
- PATCH — 케이스 by 케이스.
{"$inc": 1}는 idempotent 아님,{"name": "X"}는 맞음.
결제·송금·주문 생성 같은 가장 중요한 동작이 POST. 클라이언트가 재시도 못 하면 신뢰성 ↓, 재시도 하면 중복 ↑. 둘 다 받아주는 메커니즘이 필요.
idempotency key 의 동작
클라이언트가 요청마다 고유 키 를 헤더로 보낸다. 서버는 키와 응답을 매핑해 저장.
POST /v1/charges
Idempotency-Key: 5d8a3f2e-1c4b-4a9d-b8e7-3f2a1c4b8e7d
Content-Type: application/json
{ "amount": 5000, "currency": "usd" }- 처음 호출 — 서버가 결제 수행 + (key → 응답) 저장 + 응답 반환.
- 재시도 — 같은 key 도착. 서버가 저장된 응답을 그대로 반환. 결제 수행 X.
- 다른 key — 다른 결제로 처리.
결과: 네트워크가 어디서 끊겼든 클라이언트가 마음 놓고 재시도. 같은 key 면 서버가 책임지고 중복 방지.
Stripe 의 정확한 규칙
- 헤더 이름:
Idempotency-Key. 최대 255 자. - 저장 기간: 24 시간. 24 시간 후 같은 키는 새 요청으로 처리.
- body 일치: 같은 키 + 다른 body = 409 Conflict. 키와 body 의 해시를 함께 저장해 변경 감지.
- 응답 헤더:
Idempotency-Keyecho +Stripe-Should-Retry(true/false) 로 클라이언트에 재시도 신호. - 적용 대상: POST 만. GET 은 자연스레 idempotent.
키 형식은 무엇이든 — 흔한 선택은 UUID v4 또는 v7. UUID / ULID 생성기 에서 즉시 생성 가능. cURL 빌더 로 idempotency 헤더 포함 한 curl 명령 만들 수 있다.
서버 측 상태 머신
키별로 다음 상태 추적:
| state | 의미 | 다음 동일 키 도착 시 |
|---|---|---|
| NEW | 처음 본 키 (DB 에 없음) | 락 잡고 실행 시작 |
| IN_PROGRESS | 실행 중 (락 holding) | 409 Conflict 또는 wait-and-poll |
| COMPLETED | 응답 저장됨 | 저장된 응답 그대로 반환 |
| FAILED | 일시적 에러 (5xx) | 재시도 허용 — 저장된 응답 반환 X |
DB 스키마 예:
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
state VARCHAR(20) NOT NULL,
http_status INT,
response_body JSONB,
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ON idempotency_keys (expires_at);TTL: expires_at = created_at + 24h (Stripe 기준). 주기적 job 으로 만료된 row 삭제.
락 처리 — 동시 요청
클라이언트가 timeout 후 즉시 재시도하면 두 요청이 동시에 서버 도달 가능. 둘 다 NEW 로 본 뒤 양쪽 다 실행 = 중복. 락 필수.
Postgres 의 경우:
BEGIN;
INSERT INTO idempotency_keys (key, request_hash, state)
VALUES ($1, $2, 'IN_PROGRESS')
ON CONFLICT (key) DO NOTHING
RETURNING *;
COMMIT;RETURNING 행이 있으면 — 처음. 비어 있으면 — 이미 다른 요청 처리 중 또는 완료. 후자는 다시 조회해 state 분기.
Redis 라면 SET key value NX EX 86400. NX = exists 면 실패 → 분기.
키 생성 — 클라이언트 책임
서버가 키를 받기만 하므로 클라이언트가 키를 잘 만들어야 한다.
- 요청 단위로 새 키 — 같은 요청을 retry 하는 동안에는 같은 key 유지. 새 요청 시작 시 새 key.
- UUID v4 — 가장 흔한 선택. 충돌 사실상 0.
- UUID v7 또는 ULID — 시간 정렬 됨. DB 인덱스 효율.
- 비결정적 — request body 의 해시는 idempotency key 로 쓰지 말 것. 의도적으로 다른 요청도 같은 body 면 합쳐버림.
모바일·SPA 에서 흔한 패턴 — 사용자가 결제 버튼 누르면 즉시 UUID 생성 후 로컬 저장. 재시도 시 같은 UUID 사용. 성공 응답 받으면 UUID 삭제.
응답 헤더로 알리기
HTTP/1.1 200 OK
Idempotency-Key: 5d8a3f2e-1c4b-4a9d-b8e7-3f2a1c4b8e7d
Idempotent-Replayed: true
Content-Type: application/json
{ "id": "ch_abc", "status": "succeeded" }Idempotent-Replayed: true 가 있으면 클라이언트는 "처음 결제 안 된 것 같았지만 사실 됐었구나" 알 수 있음. 사용자 UI 분기에 유용.
흔한 함정
1. GET 에 idempotency key
GET 은 정의상 idempotent. key 없어도 안전. key 박으면 캐시·CDN 이 혼란.
2. 같은 키로 다른 endpoint
/charges 와 /refunds 에 같은 key. 둘이 다른 리소스라 충돌 X 처럼 보이지만, 키 저장소를 path 와 무관하게 두면 후자 가 전자의 응답을 반환받음. 키 + path 를 묶거나 클라이언트가 항상 새 키.
3. TTL 너무 길거나 짧음
24 시간이 Stripe 기준이고 합리적. 너무 짧으면 (1 분) 사용자가 늦게 retry 시 중복. 너무 길면 (1 년) 저장 비용. 비즈니스 요구에 맞게.
4. body 해시 안 함
같은 키 + 다른 body 인데 그냥 저장된 응답 반환 = 보안 사고 (다른 사용자 결제 정보 노출 가능). 키 + body hash 함께 검증.
5. IN_PROGRESS 상태에서 wait 무한
다른 요청이 lock 잡고 있을 때 무한 대기 → 클라이언트 timeout 의 연쇄. max 30 초 wait 후 409 또는 503 반환.
6. 자동 재시도에 대한 응답이 5xx
5xx 면 클라이언트는 "처리 안 됨" 으로 가정하고 재시도. 그런데 서버는 IN_PROGRESS 로 저장했을 수 있음. 5xx 응답 시 idempotency 레코드 삭제 (또는 FAILED 로 표시) 해 재시도 허용.
클라이언트 구현 패턴
async function chargeWithRetry(amount, currency, maxRetries = 3) {
const key = crypto.randomUUID(); // 한 번 만들고 retry 동안 유지
for (let i = 0; i <= maxRetries; i++) {
try {
const res = await fetch("/v1/charges", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({amount, currency}),
});
if (res.ok) return res.json();
if (res.status >= 400 && res.status < 500) {
throw new Error(`Client error: ${res.status}`);
}
// 5xx → 잠시 후 재시도
} catch (e) {
if (i === maxRetries) throw e;
}
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}지수 백오프 + 같은 key 유지. 4xx 는 클라이언트 문제이므로 재시도 X.
요약
- POST 는 본질적으로 idempotent 아님. 재시도 안전성을 위해 키 필요.
Idempotency-Key헤더 + 서버가 (key → 응답) 매핑 저장.- 상태 머신: NEW / IN_PROGRESS / COMPLETED / FAILED. 동시성 락 필수.
- 키 + body hash 함께 검증. body 다르면 409.
- TTL 24 시간이 Stripe 기준. 만료된 키 정기 삭제.
- 클라이언트는 한 번 만든 키를 retry 동안 유지. 새 요청에는 새 키.