본문 바로가기
inpilot.dev

재시도해도 두 번 안 나가게 — 멱등키 설계

2026-07-28 · 2 min read

$ POST /payments -H 'Idempotency-Key: 018f…'

409 in_progress — 재시도는 대기

문제는 실패가 아니라 "모르는 실패"다

결제 API를 호출했는데 타임아웃이 났다. 이때 가능한 상태는 두 가지다.

  1. 서버가 요청을 못 받았다 → 재시도해야 한다
  2. 서버는 처리했는데 응답이 못 돌아왔다 → 재시도하면 두 번 나간다

클라이언트는 이 둘을 구분할 방법이 없다. 그래서 서버가 "같은 요청은 몇 번 와도 한 번만 처리한다"를 보장해줘야 한다. 이게 멱등성이다.

키는 클라이언트가 만든다

서버가 만들면 의미가 없다. 재시도할 때마다 새 키가 생기니까. 요청을 처음 만든 쪽이 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 충돌로 경합 차단 + 본문 해시 검증. 세 개가 다 있어야 멱등이다.

새 글이 올라오면 받아보기

스팸 없이, 새 글이 올라올 때만 보내드려요.

댓글

댓글은 giscus 설정 후 표시됩니다. (docs/SETUP-features.md 참고)