클라이언트가 요청에 붙이는 고유 식별자로, 서버가 이를 기억해 중복 처리를 막는 장치.
POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{ "orderId": 1, "amount": 10000 }
무엇을 해결하나
POST는 본질적으로 비멱등이다. 그런데 재시도는 피할 수 없다.
- 결제 요청 → 타임아웃
- 처리됐는지 알 수 없다
- 재시도하면 이중 결제, 안 하면 결제 누락
같은 키로 재시도하면 서버가 "이미 처리한 요청"임을 알아본다. Stripe·Toss 등 결제 API가 표준적으로 제공하는 기능이다.
서버 처리 흐름
구현 — 동시 요청까지 고려해야 한다
CREATE TABLE idempotency (
key VARCHAR(64) PRIMARY KEY,
status VARCHAR(16), -- PROCESSING / COMPLETED
request_hash VARCHAR(64),
response_body JSON,
status_code INT,
created_at TIMESTAMP
);
핵심은 "먼저 선점" 이다.
INSERT INTO idempotency (key, status) VALUES (?, 'PROCESSING');
-- 유니크 제약 위반 → 이미 누가 처리 중이거나 완료함
조회 후 삽입하면 그 사이에 다른 요청이 끼어들 수 있다(경쟁 조건). 삽입을 먼저 시도해 DB의 유니크 제약으로 원자적으로 판정하는 것이 정석이다.
Redis라면 SET key value NX EX 86400 한 줄로 같은 효과를 낸다.
응답까지 저장하는 이유
❌ "이미 처리했습니다" 라고만 응답
- 클라이언트가 결제 ID를 못 받는다. 원래 응답이 필요하다
✅ 첫 요청의 응답 본문·상태 코드를 저장했다가 그대로 반환
- 클라이언트 입장에서는 첫 요청이 성공한 것과 완전히 동일
요청 본문 검증 — 놓치기 쉬운 부분
같은 키인데 다른 내용으로 요청이 오면?
Idempotency-Key: abc, amount: 10000 (첫 번째)
Idempotency-Key: abc, amount: 99999 (두 번째) ← 다른 요청!
요청 본문의 해시를 함께 저장해 다르면 422로 거부한다.
키 재사용 실수나 공격을 막는다.
키는 누가 만드나
클라이언트가 만든다. 서버가 만들면 의미가 없다 — 재시도할 때 같은 키를 보낼 수 있어야 하기 때문이다.
// 화면 진입 시 또는 폼 작성 시작 시 한 번 생성
const key = crypto.randomUUID();
// 재시도 시에도 같은 키를 사용 ← 이것이 핵심
주의 — 재시도할 때마다 새 키를 만들면 아무 의미가 없다. 클라이언트 구현에서 실수하기 쉬운 지점이다.
보관 기간
너무 짧으면 → 늦은 재시도를 못 막는다
- 너무 길면 — → 저장소가 계속 커진다
일반적으로 24시간 ~ 7일. 결제는 보통 24시간
TTL을 두고 만료시키되, 결제처럼 중요한 것은 별도의 정합성 검증 배치를 함께 둔다.
어디에 적용하나
✅ 결제, 송금, 포인트 적립/차감 ✅ 주문 생성 ✅ 외부 시스템 연동 (메일 발송, SMS) ❌ 단순 조회 (이미 멱등) ❌ 멱등하게 설계할 수 있는 것 (조건부 UPDATE로 충분)
모든 API에 붙일 필요는 없다. 상태 전이로 멱등해지는 것은 그렇게 하고, 그것으로 부족한 "생성" 계열에만 적용한다.