목차
요청은 한 번인데 두 번 도착한다
결제 버튼을 눌렀다. 화면이 도는데 응답이 없다. 답답해서 한 번 더 누른다.
또는 이런 경우다. 앱이 요청을 보냈는데, 응답을 받기 전에 네트워크가 끊긴다. 성공했는지 실패했는지 모르니 앱은 안전하게 재시도한다.
두 경우 다 서버엔 같은 요청이 두 번 도착한다. 그리고 그게 “주문 생성”이었다면 이렇게 된다.
주문이 둘, 결제가 두 번. 사용자는 한 번 산 건데 두 번 청구된다. 네트워크가 완벽하면 안 생길 일이지만, 네트워크는 완벽하지 않다. 요청도 응답도 샌다. 그래서 재시도는 피할 수 없고, 중복은 늘 온다.
멱등하다 = 여러 번 해도 한 번과 같다
이 문제를 막는 성질이 멱등성이다.
멱등(idempotent)하다는 건, 같은 요청을 몇 번 보내도 결과가 한 번 보낸 것과 같다는 뜻이다.
핵심은 “결과”다. 요청이 두 번 처리돼도 상태가 한 번 처리한 것과 같으면 멱등하다. 위 주문이 위험했던 건 두 번째 요청이 상태를 또 바꿔서(주문을 하나 더 만들어서)다. 멱등한 연산이라면 두 번째가 와도 상태가 더 바뀌지 않는다.
엘리베이터 버튼으로 비유하면, “3층” 버튼은 멱등하고 “한 층 위로”는 멱등하지 않다. 3층은 몇 번 눌러도 3층이다. 한 층 위로는 누를 때마다 올라간다.
HTTP 메서드로 보는 멱등성
HTTP는 이 성질을 메서드에 규정해뒀다. 어느 메서드가 멱등한지를 규격(RFC 9110)이 정한다.
| 메서드 | 멱등? | 왜 |
|---|---|---|
| GET | O | 조회만 한다. 상태를 안 바꾼다 |
| PUT | O | 통째로 교체. 같은 값으로 몇 번 덮어써도 같은 상태 |
| DELETE | O | 삭제. 두 번째는 이미 없으니 상태가 더 안 바뀐다 |
| POST | X | 생성. 부를 때마다 새로 만든다 |
가르는 기준이 보인다. “이 값으로 만들어라”(PUT)는 몇 번 해도 그 값 하나지만, “새로 만들어라”(POST)는 부를 때마다 하나씩 는다. GET은 아무것도 안 바꾸니 말할 것도 없고, DELETE는 지운 상태가 계속 지운 상태다.
그래서 맨 처음 문제가 POST에서 터졌다. 재시도가 위험한 건 POST 같은 비멱등 연산일 때다. GET·PUT·DELETE는 두 번 가도 그만이다.
멱등성은 응답이 아니라 상태다
헷갈리기 쉬운 지점을 하나 짚는다. DELETE를 두 번 하면 보통 이렇게 된다.
- 첫 번째:
200(지웠다) - 두 번째:
404(없다)
응답이 다르다. 그럼 멱등이 아닌 것 아닌가? 아니다. 멱등성은 응답 코드가 아니라 서버의 상태를 두고 하는 말이다. 첫 DELETE 뒤나 두 번째 DELETE 뒤나, “그 주문은 없다”는 상태는 똑같다. 응답이 달라도 상태가 같으면 멱등하다.
이 구분이 중요한 이유가 있다. 재시도가 안전한지를 정하는 건 상태다. 두 번째 요청이 상태를 더 안 바꾼다면, 클라이언트는 마음 놓고 재시도할 수 있다. 응답이 404여도 “어차피 지워졌다”는 결과는 원하던 그대로니까.
POST를 멱등하게 만드는 Idempotency Key
POST는 본래 멱등하지 않다. 그런데 결제·주문 같은 POST야말로 중복이 제일 위험하다. 어떻게 안전하게 만드나.
방법은 클라이언트가 요청마다 고유한 키를 붙이는 것이다. 이 키를 Idempotency Key라고 한다(Stripe 같은 결제 API가 이렇게 한다).
POST /orders
Idempotency-Key: 9f8a...c2 ← 클라이언트가 만든 고유 값서버는 이렇게 동작한다.
- 이 키를 전에 본 적 있나?
- 처음 보는 키면 - 처리하고, 키와 결과를 저장한다.
- 이미 본 키면 - 새로 처리하지 않고, 저장해둔 결과를 그대로 돌려준다.
재시도가 같은 키로 오니, 서버는 “이미 처리했다”를 안다. 그래서 주문은 하나만 생긴다. Spring이라면 대략 이런 모양이다.
@PostMapping("/orders")
public Order create(@RequestHeader("Idempotency-Key") String key,
@RequestBody OrderRequest req) {
Order existing = idempotencyStore.find(key); // 이 키를 본 적 있나
if (existing != null) {
return existing; // 있으면 저장된 결과를 그대로
}
Order order = orderService.create(req);
idempotencyStore.save(key, order); // 키와 결과를 남긴다
return order;
}여기까지 오면 자연스러운 질문이 하나 생긴다. 이 키를 누가, 어떻게 만드나.
클라이언트는 키를 어떻게 만드나
키를 만드는 건 클라이언트다. 서버가 아니다. 서버가 만들면 이미 요청이 도착한 뒤라, 중복을 가려낼 방법이 없다. 그래서 요청을 보내기 전에 클라이언트가 먼저 만들어 헤더에 실어 보낸다.
세 가지만 지키면 된다.
- 고유하게 만든다. 보통 UUID(랜덤 고유값)를 쓴다. 브라우저·서버 어디든 한 줄이면 나온다.
- 작업 하나에 키 하나. “이 작업을 시작하는 순간”에 한 번 만든다. 결제라면 결제를 시작할 때 하나 뽑는다.
- 재시도할 땐 같은 키를 다시 쓴다. 새로 만들면 안 된다. 새 키는 서버에 새 작업으로 보이니까. 재시도가 효과를 보려면 처음 그 키를 그대로 보내야 한다.
const key = crypto.randomUUID(); // 작업을 시작할 때 한 번 만든다
async function submitOrder(order) {
return fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key, // 재시도해도 이 key를 그대로
},
body: JSON.stringify(order),
});
}정리하면, 키의 수명은 “그 작업”과 같다. 작업이 시작될 때 태어나고, 그 작업의 모든 재시도가 같은 키를 공유하고, 작업이 끝나면 역할이 끝난다. 새 작업이 시작되면 그때 새 키를 만든다.
화면을 새로고침하면 날아가는 변수에만 키를 두면, 새로고침 뒤의 재시도는 새 키가 되어 중복을 못 막는다. 그게 곤란한 경우(예: 결제)엔 키를 좀 더 오래 사는 곳(로컬 스토리지 등)에 두고, 작업이 확실히 끝나면 지운다.
키를 쓸 때 챙길 것
클라이언트가 키를 만든다고 했으니, 몇 가지 실무 주의가 따라온다.
같은 키에 다른 내용이 오면 거부한다. 키는 그대로인데 주문 금액이 바뀐 요청이 온다면, 그건 재시도가 아니라 버그이거나 오용이다. 첫 결과를 돌려주면 바뀐 금액이 무시되고, 새로 처리하면 멱등성이 깨진다. 그래서 견고한 구현은 키만 저장하지 않고 요청 내용의 지문(fingerprint)까지 같이 저장해두고, 같은 키에 다른 내용이 오면 에러로 거부한다(보통 422). 키는 “이 요청”에 묶인 것이지 아무 요청에나 붙일 수 있는 게 아니다.
키는 영원히 보관하지 않는다. 재시도는 대개 짧은 시간 안에 일어나므로, 하루 이틀 정도 기억했다가 지우는 **보관 기간(TTL)**을 둔다. 그 기간이 지난 뒤 같은 키가 다시 오면 처음 보는 키로 취급한다.
같은 키가 동시에 둘 오면 조심해야 한다. 같은 키를 단 두 요청이 거의 동시에 도착하면, 둘 다 “처음 보는 키”로 판단해 둘 다 만들어버릴 수 있다.
그래서 실무에선 키 저장에 유니크 제약이나 락을 걸어, 동시에 와도 하나만 통과하게 한다. 이 경쟁 상황을 제대로 다루는 게 멱등 키 구현에서 가장 손이 많이 가는 부분이다.
애초에 PUT으로 만들 수 있으면
한 걸음 물러서면, POST에 키를 붙이는 건 비멱등 연산을 억지로 멱등하게 만드는 일이다. 그런데 처음부터 멱등한 방법을 쓸 수 있다면 이 수고가 통째로 준다.
핵심은 누가 리소스의 ID를 정하느냐다. POST는 서버가 ID를 정한다(“새로 만들고 번호를 붙여줘”). 그래서 부를 때마다 새 번호가 생긴다. 반대로 클라이언트가 ID를 정할 수 있으면 PUT을 쓸 수 있다.
PUT /orders/{클라이언트가-정한-id}“이 ID로 이 주문을 만들어라”는 몇 번을 보내도 그 ID의 주문 하나다. 이미 있으면 덮어쓸 뿐 새로 안 생긴다. PUT은 그 자체로 멱등하니 별도 키가 필요 없다.
물론 클라이언트가 ID를 정하는 게 늘 가능하진 않다. 하지만 가능한 상황이라면, Idempotency Key라는 장치를 얹기 전에 PUT으로 설계할 수 있는지 먼저 보는 게 대개 더 단순하다.
정리
| 멱등이란 | 같은 요청을 몇 번 보내도 결과(상태)가 한 번과 같은 성질 |
| 왜 필요 | 네트워크가 새서 중복·재시도가 늘 온다. 멱등하면 재시도가 안전하다 |
| HTTP | GET·PUT·DELETE는 멱등, POST는 아님 |
| 응답 vs 상태 | 멱등은 응답이 아니라 상태다 (DELETE 두 번: 상태 같음, 응답은 다를 수 있음) |
| POST 멱등화 | Idempotency Key - 클라가 키를 붙이고, 서버가 키로 중복을 걸러 저장된 결과 반환 |
| 키는 클라가 생성 | UUID로, 작업 하나에 하나, 재시도는 같은 키 재사용 |
| 키 쓸 때 | 같은 키+다른 내용은 거부 / 키엔 TTL / 동시 도착은 유니크·락 |
| PUT 대안 | 클라가 ID를 정하면 PUT이 자연 멱등, 별도 키가 필요 없다 |
네트워크를 못 믿기 때문에 생긴 성질이다. 요청이 한 번만 도착한다고 믿을 수 없으니, 여러 번 도착해도 한 번과 같게 만들어 두는 것. 그게 멱등성이다.