9.0 KiB
9.0 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| backend-idempotency-deep | Idempotency Deep — keys / windows / storage | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
Idempotency Deep
Network / retry = 같은 request 가 여러 번. Idempotency key 가 dedupe. Stripe / 모든 payment API 의 표준.
📖 핵심 개념
- 같은 key + 같은 request = 같은 result (1번 effect).
- Storage 가 매 key 의 result.
- TTL (24-48 hr).
- Concurrency 안전.
💻 코드 패턴
기본 (단순)
async function createCharge(req) {
const key = req.headers['idempotency-key'];
if (!key) return { error: 'missing key' };
const cached = await db.idempotency.findOne({ key });
if (cached) return cached.response;
const result = await stripe.charges.create(req.body);
await db.idempotency.insert({ key, response: result, created_at: new Date() });
return result;
}
→ Race condition: 두 request 가 동시 = 2번 호출 가능.
Concurrency-safe (lock)
async function createCharge(req) {
const key = req.headers['idempotency-key'];
// 1. Try insert (atomic)
try {
await db.idempotency.insert({
key, status: 'in_progress', created_at: new Date()
});
} catch (e) {
if (isUniqueViolation(e)) {
// Existing → return cached or wait
const cached = await db.idempotency.findOne({ key });
if (cached.status === 'completed') return cached.response;
if (cached.status === 'in_progress') {
// Wait or 409
return { error: 'in progress' }; // 409 Conflict
}
}
throw e;
}
// 2. Process
try {
const result = await stripe.charges.create(req.body);
await db.idempotency.update({ key }, { status: 'completed', response: result });
return result;
} catch (e) {
await db.idempotency.update({ key }, { status: 'failed', error: e.message });
throw e;
}
}
Postgres unique constraint
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
status TEXT NOT NULL,
response JSONB,
request_hash TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
);
→ INSERT 가 atomic. Same key 두 번 = unique violation.
Request hash (validation)
async function createCharge(req) {
const key = req.headers['idempotency-key'];
const reqHash = sha256(JSON.stringify(req.body));
const cached = await db.idempotency.findOne({ key });
if (cached) {
if (cached.request_hash !== reqHash) {
return { error: 'idempotency key reused with different body' }; // 422
}
return cached.response;
}
// ... 처리
await db.idempotency.insert({ key, request_hash: reqHash, ... });
}
→ "Same key 다른 body" = 클라이언트 bug.
Stripe 식
// Stripe 가 client 의 패턴
const charge = await stripe.charges.create(
{ amount: 1000, currency: 'usd', source: 'tok_x' },
{ idempotencyKey: 'order_123' }
);
→ Stripe 가 24 hr 안 같은 result.
TTL
-- 24-48 hr 후 삭제
DELETE FROM idempotency_keys WHERE expires_at < NOW();
-- 또는 partition
CREATE TABLE idempotency_keys_2026_05_09 PARTITION OF idempotency_keys
FOR VALUES FROM ('2026-05-09') TO ('2026-05-10');
→ 매일 partition + drop.
Redis 식
async function createCharge(req) {
const key = `idem:${req.headers['idempotency-key']}`;
// SETNX (atomic)
const set = await redis.set(key, 'pending', 'EX', 86400, 'NX');
if (set === null) {
// Already exists
const cached = await redis.get(key);
if (cached === 'pending') return { error: 'in progress' };
return JSON.parse(cached);
}
const result = await stripe.charges.create(req.body);
await redis.set(key, JSON.stringify(result), 'EX', 86400);
return result;
}
→ Redis = 빠름. Persistence X (RDB / AOF 가능).
Locking (advanced)
import { Redlock } from 'redlock';
async function createCharge(req) {
const key = req.headers['idempotency-key'];
const lock = await redlock.acquire([`lock:${key}`], 10000);
try {
// Check cache → process → save
} finally {
await lock.release();
}
}
→ Distributed lock 가 보장.
HTTP semantics
GET, HEAD, PUT, DELETE: idempotent (HTTP spec).
POST: NOT idempotent (default).
→ POST 의 idempotency = key 명시.
PUT 도 idempotency key 가 안전.
Status code
200/201: 첫 호출 (success).
200/201: 두 번째 (cached, same response).
422: same key, different body.
409: in progress.
410: expired key.
Async / queue idempotency
// Outbox pattern
BEGIN;
INSERT INTO outbox (id, event_type, payload) VALUES ('uuid-1', 'order.placed', ...);
// duplicate id = unique violation
COMMIT;
// Consumer
async function processEvent(event) {
if (await db.processed_events.exists(event.id)) return;
// ...
await db.processed_events.insert({ id: event.id });
}
→ Outbox + processed events = exactly-once.
Kafka (offset 기반)
Kafka consumer:
- Offset commit = "처리됨".
- Crash 후 = 옛 offset 부터 다시 → 중복 가능.
→ Idempotent processing (DB upsert + dedup).
Webhook idempotency
// Webhook receiver
async function handleWebhook(req) {
const eventId = req.body.id;
if (await db.webhook_events.exists(eventId)) {
return res.status(200).json({ received: true });
}
await db.webhook_events.insert({ id: eventId });
await processWebhook(req.body);
res.status(200).json({ received: true });
}
→ Stripe / GitHub 가 retry — duplicate event 가능.
Race in receiver
// 두 webhook 가 동시 도착
// transaction 안에서 insert + process
BEGIN;
INSERT INTO webhook_events (id) VALUES ($1); -- unique
// process
COMMIT;
Multi-step (saga)
매 step 의 idempotency.
- reserveItems(itemIds, idempotencyKey): 같은 key = 같은 reservation.
- chargePayment(amount, idempotencyKey): 같은 key = 1번 charge.
- shipOrder(orderId, idempotencyKey): 같은 key = 1번 ship.
→ Saga retry 가 안전.
Generated key
// Server side
const key = `${userId}:${orderId}:${timestamp}`;
// Client side (UUID)
const key = crypto.randomUUID();
→ Client 가 generate (Stripe 식).
Storage capacity
1M req/day × 2 KB / req = 2 GB / day.
24 hr TTL = 2 GB live.
→ 큰 system 가 partition / TTL.
Performance 영향
매 request 가 1 DB round-trip (idempotency check).
Cache (Redis) 가 mitigate.
Hot key 가 hot row.
→ Production 가 Redis.
Frontend (UI)
// Generate key once per "submit"
const idempKey = useRef(crypto.randomUUID());
const handleSubmit = async () => {
await fetch('/charge', {
method: 'POST',
headers: { 'idempotency-key': idempKey.current },
body: JSON.stringify(data),
});
};
→ Retry on network error 가 안전.
Failure 시 cleanup
try {
// process
} catch (e) {
// Mark failed (cache 가 안 stale).
await db.idempotency.delete({ key });
// 또는
await db.idempotency.update({ key }, { status: 'failed', expires_at: now + 5min });
throw e;
}
→ Failed 도 some retention (재시도 가 같은 fail).
LLM call (idempotency)
// LLM 가 같은 prompt = 같은 답 안 (random).
// Determinism: temperature=0.
const cacheKey = sha256(prompt);
const cached = await cache.get(cacheKey);
if (cached) return cached;
const r = await llm.complete(prompt, { temperature: 0 });
await cache.set(cacheKey, r);
return r;
→ LLM cache 도 idempotency 식.
함정
- TTL 너무 짧음 (1 hr): 늦은 retry 가 missed dedup.
- TTL 너무 길음: storage 폭발.
- Key 가 짧음 (timestamp 만): 충돌.
- Process 후 store (race): 같은 key 가 여러 번.
- Body hash 안: 클라이언트 가 wrong body.
- Failed 가 cache 안 cleanup: 재시도 가 fail.
표준 (다른 vendor)
Stripe: 24 hr.
PayPal: 5 min.
Square: 24 hr.
GitHub API: SHA-based.
Slack: 1 hr.
🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| Payment / 외부 API | Idempotency key (Stripe 식) |
| Webhook | Event ID dedup |
| Saga step | 매 step 의 key |
| 빠른 cache | Redis SETNX |
| Persistent | Postgres unique |
| Multi-region | Distributed (Redlock 등) |
| 작은 / 단순 | DB INSERT + unique |
❌ 안티패턴
- No idempotency: retry = 중복 charge.
- Process 후 cache: race.
- TTL 무한: storage.
- Body hash 없음: wrong body 통과.
- Failed 가 안 retry: stuck.
- Auto-generate key (timestamp): collision.
- Cache only (no persistent): restart 후 잃음.
🤖 LLM 활용 힌트
- Idempotency = unique key + atomic check.
- Stripe 가 표준 (Idempotency-Key header).
- TTL 24-48 hr.
- Failed 는 짧은 cache.