멱등성 — 두 번 눌러도 한 번만 결제되게 · 멱등 키를 지우면 보장도 끝난다 · 讲解
멱등 키를 지우면 보장도 끝난다의 설계 원리
한 줄 요약
완료·진행 중 기록을 구분하고 보관 기간과 배치 정리를 구현합니다.
왜 이게 필요했나
테이블이 커지자 오래된 멱등 키를 모두 삭제했다. 아직 결제 중인 요청의 기록도 사라졌고 재시도가 새 요청으로 들어왔다. 완료 응답의 보관 기간과 진행 중 작업의 소유권은 같은 만료 정책이 아니다. 정리는 단순 DELETE가 아니라 보장 범위를 바꾸는 상태 전이다.
어떻게 동작하나
키는 pending으로 예약하고 같은 본문 지문으로만 done으로 바꾼다. 완료 응답은 expires 시각 전까지 재생한다. 정리는 done이며 expires 이하인 행만 id 순서와 limit으로 제한해서 지운다. pending은 아무리 오래되어도 이 정리 함수가 삭제하지 않는다. 마지막 실습은 정리 전후에 같은 키를 예약해 보며, 기록 삭제 이후에는 새로운 요청으로 취급된다는 한계를 직접 확인한다.
pending → done → expires 도달 → 제한된 purge → 같은 키가 새 요청이 됨pending ─────────────────→ purge 대상 아님계약을 읽고 실패를 예측하는 워크시트
다음은 구현을 통째로 외우는 답안이 아니라 단계별 코드 리뷰입니다. 각 변경 조각은 의도적으로 계약을 깨뜨립니다. 변경 후에도 정상 사례가 통과할 수 있다는 점에 주의하세요. 실행 전에 어느 입력·예외·상태를 관측하면 차이가 드러날지 예상하고, 구현 후에는 그 예상과 결과를 비교합니다.
1. 완료 여부와 만료를 분리한다
init_db(path)는 keys(id TEXT PRIMARY KEY,fingerprint TEXT NOT NULL,status TEXT NOT NULL,response TEXT,expires REAL NOT NULL)를 멱등 생성합니다.
판단의 근거: 만료 시각만 보고 업무가 끝났다고 판단하지 않습니다.
리뷰할 잘못된 변경 조각:
CREATE TABLE keys이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
2. 진행 중 키를 예약한다
reserve(path,key,digest,expires)는 없는 키를 pending,response=NULL로 넣고 True입니다. 기존 키는 만료와 관계없이 바꾸지 않고 False입니다.
판단의 근거: 기록 삭제와 키 재사용은 분리해서 명시적으로 제어합니다.
리뷰할 잘못된 변경 조각:
INSERT OR REPLACE INTO keys이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
3. 현재 요청만 완료한다
finish(path,key,digest,response)는 일치하는 key·fingerprint이며 pending인 행만 response를 JSON으로 저장하고 done으로 바꿔 True, 나머지는 False입니다.
판단의 근거: 다른 본문의 작업자가 완료 응답을 덮지 못하게 지문까지 비교합니다.
리뷰할 잘못된 변경 조각:
AND status IN ('pending','done')이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
4. 유효한 완료 응답만 재생한다
fetch(path,key,now)는 done이고 expires>now인 행의 response를 JSON 파싱해 반환합니다. 그 외 None입니다.
판단의 근거: now==expires 경계에서 더 이상 재생하지 않는 계약을 고정합니다.
리뷰할 잘못된 변경 조각:
AND expires>=?이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
5. 정리 후보를 제한한다
expired(path,now,limit=10)는 bool 제외 int 1~100인 limit를 검사합니다. done이고 expires<=now인 id를 id 오름차순으로 최대 limit개 반환합니다.
판단의 근거: 전체 테이블을 한 번에 지우면 잠금 시간이 길어지고 진행 중 행을 섞기 쉽습니다.
리뷰할 잘못된 변경 조각:
return [r[0] for r in db.execute("SELECT id FROM keys WHERE status='done' AND expires<=? ORDER BY id DESC이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
6. 선택과 삭제를 같은 트랜잭션에 둔다
purge(path,now,limit=10)는 expired와 같은 limit 검증·조건·정렬로 후보를 고른 뒤 한 트랜잭션에서 삭제하고 삭제한 id 리스트를 반환합니다. pending은 지우지 않습니다.
판단의 근거: 후보 조회와 삭제 사이에 상태가 바뀌는 틈을 만들지 않습니다.
리뷰할 잘못된 변경 조각:
ids=[r[0] for r in db.execute("SELECT id FROM keys WHERE expires<=?이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
7. 상태별 개수를 실제로 센다
counts(path)는 {pending:개수, done:개수}입니다. 해당 상태가 없어도 키와 0이 있어야 합니다.
판단의 근거: 정리 뒤 미완료 행이 사라지지 않았는지 관측할 수 있어야 합니다.
리뷰할 잘못된 변경 조각:
"pending":0이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
8. 삭제 뒤 보장 범위가 끝남을 확인한다
retention_cycle(path,key)는 expires=10,digest='v1'로 예약하고 {receipt:1}로 완료합니다. now=10에서 purge 후 같은 키를 digest='v2',expires=20으로 새 예약해 그 결과 bool을 반환합니다. 함수는 빈 DB를 전제로 합니다.
판단의 근거: 정리 후 키가 새 요청이 된다는 한계를 숨기지 않고 직접 재현합니다.
리뷰할 잘못된 변경 조각:
purge(path,9)이 조각이 들어간 함수의 공개 계약과 비교해 보세요. 성공 사례 하나로는 구분되지 않는다면 거절되어야 할 입력이나 실패 이후의 상태를 관측 대상으로 선택합니다.
현장에서 만나는 모습
무한 기간 exactly-once 효과를 보장하지 않는다. 외부 시스템은 오래된 요청을 재전송할 수 있으므로 보관 기간은 제공자의 재시도 정책과 맞춰야 한다. abandoned pending을 복구하는 임대·보상 정책은 별도 실습의 책임이며, 이 정리 함수에서 추측해 지우지 않는다.
다음 실습에서 할 것
여덟 단계가 하나의 실행 가능한 결과물로 이어집니다. 완료 여부와 만료를 분리한다 → 진행 중 키를 예약한다 → 현재 요청만 완료한다 → 유효한 완료 응답만 재생한다 → 정리 후보를 제한한다 → 선택과 삭제를 같은 트랜잭션에 둔다 → 상태별 개수를 실제로 센다 → 삭제 뒤 보장 범위가 끝남을 확인한다.
각 단계는 함수나 파일이 존재한다는 사실이 아니라 실제 반환값·예외·상태 변화를 검사합니다. 정답을 본 뒤에는 일부러 경계 비교나 정리 코드를 바꾸어 어떤 시험이 실패하는지 확인하세요. 앞선 시험이 다음 단계에서도 유지되는 이유를 설명하고, 이 실습이 보장하지 않는 운영 조건을 한 가지 적어 보세요.