재시도해도 두 번 안 나가게 — 멱등키 설계
2026-07-28 · 2 min read
$ POST /payments -H 'Idempotency-Key: 018f…'
409 in_progress — 재시도는 대기
문제는 실패가 아니라 "모르는 실패"다
결제 API를 호출했는데 타임아웃이 났다. 이때 가능한 상태는 두 가지다.
- 서버가 요청을 못 받았다 → 재시도해야 한다
- 서버는 처리했는데 응답이 못 돌아왔다 → 재시도하면 두 번 나간다
클라이언트는 이 둘을 구분할 방법이 없다. 그래서 서버가 "같은 요청은 몇 번 와도 한 번만 처리한다"를 보장해줘야 한다. 이게 멱등성이다.
키는 클라이언트가 만든다
서버가 만들면 의미가 없다. 재시도할 때마다 새 키가 생기니까. 요청을 처음 만든 쪽이 UUID를 하나 뽑아서, 재시도 때 그대로 재사용한다.
POST /payments
Idempotency-Key: 018f2c1a-7b3d-4e5f-9a01-2c3d4e5f6a7b
Content-Type: application/json
{"amount_minor": 15000, "currency": "KRW", "order_id": "ord_991"}order_id를 키로 쓰면 안 되냐고 하면, 되는 경우도 있다.
다만 "같은 주문에 대한 부분 취소를 두 번" 같은 케이스가 생기면
주문 단위로는 구분이 안 된다. 요청 단위로 뽑는 게 안전하다.
저장 테이블
CREATE TABLE idempotency_keys (
key UUID PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- in_progress | done
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);request_hash가 있는 이유가 중요하다. 같은 키로 다른 본문이 오면
그건 재시도가 아니라 클라이언트 버그다. 조용히 첫 응답을 돌려주면
"1만원 보냈는데 5천원 결제됨" 같은 상황이 되니까 422로 거절해야 한다.
처리 흐름
1. 키로 INSERT 시도 (status=in_progress)
├─ 성공 → 최초 요청. 실제 처리 진행
└─ PK 충돌 → 기존 로우 조회
├─ request_hash 다름 → 422 거절
├─ status=done → 저장된 응답 그대로 반환 (재처리 안 함)
└─ status=in_progress → 409. 클라이언트가 잠시 후 재시도
2. 처리 완료 → status=done, response_body 저장
핵심은 INSERT 충돌을 잠금 대신 쓰는 것이다.
SELECT 후 없으면 INSERT로 짜면 두 요청이 동시에 들어왔을 때
둘 다 "없음"을 보고 둘 다 처리한다. PK 제약이 그 경합을 대신 막아준다.
그리고 이 INSERT는 실제 처리와 같은 트랜잭션에 있어야 한다. 분리되면 처리는 커밋됐는데 키 저장이 롤백되는 창이 생긴다.
만료
키를 영원히 들고 있을 필요는 없다. 재시도는 보통 몇 분 안에 끝난다. 24시간 정도 두고 지운다. 다만 결제처럼 되돌리기 어려운 도메인은 길게 잡는다. 지운 뒤에 늦은 재시도가 오면 새 요청으로 처리되니까.
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';재시도 쪽도 같이 고쳐야 한다
서버가 멱등해져도 클라이언트가 즉시 무한 재시도하면 서버가 죽는다. 지수 백오프에 지터를 섞는다. 지터가 없으면 죽었던 서버가 살아나는 순간 모든 클라이언트가 동시에 몰려 다시 죽는다.
delay = min(cap, base * 2 ** attempt) * random.uniform(0.5, 1.0)그리고 재시도해도 되는 응답만 재시도한다. 4xx는 몇 번을 보내도 같다.
5xx와 타임아웃만 대상이다.
한 줄 요약
클라이언트가 만든 키 + PK 충돌로 경합 차단 + 본문 해시 검증. 세 개가 다 있어야 멱등이다.