멱등성 — 두 번 눌러도 한 번만 결제되게 · 재시도와 중복 · 이론
재시도는 피할 수 없다
한 줄 요약
재시도는 피할 수 없다. 그러니 중복은 서버가 막아야 한다.
왜 필요한가 — 클라이언트는 구별할 수 없다
결제 요청을 보냈는데 응답이 안 왔다. 둘 중 무엇인가?
1. 요청이 서버에 도착하지 않았다 → 다시 보내야 한다
2. 도착해서 처리됐는데 응답만 못 왔다 → 다시 보내면 두 번 결제된다
클라이언트는 이걸 구별할 방법이 없다. 그래서 어느 쪽을 택하든 틀린다.
그러니 클라이언트는 재시도하고, 서버가 두 번째를 알아보게 만드는 수밖에 없다.
멱등성 키
클라이언트가 요청마다 고유한 키를 만들어 붙인다.
POST /payIdempotency-Key: 9f2c-...-a1{"user":"u1","amount":1000}재시도할 때는 같은 키를 쓴다. 서버는 그 키를 본 적 있으면 처리하지 않고 저장해 둔 응답을 그대로 돌려준다.
핵심은 이것이다 — 두 번째 요청도 성공 응답을 받는다. 오류가 아니다. 클라이언트 입장에서는 "한 번 요청해서 한 번 성공" 이고, 그게 맞다.
어디에 저장하나
메모리 사전은 안 된다. 이유가 둘이다.
1. 재시작하면 잊는다
2. 서버가 여러 대다 — 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 는 두 번째가 "이미 없다" 다. 설계가 그렇다는 것이지 구현이 저절로 그렇게 되지는 않는다.
현장에서
중복 결제 신고는 대개 사용자가 아니라 정산에서 먼저 발견됩니다. 사용자는 '결제가 안 된 것 같아 한 번 더 눌렀다' 고만 기억하고, 그 사이에 무슨 일이 있었는지는 서버 로그에만 남기 때문입니다.
그래서 멱등성 키는 사고가 난 뒤에 붙이기가 유난히 어렵습니다. 이미 나간 클라이언트들이 키를 보내지 않으므로, 서버는 한동안 키가 있는 요청과 없는 요청을 함께 받아야 합니다. 키가 없는 요청을 어떻게 처리할지 — 그대로 통과시킬지, 거절할지, 기간을 정해 유예할지 — 를 먼저 정해 두지 않으면 이관 도중에 더 큰 혼란이 생깁니다.
모바일 앱처럼 배포를 강제할 수 없는 클라이언트가 있다면, 서버가 요청 본문의 해시로 임시 키를 만들어 짧은 시간 동안만 중복을 막는 방법도 씁니다. 완전하지는 않지만, 사용자가 연달아 두 번 누르는 가장 흔한 경우는 걸러집니다.