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:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
+45
@@ -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 -->
|
||||
+40
@@ -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 -->
|
||||
+47
@@ -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 -->
|
||||
+52
@@ -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 -->
|
||||
+52
@@ -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 -->
|
||||
+68
@@ -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 -->
|
||||
Reference in New Issue
Block a user