Stripe — 멱등성 키로 이중 결제를 막는 방법
결제 버튼 연타와 네트워크 단절 속에서도 과금이 정확히 한 번만 일어나는 이유 — Idempotency-Key 레코드, 잠금, atomic phase와 recovery point 상태 머신(started→ride_created→charge_created→finished), 스테이징 잡 드레인을 21단계로 따라간다.
단계 목록 · 키보드 ←/→ 이동, Space 재생
출처: Stripe Engineering — Designing robust and predictable APIs with idempotency · brandur.org — Implementing Stripe-like Idempotency Keys in Postgres · Stripe API 공식 문서 — Idempotent requests
멱등 결제의 비밀은 '재시도를 막는 것'이 아니라 '재시도를 안전하게 만드는 것'이다 — 키 하나에 상태 머신 하나를 붙여, 같은 키의 재시도는 마지막으로 커밋된 recovery point 다음부터만 실행한다.
왜 헷갈리는가
'서버가 트랜잭션으로 감싸면 되지 않나'가 흔한 오해다. ACID 트랜잭션은 자기 DB 안에서만 유효하다 — Stripe 과금이나 이메일 발송 같은 foreign state mutation은 롤백할 수 없다. 그래서 흐름을 외부 호출 경계에서 atomic phase로 쪼개고, phase 사이마다 recovery point를 커밋해 '어디까지 했는지'를 남긴다. 또 하나: 실패 응답도 조건부로 재생된다 — 실행이 시작된 뒤의 결과는 500이라도 저장·재생되고, 검증 실패(4xx)나 409처럼 실행 전에 튕긴 요청은 저장되지 않아 재시도 때 실제로 실행된다.
애니메이션이 보여주는 것
정상 경로부터 시작한다 — 클라이언트가 V4 UUID를 Idempotency-Key 헤더에 실어 보내면, 서버는 (사용자, 키) 행을 만들어 요청 파라미터의 지문을 저장하고 locked_at으로 잠근다. 이후 로컬 변경(ride+audit 레코드)은 한 트랜잭션으로, 외부 과금은 하위 시스템용 멱등 키를 따로 만들어 호출하고, phase가 끝날 때마다 recovery point가 올라간다.
그 다음 두 가지 실패를 재현한다. 과금 성공 직후 네트워크가 끊기면 클라이언트는 지수 백오프(2^n + 지터) 후 같은 키로 재시도하고, 서버는 recovery point가 charge_created임을 보고 과금 phase를 통째로 건너뛴다 — 이중 과금이 없는 이유다. 버튼 연타에서는 잠금을 잡은 쪽만 실행되고 나머지는 409 Conflict(idempotency_key_in_use)를 받았다가, 잠시 후 저장된 응답을 재생받는다.
왜 GET에는 키가 없고, POST에만 있나
GET·DELETE는 HTTP 의미상 이미 멱등이다 — 같은 조회를 두 번 해도, 같은 삭제를 두 번 해도 결과 상태는 같다. POST는 다르다. '한 번 더 보내면 한 번 더 생긴다'. 그래서 Stripe는 POST 요청에만 Idempotency-Key(최대 255자, V4 UUID 권장)를 받고, 최초 요청의 상태 코드와 본문을 키와 함께 저장했다가 재시도에 재생한다.
키는 최소 24시간 보관 후 제거된다. 24시간이 지난 키로 다시 요청하면 새 요청으로 처리되므로, 재시도는 그 창 안에서 끝나야 한다.
phase 중간에 서버가 죽으면?
그게 바로 recovery point의 존재 이유다. 각 atomic phase는 로컬 변경과 recovery point 갱신을 한 트랜잭션으로 커밋하므로, 어디서 죽어도 '반쯤 된' 로컬 상태가 남지 않는다. 재시도가 오면 서버는 마지막으로 커밋된 지점 다음부터만 실행한다. 외부 호출 도중에 죽는 경우까지 안전한 이유는, 그 호출 자체가 하위 시스템의 멱등 키로 보호되기 때문이다 — 재시도해도 Stripe가 같은 charge를 재생한다.
부작용의 마지막 구멍은 백그라운드 잡이다. 트랜잭션 밖의 큐에 잡을 직접 넣으면 롤백돼도 잡은 이미 나가 있다. 그래서 잡을 같은 트랜잭션 안의 staged_jobs 테이블에 스테이징하고, 커밋된 것만 워커가 드레인한다(transactionally-staged job drain).
- started → ride_created → charge_created → finished: 키 하나당 상태 머신 하나
- atomic phase = 외부 호출 사이의 로컬 변경 묶음, 한 ACID 트랜잭션으로 커밋
- foreign state mutation은 롤백 불가 — 하위 시스템의 멱등 키로 보호
- 지문(fingerprint) 비교 — 같은 키에 다른 파라미터가 오면 오류
기억할 것
- 멱등성 키는 클라이언트가 만든다(V4 UUID) — 재시도는 반드시 같은 키로, 새 결제는 새 키로.
- recovery point 상태 머신이 이중 과금을 막는다 — 재시도는 완료된 phase를 건너뛴다.
- 외부 변경은 롤백할 수 없다 — Stripe 과금에도 하위 시스템용 멱등 키를 따로 전달한다.
- 동시 요청은 locked_at 잠금이 정리한다 — 늦은 쪽은 409 Conflict, 끝나면 캐시 응답 재생.
- 재시도는 지수 백오프 + 지터로, 그리고 키가 살아 있는 24시간 안에.