LabHub
배우기 러닝패스 코스

Idempotency — Two Clicks, One Charge

Two Clicks, One Charge

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

네트워크에서 재시도는 피할 수 없습니다. 클라이언트가 응답을 못 받았을 때, 요청이 도착 안 한 건지 도착했는데 응답만 못 온 건지 구별할 방법이 없기 때문입니다.

그러니 중복은 서버가 막아야 합니다. 이 실습은 일부러 멱등하지 않게 만든 결제 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.pyhandle_pay 하나입니다. idem 테이블은 이미 만들어져 있습니다 — 키·요청해시·상태·응답·시각.

서버를 다시 띄울 때

ps -eo pid,args | awk '$2 ~ /python3$/ && $3 == "server.py" {print $1}' | xargs -r kill

pkill -f server.py 를 쓰지 마세요. 그 패턴은 이 명령을 실행한 셸의 명령줄에도 걸려서 자기 자신을 죽입니다.

단계

  1. 중복 재현 → 01-duplicate.txt
  2. 같은 키면 같은 답 → 채점기가 직접 확인
  3. 키 없으면 거절 → 03-nokey.txt
  4. 같은 키 다른 본문 → 04-conflict.txt
  5. 동시 요청 → 05-race.txt
  6. 재시작해도 기억 → 06-persist.txt
  7. 재시도 정책 → 07-retry.md
  8. 정리 → 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.pyhandle_pay 를 고쳐, 같은 Idempotency-Key 로 두 번 오면 두 번째는 적립하지 않고 첫 응답을 그대로 돌려주게 하세요.

idem 테이블이 이미 만들어져 있습니다 — 키·요청해시·상태·응답·시각.

첫 요청이면 적립하고 응답을 저장합니다. 이미 있는 키면 저장해 둔 응답을 그대로 돌려줍니다. 새로 만들지 않습니다.

확인: 두 번 보내고 ./ledger.sh1건이면 됩니다. 그리고 두 응답의 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 에 세 줄 이상. 재시도가 왜 피할 수 없는지, 멱등성 키를 어디에 저장해야 하는지, 같은 키에 다른 본문이 오면 왜 거절해야 하는지.

본문에 재시도, 저장, 본문 이 들어가야 합니다.