--- kind: CASE slug: a05-f020-inspect-claim title: 만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다 topic: state-machines-and-ownership project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:a05-f020-inspect-claim evidenceCapturedOn: 2026-09-02 assets: - key: a05-f020-inspect-claim file: ../../../final/evidence/rendered/a05-f020-inspect-claim.svg - key: a05-f020-inspect-claim-postgres file: ../../../final/evidence/rendered/a05-f020-inspect-claim-postgres.svg evidence: - ../../../final/evidence/raw/a05-f020-inspect-claim.txt - ../../../final/evidence/raw/a05-f020-inspect-claim-postgres.txt source: - 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §59.1 이다. 두 경로가 만료된 완료 행을 다르게 해석한다는 판정과 그 실행 탐침 값이 그 절에 있다. 같은 §59 의 나머지 한 군데는 §59.2 이고 별도 사례가 담당한다. - 소비자 세 자리의 분기별 동작과 시험 범위, 레디스 구현과의 대조는 이 기록에서 확인했다. --- # 만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다 멱등성 저장소의 청구 경로는 행을 잠근 뒤 데이터베이스 시각을 읽어 재생 유효 기간이 지난 완료 행을 인계로 보낸다. 조회 경로는 그 시각을 한 번도 읽지 않고 완료 상태에 응답이 있으면 재생으로 답한다. 실제 PostgreSQL 에서 만료 뒤 같은 행에 두 답이 나온다. ## 관계 - **만료된 claim과 만료된 실행은 다르게 다뤄야 한다** 같은 상태 기계의 만료 처리 규칙이다. - **시간은 DB에서, 그리고 행을 잠근 다음에 읽는다** 두 경로가 같은 시각 기준을 써야 하는 이유다. - **전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다** 같은 분석 절이 짚은 나머지 한 군데 재생 경계다. ## 문제 멱등성 저장소에는 두 진입 경로가 있다. 조회는 이 연산이 이미 처리됐는지 묻고, 청구는 지금 처리해도 되는지 묻는다. 같은 행에 대한 두 답이 갈리면 어느 쪽을 믿을지 정하는 규칙이 코드에 없다. ## 결론 청구가 데이터베이스 시각을 읽는 것은 행을 잠근 다음이고 한 번뿐이다. 그 시각으로 완료 상태이면서 재생 유효 기간이 지난 행을 먼저 걸러 인계로 보내고, 그 검사가 재생 응답 분기보다 앞에 있다. 조회 쪽에는 그 호출이 없다. replayUntil 은 응답에 실려 나갈 뿐 어디에서도 읽히지 않는다. 만료를 비교하는 헬퍼가 하나 있고, 그것을 부르는 것은 청구뿐이다. 조회가 쓰는 조회 SQL 에도 시간 술어가 없다. 실제 PostgreSQL 16 에 이 리프의 마이그레이션을 적용하고 재생 유효 기간 2초로 완료했다. 만료 시각 전 조회는 재생으로 답하고, 만료 뒤 조회도 같은 답과 같은 옛 응답을 준다. 같은 행을 다시 청구하면 인계가 돌아온다. 멱등성 실행기는 조회의 재생 결과를 세 곳에서 받고, 셋 다 청구나 시작이나 완료가 불확정으로 끝난 뒤의 복구 경로다. 그중 둘은 저장된 응답을 그대로 돌려주고 행동을 실행하지 않는다. 그 둘에서 만료가 소비자에게 번진다. 나머지 하나는 저장된 응답을 호출자가 방금 만든 결과와 비교하고 다르면 던진다. 그 경로는 호출자가 이미 실행한 뒤에만 닿고 돌려주는 값도 자기 결과이므로 이 문제가 번지지 않는다. 재생 창 만료를 짚는 시험은 없다. 통합 시험에 이름이 만료인 시험이 둘 있지만 둘 다 처리 임차를 25밀리초로 몰아 만든 것이고, 그 파일의 요청 헬퍼는 재생 유효 기간을 언제나 24시간으로 고정한다. 같은 계약의 레디스 구현에는 이 비교가 없다. 완료가 키에 재생 유효 기간을 그대로 만료로 걸어서, 창이 끝나면 해시가 사라지고 조회는 부재로 답한다. ## 검증 환경 OpenJDK : 21.0.12 데이터베이스 : PostgreSQL 16.15, 실제 실행 확인 방식 : 두 경로의 시각 참조 계수, 실제 PostgreSQL 에 마이그레이션 적용 후 만료 전후 호출, 소비자 세 자리 추적 소스 수정 : x ## 재현 조건 1. 청구 경로에서 데이터베이스 시각을 읽는 줄과 만료 판정 헬퍼를 찾고, 그것이 재생 응답 분기보다 앞인지 본다. 2. 조회 경로 전문에서 데이터베이스 시각 호출을 세고, replayUntil 이 어디에 쓰이는지 본다. 3. 조회가 쓰는 조회 SQL 에 시간 술어가 있는지 본다. 4. 실제 PostgreSQL 에 이 리프의 마이그레이션을 적용하고, 짧은 재생 유효 기간으로 완료한 뒤 만료 전후로 조회와 청구를 부른다. 5. 조회의 재생 결과를 받는 세 자리가 각각 무엇을 하는지 읽는다. 6. 통합 시험의 만료 시험이 무엇을 만료시키는지, 요청 헬퍼의 재생 유효 기간이 무엇인지 본다. ## 본문 청구는 행을 잠근 뒤 데이터베이스 시각을 한 번 읽는다. 조회는 그 호출이 0 이다. ## 청구는 만료를 먼저 본다 :::evidence key="a05-f020-inspect-claim" alt="청구 경로가 데이터베이스 시각을 읽고 만료된 완료 행을 먼저 걸러 내는 구간과 그 판정 헬퍼, 청구 본문의 시각 참조 수, 조회 경로 전문과 그 본문의 시각 참조 수와 replayUntil 이 쓰이는 자리, 만료 비교 헬퍼를 부르는 곳, 조회가 쓰는 조회 SQL, 조회 결과를 받는 세 자리, 그리고 출하 통합 시험의 만료 시험이 무엇을 만료시키는지와 요청 헬퍼의 재생 유효 기간을 출력한 터미널 기록." caption="청구는 databaseNow 1회와 만료 선분기 · 조회는 시각 0회, replayUntil 은 생성자 인자로만 · 만료 비교 헬퍼는 청구만 호출 · 조회 SQL 에 시간 술어 없음 · 만료 시험 둘은 처리 임차, 재생 창은 늘 24시간 — 95줄 · exit 0" zoom="true" ::: ```java Instant dbNow = rows.databaseNow(); ... if (isExpiredCompleted(row, dbNow)) { return resetClaim(request, row); } ... if (row.state() == IdempotencyState.COMPLETED && row.replayUntil() != null) { return new IdempotencyClaimOutcome.CompletedReplay( new StoredResponse(row.responsePayload()), row.replayUntil()); } ``` 만료 판정이 재생 응답 분기보다 앞에 있다. ## 조회는 만료된 완료 행도 재생으로 답한다 ```java if (row.state() == IdempotencyState.COMPLETED && row.responsePayload() != null) { return new IdempotencyInspection( IdempotencyInspectionOutcome.COMPLETED_REPLAY, ... Optional.ofNullable(row.replayUntil())); } ``` `replayUntil` 은 응답 생성자에 한 번 실려 나갈 뿐 비교되지 않는다. 만료 비교를 하는 헬퍼는 이 파일에 하나뿐이고 청구만 부른다. 조회가 쓰는 조회 SQL 도 범위 해시와 레코드 버전만 술어로 쓴다. ## 실제 PostgreSQL 에서 두 답이 갈린다 :::evidence key="a05-f020-inspect-claim-postgres" alt="실제 PostgreSQL 컨테이너를 세우고 이 리프의 마이그레이션 세 스트림을 적용해 만들어진 테이블 목록, 그리고 재생 유효 기간 2초로 완료한 뒤 저장된 만료 시각과 만료 전 조회 결과, 만료 뒤 조회 결과와 그때 돌아온 응답, 같은 행에 대한 청구 결과를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 마이그레이션 적용 · 재생 창 2초로 완료 · 만료 전 조회는 재생 · 만료 뒤 조회도 재생과 옛 응답 · 같은 행의 청구는 인계 — 12줄 · exit 0" zoom="true" ::: ```text replay_until : 2026-09-02 04:35:51.753222+00 지금 : 2026-09-02 04:35:49.768587+00 만료 전 조회 : COMPLETED_REPLAY 지금 : 2026-09-02 04:35:53.285753+00 만료 후 조회 : COMPLETED_REPLAY 응답={"v":"OLD-RESPONSE"} 만료 후 청구 : TakenOverClaimed ``` 만료 시각을 지난 뒤에도 조회는 같은 답과 같은 옛 응답을 준다. 같은 행에 대한 청구는 인계를 돌려준다. ## 조회 결과를 그대로 돌려주는 두 자리 멱등성 실행기는 조회의 재생 결과를 세 곳에서 받는다. 모두 청구나 시작이나 완료가 불확정으로 끝난 뒤의 복구 경로다. 그중 둘은 저장된 응답을 그대로 돌려준다. ```java case COMPLETED_REPLAY -> codec.deserialize(requireResponse(inspection).payload()); ``` 두 경로 모두 행동을 실행하지 않는다. 그래서 재생 유효 기간이 지나 청구라면 인계했을 행에서도, 청구나 시작이 불확정으로 끝난 호출자는 만료된 이전 응답을 자기 답으로 받는다. 세 번째는 다르다. ```java StoredResponse stored = requireResponse(inspection); if (stored.payload().equals(codec.serialize(result))) { yield result; } throw recovery("the completed response conflicts with the one this caller produced"); ``` 이 경로는 호출자가 이미 행동을 실행한 뒤에만 닿고, 돌려주는 값도 저장된 응답이 아니라 호출자 자신의 결과다. 만료가 번지는 자리는 앞의 둘이다. ## 재생 창 만료를 짚는 시험은 없다 통합 시험에 이름이 만료인 시험이 둘 있다. 만료된 청구를 인계하되 낡은 소유자는 시작하지 못한다는 것과, 만료된 실행 중 행은 조정을 요구한다는 것이다. 둘 다 처리 임차를 25밀리초로 몰아 만든 것이고, 그 파일의 요청 헬퍼는 재생 유효 기간을 언제나 24시간으로 고정한다. 만료 판정 헬퍼가 참이 되는 분기는 이 파일의 어느 시험도 밟지 않는다. 조회는 그 파일 전체에서 한 번 불리고, 포기 상태를 확인한다. ## 같은 계약의 레디스 구현에는 이 비교가 없다 완료가 키에 재생 유효 기간을 그대로 만료로 건다. JPA 쪽이 완료 SQL 에서 `replay_until` 로 적는 값과 같은 값이다. 창이 끝나면 해시가 사라지고 조회는 부재로 답한다. 시작과 갱신은 만료를 건드리지 않고, 실패 표시는 보존 기간을 건다. ## 고칠 방향 조회도 청구와 같은 데이터베이스 시각 기준을 써야 한다. 그리고 재생 유효 기간이 지난 뒤 조회가 무엇을 답하는지 고정하는 경계 시험이 있어야 한다. ## 확인하지 못한 것 두 답이 공존하는 창이 얼마나 지속되는지는 재지 않았다. 탐침에서 인계가 끝나면 조회는 다른 답으로 바뀌므로, 갈림은 먼저 도는 쪽이 상태를 바꿀 때까지다.