The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
11 KiB
Markdown
175 lines
11 KiB
Markdown
---
|
|
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. 통합 시험의 만료 시험이 무엇을 만료시키는지, 요청 헬퍼의 재생 유효 기간이 무엇인지 본다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
청구는 행을 잠근 뒤 데이터베이스 시각을 한 번 읽는다. 조회는 그 호출이 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` 로 적는 값과 같은 값이다. 창이 끝나면 해시가 사라지고 조회는 부재로 답한다.
|
|
|
|
시작과 갱신은 만료를 건드리지 않고, 실패 표시는 보존 기간을 건다.
|
|
|
|
## 고칠 방향
|
|
|
|
조회도 청구와 같은 데이터베이스 시각 기준을 써야 한다. 그리고 재생 유효 기간이 지난 뒤 조회가 무엇을 답하는지 고정하는 경계 시험이 있어야 한다.
|
|
|
|
## 확인하지 못한 것
|
|
|
|
두 답이 공존하는 창이 얼마나 지속되는지는 재지 않았다. 탐침에서 인계가 끝나면 조회는 다른 답으로 바뀌므로, 갈림은 먼저 도는 쪽이 상태를 바꿀 때까지다.
|
|
|
|
<!-- body:end -->
|