왜 멱등성을 공부하게 됐나
결제 API를 만들고 있었습니다. 테스트 중에 네트워크가 불안정해서 같은 결제 요청이 두 번 들어왔고, 사용자에게 이중 과금이 발생했습니다. 이건 큰 문제였죠. "어떻게 하면 같은 요청이 여러 번 와도 한 번만 처리할 수 있을까?"
이 질문이 저를 멱등성(Idempotency)이라는 개념으로 이끌었습니다. 처음엔 "그냥 중복 체크하면 되는 거 아니야?"라고 생각했는데, 알고 보니 훨씬 깊은 개념이었습니다.
처음엔 뭐가 이해가 안 갔나
가장 혼란스러웠던 부분은 "왜 GET은 멱등하고 POST는 아닌가?"였습니다. 둘 다 HTTP 메서드인데 뭐가 다른 걸까?
또 다른 혼란은 "Idempotency Key를 어디에 저장해야 하는가?"였습니다. 데이터베이스? 메모리? Redis?
그리고 "얼마나 오래 저장해야 하는가?"도 궁금했습니다. 영구적으로? 아니면 일정 시간 후 삭제?
어떤 포인트에서 이해가 됐나
이해하는 데 결정적이었던 비유는 "전등 스위치"였습니다.
멱등한 동작 = 전등 끄기:
- 이미 꺼진 전등을 또 끄려고 해도 결과는 같음 (꺼진 상태)
- 몇 번을 눌러도 결과는 동일
비멱등한 동작 = 동전 넣기:
- 자판기에 동전을 넣을 때마다 잔액이 증가
- 같은 동작을 반복하면 결과가 달라짐
이 비유로 이해했습니다. 멱등성은 "같은 동작을 여러 번 해도 결과가 동일한 성질"이라는 것을. 그리고 이게 왜 분산 시스템에서 중요한지도 깨달았죠.
멱등성이란?
멱등성(Idempotency)은 같은 요청을 여러 번 수행해도 결과가 동일한 성질을 말합니다. 수학에서 온 개념인데, 컴퓨터 과학에서는 주로 API 설계와 분산 시스템에서 중요하게 다뤄집니다.
일상 생활의 예시
- 멱등: 방문을 잠그기 (이미 잠긴 문을 또 잠가도 결과는 같음)
- 비멱등: 계단 오르기 (한 계단씩 올라갈 때마다 위치가 변함)
왜 중요한가?
네트워크는 불안정합니다. 요청을 보냈는데 응답이 안 오면, 클라이언트는 재시도를 합니다. 이때 서버가 이미 요청을 처리했다면? 멱등성이 없으면 중복 처리가 발생합니다.
- 결제 API: 같은 결제가 두 번 처리됨 (이중 과금)
- 주문 API: 같은 상품이 두 번 주문됨
- 메시지 전송: 같은 메시지가 두 번 전송됨
HTTP 메서드와 멱등성
HTTP 메서드는 설계상 멱등성이 정해져 있습니다.
| 메서드 | 멱등성 | 설명 |
|---|---|---|
| GET | ✅ | 조회만 하므로 몇 번을 호출해도 서버 상태가 변하지 않음 |
| PUT | ✅ | 전체 교체이므로 같은 데이터로 여러 번 교체해도 결과는 같음 |
| DELETE | ✅ | 이미 삭제된 리소스를 또 삭제해도 결과는 같음 (404 또는 성공) |
| POST | ❌ | 생성 요청이므로 매번 새로운 리소스가 생성됨 |
| PATCH | ❌ | 부분 수정이므로 연산에 따라 결과가 달라질 수 있음 |
GET의 멱등성
// 몇 번을 호출해도 서버 상태는 변하지 않음
GET /users/123
GET /users/123
GET /users/123
// 결과: 항상 같은 사용자 정보 반환
PUT의 멱등성
// 같은 데이터로 여러 번 교체해도 결과는 같음
PUT /users/123
{
"name": "John",
"email": "john@example.com"
}
// 다시 호출
PUT /users/123
{
"name": "John",
"email": "john@example.com"
}
// 결과: 사용자 정보는 동일하게 유지됨
DELETE의 멱등성
// 첫 번째 호출: 삭제 성공 (200 OK)
DELETE /users/123
// 두 번째 호출: 이미 없음 (404 Not Found 또는 200 OK)
DELETE /users/123
// 결과: 어쨌든 리소스는 없는 상태로 동일
POST의 비멱등성
// 첫 번째 호출: 주문 1 생성
POST /orders
{
"product": "iPhone",
"quantity": 1
}
// 응답: { "id": 1, "product": "iPhone" }
// 두 번째 호출: 주문 2 생성 (중복!)
POST /orders
{
"product": "iPhone",
"quantity": 1
}
// 응답: { "id": 2, "product": "iPhone" }
// 문제: 같은 상품이 두 번 주문됨!
POST를 멱등하게 만들기: Idempotency Key
POST는 기본적으로 비멱등하지만, Idempotency Key를 사용하면 멱등하게 만들 수 있습니다.
기본 개념
클라이언트가 요청할 때 고유한 키를 함께 보냅니다. 서버는 이 키를 기록해두고, 같은 키로 요청이 다시 오면 이전 결과를 반환합니다.
// 클라이언트
POST /orders
Headers:
Idempotency-Key: "order-2024-abc-123"
Body:
{ "product": "iPhone", "quantity": 1 }
// 서버: 처음 보는 키 → 주문 처리
// 응답: { "id": 1, "product": "iPhone" }
// 네트워크 문제로 재시도
POST /orders
Headers:
Idempotency-Key: "order-2024-abc-123" // 같은 키!
Body:
{ "product": "iPhone", "quantity": 1 }
// 서버: 이미 본 키 → 이전 결과 반환
// 응답: { "id": 1, "product": "iPhone" } // 중복 주문 방지!
Node.js 구현 예시
const express = require('express');
const app = express();
// 메모리에 저장 (실제로는 Redis 사용 권장)
const processedKeys = new Map();
app.post('/orders', (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
// Idempotency Key가 없으면 에러
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key header required' });
}
// 이미 처리된 요청인지 확인
if (processedKeys.has(idempotencyKey)) {
const cachedResult = processedKeys.get(idempotencyKey);
console.log('Returning cached result for key:', idempotencyKey);
return res.status(200).json(cachedResult);
}
// 새로운 요청 처리
const order = {
id: Date.now(),
product: req.body.product,
quantity: req.body.quantity,
createdAt: new Date()
};
// 결과 저장
processedKeys.set(idempotencyKey, order);
res.status(201).json(order);
});
app.listen(3000);
Redis를 사용한 실제 구현
메모리에 저장하면 서버가 재시작될 때 데이터가 사라집니다. 실제로는 Redis를 사용합니다.
const express = require('express');
const redis = require('redis');
const app = express();
const redisClient = redis.createClient();
app.post('/orders', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key required' });
}
const cacheKey = `idempotency:${idempotencyKey}`;
// Redis에서 확인
const cached = await redisClient.get(cacheKey);
if (cached) {
return res.status(200).json(JSON.parse(cached));
}
// 주문 처리
const order = await createOrder(req.body);
// Redis에 저장 (24시간 TTL)
await redisClient.setEx(
cacheKey,
86400, // 24시간
JSON.stringify(order)
);
res.status(201).json(order);
});
팁
1. Idempotency Key 생성
클라이언트가 UUID를 생성해서 보냅니다.
// 클라이언트 (브라우저)
import { v4 as uuidv4 } from 'uuid';
async function createOrder(orderData) {
const idempotencyKey = uuidv4(); // "a3bb189e-8bf9-3888-9912-ace4e6543002"
const response = await fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(orderData)
});
return response.json();
}
2. TTL 설정
Idempotency Key를 영구적으로 저장하면 메모리가 부족해집니다. 적절한 TTL을 설정하세요.
- 결제 API: 24시간 (하루 안에 재시도가 대부분 발생)
- 주문 API: 1시간
- 메시지 전송: 10분
3. 상태 코드
- 첫 번째 요청:
201 Created - 중복 요청:
200 OK(이미 생성된 리소스 반환)
4. 데이터베이스 트랜잭션
주문 생성과 Idempotency Key 저장을 하나의 트랜잭션으로 처리하세요.
app.post('/orders', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
// 트랜잭션 시작
const session = await mongoose.startSession();
session.startTransaction();
try {
// 중복 체크
const existing = await IdempotencyKey.findOne({ key: idempotencyKey }).session(session);
if (existing) {
await session.abortTransaction();
return res.status(200).json(existing.result);
}
// 주문 생성
const order = await Order.create([req.body], { session });
// Idempotency Key 저장
await IdempotencyKey.create([{
key: idempotencyKey,
result: order[0],
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
}], { session });
await session.commitTransaction();
res.status(201).json(order[0]);
} catch (error) {
await session.abortTransaction();
res.status(500).json({ error: error.message });
} finally {
session.endSession();
}
});
실제 사례 - Stripe의 멱등성
Stripe(결제 서비스)는 모든 POST 요청에 Idempotency Key를 지원합니다.
const stripe = require('stripe')('sk_test_...');
// 결제 생성
const payment = await stripe.paymentIntents.create(
{
amount: 2000,
currency: 'usd',
},
{
idempotencyKey: 'payment-2024-abc-123' // 같은 키로 재시도해도 중복 결제 안 됨
}
);
멱등성 구현 시 주의사항
1. 키 충돌 방지
Idempotency Key는 전역적으로 고유해야 합니다. 단순히 주문 ID를 사용하면 안 됩니다.
// ❌ 나쁜 예: 주문 ID 사용
const idempotencyKey = `order-${orderId}`; // 다른 사용자가 같은 ID 사용 가능
// ✅ 좋은 예: UUID 사용
const idempotencyKey = uuidv4(); // 전역적으로 고유
2. 부분 실패 처리
주문 생성은 성공했는데 Idempotency Key 저장이 실패하면? 트랜잭션을 사용해야 합니다.
// ❌ 나쁜 예: 트랜잭션 없음
const order = await createOrder(req.body); // 성공
await saveIdempotencyKey(key, order); // 실패 → 중복 주문 발생 가능
// ✅ 좋은 예: 트랜잭션 사용
const session = await mongoose.startSession();
session.startTransaction();
try {
const order = await createOrder(req.body, session);
await saveIdempotencyKey(key, order, session);
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
}
3. 동시 요청 처리
같은 Idempotency Key로 동시에 두 요청이 들어오면? 락(Lock)을 사용해야 합니다.
const redis = require('redis');
const redisClient = redis.createClient();
app.post('/orders', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
const lockKey = `lock:${idempotencyKey}`;
// 락 획득 시도 (5초 타임아웃)
const lock = await redisClient.set(lockKey, '1', {
NX: true, // 키가 없을 때만 설정
EX: 5 // 5초 후 자동 삭제
});
if (!lock) {
// 다른 요청이 이미 처리 중
return res.status(429).json({ error: 'Request already in progress' });
}
try {
// 주문 처리
const order = await createOrder(req.body);
await saveIdempotencyKey(idempotencyKey, order);
res.status(201).json(order);
} finally {
// 락 해제
await redisClient.del(lockKey);
}
});
멱등성과 재시도 전략
멱등성은 재시도 전략과 함께 사용됩니다.
지수 백오프 (Exponential Backoff)
async function createOrderWithRetry(orderData, maxRetries = 3) {
const idempotencyKey = uuidv4();
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(orderData)
});
if (response.ok) {
return await response.json();
}
// 5xx 에러만 재시도
if (response.status >= 500) {
const delay = Math.pow(2, attempt) * 1000; // 1초, 2초, 4초
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
// 4xx 에러는 재시도 안 함
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === maxRetries - 1) {
throw error;
}
}
}
}
멱등성의 실제 적용 사례
제가 실제로 멱등성을 적용한 프로젝트들입니다.
1. 결제 시스템
문제: 네트워크 타임아웃으로 인한 중복 결제가 한 달에 10건 이상 발생
해결: Idempotency Key 도입
- 클라이언트에서 UUID 생성
- Redis에 24시간 TTL로 저장
- 같은 키로 재시도 시 이전 결과 반환
결과: 중복 결제 0건, CS 문의 90% 감소
2. 주문 시스템
문제: 사용자가 "주문하기" 버튼을 여러 번 클릭하여 중복 주문 발생
해결:
- 프론트엔드: 버튼 비활성화 + Idempotency Key
- 백엔드: 트랜잭션 + Redis 락
결과: 중복 주문 완전 차단
3. 메시지 전송 시스템
문제: 네트워크 재시도로 같은 메시지가 여러 번 전송
해결:
- 메시지 ID를 Idempotency Key로 사용
- 10분 TTL (메시지는 빠르게 처리되므로)
결과: 중복 메시지 0건
Stripe의 멱등성 구현 해부 제대로 이해하기
세계 최고의 결제 기업 Stripe는 멱등성을 어떻게 구현했을까요? 엔지니어링 블로그를 분석해보면 흥미로운 디테일이 있습니다.
- Atomic Operation: Stripe는 멱등성 키 저장과 결제 생성을 하나의 트랜잭션으로 묶지 않습니다(일부 경우). 대신 "멱등성 키 레코드 생성 -> 락 획득 -> API 처리 -> 결과 업데이트"의 4단계를 거칩니다.
- Recovery: 서버가 API 처리 중에 죽으면 어떻게 될까요? Stripe는 멱등성 키에 "처리 중(Processing)" 상태를 둡니다. 만약 일정 시간이 지나도 상태가 안 바뀌면, 다음 요청이 왔을 때 "이전 요청이 실패했다"고 판단하고 재시도를 허용하거나 복구 로직을 돌립니다.
- Scoped Keys: 멱등성 키는 "사용자 + API 엔드포인트" 범위 내에서만 유효합니다. 즉, 같은 키라도 다른 사용자가 쓰면 충돌나지 않습니다.
8.9. FAQ: 멱등성과 안전성(Safety)은 다른가요?
많이 혼동하는 개념입니다.
- 안전(Safe): 리소스를 변경하지 않음. (GET, HEAD, OPTIONS). 호출해도 서버 상태가 안 바뀜.
- 멱등(Idempotent): 여러 번 호출해도 결과가 같음. (PUT, DELETE). 서버 상태는 바뀌지만, 최종 상태는 동일함.
POST는 기본적으로 둘 다 아닙니다. 안전하지도 않고(리소스 생성), 멱등하지도 않죠(매번 생성). 그래서 우리가 멱등성 키로 "인위적인 멱등성"을 부여하는 것입니다.
정리하며
멱등성은 분산 시스템에서 필수적인 개념입니다. 네트워크는 언제나 불안정하고, 재시도는 피할 수 없습니다. 멱등성을 보장하지 않으면 중복 처리로 인한 심각한 문제가 발생할 수 있습니다.
- GET, PUT, DELETE: 기본적으로 멱등함
- POST: Idempotency Key로 멱등하게 만들 수 있음
- 저장소: Redis 사용 권장 (TTL 설정)
- 트랜잭션: 주문 생성과 키 저장을 원자적으로 처리
- 동시성: 락을 사용해서 동시 요청 처리
- 재시도: 지수 백오프와 함께 사용
저는 이 개념을 이해하고 나서, 모든 중요한 POST API에 Idempotency Key를 적용했습니다. 그 결과 네트워크 재시도로 인한 중복 처리 문제가 완전히 사라졌습니다. 사용자 경험도 좋아졌고, 운영 부담도 크게 줄었죠.
특히 결제 API에서 효과가 컸습니다. 이전에는 네트워크 타임아웃으로 인한 중복 결제 문의가 한 달에 10건 이상 들어왔는데, Idempotency Key를 도입한 후로는 단 한 건도 발생하지 않았습니다. 고객 만족도도 올라가고, CS 팀의 업무 부담도 줄어들었습니다.
Why I Started Learning Idempotency
I was building a payment API. During testing, the network was unstable and the same payment request came in twice, causing double charging to the user. This was a serious problem. "How can I process a request only once even if it comes multiple times?"
This question led me to the concept of Idempotency. At first I thought "Can't I just check for duplicates?" but it turned out to be a much deeper concept.
The Confusion
The most confusing part was "Why is GET idempotent but POST isn't?" Both are HTTP methods, so what's the difference?
Another confusion was "Where should I store the Idempotency Key?" Database? Memory? Redis?
And "How long should I store it?" Permanently? Or delete after a certain time?
The 'Aha!' Moment
The decisive analogy was "light switch."
Idempotent action = Turning off light:
- Trying to turn off an already-off light results in the same state (off)
- No matter how many times you press, result is identical
Non-idempotent action = Inserting coin:
- Each coin inserted into vending machine increases balance
- Repeating the same action changes the result
This analogy helped me understand. Idempotency is "the property where repeating the same action produces the same result." And I realized why this is crucial in distributed systems.
What is Idempotency?
Idempotency means performing the same request multiple times produces the same result. It's a concept from mathematics, but in computer science it's mainly important in API design and distributed systems.
Real-life Examples
- Idempotent: Locking a door (locking an already-locked door has the same result)
- Non-idempotent: Climbing stairs (each step changes your position)
Why Important?
Networks are unstable. When you send a request and don't get a response, the client retries. What if the server already processed the request? Without idempotency, duplicate processing occurs.
- Payment API: Same payment processed twice (double charging)
- Order API: Same product ordered twice
- Message sending: Same message sent twice
HTTP Methods and Idempotency
HTTP methods have designed-in idempotency.
| Method | Idempotent | Description |
|---|---|---|
| GET | ✅ | Only reads, so server state doesn't change no matter how many calls |
| PUT | ✅ | Full replacement, so replacing with same data multiple times has same result |
| DELETE | ✅ | Deleting already-deleted resource has same result (404 or success) |
| POST | ❌ | Creation request, so each call creates new resource |
| PATCH | ❌ | Partial update, so result can vary depending on operation |
GET Idempotency
// No matter how many times called, server state doesn't change
GET /users/123
GET /users/123
GET /users/123
// Result: Always returns same user info
PUT Idempotency
// Replacing with same data multiple times has same result
PUT /users/123
{
"name": "John",
"email": "john@example.com"
}
// Call again
PUT /users/123
{
"name": "John",
"email": "john@example.com"
}
// Result: User info remains identical
DELETE Idempotency
// First call: Delete success (200 OK)
DELETE /users/123
// Second call: Already gone (404 Not Found or 200 OK)
DELETE /users/123
// Result: Either way, resource is in non-existent state
POST Non-idempotency
// First call: Create order 1
POST /orders
{
"product": "iPhone",
"quantity": 1
}
// Response: { "id": 1, "product": "iPhone" }
// Second call: Create order 2 (duplicate!)
POST /orders
{
"product": "iPhone",
"quantity": 1
}
// Response: { "id": 2, "product": "iPhone" }
// Problem: Same product ordered twice!
Making POST Idempotent: Idempotency Key
POST is non-idempotent by default, but can be made idempotent using an Idempotency Key.
Basic Concept
Client sends a unique key with the request. Server records this key, and if a request comes again with the same key, returns the previous result.
// Client
POST /orders
Headers:
Idempotency-Key: "order-2024-abc-123"
Body:
{ "product": "iPhone", "quantity": 1 }
// Server: First time seeing this key → Process order
// Response: { "id": 1, "product": "iPhone" }
// Retry due to network issue
POST /orders
Headers:
Idempotency-Key: "order-2024-abc-123" // Same key!
Body:
{ "product": "iPhone", "quantity": 1 }
// Server: Already seen this key → Return previous result
// Response: { "id": 1, "product": "iPhone" } // Duplicate order prevented!
Node.js Implementation
const express = require('express');
const app = express();
// Store in memory (Redis recommended for production)
const processedKeys = new Map();
app.post('/orders', (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
// Error if no Idempotency Key
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key header required' });
}
// Check if already processed
if (processedKeys.has(idempotencyKey)) {
const cachedResult = processedKeys.get(idempotencyKey);
console.log('Returning cached result for key:', idempotencyKey);
return res.status(200).json(cachedResult);
}
// Process new request
const order = {
id: Date.now(),
product: req.body.product,
quantity: req.body.quantity,
createdAt: new Date()
};
// Save result
processedKeys.set(idempotencyKey, order);
res.status(201).json(order);
});
app.listen(3000);
Production Implementation with Redis
Storing in memory loses data when server restarts. In production, use Redis.
const express = require('express');
const redis = require('redis');
const app = express();
const redisClient = redis.createClient();
app.post('/orders', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key required' });
}
const cacheKey = `idempotency:${idempotencyKey}`;
// Check Redis
const cached = await redisClient.get(cacheKey);
if (cached) {
return res.status(200).json(JSON.parse(cached));
}
// Process order
const order = await createOrder(req.body);
// Store in Redis (24-hour TTL)
await redisClient.setEx(
cacheKey,
86400, // 24 hours
JSON.stringify(order)
);
res.status(201).json(order);
});
Practical Tips
1. Idempotency Key Generation
Client generates UUID and sends it.
// Client (browser)
import { v4 as uuidv4 } from 'uuid';
async function createOrder(orderData) {
const idempotencyKey = uuidv4();
const response = await fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(orderData)
});
return response.json();
}
2. TTL Setting
Storing Idempotency Keys permanently causes memory shortage. Set appropriate TTL.
- Payment API: 24 hours (most retries happen within a day)
- Order API: 1 hour
- Message sending: 10 minutes
3. Status Codes
- First request:
201 Created - Duplicate request:
200 OK(return already-created resource)
4. Database Transactions
Process order creation and Idempotency Key storage in one transaction.
app.post('/orders', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
// Start transaction
const session = await mongoose.startSession();
session.startTransaction();
try {
// Check for duplicate
const existing = await IdempotencyKey.findOne({ key: idempotencyKey }).session(session);
if (existing) {
await session.abortTransaction();
return res.status(200).json(existing.result);
}
// Create order
const order = await Order.create([req.body], { session });
// Save Idempotency Key
await IdempotencyKey.create([{
key: idempotencyKey,
result: order[0],
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
}], { session });
await session.commitTransaction();
res.status(201).json(order[0]);
} catch (error) {
await session.abortTransaction();
res.status(500).json({ error: error.message });
} finally {
session.endSession();
}
});
Implementation Considerations
1. Key Collision Prevention
Idempotency Key must be globally unique. Don't simply use order ID.
// ❌ Bad: Using order ID
const idempotencyKey = `order-${orderId}`; // Different users can have same ID
// ✅ Good: Using UUID
const idempotencyKey = uuidv4(); // Globally unique
2. Partial Failure Handling
What if order creation succeeds but Idempotency Key storage fails? Use transactions.
// ❌ Bad: No transaction
const order = await createOrder(req.body); // Success
await saveIdempotencyKey(key, order); // Failure → Duplicate order possible
// ✅ Good: Using transaction
const session = await mongoose.startSession();
session.startTransaction();
try {
const order = await createOrder(req.body, session);
await saveIdempotencyKey(key, order, session);
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
}
3. Concurrent Request Handling
What if two requests come simultaneously with same Idempotency Key? Use locks.
const redis = require('redis');
const redisClient = redis.createClient();
app.post('/orders', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
const lockKey = `lock:${idempotencyKey}`;
// Try to acquire lock (5 second timeout)
const lock = await redisClient.set(lockKey, '1', {
NX: true, // Set only if key doesn't exist
EX: 5 // Auto-delete after 5 seconds
});
if (!lock) {
// Another request already processing
return res.status(429).json({ error: 'Request already in progress' });
}
try {
// Process order
const order = await createOrder(req.body);
await saveIdempotencyKey(idempotencyKey, order);
res.status(201).json(order);
} finally {
// Release lock
await redisClient.del(lockKey);
}
});
Idempotency and Retry Strategy
Idempotency is used together with retry strategies.
Exponential Backoff
async function createOrderWithRetry(orderData, maxRetries = 3) {
const idempotencyKey = uuidv4();
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(orderData)
});
if (response.ok) {
return await response.json();
}
// Retry only on 5xx errors
if (response.status >= 500) {
const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
// Don't retry on 4xx errors
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (attempt === maxRetries - 1) {
throw error;
}
}
}
}
Real Case: Stripe's Idempotency
Stripe (payment service) supports Idempotency Key for all POST requests.
const stripe = require('stripe')('sk_test_...');
const payment = await stripe.paymentIntents.create(
{
amount: 2000,
currency: 'usd',
},
{
idempotencyKey: 'payment-2024-abc-123' // No duplicate payment even with retry
}
);
Why Stripe Uses Idempotency
Payment systems are especially vulnerable to duplicate processing. Network timeouts are common, and users often retry failed payments. Without idempotency:
- User clicks "Pay" → Network timeout → No response
- User clicks "Pay" again → Charged twice
- Customer complaint → Refund process → Bad experience
With idempotency:
- User clicks "Pay" → Network timeout → No response
- User clicks "Pay" again → Same Idempotency Key → Returns first payment result
- No duplicate charge → Happy customer
Best Practices
1. Always Validate Idempotency Key Format
function isValidIdempotencyKey(key) {
// UUID v4 format
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(key);
}
app.post('/orders', (req, res) => {
const key = req.headers['idempotency-key'];
if (!key || !isValidIdempotencyKey(key)) {
return res.status(400).json({
error: 'Valid Idempotency-Key header required (UUID v4 format)'
});
}
// Process order...
});
2. Include Request Hash in Key Storage
Store a hash of the request body to detect if the same key is used with different data.
const crypto = require('crypto');
function hashRequest(body) {
return crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
}
app.post('/orders', async (req, res) => {
const key = req.headers['idempotency-key'];
const requestHash = hashRequest(req.body);
const cached = await redis.get(`idempotency:${key}`);
if (cached) {
const cachedData = JSON.parse(cached);
// Check if request body matches
if (cachedData.requestHash !== requestHash) {
return res.status(422).json({
error: 'Idempotency key reused with different request body'
});
}
return res.status(200).json(cachedData.result);
}
// Process order...
const order = await createOrder(req.body);
await redis.setEx(`idempotency:${key}`, 86400, JSON.stringify({
result: order,
requestHash: requestHash
}));
res.status(201).json(order);
});
3. Monitor Idempotency Key Usage
Track metrics to understand retry patterns.
// Metrics tracking
const metrics = {
totalRequests: 0,
duplicateRequests: 0,
uniqueKeys: new Set()
};
app.post('/orders', async (req, res) => {
const key = req.headers['idempotency-key'];
metrics.totalRequests++;
const cached = await redis.get(`idempotency:${key}`);
if (cached) {
metrics.duplicateRequests++;
console.log(`Duplicate rate: ${(metrics.duplicateRequests / metrics.totalRequests * 100).toFixed(2)}%`);
return res.status(200).json(JSON.parse(cached));
}
metrics.uniqueKeys.add(key);
// Process order...
});
Wrapping Up
Idempotency is essential in distributed systems. Networks are always unstable, and retries are unavoidable. Without idempotency guarantees, serious problems from duplicate processing can occur.
Key Takeaways:
- GET, PUT, DELETE: Idempotent by default
- POST: Can be made idempotent with Idempotency Key
- Storage: Redis recommended (with TTL)
- Transaction: Atomically process order creation and key storage
- Concurrency: Use locks to handle concurrent requests
- Retry: Use with exponential backoff
- Validation: Always validate key format and request consistency
After understanding this concept, I applied Idempotency Key to all important POST APIs. As a result, duplicate processing problems from network retries completely disappeared. User experience improved, and operational burden significantly reduced.
Especially effective for payment APIs. Previously, we received 10+ duplicate payment inquiries per month due to network timeouts. After introducing Idempotency Key, not a single case occurred. Customer satisfaction increased, and CS team workload decreased.
The implementation was straightforward: add UUID generation on the client, validate and check keys on the server, and store results in Redis with appropriate TTL. The benefits far outweighed the implementation cost.
Common Pitfalls to Avoid
1. Not Setting TTL
Without TTL, your Redis will fill up with old idempotency keys. Always set appropriate expiration.
2. Using Sequential IDs
Don't use predictable IDs like order-1, order-2. Use UUIDs to prevent key collisions across different users.
3. Ignoring Request Body Changes
If the same idempotency key is used with different request bodies, that's likely a client bug. Detect and reject it.
4. Forgetting About Partial Failures
Always use database transactions to ensure atomicity between business logic and idempotency key storage.
9.5. Deep Dive: Dissecting Stripe's Implementation
How does Stripe, the gold standard of payments, handle this? Their engineering blog reveals fascinating details:
- Atomic Operation: They don't always bind key storage and payment creation in one DB transaction. Instead, they use a 4-step flow: Insert Key Record -> Acquire Lock -> Process API -> Update Result.
- Recovery: What if the server crashes mid-process? Stripe marks keys as "Processing". If a key stays in this state too long, the next request knows the previous attempt failed and triggers recovery logic.
- Scoped Keys: Keys are scoped by "User + API Endpoint". So the same key used by different users won't collide.
9.9. FAQ: Idempotency vs Safety
Commonly confused concepts.
- Safe: Does not modify resources. (GET, HEAD, OPTIONS). Server state remains unchanged.
- Idempotent: Multiple calls yield the same result. (PUT, DELETE). Server state changes, but the final state is identical.
POST is neither by default. It changes state (Unsafe) and creates new things each time (Non-idempotent). That's why we artificially enforce idempotency using keys.