은행 현장의 언어 · 재시도가 만든 이중 이체 · 실습
같은 이체가 두 번 나갔다 — 막는 쪽을 만든다
목표
클라이언트 재시도로 같은 이체 요청이 두 번 들어와도 돈이 한 번만 나가게 하는 계층을 직접 만든다. 멱등 키 표와 UNIQUE 제약, 요청 본문의 정규화 지문, 처리 중 상태, 응답 재생, 키 범위와 보관 기간까지 붙이고, 하루치 요청 로그를 다시 흘려보내 이중 이체가 0 임을 증명한다.
왜 중요한가
응답을 못 받은 클라이언트는 다시 보낸다. 이것을 막을 방법은 없고, 막아서도 안 된다. RFC 9110 은 POST 를 멱등으로 보지 않으므로 프로토콜이 주는 보증은 없다 — 보증은 애플리케이션이 만든다.
만드는 방법의 뼈대는 IETF 초안(draft-ietf-httpapi-idempotency-key-header)이 정리해 두었다. 키는 클라이언트가 만들고, 지문은 서버가 만들고, 완료된 키의 재시도에는 저장한 응답을 재생하고, 처리 중인 키의 재시도에는 충돌로 답한다.
이 실습에서 어려운 것은 코드가 아니라 경계다. 조회하고 삽입하면 동시 재시도를 놓치고, 키를 완료로 먼저 적으면 죽었을 때 돈이 영영 안 나가고, 지문을 안 보면 금액이 바뀐 요청이 조용히 무시된다.
채점기는 여러분의 문구를 믿지 않는다. 임시 디렉터리에 채점기가 만든 계좌 데이터베이스를 차리고, 매번 다른 계좌·금액·키로 여러분의 스크립트를 실제로 실행해 응답 JSON·이체 표·잔액을 직접 잰 값과 대조한다.
단계
1. /root/idem/gen_requests.py 를 만들어 실행해 /root/idem/idem.db 를 만드세요. 계좌 8개, 요청 120건(서로 다른 키 100개), 그날의 이체 120건이 들어갑니다.
2. 재시도가 만든 이중 이체를 집계해 /root/idem/dup_report.json 에 requests·unique_keys·duplicate_keys·extra_transfers·double_paid·keys 로 적으세요.
3. /root/idem/idem_api.py 를 만들어 멱등 키 표의 UNIQUE 제약으로 같은 키의 두 번째 요청이 이체를 만들지 못하게 하세요. 키가 다르면 막지 않습니다.
4. idem_api.py 가 요청 본문의 정규화 지문을 저장하고, 같은 키에 본문이 다르면 422 로 거절하게 하세요. 필드 순서만 다른 같은 본문은 같은 요청입니다.
5. idem_api.py 의 키 기록을 선점(in_progress)과 완료(completed) 두 단계로 나누고, 처리 중인 키의 재시도에 409 in_progress 로 답하게 하세요. --crash-after-claim 으로 처리 전에 죽는 상황을 만들 수 있어야 합니다.
6. 완료된 키의 재시도에 저장해 둔 응답을 그대로 재생하게 하세요. status 는 처음 응답과 같고 replay 는 true 이며 본문은 한 글자도 다르지 않아야 합니다.
7. 키 범위를 (client_id, endpoint, idem_key) 로 넓히고 --purge-before 로 보관 기간이 지난 키를 지우세요. 정책은 /root/idem/policy.json 에 적습니다.
8. /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코어입니다.
단계 8개
- 그날의 요청 로그 만들기
- 재시도가 만든 이중 이체 세기
- 같은 키의 두 번째 요청 막기
- 같은 키인데 본문이 다르면 거절하기
- 처리 중인 키와 동시 재시도
- 저장한 응답을 그대로 재생하기
- 키의 범위와 보관 기간 정하기
- 하루치를 다시 흘려보내 증명하기