docs(keycloak-session-store): import the session-storage lab as a new project

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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,226 @@
# grpc-operation-ledger-jpa 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 3파일과 마이그레이션 1개 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-operation-ledger-jpa`
> SSOT owner: `grpc-operation-ledger-jpa`
> integration/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. 모듈의 정체
```groovy
// 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. 스키마가 계약이다
```sql
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):
```java
public String storageKey() {
return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash;
}
```
즉 기본 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 엔티티 javadoc 이 그 이중 저장을 설명한다 — 복합 쪽이 원자성을 주고, 파생 키가 조회에 단일 컬럼 기본 키를 준다.
같은 신원의 두 번째 청구는 **같은 기본 키 행**을 겨냥한다. §17.1 이 그 사실에서 나온다.
## 4. 좁은 저장소 인터페이스
`Repository` 를 확장하고 네 메서드만 이름 짓는다.
> "`JpaRepository` publishes `deleteAll`, `findAll` and `saveAll` on the table that decides whether a
> payment runs twice."
## 5. 어댑터의 주장
`JpaGrpcOperationLedger` javadoc:
> "`claim` is 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` 계약과 어긋난다. 그리고 테스트 이중이 그 차이를 가린다
어댑터는 이렇게 쓴다.
```java
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 에서 일어나는 일은 이렇다.
1. 두 번째 청구가 `merge` 로 들어간다. 그 행은 이미 존재한다.
2. 유니크 제약이 발화하지 않는다. 새 행을 넣는 것이 아니라 같은 행을 갱신하기 때문이다.
3. 분리 상태의 새 엔티티가 기존 행 위에 복사된다 — `state``IN_PROGRESS`, `outcome_reference` 는 널, `completed_at` 은 널, `claimed_at` 은 지금.
4. 예외가 없으므로 `claim``Optional.empty()` 를 돌려준다. 호출자는 자기가 청구를 소유했다고 읽는다.
즉 이미 커밋된 연산의 결과 참조가 지워지고, 재시도가 그 변경을 다시 실행한다. 이 모듈이 존재하는 이유로 든 바로 그 결과다.
세 CHECK 제약도 이것을 막지 못한다. 갱신 후 상태는 `IN_PROGRESS` + `completed_at` 널이라 전부 합법이다.
덧붙여 `merge` 는 즉시 flush 하지 않으므로, 서로 다른 트랜잭션의 진짜 경합에서 제약 위반이 나더라도 그것은 flush 나 커밋 시점에 도착한다 — `try` 블록 밖이다.
**테스트가 이것을 볼 수 없는 이유.** 인메모리 이중의 `save` 는 키가 이미 있으면 예외를 던진다.
```java
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` 는 조용히 넘어간다
```java
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 부재 확인)
```