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>
5.9 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | assigned-id-turns-claim-into-upsert | 배정 식별자 때문에 insert-first 청구가 UPSERT 가 되어 커밋된 결과를 덮었다 | state-ownership-and-concurrency | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:assigned-id-turns-claim-into-upsert | 2026-09-01 | case-assigned-id-turns-claim-into-upsert.body.md |
|
|
|
배정 식별자 때문에 insert-first 청구가 UPSERT 가 되어 커밋된 결과를 덮었다
어댑터가 먼저 넣고 충돌에서 읽는 순서를 자기 존재 이유로 적는다. 엔티티의 식별자가 배정값이라 저장 메서드가 병합으로 가고, 파생 기본 키가 유니크 제약과 같은 행을 가리켜 두 번째 청구가 위반 없이 기존 행을 덮는다.
관계
- Atomic 타입의 존재는 원자성의 증거가 아니다 같은 계열의 규칙이다.
- 중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다 테스트 이중과 실제 저장소가 갈리는 지점을 찾는 방법이다.
- 커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다 같은 리프 계열의 소유권 사례다.
문제
어댑터의 자바독이 순서를 계약으로 적는다.
먼저 넣고 충돌에서 읽는다는 것이다. 읽고 나서 넣는 구현은 읽기와 넣기 사이에 창이 있고, 그 창의 너비가 정확히 그것이 닫으려는 경합의 너비이며, 두 시도를 동시에 돌리지 않는 모든 테스트를 통과한다는 것이다.
전제는 저장 호출이 삽입이고, 같은 신원의 두 번째 청구가 유니크 제약을 건드린다는 것이다.
결론
두 전제가 모두 성립하지 않는다.
엔티티의 식별자는 호출자가 배정한다. 청구 팩토리가 신원에서 파생한 저장 키를 필드에 채우므로 식별자가 널이 아니다.
Spring Data 의 저장 구현은 식별자가 널이 아닌 엔티티를 새 것으로 보지 않는다. 병합으로 보낸다.
그리고 저장 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 호출자 지문과 정규 메서드 이름과 멱등 키 해시다.
그러므로 같은 신원의 두 번째 청구는 같은 기본 키 행을 겨냥한다.
실제로 일어나는 일은 이렇다.
병합이 그 행을 찾는다. 유니크 제약은 발화하지 않는다. 새 행을 넣는 것이 아니라 같은 행을 갱신하기 때문이다.
분리 상태의 새 엔티티가 기존 행 위에 복사된다. 상태가 진행 중으로, 결과 참조가 널로, 완료 시각이 널로, 청구 시각이 지금으로 바뀐다.
예외가 없으므로 청구는 빈 값을 돌려준다. 호출자는 자기가 청구를 소유했다고 읽는다.
즉 이미 커밋된 연산의 결과 참조가 지워지고, 재시도가 그 변경을 다시 실행한다. 이 모듈이 존재하는 이유로 든 결과 그대로다.
데이터베이스의 검사 제약도 막지 못한다. 갱신 후 상태는 진행 중이고 완료 시각이 널이라 셋 다 합법이다.
테스트가 이것을 볼 수 없는 이유는 이중에 있다. 인메모리 저장소의 저장 메서드는 키가 이미 있으면 예외를 던진다. 삽입과 유니크 제약을 모사한다.
즉 이중은 삽입을 하고 실제 저장소는 갱신을 한다. 빌드 파일 주석이 실제 데이터스토어를 쓰지 않는 근거로 벤더 의미론이 시험 대상일 때만 정당하다고 적었는데, 여기서 갈린 것이 정확히 벤더 의미론이다.
검증 환경
OpenJDK : 21.0.12 Spring Data JPA : 4.0.7 확인 방식 : 엔티티 식별자 형태와 저장 계약 대조, 파생 키 구성 확인 소스 수정 : x
재현 조건
원문은 document-detail 의 analysis/grpc/grpc-operation-ledger-jpa.md 에 있다.
- 어댑터의 청구 메서드와 그 자바독을 읽는다.
- 엔티티의 식별자가 어디서 채워지는지 확인한다.
- 저장 구현이 식별자 널 여부로 무엇을 고르는지 확인한다.
- 저장 키의 파생식과 유니크 제약의 컬럼을 대조한다.
- 테스트 이중의 저장 메서드가 무엇을 하는지 읽는다.
본문
claim 은 insert-first, read-on-conflict 를 주장한다. 그러나 엔티티의 @Id 가 배정값(caller|method|keyHash)이라 SimpleJpaRepository.save 가 persist 가 아니라 merge 로 간다.
claim 이 주장하는 순서
:::evidence key="assigned-id-turns-claim-into-upsert" alt="분석 문서 analysis/grpc/grpc-operation-ledger-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-operation-ledger-jpa.md 발췌 — 15줄" zoom="true" :::
위반 대신 갱신이 일어난다
그 파생 키가 유니크 제약의 세 컬럼과 같은 행을 가리키므로 두 번째 청구는 위반을 일으키지 않고 기존 행을 갱신한다 — 상태가 IN_PROGRESS 로, outcome_reference 가 널로 되돌아가고 claim 은 빈 값을 돌려줘 호출자가 소유를 얻었다고 읽는다.
테스트 이중이 그 차이를 가린다
인메모리 테스트 이중의 save 는 키가 있으면 던지므로 INSERT 를 흉내 낸다.
확인하지 못한 것
실제 데이터베이스로 같은 신원을 두 번 청구해 재현하지 않았다. 이 리프는 어떤 배포에도 조립되지 않아 실행 경로가 없다.