멱등성 — 두 번 눌러도 한 번만 결제되게 · 같은 키와 같은 요청은 다르다 · 讲解
같은 키와 같은 요청은 다르다의 설계 원리
한 줄 요약
키 범위·본문 정규화·재사용 충돌을 분리해 재전송을 판정합니다.
왜 이게 필요했나
두 고객이 우연히 같은 멱등 키를 사용하자 한 고객에게 다른 고객의 응답이 반환됐다. 다른 요청에서는 같은 키로 금액을 바꿨는데도 이전 성공을 돌려줬다. 멱등 키 자체만 비교하면 요청의 의미와 보안 경계를 놓친다. 키는 테넌트와 작업 범위에 묶고 본문의 의미는 별도 지문으로 검사해야 한다.
어떻게 동작하나
문자열 키의 문법을 검증하고 HTTP 메서드와 정확한 경로를 테넌트와 묶는다. JSON 키 순서와 공백은 정규화하지만 배열 순서는 보존한다. NaN은 표준 JSON 값이 아니므로 거절한다. 요청 지문은 SHA-256으로 계산하고 같은 범위 키에 다른 지문이 들어오면 충돌로 처리한다. 저장된 응답은 깊은 복사로 반환해 호출자가 이후 재전송의 결과를 바꾸지 못하게 한다.
tenant + method + path + key → 범위 키본문 → 정규 JSON → 지문 → 최초 저장 / 같은 요청 재생 / 다른 요청 충돌계약을 읽고 실패를 예측하는 워크시트
다음은 구현을 통째로 외우는 답안이 아니라 단계별 코드 리뷰입니다. 각 변경 조각은 의도적으로 계약을 깨뜨립니다. 변경 후에도 정상 사례가 통과할 수 있다는 점에 주의하세요. 실행 전에 어느 입력·예외·상태를 관측하면 차이가 드러날지 예상하고, 구현 후에는 그 예상과 결과를 비교합니다.
1. 키 문법을 검증한다
valid_key(value)는 영문·숫자·밑줄·하이픈 1~64자만 그대로 반환하고 다른 입력은 ValueError입니다.
판단의 근거: 키 길이와 허용 문자를 제한하고 빈 키를 정상 재전송으로 처리하지 않습니다.
리뷰할 잘못된 변경 조각:
{1,128}이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
2. 객체 순서는 접고 배열 순서는 보존한다
canonical(body)는 dict만 받아 sort_keys=True, separators=(',',':'), ensure_ascii=False, allow_nan=False인 JSON 문자열로 반환합니다. 직렬화 불가능한 값은 ValueError로 통일합니다.
판단의 근거: 배열을 정렬하면 사용자가 요청한 작업 순서를 바꾸게 됩니다.
리뷰할 잘못된 변경 조각:
sort_keys=False이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
3. 본문 지문을 계산한다
fingerprint(body)는 canonical(body)의 UTF-8 바이트에 SHA-256을 적용한 64자 hex 문자열입니다.
판단의 근거: 파이썬 hash()는 프로세스마다 바뀌므로 저장 지문으로 쓰지 않습니다.
리뷰할 잘못된 변경 조각:
hashlib.sha512(이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
4. 테넌트와 작업으로 키를 구분한다
scoped_key(tenant, method, path, key)는 tenant와 key를 valid_key로 검증하고, method를 대문자로 바꿉니다. path는 /로 시작하는 문자열이어야 합니다. 네 값을 JSON 배열로 separators=(',',':') 인코딩해 반환합니다.
판단의 근거: 단순 구분자 이어붙이기보다 구조를 인코딩해야 경계가 명확합니다. 경로의 대소문자는 보존합니다.
리뷰할 잘못된 변경 조각:
method.upper(), path.lower(),이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
5. 세 가지 판정을 구분한다
classify(record, digest)는 record=None이면 'new', record['fingerprint']==digest면 'replay', 아니면 'conflict'입니다.
판단의 근거: 키가 존재한다는 이유만으로 모든 재요청을 성공으로 재생하지 않습니다.
리뷰할 잘못된 변경 조각:
else "replay"이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
6. 응답을 저장할 때 복사한다
remember(records, key, digest, response)는 새 키에 {fingerprint:digest, response:response의 deepcopy}를 저장합니다. 이미 있으면 ValueError이고 기존 기록은 보존합니다.
판단의 근거: 응답 안의 리스트도 복사하지 않으면 중첩 상태가 공유됩니다.
리뷰할 잘못된 변경 조각:
response이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
7. 응답을 읽을 때도 복사한다
replay(record)는 record['response']의 깊은 사본입니다.
판단의 근거: 첫 응답을 수정한 호출자가 다음 재전송 결과까지 바꾸지 못하게 합니다.
리뷰할 잘못된 변경 조각:
record["response"]이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
8. 업무 함수를 한 번만 호출한다
execute(records, tenant, method, path, key, body, action)는 scope와 지문을 계산합니다. new면 action() 결과를 remember하고 사본 반환, replay면 기존 응답 사본 반환, conflict면 ValueError입니다. action 예외는 전파하고 기록을 남기지 않습니다.
판단의 근거: 업무 함수 호출 횟수와 실패 뒤 남은 기록까지 검사해야 재전송 계약을 알 수 있습니다.
리뷰할 잘못된 변경 조각:
if state in ("new", "replay"):이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
현장에서 만나는 모습
이 실습은 단일 프로세스 메모리 사전으로 키와 요청의 의미를 분리하는 계약을 배운다. 프로세스 장애 후 보존이나 여러 워커의 동시성은 다음 SQLite 실습에서 다룬다. 해시는 암호화가 아니며 JSON 정규화가 모든 언어의 숫자 표현까지 표준화하는 국제 규격이라고 주장하지 않는다.
다음 실습에서 할 것
여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 키 문법을 검증한다 → 객체 순서는 접고 배열 순서는 보존한다 → 본문 지문을 계산한다 → 테넌트와 작업으로 키를 구분한다 → 세 가지 판정을 구분한다 → 응답을 저장할 때 복사한다 → 응답을 읽을 때도 복사한다 → 업무 함수를 한 번만 호출한다.
각 단계는 함수나 파일이 존재한다는 사실이 아니라 실제 반환값·예외·상태 변화를 검사합니다. 정답을 본 뒤에는 일부러 경계 비교나 정리 코드를 바꾸어 어떤 시험이 실패하는지 확인하세요. 앞선 시험이 다음 단계에서도 유지되는 이유를 설명하고, 이 실습이 보장하지 않는 운영 조건을 한 가지 적어 보세요.