Order Status Sometimes Goes Backwards — Build the Receiving Side
한국어 원문으로 표시합니다.
목표
남의 시스템이 밀어 넣는 웹훅을 받는 쪽을 직접 만든다. HMAC 서명 검증과 상수시간 비교, 재생을 막는 시각 창, 배달 번호와 이벤트 번호 두 열쇠의 중복 제거, 판 비교로 순서 뒤집힘 흡수, 그리고 빨리 200 을 주고 나중에 처리하는 구조까지 붙이고, 하루치 배달을 다시 흘려보내 각 갈래를 집계한다.
왜 중요한가
웹훅은 우리가 부르는 것이 아니라 받는 것이라 통제권이 반대편에 있다. 수신 종단은 열려 있어야 하므로 아무나 POST 할 수 있고, 우리 응답이 늦으면 상대는 재전송하고, 순서는 보장되지 않는다. 그래서 받는 쪽은 네 가지를 스스로 판단해야 한다. 누가 보냈는가(서명), 언제 보낸 것인가(시각 창), 이미 본 것인가(중복), 지금 것보다 새것인가(판). 이 중 하나라도 빠지면 가짜 이벤트가 원장에 들어가거나 주문 상태가 뒤로 간다. 중복의 열쇠가 둘이라는 점이 특히 함정이다. 배달 번호는 한 번의 전송을, 이벤트 번호는 벌어진 사건 하나를 가리킨다. 막아야 하는 것은 사건의 중복 적용이므로 배달 번호만으로 거르면 절반만 막힌다. 채점기는 여러분의 문장을 믿지 않는다. 여러분이 만든 발신기와 수신기를 채점기가 고른 포트에 직접 띄우고, 채점기가 만든 비밀과 배달로 검증기·원장을 다시 실행해 답을 맞춰 본다.
단계
- /root/wh/sender.py 를 만들어 포트 8012 에 띄우고,
/deliveries를 /root/wh/deliveries.json 에, 공유 비밀을 /root/wh/secret.txt 에 저장하세요. - /root/wh/verify.py 를 만들어 서명을 상수시간으로 비교하고
{"ok": ..., "reason": ...}를 내게 하세요. - verify.py 에 시각 창을 붙여 창 밖의 배달을
stale로 거절하게 하세요. - /root/wh/ledger.py 를 만들어 배달 번호와 이벤트 번호 두 열쇠로 중복을 거르게 하세요.
- ledger.py 가 지금 저장된 판보다 새것일 때만 적용하고, 뒤늦게 온 옛 판은
stale_version으로 남기게 하세요. - /root/wh/receiver.py 를 만들어 검증만 하고 큐에 넣은 뒤 곧바로 200 을 주게 하고,
--drain으로 큐를 원장에 흘려보내게 하세요. - /root/wh/replay_day.py 로 하루치 배달을 모두 흘려보내 /root/wh/day.db 와 /root/wh/result.json 을 만드세요.
- /root/wh/wh_report.md 에 네 절로 보고하세요.
참고
- 발신기 실행 계약:
python3 /root/wh/sender.py --port <포트> [--secret <비밀>]./health는{"ok": true, "events": 30, "deliveries": 41},/deliveries는{"now": <기준 시각>, "tolerance": 300, "deliveries": [...]}를 냅니다. 배달 하나는{"delivery_id": ..., "signature": ..., "body": <원본 문자열>}입니다. - 서명 형식:
t=<epoch>,v1=<hex>. 서명 재료는"<t>.<body>"이고 HMAC-SHA256 입니다. body 는 받은 문자열 그대로 씁니다. 다시 파싱해 직렬화하면 서명이 어긋납니다. - 이 하루치는 사건 30건(주문 10건 × 판 3개)에 재전송 4건, 같은 사건의 새 배달 3건, 오래된 재생 2건, 가짜 서명 2건을 더한 41건입니다. 판이 도착하는 순서는 주문마다 다릅니다.
- 검증기 실행 계약:
python3 verify.py --secret <파일> --delivery <파일> [--now <epoch>] [--tolerance <초>]는{"ok": true|false, "reason": "ok"|"bad_signature"|"stale"|"malformed"}를 냅니다.--now를 안 주면 지금 시각을 씁니다. 2번 단계에서도 네 인자를 모두 받아 두세요(창은 3번에서 붙입니다). 서명 모양이 아니면 malformed, 서명이 틀리면 bad_signature, 서명은 맞고 시각이 창 밖이면 stale 입니다. - 원장 실행 계약:
python3 ledger.py --db <sqlite> --delivery <파일>은{"stored": ..., "applied": ..., "reason": ...}를 냅니다. reason 은 new · duplicate_delivery · duplicate_event · stale_version 입니다. 표는order_state(order_id, version, status)를 반드시 포함합니다. - 수신 종단 실행 계약:
python3 receiver.py --port <포트> --db <sqlite> --secret <파일> [--tolerance <초>]는GET /health와POST /webhook을 냅니다. 배달 번호는X-Delivery-Id, 서명은X-Signature헤더로 옵니다. 통과하면 200{"queued": true}, 서명이나 시각 창에서 걸리면 400 입니다.--drain을 주면 서버를 띄우지 않고 큐를 원장에 흘려보낸 뒤 집계를 냅니다. 큐 표 이름은inbox입니다. - 재현기 실행 계약:
python3 replay_day.py --deliveries <파일> --db <sqlite> --out <파일>. 집계 칸은 deliveries · accepted · rejected_signature · rejected_stale · duplicate_delivery · duplicate_event · stored · applied · stale_version · orders 열 개입니다.accepted는 서명과 시각 창을 통과한 배달,stored는 중복이 아니어서 원장에 들어간 사건의 수입니다. - 흔한 실수: 본문을 파싱했다가 다시 직렬화해 서명 계산하기,
==로 서명 비교하기, 배달 번호로만 중복 거르기, 받는 자리에서 원장까지 처리해 200 이 늦어지기. - 서버는 백그라운드로 띄우고
/health가 200 이 될 때까지 기다린 뒤 다음으로 갑니다. 채점기는 여러분이 띄워 둔 프로세스를 보지 않고 스크립트를 직접 다시 띄웁니다.
하루치 배달 손에 쥐기
/root/wh/sender.py 를 만들어 포트 8012 에 띄우고, /deliveries 응답을 /root/wh/deliveries.json 에, 공유 비밀을 /root/wh/secret.txt 에 저장하세요. 배달은 41건, 사건은 30건입니다.
flask 로 /health 와 /deliveries 두 경로를 만듭니다. 배달 목록은 사건 30건에 재전송·같은 사건의 새 배달·오래된 재생·가짜 서명을 더해 만듭니다. 기준 시각을 응답에 함께 실어 두면 나중에 시험이 시계에 흔들리지 않습니다.
누가 보냈는지 서명으로 가리기
/root/wh/verify.py 를 만들어 배달 하나의 서명을 검증하고 {"ok": ..., "reason": ...} 를 내게 하세요. 서명 모양이 아니면 malformed, 서명이 틀리면 bad_signature 입니다. 비교는 반드시 상수시간으로 하세요.
서명 문자열 t=...,v1=... 을 쪼개 t 와 v1 을 얻고, "<t>.<body>" 를 재료로 HMAC-SHA256 을 계산합니다. body 는 받은 문자열 그대로 써야 합니다. 파이썬 hmac 모듈에는 길이에 비례하는 시간으로 비교하는 함수가 있습니다 — == 는 첫 다른 바이트에서 끝나 시간이 비밀을 흘립니다.
오래된 것을 다시 밀어 넣는 손
verify.py 에 시각 창을 붙이세요. 서명이 맞아도 --now 와 배달의 t 차이가 --tolerance(기본 300초)를 넘으면 stale 로 거절해야 합니다. 앞뒤 양쪽 모두 창 밖입니다.
서명만으로는 예전에 오간 유효한 요청을 그대로 다시 밀어 넣는 것을 막을 수 없습니다. 그래서 서명 재료에 타임스탬프가 들어 있고, 받는 쪽은 지금 시각과의 차이를 봅니다. 미래 쪽도 막아야 합니다 — 상대 시계가 빠른 것과 누군가 t 를 미래로 적어 둔 것은 구별되지 않습니다. 순서는 서명이 먼저입니다.
열쇠가 둘이다
/root/wh/ledger.py 를 만들어 검증을 통과한 배달을 원장에 넣되, 같은 배달 번호가 다시 오면 duplicate_delivery, 배달 번호는 새것인데 이벤트 번호가 이미 있으면 duplicate_event 로 거르게 하세요. 표는 sqlite 파일에 남겨야 합니다.
조회해서 없으면 넣는 방식은 같은 순간 들어온 두 건을 둘 다 통과시킵니다. 두 열쇠를 각각 기본키로 둔 표에 곧바로 INSERT 하고 제약 위반 예외를 '이미 있다' 의 신호로 쓰세요. 배달 번호 검사가 먼저입니다 — 순서를 바꾸면 재전송이 duplicate_event 로 찍힙니다.
주문 상태가 뒤로 가지 않게
ledger.py 가 order_state(order_id, version, status) 를 두고, 지금 저장된 판보다 새것일 때만 적용하게 하세요. 뒤늦게 온 옛 판은 stored 는 true 지만 applied 는 false 이고 reason 은 stale_version 입니다.
도착 순서를 믿으면 안 됩니다. 이벤트가 들고 있는 version 을 지금 저장된 값과 견주세요. 처음 보는 주문이면 그대로 넣고, 이미 있으면 더 클 때만 갱신합니다. 같은 판이 다시 오는 경우도 갱신 대상이 아닙니다.
빨리 200 을 주고 나중에 처리하기
/root/wh/receiver.py 를 만들어 POST /webhook 이 서명과 시각 창만 보고 inbox 큐에 넣은 뒤 곧바로 200 {"queued": true} 를 주게 하세요. 받는 자리에서 원장을 건드리면 안 됩니다. --drain 은 서버를 띄우지 않고 큐를 원장에 흘려보냅니다.
배달 번호는 X-Delivery-Id, 서명은 X-Signature 헤더로 옵니다. 본문은 파싱하지 말고 원본 문자열 그대로 검증에 넘기세요. 검증에서 걸리면 400 입니다. 큐에 넣는 것과 원장에 적용하는 것을 나누면, 처리 시간이 상대의 타임아웃을 건드리지 않게 됩니다.
하루치를 하나도 빼지 않고 다시
/root/wh/replay_day.py 로 /root/wh/deliveries.json 의 배달을 모두 흘려보내 /root/wh/day.db 와 /root/wh/result.json 을 만드세요. 집계 칸은 deliveries·accepted·rejected_signature·rejected_stale·duplicate_delivery·duplicate_event·stored·applied·stale_version·orders 열 개입니다.
배달 목록 파일에 들어 있는 now 와 tolerance 를 그대로 기준 시각으로 쓰세요. 그래야 시험이 시계에 흔들리지 않습니다. 검증에서 떨어진 것은 원장까지 가지 않고, 중복으로 걸린 것은 stored 에 세지 않습니다. 하나도 빼지 말고 전부 흘려보내세요.
받는 쪽 점검 보고서
/root/wh/wh_report.md 에 ## 무엇이 들어왔나 ## 중복을 어떻게 걸렀나 ## 순서를 어떻게 다뤘나 ## 남은 위험과 운영 규칙 네 절로 적으세요. result.json 의 숫자가 본문에 들어가야 합니다.
읽는 사람은 우리 팀장이기도 하고 파트너사 담당자이기도 합니다. 각 갈래가 몇 건이었는지 숫자로 적고, 배달 번호만으로 걸렀다면 무엇을 놓쳤을지도 함께 적으세요. 남은 위험에는 비밀 교체와 시각 창의 폭이 반드시 들어갑니다.