Idempotency — Two Clicks, One Charge
Two Clicks, One Charge
한국어 원문으로 표시합니다.
목표
네트워크에서 재시도는 피할 수 없습니다. 클라이언트가 응답을 못 받았을 때, 요청이 도착 안 한 건지 도착했는데 응답만 못 온 건지 구별할 방법이 없기 때문입니다.
그러니 중복은 서버가 막아야 합니다. 이 실습은 일부러 멱등하지 않게 만든 결제 API 를 고쳐 나갑니다.
시작
cp /opt/lab/idem/* . && chmod +x *.sh
setsid nohup python3 server.py > s.log 2>&1 </dev/null &
./probe.sh k1 # 키 k1 로 결제
./ledger.sh # 지금까지 적립된 것
setsid nohup 을 붙이세요. 그냥 & 로 띄우면 셸이 바뀔 때 같이 죽습니다.
고칠 곳
server.py 의 handle_pay 하나입니다. idem 테이블은 이미 만들어져
있습니다 — 키·요청해시·상태·응답·시각.
서버를 다시 띄울 때
ps -eo pid,args | awk '$2 ~ /python3$/ && $3 == "server.py" {print $1}' | xargs -r kill
pkill -f server.py 를 쓰지 마세요. 그 패턴은 이 명령을 실행한 셸의
명령줄에도 걸려서 자기 자신을 죽입니다.
단계
- 중복 재현 →
01-duplicate.txt - 같은 키면 같은 답 → 채점기가 직접 확인
- 키 없으면 거절 →
03-nokey.txt - 같은 키 다른 본문 →
04-conflict.txt - 동시 요청 →
05-race.txt - 재시작해도 기억 →
06-persist.txt - 재시도 정책 →
07-retry.md - 정리 →
08-notes.md
참고
2·5·6단계는 채점기가 직접 서버를 띄워 요청을 보냅니다. 로그를 베껴 오는 것으로는 통과하지 못합니다.
재시도가 중복 결제를 만든다
서버를 띄우고 같은 요청을 두 번 보낸 뒤 원장을 확인해 01-duplicate.txt 에 남기세요.
cp /opt/lab/idem/* . && chmod +x *.sh
setsid nohup python3 server.py > s.log 2>&1 </dev/null &
./probe.sh k1
./probe.sh k1
./ledger.sh
payment_id 가 1, 2 로 늘고 원장이 2건이 됩니다. 클라이언트는 한 번 결제했다고 생각합니다 — 타임아웃 때문에 재시도했을 뿐이니까요.
네트워크에서 재시도는 피할 수 없습니다. 응답을 못 받았을 때 요청이 도착 안 한 건지, 도착했는데 응답만 못 온 건지 클라이언트는 구별할 수 없습니다.
같은 키면 같은 답을 돌려준다
server.py 의 handle_pay 를 고쳐, 같은 Idempotency-Key 로 두 번 오면 두 번째는 적립하지 않고 첫 응답을 그대로 돌려주게 하세요.
idem 테이블이 이미 만들어져 있습니다 — 키·요청해시·상태·응답·시각.
첫 요청이면 적립하고 응답을 저장합니다. 이미 있는 키면 저장해 둔 응답을 그대로 돌려줍니다. 새로 만들지 않습니다.
확인: 두 번 보내고 ./ledger.sh 가 1건이면 됩니다. 그리고 두 응답의 payment_id 가 같아야 합니다.
키가 없으면 거절한다
Idempotency-Key 없이 온 요청을 400 으로 거절하게 만들고 03-nokey.txt 에 남기세요.
./probe.sh 를 인자 없이 부르면 키 없이 갑니다.
돈이 움직이는 요청에 키를 선택으로 두면, 키를 안 보내는 클라이언트가 반드시 생깁니다. 그리고 그 클라이언트가 중복 결제를 만듭니다. 처음부터 필수로 두는 편이 낫습니다.
같은 키에 다른 본문이 오면
같은 키로 다른 금액을 보내면 422 로 거절하게 만들고 04-conflict.txt 에 남기세요.
요청 본문의 해시를 idem.request_hash 에 저장해 두고 비교합니다.
./probe.sh k1 '{"user":"u1","amount":1000}'
./probe.sh k1 '{"user":"u1","amount":99999}'
두 번째를 조용히 통과시키면 1000원짜리 응답을 99999원 요청에 돌려주게 됩니다. 키를 재사용하는 버그가 조용히 묻힙니다.
동시에 같은 키가 오면
같은 키로 10개를 동시에 보내도 원장이 1건인지 확인해 05-race.txt 에 남기세요.
pids=""
for i in $(seq 10); do ./probe.sh race >/dev/null 2>&1 & pids="$pids $!"; done
wait $pids
./ledger.sh
wait 뒤에 PID 를 반드시 적으세요. 그냥 wait 만 쓰면 1단계에서 배경으로 띄운 서버까지 기다립니다 — 서버는 끝나지 않으므로 터미널이 그대로 멈춥니다.
"먼저 조회하고 없으면 넣는다" 는 순진한 방식은 여기서 깨집니다 — 열 개가 동시에 '없다' 를 읽습니다.
제대로 하려면 키에 유일 제약을 걸고 삽입이 실패하는 쪽을 기다리게 하거나, 잠금으로 감싸야 합니다. idem.key 는 이미 기본 키입니다.
프로세스를 껐다 켜도 기억하나
결제 한 번 뒤 서버를 껐다 켜고 같은 키로 다시 보내, 여전히 중복이 안 생기는지 06-persist.txt 에 남기세요.
메모리 사전으로 구현했다면 여기서 깨집니다. 재시작하면 잊어버리니까요.
실무에서는 더 흔한 이유로 깨집니다 — 서버가 여러 대라서요. 1번 파드가 기억한 것을 2번 파드는 모릅니다. 그래서 저장은 모두가 같이 보는 곳에 해야 합니다(여기서는 sqlite, 실무에서는 DB 나 Redis).
어떤 오류에 재시도해야 하나
07-retry.md 에 재시도해도 되는 응답과 하면 안 되는 응답을 나누고, 각각 이유를 적으세요. 최소 4가지.
생각할 것 — 500, 503, 429, 400, 422, 그리고 응답을 아예 못 받은 경우.
힌트 하나: 400 을 재시도하면 영원히 400 입니다. 그리고 재시도할 때는 간격을 늘려 가며(exponential backoff) 하고, 여러 클라이언트가 동시에 재시도하지 않게 흔들어(jitter) 줍니다. 안 그러면 회복하려는 서버를 재시도가 다시 눕힙니다.
세 가지를 정리한다
08-notes.md 에 세 줄 이상. 재시도가 왜 피할 수 없는지, 멱등성 키를 어디에 저장해야 하는지, 같은 키에 다른 본문이 오면 왜 거절해야 하는지.
본문에 재시도, 저장, 본문 이 들어가야 합니다.