The starter's handle() subtracts the amount on every call and never looks at the idempotencyKey, so a network retry of the same request charges twice. The fix is an idempotency key: keep a map from key → prior Result. On the first sight of a key, apply and store; on any later sight of the same key, return the stored Result with replayed=true instead of touching the balance. The key is the unit of 'exactly once', not the network call.
résumé · SK AX — staging-table + batch-worker dedup keyed on a request key, cutting ~20 monthly reprocessing/double-apply cases to ~0.
You'll predict the failure first, then write the fix and watch your own code drive the behavior.
손님이 '100원 중에 20원 결제'를 보낸다. 정상이면 잔액은 80원이 된다.
Store store = new Store(); // balance = 100
Store.Result r = store.handle("K1", 20); // charge 20
// r.balance == 80“A client sends a request to charge 20 from a balance of 100.”
checking microphone…
그런데 네트워크가 불안해서 같은 요청이 또 날아간다. 손님은 한 번 누른 줄 안다.
⚖️ Trade-off: 인터넷은 '한 번 보낸 게 진짜 한 번 도착했는지' 모른다. 그래서 안전하게 다시 보낸다.
// flaky network: the SAME request is resent with the SAME key
Store.Result retry = store.handle("K1", 20); // same key K1“On a flaky network, the same request gets sent again.”
checking microphone…
지금 코드는 올 때마다 그냥 돈을 뺀다. 그래서 두 번 빠진다 (80원이 아니라 60원).
⚖️ Trade-off: '요청이 오면 처리한다'만 있으면, 같은 요청 두 번 = 두 번 처리 = 이중 결제.
// naive handle() always subtracts -> retry double-charges to 60. // correct: guard on the key first Result prior = seen.get(idempotencyKey); if (prior != null) return new Result(true, prior.balance); // no second charge
“The naive code charges on every call, so a retry double-charges down to 60.”
checking microphone…
고치는 법: 손님이 보낼 때 영수증 번호 같은 '키'를 같이 붙인다. 재시도해도 키는 똑같다.
✅ Fix: 네트워크 횟수 말고 '키'를 한 번의 기준으로 삼는다. 그래야 재시도와 진짜 새 요청을 구분한다.
🔧 도구:Idempotency-Key 헤더클라이언트 생성 UUID
// the key is the unit of "exactly once", not the network call
String idempotencyKey = request.header("Idempotency-Key"); // client UUID
Store.Result r = store.handle(idempotencyKey, amount);“I attach a client-supplied key, and a retry carries the same key.”
checking microphone…
처음 보는 키: 돈을 빼고, 그 결과를 키 옆에 저장해 둔다. 둘을 같은 거래로 묶어서.
⚖️ Trade-off: 돈 빼는 것과 결과 저장이 따로 놀면, 둘 사이에서 죽었을 때 또 어긋난다.
✅ Fix: 효과(돈)와 키 저장을 같은 트랜잭션 한 묶음으로 커밋한다. 같이 되거나 같이 안 되거나.
🔧 도구:map: key → 저장된 결과DB 유니크 제약 (key 컬럼)단일 트랜잭션
// first time we see this key: apply the charge AND store the result together balance -= amount; Result r = new Result(false, balance); seen.put(idempotencyKey, r); // same transaction as the side effect return r;
“On a new key I apply the charge and store the result in the same transaction.”
checking microphone…
이미 본 키가 또 오면: 돈은 안 건드리고 저장해 둔 결과를 그대로 돌려준다 (replayed=true).
✅ Fix: 재시도는 '이미 했음'이라고 알려주는 셈. 잔액은 그대로 80원.
🔧 도구:replayed=true 표시
// seen key again: replay the stored result, do NOT charge Result prior = seen.get(idempotencyKey); if (prior != null) return new Result(true, prior.balance); // balance stays 80
“If I've seen the key before, I replay the stored result and don't charge again.”
checking microphone…
진짜 새 요청(다른 키)은 당연히 정상 처리한다. 막는 건 '같은 키'뿐이다.
// a genuinely new key (K2) is a new request -> applies normally
Store.Result r2 = store.handle("K2", 20); // 80 -> 60
// r2.replayed == false“A genuinely new key is a new request and applies normally.”
checking microphone…
비용: 키마다 한 줄씩 저장하니까 공간이 든다. 그래서 며칠 지난 건 지우는 보관 기간을 둔다.
⚖️ Trade-off: 키 기록을 영원히 들고 있을 순 없다 (계속 쌓인다).
✅ Fix: 보관 기간을 정해서 오래된 키는 정리. SK AX에선 스테이징 테이블 + 배치로 키 중복 제거해서 월 20건 재처리를 거의 0으로 줄였다.
🔧 도구:TTL / 보관 기간스테이징 테이블 + 배치 워커
-- cost: one row per key + a retention window; purge old keys DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '7 days';
“The cost is one row per key and a retention window, and that's worth it.”
checking microphone…
8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.
Delivery is at-least-once (client retries, queue redelivers). The same request can arrive twice. The effect (charge, create) must apply once.
Set<String> seen = new HashSet<>(); if (!seen.add(requestId)) return; // already processed
Use when: Single instance, non-durable. Demos only.
Cost / limit: Lost on restart; doesn't work across instances.
-- unique key = the mutex
CREATE TABLE idempotency(key VARCHAR PRIMARY KEY, result JSONB, created_at TIMESTAMP);
// insert the key + apply the effect in ONE transaction
@Transactional
Result handle(String key, Cmd cmd) {
try {
repo.insert(key); // duplicate -> unique violation
Result r = apply(cmd);
repo.saveResult(key, r);
return r;
} catch (DuplicateKeyException e) {
return repo.getResult(key); // replay the prior result, don't re-apply
}
}Use when: Any create/charge that can be retried. Most cases.
Cost / limit: Extra row + lookup on the hot path; a key-retention/TTL policy.
// producer: write state + an outbox row in ONE local transaction // consumer: dedupe on the message id before applying (processed-ids table or upsert) if (!processed.add(messageId)) return; // at-least-once -> exactly-once effect
Use when: A write must trigger a downstream call/event you don't own.
Cost / limit: Carries duplicates (you dedupe them); a relay + a dedup store to operate.
Know the ladder — start at the simplest that meets the need, and say what you'd reach for next and why.
Say it out loud in your own words — hit these terms — then check against the gold answer.
Say it in your own words — but hit these: idempotency · idempotency key · exactly once · dedup
checking microphone…