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>
12 KiB
grpc-operation-ledger-jpa 완전 해부
상태: COMPLETE 재오픈 게이트: cycle 2 —
src/mainproduction 3파일과 마이그레이션 1개 축자 통독 완료.STRUCTURAL_ONLY잔여 없음. 기준 revision:21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/grpc/grpc-operation-ledger-jpaSSOT owner:grpc-operation-ledger-jpaintegration/family document:analysis/20-grpc-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지와 숫자 지도
allowed_dependencies:["grpc-core-api"]runtime_memberships:[]— build-only
| 파일 | LOC | 성격 |
|---|---|---|
GrpcOperationLedgerEntity |
155 | JPA 엔티티 + 상태 전이 |
JpaGrpcOperationLedger |
93 | 포트 구현 (insert-first 주장) |
GrpcOperationLedgerRepository |
28 | Spring Data 인터페이스 (메서드 4개) |
V001__create_grpc_operation_ledger.sql |
39 | 테이블 + 제약 4 + 인덱스 1 |
GrpcOperationLedgerRepositoryTest |
208 | 테스트 (인메모리 이중) |
build.gradle |
17 | project 1 + vendor 2 |
Coverage ledger
| scope | count | disposition | reason |
|---|---|---|---|
main/java/** |
3 | FULL_READ |
155+93+28 전 본문 |
main/resources/db/migration/grpc/*.sql |
1 | FULL_READ |
39줄 전문 |
test/java/** |
1 | FULL_READ |
208줄, 인메모리 이중 구현 포함 |
build.gradle |
1 | FULL_READ |
17줄 |
gradle.lockfile |
1 | STRUCTURAL_ONLY |
잠금 파일 |
UNCLASSIFIED 0.
1. 모듈의 정체
// build.gradle:3-8
// Durable mutation idempotency: the operation ledger entity, its state machine, the vendor-neutral
// repository port and the Spring Data JPA binding, plus the migration that owns the unique
// constraint the whole contract rests on.
// … Tests here run against a hand-rolled in-memory port implementation — a real datastore is only
// justified when vendor semantics are the thing under test, and the constraint is asserted by the migration.
마지막 문장이 이 리프의 검증 전략을 규정한다. §17.1 이 그 전략의 경계를 다룬다.
2. 스키마가 계약이다
CONSTRAINT pk_grpc_operation_ledger PRIMARY KEY (storage_key),
CONSTRAINT uq_grpc_operation_ledger_identity
UNIQUE (caller_fingerprint, full_method_name, idempotency_key_hash),
CONSTRAINT ck_grpc_operation_ledger_state
CHECK (state IN ('IN_PROGRESS', 'COMMITTED', 'FAILED_TERMINAL')),
CONSTRAINT ck_grpc_operation_ledger_committed_has_outcome
CHECK (state <> 'COMMITTED' OR outcome_reference IS NOT NULL),
CONSTRAINT ck_grpc_operation_ledger_terminal_has_completion
CHECK (state = 'IN_PROGRESS' OR completed_at IS NOT NULL)
마이그레이션 헤더가 왜 애플리케이션 검사가 아니라 제약인지 적는다.
"A uniqueness check in application code instead would be a read followed by a write, with a window between them precisely as wide as the race it is meant to close."
커밋 행이 결과를 반드시 갖는다는 검사를 자바 record 와 DB 양쪽에 둔 이유도 적혀 있다 — 마이그레이션·백필·지원 스크립트가 쓴 행은 record 를 지나지 않는다.
전용 Flyway 위치(db/migration/grpc)를 쓰는 이유도 적혀 있다. gRPC 플랫폼을 채택하지 않은 배포가 이 테이블을 만들도록 강요받지 않기 위해서다.
3. 저장 키와 유니크 제약이 같은 행을 가리킨다
GrpcOperationIdentity(grpc-core-api):
public String storageKey() {
return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash;
}
즉 기본 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 엔티티 javadoc 이 그 이중 저장을 설명한다 — 복합 쪽이 원자성을 주고, 파생 키가 조회에 단일 컬럼 기본 키를 준다.
같은 신원의 두 번째 청구는 같은 기본 키 행을 겨냥한다. §17.1 이 그 사실에서 나온다.
4. 좁은 저장소 인터페이스
Repository 를 확장하고 네 메서드만 이름 짓는다.
"
JpaRepositorypublishesdeleteAll,findAllandsaveAllon the table that decides whether a payment runs twice."
5. 어댑터의 주장
JpaGrpcOperationLedger javadoc:
"
claimis insert-first, read-on-conflict — not read-then-insert. That ordering is the whole adapter… A read-first implementation has a window between the read and the insert that is exactly as wide as the race it is supposed to close, and it passes every test that does not run the two attempts concurrently."
트랜잭션 애너테이션이 없는 이유도 적혀 있다 — 커밋은 호출자의 업무 트랜잭션 안에서 일어나야 하고 REQUIRES_NEW 는 변경이 내구적인데 청구는 아닌 창을 다시 만든다.
6. 상태 전이
IN_PROGRESS 에서만 전이할 수 있다(requireInProgress). 커밋은 결과 참조가 비면 거부한다. EnumType.STRING 을 쓰는 이유가 javadoc 에 있다 — 서수 컬럼은 열거형에 값이 끼어들면 저장된 모든 행을 조용히 다른 값으로 만든다.
10. 테스트 레인
11개 테스트. 인메모리 저장소 이중이 putIfAbsent 로 기존 행이 있으면 DataIntegrityViolationException 을 던진다 — INSERT + 유니크 제약의 동작을 모사한다.
마지막 테스트가 마이그레이션 파일을 직접 읽어 유니크 제약 문장이 있는지 단언한다.
12. negative-space probes
12.1 도달성. build-only. JpaGrpcOperationLedger 를 만드는 production 코드가 없다.
12.2 마이그레이션 적용 경로. db/migration/grpc 를 가리키는 설정이 저장소에 없다. main 설정 어디에도 spring.flyway.locations 가 없고(app-bootstrap 의 네 프로파일 yml 전수 확인), 그 경로를 이름으로 부르는 것은 이 리프의 테스트 한 곳뿐이다. messaging 가족이 §7.2 에서 기록한 것과 같은 형태다.
12.4 드리프트. build.gradle 주석이 서술한 네 요소(엔티티·상태 기계·포트·Spring Data 바인딩)와 마이그레이션이 전부 존재한다. 드리프트 없음.
16. 확인하지 못한 것
- 실제 데이터베이스로
claim을 두 번 돌려 §17.1 을 재현하지 않았다. Spring Data JPA 의save계약과 이 엔티티의 식별자 형태로 판정했다. - 동시 청구를 실제 커넥션 둘로 재현하지 않았다.
17. 손볼 것
17.1 P2 — insert-first 주장이 Spring Data 의 save 계약과 어긋난다. 그리고 테스트 이중이 그 차이를 가린다
어댑터는 이렇게 쓴다.
try {
repository.save(GrpcOperationLedgerEntity.claim(identity, requestFingerprint, now));
return Optional.empty(); // 내가 이겼다
} catch (DataIntegrityViolationException alreadyClaimed) {
return repository.findById(identity.storageKey()).map(entity -> entity.toRecord(identity));
}
전제는 save 가 INSERT 이고, 같은 신원의 두 번째 청구가 유니크 제약을 건드린다는 것이다.
그러나 이 엔티티의 식별자는 호출자가 배정한다. claim(...) 팩토리가 storageKey 를 identity.storageKey() 로 채우므로 @Id 가 널이 아니다. Spring Data JPA 의 SimpleJpaRepository.save 는 식별자가 널이 아닌 엔티티를 새 것으로 보지 않고 EntityManager.merge 로 보낸다.
그리고 §3 에서 확인했듯 기본 키는 유니크 제약의 세 컬럼에서 파생된다. 같은 신원의 두 번째 청구는 같은 행을 겨냥한다.
따라서 실제 JPA 에서 일어나는 일은 이렇다.
- 두 번째 청구가
merge로 들어간다. 그 행은 이미 존재한다. - 유니크 제약이 발화하지 않는다. 새 행을 넣는 것이 아니라 같은 행을 갱신하기 때문이다.
- 분리 상태의 새 엔티티가 기존 행 위에 복사된다 —
state는IN_PROGRESS,outcome_reference는 널,completed_at은 널,claimed_at은 지금. - 예외가 없으므로
claim은Optional.empty()를 돌려준다. 호출자는 자기가 청구를 소유했다고 읽는다.
즉 이미 커밋된 연산의 결과 참조가 지워지고, 재시도가 그 변경을 다시 실행한다. 이 모듈이 존재하는 이유로 든 바로 그 결과다.
세 CHECK 제약도 이것을 막지 못한다. 갱신 후 상태는 IN_PROGRESS + completed_at 널이라 전부 합법이다.
덧붙여 merge 는 즉시 flush 하지 않으므로, 서로 다른 트랜잭션의 진짜 경합에서 제약 위반이 나더라도 그것은 flush 나 커밋 시점에 도착한다 — try 블록 밖이다.
테스트가 이것을 볼 수 없는 이유. 인메모리 이중의 save 는 키가 이미 있으면 예외를 던진다.
GrpcOperationLedgerEntity existing = rows.putIfAbsent(entity.getStorageKey(), entity);
if (existing != null && existing != entity) { throw new DataIntegrityViolationException(...); }
즉 이중은 INSERT 를, 실제 저장소는 UPSERT 를 한다. build.gradle 주석이 실제 데이터스토어를 쓰지 않는 근거로 "vendor semantics 가 시험 대상일 때만 정당하다" 고 적었는데, 여기서 어긋난 것이 정확히 vendor semantics 다.
등급. 이 리프는 build-only 이고 어떤 배포도 이 어댑터를 조립하지 않는다. 그래서 오늘의 사고는 아니다. 배선하는 순간 성립한다.
수정. 셋 중 하나다.
- 엔티티가
Persistable<String>을 구현해isNew()를 명시한다. 신규 여부를 어댑터가 안다. - 저장소에
@Modifying @Query로 명시적 INSERT 를 두고save를 청구 경로에서 쓰지 않는다. - 청구를
INSERT … ON CONFLICT DO NOTHING의 영향 행 수로 판정한다.
어느 쪽이든 테스트 이중이 아니라 실제 데이터베이스에서 두 번 청구하는 계약 테스트가 함께 필요하다.
17.2 P3 — 낙관적 잠금 컬럼이 없어 전이 가드가 메모리 안에만 있다
requireInProgress() 가 두 번째 종결 전이를 막는다. 그 가드는 한 영속성 컨텍스트 안의 인스턴스 상태에만 적용된다. 엔티티에 @Version 이 없으므로 두 트랜잭션이 같은 행을 읽어 각각 전이하면 나중 쓰기가 앞의 것을 덮는다.
DB 의 세 CHECK 제약은 행의 모양을 지키지 지 전이 순서를 지키지 않는다. COMMITTED 행이 다른 결과 참조로 갱신되는 것을 막는 제약이 없다.
청구가 배타적이라는 설계 전제 아래서는 도달성이 낮다. 다만 §17.1 을 고치면 이 전제가 실제로 성립하는지가 함께 확인되어야 한다.
17.3 P3 — markCommitted 는 던지고 markFailed 는 조용히 넘어간다
markCommitted → findById(...).orElseThrow(IllegalStateException…) // 청구 없으면 실패
markFailed → findById(...).ifPresent(entity -> …) // 청구 없으면 무동작
커밋 쪽의 근거는 자바독에 있다 — 청구 없이 커밋하면 변경은 내구적이고 보호받지 못한다.
실패 쪽에는 근거가 없다. 청구가 사라진 뒤 도착한 종결 실패가 아무 흔적도 남기지 않는다. 회수가 청구를 지운 뒤 원래 소유자가 실패를 기록하려는 경우가 그 형태다. 의도라면 그 이유를 자바독에 적어야 하고, 아니라면 커밋 쪽과 같게 다뤄야 한다.
확인된 설계(문제 아님)
- 유니크 제약을 애플리케이션 검사 대신 쓰기로 한 판단과 그 근거.
- 커밋 행이 결과를 갖는다는 규칙을 record 와 DB 양쪽에 둔 것 — 마이그레이션·백필·지원 스크립트는 record 를 지나지 않는다.
Repository를 확장해 네 메서드만 노출한 것.EnumType.STRING— 서수 컬럼의 조용한 재지정을 피한다.- 전용 Flyway 위치 — 채택하지 않은 배포에 테이블을 강요하지 않는다.
- 트랜잭션 애너테이션을 두지 않은 것과 그 근거.
- 상태·완료 시각 CHECK 제약 — 종결 상태는 완료 시각을 갖는다.
Source anchors
src/grpc/grpc-operation-ledger-jpa/build.gradle:1-17
main/java/…/ledger/JpaGrpcOperationLedger.java:1-93
main/java/…/ledger/GrpcOperationLedgerEntity.java:1-155
main/java/…/ledger/GrpcOperationLedgerRepository.java:1-28
main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql:1-39
test/java/…/ledger/GrpcOperationLedgerRepositoryTest.java:1-208
src/grpc/grpc-core-api/…/ledger/GrpcOperationIdentity.java:36-38
src/app-bootstrap/src/main/resources/application*.yml (flyway locations 부재 확인)