멱등성 — 두 번 눌러도 한 번만 결제되게 · 서명은 맞지만 같은 알림이 또 왔다 · 讲解
서명은 맞지만 같은 알림이 또 왔다의 설계 원리
한 줄 요약
원문 서명·시간 창·이벤트 중복 제거를 하나의 웹훅 처리 경로로 연결합니다.
왜 이게 필요했나
결제 제공자가 응답을 받지 못해 같은 이벤트를 다시 보냈다. 서버는 서명이 맞으니 정상 요청이라고 생각하고 매출을 다시 더했다. 서명은 누가 보냈는지에 관한 증거이지 처음 처리하는 요청이라는 증거가 아니다. 재전송을 허용하는 시간 창과 저장된 이벤트 id를 각각 확인해야 한다.
어떻게 동작하나
서명 대상은 타임스탬프 문자열과 원문 body 바이트다. JSON을 다시 직렬화한 뒤 서명을 비교하면 공백이나 키 순서가 달라져 정상 요청을 거절한다. HMAC-SHA256과 compare_digest를 사용하고 시간 차이는 양방향으로 검사한다. 유효한 이벤트는 id와 본문 지문을 inbox에 기록하며 매출 합계와 같은 트랜잭션으로 커밋한다. 중간 예외는 두 쓰기를 모두 롤백한다.
원문+시각 → 서명·시간 검사 → inbox id+본문지문 → 합계 반영 → 동일 트랜잭션계약을 읽고 실패를 예측하는 워크시트
다음은 구현을 통째로 외우는 답안이 아니라 단계별 코드 리뷰입니다. 각 변경 조각은 의도적으로 계약을 깨뜨립니다. 변경 후에도 정상 사례가 통과할 수 있다는 점에 주의하세요. 실행 전에 어느 입력·예외·상태를 관측하면 차이가 드러날지 예상하고, 구현 후에는 그 예상과 결과를 비교합니다.
1. 서명할 원문을 보존한다
signed_bytes(timestamp, body)는 bool 제외 int timestamp와 bytes body만 받아 str(timestamp).encode()+b'.'+body를 반환합니다. 잘못된 타입은 ValueError입니다.
판단의 근거: 원문 body를 JSON으로 파싱했다가 다시 만드는 것은 서명 대상이 달라지는 일입니다.
리뷰할 잘못된 변경 조각:
+ b":" + body이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
2. HMAC을 계산한다
signature(secret, timestamp, body)는 bytes secret으로 signed_bytes에 HMAC-SHA256을 적용한 hex 문자열입니다.
판단의 근거: 일반 해시에 비밀을 덧붙이는 방식 대신 표준 HMAC을 사용합니다.
리뷰할 잘못된 변경 조각:
body, hashlib.sha256이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
3. 잘못된 서명을 거절한다
verify(secret, timestamp, body, supplied)는 supplied가 str이며 계산한 서명과 compare_digest로 같을 때 True, 아니면 False입니다.
판단의 근거: 서명이 있느냐와 서명이 맞느냐는 다른 검사입니다.
리뷰할 잘못된 변경 조각:
signature(secret,timestamp,body), signature(secret,timestamp,body)이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
4. 과거와 미래의 재생을 제한한다
fresh(timestamp, now, tolerance=300)는 timestamp와 now가 bool 제외 int이고 abs(now-timestamp)<=tolerance면 True입니다. 나머지는 False입니다. tolerance는 호출자가 주는 양의 int입니다.
판단의 근거: 미래 타임스탬프를 무조건 허용하면 공격자가 유효 기간을 늘릴 수 있습니다.
리뷰할 잘못된 변경 조각:
(now-timestamp)이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
5. 이벤트와 효과를 함께 저장할 준비를 한다
init_db(path)는 inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)와 total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)을 만들고 total에 id=1,amount=0을 중복 없이 넣습니다.
판단의 근거: 중복 이벤트 기록과 업무 합계가 같은 DB 트랜잭션에 있어야 합니다.
리뷰할 잘못된 변경 조각:
VALUES (1,1)이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
6. 기록과 매출 사이의 실패를 롤백한다
apply(path, event, fault=lambda:None)는 event의 id가 비어 있지 않은 str, amount가 bool 제외 양의 int인지 검증합니다. id·amount를 정규 JSON으로 지문 계산합니다. 같은 id/지문은 False, 다른 지문은 ValueError, 새 이벤트는 inbox 삽입→fault()→합계 증가 후 True입니다.
판단의 근거: fault에서 예외가 나면 inbox도 합계도 남지 않아야 합니다.
리뷰할 잘못된 변경 조각:
db.commit() fault()이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
7. 별도 연결에서 합계를 읽는다
total(path)는 total의 id=1인 amount 정수를 반환합니다.
판단의 근거: 콜백의 실행 횟수 대신 DB에 실제로 남은 업무 효과를 확인합니다.
리뷰할 잘못된 변경 조각:
SELECT id FROM total WHERE id=1이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
8. 실제 웹훅 요청을 처리한다
create_app(path, secret, clock)는 POST /webhook에서 원문 body, X-Timestamp, X-Signature를 읽습니다. 시각 형식·시간 창·서명 실패는 401, JSON 파싱 실패는 400, apply의 ValueError는 409입니다. 정상은 200 {accepted:True, duplicate:첫처리면False}입니다.
판단의 근거: 서명을 먼저 검증한 뒤 JSON을 읽고, 중복도 정상 확인 응답으로 반환합니다.
리뷰할 잘못된 변경 조각:
"duplicate":False이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
현장에서 만나는 모습
이 서명 형식은 교육용 프로토콜이며 실제 결제 제공자의 규격을 대신하지 않는다. 학습용 secret만 사용한다. 서버 시계의 신뢰성, 키 회전, 허용 본문 크기와 영구 보관 기간은 별도 운영 과제다. 중복 제거는 같은 이벤트 id와 같은 본문에 적용되고 다른 본문으로 같은 id를 재사용하면 충돌이다.
다음 실습에서 할 것
여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 서명할 원문을 보존한다 → HMAC을 계산한다 → 잘못된 서명을 거절한다 → 과거와 미래의 재생을 제한한다 → 이벤트와 효과를 함께 저장할 준비를 한다 → 기록과 매출 사이의 실패를 롤백한다 → 별도 연결에서 합계를 읽는다 → 실제 웹훅 요청을 처리한다.
각 단계는 함수나 파일이 존재한다는 사실이 아니라 실제 반환값·예외·상태 변화를 검사합니다. 정답을 본 뒤에는 일부러 경계 비교나 정리 코드를 바꾸어 어떤 시험이 실패하는지 확인하세요. 앞선 시험이 다음 단계에서도 유지되는 이유를 설명하고, 이 실습이 보장하지 않는 운영 조건을 한 가지 적어 보세요.