同一笔转账出去了两次 — 做拦住它的那一侧
한국어 원문으로 표시합니다.
목표
클라이언트 재시도로 같은 이체 요청이 두 번 들어와도 돈이 한 번만 나가게 하는 계층을 직접 만든다. 멱등 키 표와 UNIQUE 제약, 요청 본문의 정규화 지문, 처리 중 상태, 응답 재생, 키 범위와 보관 기간까지 붙이고, 하루치 요청 로그를 다시 흘려보내 이중 이체가 0 임을 증명한다.
왜 중요한가
응답을 못 받은 클라이언트는 다시 보낸다. 이것을 막을 방법은 없고, 막아서도 안 된다. RFC 9110 은 POST 를 멱등으로 보지 않으므로 프로토콜이 주는 보증은 없다 — 보증은 애플리케이션이 만든다. 만드는 방법의 뼈대는 IETF 초안(draft-ietf-httpapi-idempotency-key-header)이 정리해 두었다. 키는 클라이언트가 만들고, 지문은 서버가 만들고, 완료된 키의 재시도에는 저장한 응답을 재생하고, 처리 중인 키의 재시도에는 충돌로 답한다. 이 실습에서 어려운 것은 코드가 아니라 경계다. 조회하고 삽입하면 동시 재시도를 놓치고, 키를 완료로 먼저 적으면 죽었을 때 돈이 영영 안 나가고, 지문을 안 보면 금액이 바뀐 요청이 조용히 무시된다. 채점기는 여러분의 문구를 믿지 않는다. 임시 디렉터리에 채점기가 만든 계좌 데이터베이스를 차리고, 매번 다른 계좌·금액·키로 여러분의 스크립트를 실제로 실행해 응답 JSON·이체 표·잔액을 직접 잰 값과 대조한다.
단계
- /root/idem/gen_requests.py 를 만들어 실행해 /root/idem/idem.db 를 만드세요. 계좌 8개, 요청 120건(서로 다른 키 100개), 그날의 이체 120건이 들어갑니다.
- 재시도가 만든 이중 이체를 집계해 /root/idem/dup_report.json 에 requests·unique_keys·duplicate_keys·extra_transfers·double_paid·keys 로 적으세요.
- /root/idem/idem_api.py 를 만들어 멱등 키 표의 UNIQUE 제약으로 같은 키의 두 번째 요청이 이체를 만들지 못하게 하세요. 키가 다르면 막지 않습니다.
- idem_api.py 가 요청 본문의 정규화 지문을 저장하고, 같은 키에 본문이 다르면 422 로 거절하게 하세요. 필드 순서만 다른 같은 본문은 같은 요청입니다.
- idem_api.py 의 키 기록을 선점(in_progress)과 완료(completed) 두 단계로 나누고, 처리 중인 키의 재시도에 409 in_progress 로 답하게 하세요.
--crash-after-claim으로 처리 전에 죽는 상황을 만들 수 있어야 합니다. - 완료된 키의 재시도에 저장해 둔 응답을 그대로 재생하게 하세요. status 는 처음 응답과 같고 replay 는 true 이며 본문은 한 글자도 다르지 않아야 합니다.
- 키 범위를 (client_id, endpoint, idem_key) 로 넓히고
--purge-before로 보관 기간이 지난 키를 지우세요. 정책은 /root/idem/policy.json 에 적습니다. - /root/idem/replay_day.py 로 요청 120건을 하나도 빼지 않고 다시 흘려보내 /root/idem/day.db 와 /root/idem/result.json 을 만들고, /root/idem/idem_report.md 에 네 절로 보고하세요.
참고
- 실행 계약:
python3 /root/idem/idem_api.py --db <DB> --request <요청 JSON>은 응답 JSON 한 덩어리를 표준출력에 내고 종료 코드 0 으로 끝납니다. 요청 파일을 읽을 수 없으면 3 입니다. - 요청 JSON:
{"client_id": …, "endpoint": …, "idem_key": …, "body": {"src": …, "dst": …, "amount": 정수, "currency": "KRW"}} - 응답 JSON:
{"status": 정수, "replay": true|false, "reason": 문자열|null, "body": 객체|null}. 새로 실행했으면 status 201 · replay false 이고 body 에 transfer_id 가 있습니다. - 표 이름과 칼럼:
account(acct_id, holder, balance)·transfer(transfer_id, src, dst, amount, currency, created_at)· 멱등 키 표는 여러분이 정합니다. 채점기는 account 표만 미리 채워 두고 나머지는 여러분의 스크립트가 만들기를 기대합니다. - 잠금: 파이썬 sqlite3 은 기본이 지연 트랜잭션입니다. 선점처럼 쓰기가 확실한 자리는
BEGIN IMMEDIATE, 기다림은PRAGMA busy_timeout으로 다룹니다. - 흔한 실수: 조회한 뒤 삽입하기(동시 재시도를 놓칩니다), 키를 완료로 먼저 적기, 지문을 원본 문자열로 계산하기, 재생할 때 응답을 다시 계산하기.
- 보관 기간 72시간과 범위 세 칸은 이 실습의 가정입니다. IETF 초안은 만료 정책을 문서로 공개하라고만 적고 숫자를 정하지 않습니다.
- 부하 시험을 만들지 마세요. 채점 하나의 예산은 60초이고 파드는 2코어입니다.
그날의 요청 로그 만들기
/root/idem/gen_requests.py 를 만들어 실행해 /root/idem/idem.db 를 만드세요. 계좌 8개, 요청 120건(서로 다른 키 100개), 그날의 이체 120건입니다.
먼저 /root/idem 을 만들고 그 안에서 python3 로 sqlite DB 를 만듭니다. 표는 account, req_log, transfer_v1 셋입니다. req_log 는 게이트웨이가 받은 요청 그대로라서 재시도도 한 줄씩 들어 있고, transfer_v1 은 그 요청들이 실제로 만든 이체입니다.
재시도가 만든 이중 이체 세기
/root/idem/dup_report.json 에 requests·unique_keys·duplicate_keys·extra_transfers·double_paid·keys 를 적으세요. duplicate_keys 는 이체가 두 건 이상 생긴 키의 개수이고, keys 는 그 키 목록입니다.
extra_transfers 는 키마다 첫 이체를 빼고 남은 건수입니다. double_paid 는 그 남은 이체들의 금액 합입니다. SQLite 3.25 부터 쓸 수 있는 ROW_NUMBER() OVER (PARTITION BY … ORDER BY …) 로 키 안에서 몇 번째 이체인지 매기면 한 번에 나옵니다.
같은 키의 두 번째 요청 막기
/root/idem/idem_api.py 를 만들어 멱등 키 표의 UNIQUE 제약으로 같은 키의 두 번째 요청이 이체를 만들지 못하게 하세요. 키가 다르면 같은 본문이라도 막지 않습니다.
조회해서 없으면 넣는 방식은 같은 순간 들어온 두 건을 둘 다 통과시킵니다. 키를 기본키로 둔 표에 곧바로 INSERT 하고, 제약 위반 예외(sqlite3.IntegrityError)를 '이미 있다' 의 신호로 쓰세요. 두 번째 응답은 replay 를 true 로 하거나 409 로 답하면 됩니다.
같은 키인데 본문이 다르면 거절하기
idem_api.py 가 요청 본문의 정규화 지문을 키와 함께 저장하고, 같은 키에 본문이 다르면 422 로 거절하게 하세요. 필드 순서만 다른 같은 본문은 같은 요청으로 봐야 합니다.
지문을 원본 문자열로 계산하면 필드 순서나 공백만 달라도 다른 요청이 됩니다. RFC 8785 가 규정하는 정규화의 핵심은 키 정렬과 공백 제거입니다. 파이썬에서는 json.dumps 의 sort_keys 와 separators 로 근사할 수 있고, 그 바이트열을 sha256 에 넣습니다.
처리 중인 키와 동시 재시도
키 기록을 선점(in_progress)과 완료(completed) 두 단계로 나누고, 처리 중인 키의 재시도에 409 와 reason in_progress 로 답하게 하세요. --crash-after-claim 은 선점만 하고 이체 없이 0 이 아닌 코드로 끝나야 합니다.
키를 완료로 먼저 적으면, 이체 도중 죽었을 때 돈은 안 나갔는데 재시도는 '이미 처리됨' 을 받습니다. 선점 트랜잭션과 실행 트랜잭션을 갈라 두면 중간 상태가 표에 남습니다. 채점기는 같은 순간에 들어온 재시도 두 건도 보냅니다 — 둘 중 새로 실행되는 것은 정확히 하나여야 합니다.
저장한 응답을 그대로 재생하기
완료된 키의 재시도에 저장해 둔 응답을 그대로 돌려주게 하세요. status 는 처음 응답과 같고, replay 는 true 이며, body 는 처음 응답과 한 글자도 달라서는 안 됩니다.
재생은 다시 계산하는 것이 아닙니다. 응답 본문을 완료 시점에 통째로 저장해 두었다가 그대로 꺼내 씁니다. 다시 계산하면 잔액이나 시각이 달라져 고객 화면이 두 번 다르게 보입니다. 처리 중인 키는 여전히 409 여야 합니다.
키의 범위와 보관 기간 정하기
키 범위를 (client_id, endpoint, idem_key) 로 넓히고, --purge-before <RFC 3339 시각> 이 그보다 오래된 키를 지우고 {"purged": 개수} 를 내게 하세요. 정책은 /root/idem/policy.json 에 scope·retention_hours(72)·on_fingerprint_mismatch(422)·on_in_flight(409) 로 적습니다.
키가 전역이면 다른 고객이 우연히 같은 문자열을 보냈을 때 남의 응답을 받습니다. 기본키를 세 칼럼으로 바꾸면 범위가 생깁니다. 보관 기간이 지나 키를 지우면 그 뒤의 같은 요청은 새 요청입니다 — 그래서 보관 기간은 클라이언트의 재시도 상한보다 길어야 합니다.
하루치를 다시 흘려보내 증명하기
/root/idem/replay_day.py 로 요청 120건을 하나도 빼지 않고 다시 보내 /root/idem/day.db 와 /root/idem/result.json 을 만들고, /root/idem/idem_report.md 에 ## 무엇이 잘못됐나 ## 어떻게 막았나 ## 남은 위험 ## 운영 규칙 네 절로 적으세요.
요청을 골라 내면 증명이 되지 않습니다. req_log 를 seq 순서대로 전부 보내고, 결과를 requests·created·replayed·rejected·transfers·total_transferred·v1_total·double_paid_avoided 로 셉니다. idem_api.py 를 모듈로 불러 쓰면 프로세스를 120번 띄우지 않아도 됩니다. 보고서에는 이번에 막아 낸 금액을 숫자로 적으세요.