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,45 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c10
title: 자동설정이 참조하는 것은 58개 파일 중 둘이다
topic: query-and-pagination-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c10
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c10
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c10.svg
- key: adapter-inbound-graphql-c10-diagram
file: ../../../final/assets/diagrams/adapter-inbound-graphql-c10.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c10.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L719 이다.
module: adapter-inbound-graphql
---
# 자동설정이 참조하는 것은 58개 파일 중 둘이다
58개 main 파일 중 자동설정이 참조하는 것은 `GraphQlBatchPolicyRegistry`(4)와 `GraphQlDataLoaderFactory`(4) 둘이다. 나머지 56개는 autoconf=0이고, 그중 41개는 어떤 배선 경로에도 놓여 있지 않다.
## 본문
<!-- body:start -->
58개 main 파일 중 자동설정이 참조하는 것은 **둘**이다 — `GraphQlBatchPolicyRegistry`(4) · `GraphQlDataLoaderFactory`(4). 나머지 56개는 autoconf=0이다.
## 배선 경로가 닿는 패키지
:::evidence key="adapter-inbound-graphql-c10-diagram" alt="자동설정과 배치 로더 등록기에서 dataloader 로만 화살표가 가고, 나머지 세 패키지는 배선 경로 없음 이라고 이름 붙은 별도 영역 안에 화살표 없이 놓인다" caption="패키지별 도달 경로" zoom="false"
:::
`dataloader``runtime/GraphQlBatchLoaderRegistrar`를 통해 도달하므로 배선돼 있다. `fetch`·`pagination`·`mutation` 41개 파일은 어떤 배선 경로에도 없다.
## GraphQlBatchPolicyRegistry 참조 위치
:::evidence key="adapter-inbound-graphql-c10" alt="코드베이스에서 GraphQlBatchPolicyRegistry 를 검색한 출력 20줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlBatchPolicyRegistry 코드베이스 검색 — 20줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c06
title: durable-operation 게이트는 켤 수 없고 켜면 부팅이 실패한다
topic: query-and-pagination-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c06
file: ../../../final/evidence/rendered/adapter-inbound-web-c06.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c06.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L762 이다.
module: adapter-inbound-web
---
# durable-operation 게이트는 켤 수 없고 켜면 부팅이 실패한다
`app.web-platform.durable-operations` 문자열은 저장소의 어떤 yaml에도 없다. 그리고 켜더라도 컨트롤러 생성자가 요구하는 `OperationQueryService` 빈을 선언하는 코드가 main·app-bootstrap에 없다.
## 본문
<!-- body:start -->
`app.web-platform.durable-operations` 문자열은 저장소의 어떤 yaml에도 없다. `@ConditionalOnProperty``matchIfMissing`이 없으므로 기본값은 꺼짐이다.
## 켜더라도 생성자가 요구하는 빈이 없다
`OperationHttpController`의 생성자는 `OperationQueryService`를 요구한다. 그 빈을 선언하는 코드가 main·app-bootstrap에 없다(testkit에만 생성). §16.3과 같은 형태 — 게이트를 켜면 부팅이 실패한다.
## OperationQueryService 참조 위치
:::evidence key="adapter-inbound-web-c06" alt="코드베이스에서 OperationQueryService 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OperationQueryService 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,47 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c27
title: JpaRepositoryFragmentSupport는 CRUD가 아니라 실행 정책이다
topic: query-and-pagination-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c27
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c27
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c27.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c27.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L1860 이다.
module: adapter-outbound-persistence-jpa
---
# JpaRepositoryFragmentSupport는 CRUD가 아니라 실행 정책이다
`JpaRepositoryFragmentSupport`는 domain-specific repository adapter가 사용할 공통 실행 support이며 범용 business repository contract는 제공하지 않는다.
## 본문
<!-- body:start -->
`JpaRepositoryFragmentSupport`는 domain-specific repository adapter가 사용할 공통 실행 support다. 제공하는 것은 대략 네 가지다.
- `EntityManager` access
- query name context
- fetch plan application
- bounded query observation scope
범용 business repository contract는 제공하지 않는다.
## JpaRepositoryFragmentSupport 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c27" alt="코드베이스에서 JpaRepositoryFragmentSupport 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaRepositoryFragmentSupport 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## application-core가 JPA를 알 필요가 없는 이유
이 구조는 Clean Architecture 관점에서 의미가 있다. application-core가 `JpaRepository`, `EntityManager`, `Specification`을 알 필요가 없고, 실제 domain repository port를 구현하는 outbound adapter 내부에서만 Spring Data/JPA mechanics를 사용한다.
<!-- body:end -->
@@ -0,0 +1,52 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c28
title: KeysetPredicateBuilder가 만드는 것은 사전식 술어다
topic: query-and-pagination-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c28
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c28
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c28.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c28.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L1933 이다.
module: adapter-outbound-persistence-jpa
---
# KeysetPredicateBuilder가 만드는 것은 사전식 술어다
`KeysetPredicateBuilder`는 conjunction이 아니라 lexicographic predicate를 만든다. 그래서 `(Instant, UUID)`처럼 term type이 다르고 direction도 다른 ordering을 표현할 수 있다.
## 본문
<!-- body:start -->
`KeysetPredicateBuilder`는 conjunction이 아니라 lexicographic predicate를 만든다.
## 커서 뒤가 뜻하는 조건
`(createdAt ASC, id DESC)`라면 cursor 뒤는 개념적으로 이렇다.
```text
createdAt > cursorTime
OR
(createdAt = cursorTime AND id < cursorId)
```
현재 `KeysetTerm<T>`는 각 term마다 expression, cursor value, direction을 가진다. 그래서 `(Instant, UUID)`처럼 term type이 다르고 direction도 다른 ordering을 표현할 수 있다. source history에는 과거 one-type/one-direction API가 mixed order에서 rows를 skip/repeat했던 이유가 주석으로 남아 있고, 현재 code/test는 이를 보완했다.
## KeysetPredicateBuilder 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c28" alt="코드베이스에서 KeysetPredicateBuilder 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="KeysetPredicateBuilder 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 마지막 term의 유일성은 타입이 아니라 호출자 계약이다
builder는 "마지막 term이 unique tie-breaker여야 한다"고 문서화하지만 runtime에서 uniqueness를 증명할 metadata는 받지 않는다. 따라서 uniqueness는 caller/registry contract다. 현재 evidence만으로 이를 defect라 단정하지 않는다. platform이 이를 fail-closed invariant로 승격하려면 unique-key metadata까지 contract에 포함해야 한다.
<!-- body:end -->
@@ -0,0 +1,52 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c29
title: size + 1로 hasNext를 판정하고 COUNT를 없앤다
topic: query-and-pagination-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c29
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c29
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c29.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c29.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L1973 이다.
module: adapter-outbound-persistence-jpa
---
# size + 1로 hasNext를 판정하고 COUNT를 없앤다
`JpaKeysetQuerySupport``size + 1`을 가져와 최대 `size`개를 반환하고 추가 1개로 `hasNext`를 판단한다. 이 path에는 `COUNT(*)`가 없다.
## 본문
<!-- body:start -->
`JpaKeysetQuerySupport`의 실행 형태는 이렇다.
```text
query.setMaxResults(page.fetchSize()) // size + 1
-> result
-> KeysetSliceAssembler
```
반환은 최대 `size`개이고 추가 1개로 `hasNext`를 판단한다. 이 path에는 `COUNT(*)`가 없다.
## JpaKeysetQuerySupport 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c29" alt="코드베이스에서 JpaKeysetQuerySupport 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaKeysetQuerySupport 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## COUNT를 다시 붙이지 않는 이유
keyset을 도입해 OFFSET full-walk 비용을 줄여 놓고 total count로 다시 full-work를 추가하는 구조를 피한다.
## 별도 레인에서만 확인한 것
실제 PostgreSQL readiness query도 `(occurred_at,id) > (?,?) ORDER BY ... LIMIT ?` 형태와 representative index 사용을 별도 integration lane에서 검증한다. 해당 entire integration lane 자체는 later sub-scope 11의 denominator이므로 여기서는 cross-scope evidence로만 사용한다.
<!-- body:end -->
@@ -0,0 +1,68 @@
---
kind: CONCEPT
slug: application-core-c01
title: application-core가 아는 유일한 프로젝트 의존은 shared-contract다
topic: query-and-pagination-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c01
file: ../../../final/evidence/rendered/application-core-c01.svg
- key: application-core-c01-diagram
file: ../../../final/assets/diagrams/application-core-c01.svg
evidence:
- ../../../final/evidence/raw/application-core-c01.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L58 이다.
module: application-core
---
# application-core가 아는 유일한 프로젝트 의존은 shared-contract다
`build.gradle`의 production project dependency는 `:shared-contract` 하나뿐이다. 실행 정책은 use case 타입이 아니라 `@UseCaseCapability`에 선언되고, `CleanArchitectureTest`가 그 선언과 실제 호출의 일치를 검사한다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**Observed.** `build.gradle`의 production project dependency는 `:shared-contract` 하나뿐이다. application-core가 Spring, JPA, Redis, Kafka, filesystem provider 같은 구현 모듈을 직접 참조하지 않고, 외부 구현은 composition root와 adapter가 역으로 이 모듈의 port를 구현한다.
## 의존이 흐르는 방향
:::evidence key="application-core-c01-diagram" alt="어댑터와 application-core 와 shared-contract 가 위에서 아래로 쌓이고 의존 방향 화살표가 아래쪽 하나로만 그려진 구조" caption="의존이 흐르는 한 방향" zoom="false"
:::
## 실행 정책은 애너테이션에 따로 선언된다
`CommandUseCase<C extends Command,R>``QueryUseCase<Q extends Query,R>``UseCase<I,O>.handle(I)`를 write/read intent에 맞게 타입으로 좁힌다. 자체적으로 transaction을 열거나 security interceptor를 실행하지 않는다. 실행 정책은 `@UseCaseCapability`에 별도로 선언된다 — runtime TYPE annotation이며 `transactionMode`, `idempotency`, `repositoryAccess`를 필수로 받고 `externalOutboundAllowed`, `sensitiveRead`, `bulkWrite`, `crossTenantAdmin`을 추가 선언한다.
## CleanArchitectureTest 참조 위치
:::evidence key="application-core-c01" alt="코드베이스에서 CleanArchitectureTest 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CleanArchitectureTest 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 적합성 함수가 검사하는 일곱 가지
annotation 자체는 metadata에 불과하지만 `CleanArchitectureTest`가 concrete Command/Query use case에 annotation 존재를 강제한다. 그 위에서 architecture fitness function이 다음 coherence를 직접 검사한다.
- `READ_ONLY + READ_REPOSITORY``TransactionPort.inRead`를 직접 호출해야 한다.
- `WRITE + WRITE_REPOSITORY``inWrite` 또는 `inRootWrite`를 직접 호출해야 한다.
- `REQUIRES_NEW``inNew`를 직접 호출해야 한다.
- `repositoryAccess != WRITE_REPOSITORY`인 use case가 repository write verb를 직접 호출하면 실패한다.
- `bulkWrite=true``WRITE_REPOSITORY`를 요구한다.
- mutating use case는 type-level `@RequiresPermission`을 선언해야 한다.
- application/domain은 Spring Security에 의존할 수 없다.
## 이 강제가 잡지 못하는 것
이 enforcement에는 의도적으로 한계가 있다. ArchUnit의 direct-call 분석이므로 helper 뒤에 숨은 repository mutation/transaction call은 잡지 못하고, AOP self-invocation/non-bean path도 static rule만으로 보장하지 않는다. 이 제한은 테스트 설명 자체에 명시돼 있어 최종 계약의 일부로 봐야 한다.
<!-- body:end -->