본문으로 건너뛰기
yutils

Idempotency Key — Stripe 가 재시도를 안전하게 만든 방법과 구현 가이드

재시도가 왜 중복 결제를 만드는지, Stripe·PayPal 의 idempotency key 동작, 서버 상태 머신, 키 생성 전략, 저장소 TTL.

약 8분 읽기

결제 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" }
  1. 처음 호출 — 서버가 결제 수행 + (key → 응답) 저장 + 응답 반환.
  2. 재시도 — 같은 key 도착. 서버가 저장된 응답을 그대로 반환. 결제 수행 X.
  3. 다른 key — 다른 결제로 처리.

결과: 네트워크가 어디서 끊겼든 클라이언트가 마음 놓고 재시도. 같은 key 면 서버가 책임지고 중복 방지.

Stripe 의 정확한 규칙

  • 헤더 이름: Idempotency-Key. 최대 255 자.
  • 저장 기간: 24 시간. 24 시간 후 같은 키는 새 요청으로 처리.
  • body 일치: 같은 키 + 다른 body = 409 Conflict. 키와 body 의 해시를 함께 저장해 변경 감지.
  • 응답 헤더: Idempotency-Key echo + 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 동안 유지. 새 요청에는 새 키.
가이드 목록으로