--- kind: CASE slug: a05-f021-complete-replayttl title: 전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다 topic: state-machines-and-ownership project: clean-architecture-backend-template status: 게시 전 sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 rootTreeNode: case:a05-f021-complete-replayttl evidenceCapturedOn: 2026-09-02 assets: - key: a05-f021-complete-replayttl-postgres file: ../../../final/evidence/rendered/a05-f021-complete-replayttl-postgres.svg - key: a05-f021-complete-replayttl file: ../../../final/evidence/rendered/a05-f021-complete-replayttl.svg evidence: - ../../../final/evidence/raw/a05-f021-complete-replayttl-postgres.txt - ../../../final/evidence/raw/a05-f021-complete-replayttl.txt source: - 원본 분석 절은 `final/document.md#a05` §59.2 다. 등급은 P2 이고, 만료 해석 불일치인 §59.1 은 형제 기록이 다룬다. - 디지스트 정책 단위 시험이 이 값을 이미 고정한다는 것은 그 절이 짚는다. 레디스 구현의 같은 자리와 공용 헬퍼를 쓰는 전이 넷은 이 기록에서 확인했다. --- # 전이 다섯 중 넷은 공용 판정을 쓰고 완료만 손으로 비교한다 이미 완료된 같은 연산의 재생 분기는 응답 다이제스트만 비교한다. 완료가 다이제스트에 넣어 열에 저장한 재생 창은 읽히지 않는다. 나머지 네 전이는 그 열을 비교하는 공용 헬퍼를 쓰고 다른 인자를 들고 온 재시도를 충돌로 돌려보낸다. ## 관계 - **transition digest가 "누가·언제"만 덮고 "무엇"을 덮지 않아 다른 전이를 같다고 보고했다** 같은 다이제스트가 무엇을 덮어야 하는지 다룬 사례다. - **digest는 길이 프레이밍하고 버전을 붙인다** 이 다이제스트의 형식 규칙이다. - **만료를 보는 청구와 시각을 읽지 않는 조회가 같은 행을 다르게 답한다** 같은 분석 절이 짚은 나머지 한 군데 재생 경계다. ## 문제 완료가 정하는 것은 둘이다. 응답으로 무엇을 남길지, 그리고 그것을 언제까지 재생할지다. 첫 완료는 둘 다 다이제스트에 넣는다. 두 번째 완료는 앞의 것만 본다. ## 결론 실제 PostgreSQL 16 에서 같은 연산과 같은 응답에 1시간과 9시간을 차례로 넣었다. 두 번째는 이미 완료된 같은 결과로 답하고, 행에는 첫 1시간이 남고, 전이 다이제스트도 그대로다. 값이 다르게 계산된다는 것은 이미 단위 시험이 고정하고 있다. 60000 과 90000 을 넣은 완료 다이제스트가 다르다는 시험이 같은 모듈에 있다. 값은 계산되고, 열에 저장되고, 시험으로 지켜진다. 그것을 읽지 않는 쪽이 재생 판정이다. 나머지 네 전이는 다르게 한다. 시작과 갱신과 실패 표시와 해제가 공용 헬퍼에 자기 다이제스트를 넘기고, 그 헬퍼가 행의 전이 다이제스트와 비교한다. 완료는 그 헬퍼를 부르지 않는다. 갱신 경로의 주석이 왜 그래야 하는지 적는다. 임대 유효 기간이 갱신이 결정한 것의 일부이므로 다이제스트에 들어가고, 그것이 없으면 다른 임대를 요청한 재시도가 이미 적용된 갱신으로 확인된다는 것이다. 레디스 구현도 같은 자리에서 멈춘다. 완료 재생에서 응답 페이로드만 비교하고, 전이 스크립트는 이미 목표 상태이면 재생 창을 다시 걸기 전에 반환한다. 레디스에는 인자 비교라는 개념 자체가 없다. 한 구현의 누락이 아니라 포트가 정하지 않은 자리다. 갈리는 조건은 좁다. 멱등성 실행기 쪽은 주입 시점의 재생 창을 끝까지 들고 간다. 창이 갈리는 조건은 둘이다. 설정 변경 뒤의 재시도이거나, 이 포트를 직접 부르는 별도 호출자다. 원본 분석이 이것을 만료 해석 불일치와 달리 P2 로 둔 자리도 거기다. 고치려면 주의가 필요하다. 헬퍼는 어긋났다는 답만 주고, 응답 때문인지 창 때문인지는 말하지 않는다. 완료가 지금 돌려주는 응답 충돌을 그대로 두려면, 헬퍼의 어긋남 판정 뒤에 다이제스트 비교를 한 번 더 넣어 두 경우를 갈라야 한다. ## 검증 환경 OpenJDK : 21.0.12 데이터베이스 : PostgreSQL 16.15, 실제 실행 확인 방식 : 다섯 전이의 재생 판정 경로 대조, 실제 PostgreSQL 에서 같은 응답에 두 재생 창으로 완료 호출, 형제 구현 대조 소스 수정 : x ## 재현 조건 1. 완료의 재생 분기와 첫 완료의 다이제스트 인자를 나란히 읽는다. 2. 같은 파일의 공용 재생 판정 헬퍼와 그것이 읽는 행 열, 그리고 그 열의 마이그레이션을 확인한다. 3. PostgreSQL 을 띄우고 청구와 시작을 거쳐 1시간으로 완료한 뒤, 같은 연산·같은 응답에 9시간으로 다시 완료한다. 4. 두 번째 결과와 행의 재생 창, 그리고 전이 다이제스트를 본다. ## 본문 완료는 두 가지를 정한다. 무엇을 응답으로 남길지, 그리고 그 응답을 언제까지 재생할지다. ## 같은 응답에 다른 창을 넣으면 :::evidence key="a05-f021-complete-replayttl-postgres" alt="실제 PostgreSQL 컨테이너를 세우고 이 리프의 마이그레이션 세 스트림을 적용한 뒤, 같은 연산과 같은 응답에 재생 창만 1시간과 9시간으로 바꿔 완료를 두 번 부르고 각 호출의 결과와 행에 저장된 재생 창, 그리고 전이 다이제스트가 그대로인지를 출력한 터미널 기록." caption="PostgreSQL 16.15 에 마이그레이션 적용 · 1시간으로 완료 뒤 저장 3600초 · 같은 응답에 9시간을 넣은 둘째 완료는 이미 완료된 같은 결과 · 저장은 3600초 그대로, 전이 다이제스트도 그대로 — 8줄 · exit 0" zoom="true" ::: ```text 첫 완료 (재생 창 1시간) : COMPLETED 저장된 재생 창(초) : 3600 둘째 완료 (재생 창 9시간) : ALREADY_COMPLETED_SAME_RESULT 저장된 재생 창(초) : 3600 전이 다이제스트 그대로인가 : true ``` 두 번째 호출은 다른 인자를 들고 왔는데 같은 결과로 확인됐다. ## 두 번째 완료가 비교하는 것 :::evidence key="a05-f021-complete-replayttl" alt="이미 완료된 같은 연산의 재생 분기가 비교하는 값, 첫 완료가 전이 다이제스트에 넣는 인자와 그 위 주석, 같은 파일의 공용 재생 판정 헬퍼와 그것이 읽는 행 열과 그 열의 마이그레이션, 그 헬퍼를 쓰는 네 전이와 완료 본문에서의 호출 수, 재생 창이 다르면 완료 다이제스트가 다르다는 단위 시험, 갱신 경로의 주석, 출하 통합 시험이 덮는 인자 재생과 완료 호출 수, 그리고 레디스 구현의 완료 재생 분기와 전이 스크립트를 출력한 터미널 기록." caption="재생 분기는 응답 다이제스트만 비교 · 첫 완료는 재생 창을 다이제스트에 넣음 · 헬퍼는 행의 전이 다이제스트를 비교하고 그 열은 마이그레이션에 있음 · 그 헬퍼를 쓰는 전이 넷, 완료 0 · 창이 다르면 다이제스트가 다르다는 단위 시험 · 레디스도 응답만 비교 — 81줄 · exit 0" zoom="true" ::: ```java if (row.state() == IdempotencyState.COMPLETED && "COMPLETE".equals(row.lastTransitionKind()) && operationId.value().equals(row.lastTransitionOperationId())) { return responseDigest.equals(row.responseDigest()) ? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT : IdempotencyCompleteOutcome.RESPONSE_CONFLICT; } ``` ## 첫 완료가 다이제스트에 넣는 것 ```java // The transition digest, not the response digest. Reusing the response digest here made // two completions of different operations with identical payloads indistinguishable, // and lost the replay window the completion also decided. transitionDigest( "COMPLETE", operationId, owner, responseDigest, Long.toString(replayTtl.toMillis())), ``` 주석은 응답 다이제스트를 쓰던 때에 무엇을 잃었는지 과거형으로 적는다. 재생 창도 그 목록에 있다. ## 값이 다르다는 것은 이미 시험이 고정한다 같은 모듈의 단위 시험에 완료 다이제스트가 재생 창에 따라 달라진다는 것을 고정하는 시험이 있다. ```java assertThat(IdempotencyDigestPolicy.of("COMPLETE", "op-1", "owner-1", 1, 3, "digest-a", "60000")) .isNotEqualTo( IdempotencyDigestPolicy.of("COMPLETE", "op-1", "owner-1", 1, 3, "digest-a", "90000")); ``` 값은 계산되고, `last_transition_result_digest` 열에 저장되고, 시험으로 지켜진다. 재생 판정만 그것을 읽지 않는다. ## 공용 헬퍼와 그것을 쓰는 네 전이 ```java if (!transitionKind.equals(row.lastTransitionKind()) || !operationId.value().equals(row.lastTransitionOperationId())) { return ReplayVerdict.NOT_A_REPLAY; } return expectedDigest.equals(row.lastTransitionResultDigest()) ? ReplayVerdict.SAME_ARGUMENTS : ReplayVerdict.DIFFERENT_ARGUMENTS; ``` ```text 182: switch (replayVerdict(row, "START", operationId, startDigest)) { 221: switch (replayVerdict(row, "RENEW", operationId, renewDigest)) { 319: switch (replayVerdict(row, transitionKind, operationId, failDigest)) { 366: switch (replayVerdict(row, "RELEASE", operationId, releaseDigest)) { ``` 완료 본문에서 그것을 부르는 줄은 0 이다. ## 갱신 경로의 주석 ```text 216: // The lease TTL is part of what a renewal decided, so it is part of the digest. Without it, a 217: // retry asking for a different lease was confirmed as the renewal already applied, and the 218: // caller went on believing it held the record for longer than the row says it does. ``` 재생 창도 호출자가 나중에 읽는 지속 상태다. ## 출하 통합 시험이 덮는 인자 재생 같은 연산 식별자에 다른 보존 기간이 오면 충돌이라는 시험, 다른 임대가 오면 충돌이라는 시험, 같은 인자면 확인이라는 시험이 있다. 완료를 두 번 부르는 시험은 그 파일에 없다. ## 레디스 구현도 같은 자리에서 멈춘다 ```java case "ALREADY" -> reply.payload().equals(response.payload()) ? IdempotencyCompleteOutcome.ALREADY_COMPLETED_SAME_RESULT : IdempotencyCompleteOutcome.RESPONSE_CONFLICT; ``` 전이 스크립트는 이미 목표 상태이면 재생 창을 다시 걸기 전에 반환한다. 레디스에는 인자 비교라는 개념 자체가 없으므로, 이것은 한 구현의 누락이 아니라 포트가 정하지 않은 자리다. ## 언제 갈리는가 애플리케이션의 멱등성 실행기는 주입받은 재생 창 하나를 계속 쓴다. 창이 달라지려면 설정이 바뀐 뒤 재시도가 넘어오거나, 이 포트를 직접 부르는 다른 호출자가 있어야 한다. ## 고칠 방향 완료의 재생 분기도 완료 전이 다이제스트를 먼저 계산해 공용 헬퍼에 넘기면 창이 다른 호출을 걸러낼 수 있다. 다만 그 헬퍼의 판정은 응답이 달라서 어긋난 경우와 창이 달라서 어긋난 경우를 구분하지 않는다. 지금 완료가 돌려주는 응답 충돌을 유지하려면, 헬퍼가 어긋났다고 답한 뒤 응답 다이제스트를 한 번 더 비교해 두 답을 나눠야 한다. ## 확인하지 못한 것 첫 창이 남은 뒤 실제 재생 요청이 어떻게 처리되는지는 관측하지 않았다. 확인한 것은 두 번째 완료의 답과 행에 남은 값까지다.