Idempotency — Two Clicks, One Charge
Retries Are Unavoidable
한국어 원문으로 표시합니다.
한 줄 요약
재시도는 피할 수 없다. 그러니 중복은 서버가 막아야 한다.
왜 필요한가 — 클라이언트는 구별할 수 없다
결제 요청을 보냈는데 응답이 안 왔다. 둘 중 무엇인가?
- 요청이 서버에 도착하지 않았다 → 다시 보내야 한다
- 도착해서 처리됐는데 응답만 못 왔다 → 다시 보내면 두 번 결제된다
클라이언트는 이걸 구별할 방법이 없다. 그래서 어느 쪽을 택하든 틀린다.
그러니 클라이언트는 재시도하고, 서버가 두 번째를 알아보게 만드는 수밖에 없다.
멱등성 키
클라이언트가 요청마다 고유한 키를 만들어 붙인다.
POST /pay
Idempotency-Key: 9f2c-...-a1
{"user":"u1","amount":1000}
재시도할 때는 같은 키를 쓴다. 서버는 그 키를 본 적 있으면 처리하지 않고 저장해 둔 응답을 그대로 돌려준다.
핵심은 이것이다 — 두 번째 요청도 성공 응답을 받는다. 오류가 아니다. 클라이언트 입장에서는 "한 번 요청해서 한 번 성공" 이고, 그게 맞다.
어디에 저장하나
메모리 사전은 안 된다. 이유가 둘이다.
- 재시작하면 잊는다
- 서버가 여러 대다 — 1번 파드가 기억한 것을 2번 파드는 모른다
2번이 훨씬 흔하다. 파드 하나로 테스트하면 완벽하게 동작하고, 오토스케일이 붙는 순간 중복이 난다.
그래서 저장은 모두가 같이 보는 곳이어야 한다 — DB 나 Redis.
같은 키에 다른 본문이 오면
Idempotency-Key: k1 {"amount": 1000}
Idempotency-Key: k1 {"amount": 99999}
두 번째를 조용히 통과시키면 1000원짜리 응답을 99999원 요청에 돌려준다. 클라이언트는 99999원이 결제된 줄 안다.
그래서 요청 본문의 해시를 함께 저장하고, 다르면 422 로 거절한다. 키를 재사용하는 버그가 조용히 묻히지 않게 하는 장치다.
동시에 같은 키가 오면
가장 자주 틀리는 자리다.
row = db.get(key) # 없다
if not row: # ← 열 개가 동시에 여기를 통과한다
charge()
db.put(key, response)
조회와 삽입 사이에 남이 끼어든다. 순진한 방식은 부하가 붙는 순간 깨진다.
제대로 하려면 키에 유일 제약을 걸고 삽입이 실패하는 쪽이 기다렸다가 저장된 응답을 읽게 하거나, 잠금으로 감싼다. 데이터베이스가 이미 갖고 있는 보장을 쓰는 것이 가장 싸다.
저장한 응답은 언제까지 두나
영원히 둘 수는 없다. 보통 24시간 쯤 두고 지운다.
너무 짧으면 늦은 재시도가 중복을 만들고, 너무 길면 저장소가 계속 는다. 클라이언트의 재시도 창보다 넉넉히 길게 잡는다.
어떤 오류에 재시도하나
| 응답 | 재시도 | 왜 |
|---|---|---|
| 응답 없음(타임아웃) | ✅ | 도착했는지 모른다 |
500, 502, 503 |
✅ | 서버 쪽 일시 문제 |
429 |
✅ (기다렸다가) | Retry-After 를 지킨다 |
400, 422 |
❌ | 요청이 틀렸다. 영원히 같다 |
404 |
❌ | 대개 영구적 |
그리고 간격을 늘려 가며(exponential backoff) 하고, 클라이언트마다 흔들어(jitter) 준다. 안 그러면 회복하려는 서버에 재시도가 같은 순간 몰려서 다시 눕힌다.
어떤 요청에 붙이나
돈이 움직이거나 무언가를 만드는 요청에 붙인다. POST, 그리고 부작용이 있는 PATCH.
GET·PUT·DELETE 는 원래 멱등하도록 설계된 메서드다. PUT 은 같은 것을 두 번 써도 결과가 같고, DELETE 는 두 번째가 "이미 없다" 다. 설계가 그렇다는 것이지 구현이 저절로 그렇게 되지는 않는다.
저장한 응답을 돌려줄 때의 함정
멱등성을 붙이고 나서 새로 생기는 문제가 있다. 처음의 응답을 그대로 돌려주는 것이 언제나 옳지는 않다.
상태가 그사이 바뀌었을 수 있다. 주문을 만든 뒤 사용자가 취소했는데, 재시도가 와서 저장된 201 응답("생성됨")을 그대로 돌려주면 클라이언트는 살아 있는 주문이 있다고 믿는다. 저장하는 것은 그 요청의 결과여야 하고, 클라이언트가 현재 상태를 알아야 한다면 응답에 조회 경로를 함께 준다.
응답을 통째로 저장하면 커진다. 큰 본문을 그대로 담으면 저장소가 빠르게 찬다. 필요한 것은 대개 상태 코드와 만들어진 자원의 식별자뿐이므로, 그것만 담고 나머지는 다시 만들어 준다.
보관 기간을 정하고 지운다. 멱등 키는 영원히 필요하지 않다. 클라이언트의 재시도 창(대개 24시간)보다 넉넉하게 잡고 그 뒤에는 지운다. 지우지 않으면 표가 계속 커지고, 인덱스가 커지면 원래 빨랐던 조회가 느려진다.
delete from idempotency_keys where created_at < now() - interval '7 days';
만료된 키가 다시 오면 어떻게 할지 정한다. 조용히 새로 처리하면 중복이 생기고, 거절하면 아주 늦은 재시도가 실패한다. 거절하는 쪽이 안전하고, 그때는 "이 키는 만료되었으니 새 키로 다시 보내라" 를 명확히 알린다.
응답 헤더로 재시도인지 알려 준다. Idempotency-Replayed: true 같은 헤더를
붙이면, 클라이언트와 조사하는 사람 모두 이 응답이 새로 처리된 것인지 저장된
것인지 안다. 로그에도 남기면 "요청이 두 배로 늘었다" 가 재시도인지 실제 증가인지
바로 갈린다.
키 자체를 신뢰하지 않는다. 클라이언트가 만든 값이므로 다른 사용자의 키와 겹칠 수 있다. 조직이나 사용자 식별자와 함께 유일성을 잡아야 남의 응답을 받아 보는 일이 없다.
현장에서
중복 결제 신고는 대개 사용자가 아니라 정산에서 먼저 발견됩니다. 사용자는 '결제가 안 된 것 같아 한 번 더 눌렀다' 고만 기억하고, 그 사이에 무슨 일이 있었는지는 서버 로그에만 남기 때문입니다.
그래서 멱등성 키는 사고가 난 뒤에 붙이기가 유난히 어렵습니다. 이미 나간 클라이언트들이 키를 보내지 않으므로, 서버는 한동안 키가 있는 요청과 없는 요청을 함께 받아야 합니다. 키가 없는 요청을 어떻게 처리할지 — 그대로 통과시킬지, 거절할지, 기간을 정해 유예할지 — 를 먼저 정해 두지 않으면 이관 도중에 더 큰 혼란이 생깁니다.
모바일 앱처럼 배포를 강제할 수 없는 클라이언트가 있다면, 서버가 요청 본문의 해시로 임시 키를 만들어 짧은 시간 동안만 중복을 막는 방법도 씁니다. 완전하지는 않지만, 사용자가 연달아 두 번 누르는 가장 흔한 경우는 걸러집니다.