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,147 @@
---
kind: CONCEPT
slug: three-assembly-paths
title: Spring 조립의 세 경로와 각각이 결정하는 것
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:three-assembly-paths
evidenceCapturedOn: 2026-09-01
assets:
- key: three-assembly-paths
file: ../../../final/evidence/rendered/three-assembly-paths.svg
- key: three-assembly-paths-diagram
file: ../../../final/assets/diagrams/three-assembly-paths.svg
evidence:
- ../../../final/evidence/raw/three-assembly-paths.txt
- ../../../final/evidence/raw/tl-web-six-unowned-components.txt
source:
- 원본 분석 절은 final/document.md#1-4, #7-1 · analysis/18 · analysis/14 §7.1 이다.
---
# Spring 조립의 세 경로와 각각이 결정하는 것
이 저장소의 빈은 컴포넌트 스캔, 자동설정 등록, 그리고 컨텍스트 이전 확장의 세 경로로 들어온다. 어떤 컴포넌트가 실제로 존재하는지는 그 셋 중 어느 것이 그것을 소유하는가로 정해진다.
## 관계
- **스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다**
경로가 바뀌는 지점에서 소유권이 끊긴 사례다.
- **넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다**
같은 형태가 퍼시스턴스 리프에서 나타난 사례다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
이 개념을 확인 절차로 옮긴 규칙이다.
## 본문
<!-- body:start -->
이 저장소에서 빈이 컨텍스트에 들어오는 경로가 셋이고 각각 다른 질문에 답한다.
## 조립의 세 경로
:::evidence key="three-assembly-paths-diagram" alt="빈이 들어오는 경로에서 컴포넌트 스캔과 imports 와 spring.factories 세 갈래가 나온다" caption="조립의 세 경로" zoom="false"
:::
**컴포넌트 스캔**(`@ComponentScan` + `AUTO_CONFIGURED_PACKAGES` 제외 정규식) · **`.imports`**(8개 파일 / 13개 클래스가 전부) · **`spring.factories`**(EnvironmentPostProcessor 6 · SpringBootExceptionReporter · AutoConfigurationImportFilter · ApplicationListener).
## 세 경로가 각각 답하는 질문
:::evidence key="three-assembly-paths" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 조립 표면이 좁다
조립 결함을 판정할 때 세 경로를 다 읽어야 한다. main Java 4,614개 중 스테레오타입 보유가 206개(4.5%)뿐이다.
:::note
없음 — 세 경로의 내용을 전수 판독했다
:::
## 경로 1 — 컴포넌트 스캔
컴포지션 루트는 `@SpringBootApplication` 대신 `@SpringBootConfiguration` + `@EnableAutoConfiguration` + `@ComponentScan`을 직접 쓴다. javadoc이 이유를 적는다 — 스캔에 `excludeFilters`가 필요한데 그 애너테이션은 속성을 노출하지 않으므로, 스캔할 패키지를 대신 명시한다.
```java
@ComponentScan(
basePackages = {
"dev.caskeleton.bootstrap",
"dev.caskeleton.adapter",
"dev.caskeleton.application",
"dev.caskeleton.domain",
"dev.caskeleton.shared"
},
excludeFilters = {
@ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@ComponentScan.Filter(
type = FilterType.CUSTOM,
classes = AutoConfigurationExcludeFilter.class),
@ComponentScan.Filter(
type = FilterType.REGEX,
pattern = CaSkeletonApplication.AUTO_CONFIGURED_PACKAGES)
})
```
정규식은 13개 패키지 접두사를 제외한다. 클래스 목록이 아니라 접두사인 이유도 적혀 있다 — 선택적 능력에 새 설정이 추가됐을 때, 아무도 제외를 기억하지 못했다는 이유로 활성화되면 안 되기 때문이다.
## 경로 2 — 자동설정 등록
제외된 패키지는 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`로 들어온다. 이 저장소에는 그런 파일이 8개 있다.
```text
adapter/inbound/graphql
adapter/inbound/web
adapter/outbound/cache-redis
adapter/outbound/messaging
adapter/outbound/persistence-mongo
app-bootstrap
grpc/grpc-spring-boot-starter
messaging/messaging-spring-boot-starter
```
여기에 관리 컨텍스트용 `ManagementContextConfiguration.imports`가 하나 더 있다. Fileserver admin 라우트가 공개 커넥터가 아니라 관리 평면에 실리는 것이 이 파일의 역할이다.
## 경로 3 — 컨텍스트가 생기기 전
`META-INF/spring.factories`는 컨텍스트가 존재하기 전에 동작하는 확장을 등록한다.
```text
org.springframework.boot.EnvironmentPostProcessor=\
dev.caskeleton.bootstrap.activation.MasterSwitchEnvironmentPostProcessor,\
dev.caskeleton.bootstrap.activation.RuntimeEnvironmentProfileValidator,\
dev.caskeleton.bootstrap.activation.CapabilityDependencyEnvironmentValidator,\
...
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
dev.caskeleton.bootstrap.autoconfigure.persistencejpa.JpaOffAutoConfigurationImportFilter
```
`AutoConfigurationImportFilter`가 특히 중요하다. 이것은 후보 집합 자체를 좁히므로, 프로젝트의 어떤 조건보다 먼저 동작한다. 프레임워크 자신의 자동설정을 후보에서 빼는 것이 그 일이다.
## 세 경로가 만드는 함정
| 상황 | 증상 |
|---|---|
| 스캔에서 뺐는데 자동설정이 소유하지 않음 | 컴포넌트가 아무 데도 없다. 기동은 성공한다 |
| 자동설정 파일에 없는데 이름만 AutoConfiguration | 등록되지 않는다. 능력 리포트는 있다고 말한다 |
| `@Import`로 들어오는 클래스에 빈 조건 | 파싱 시점 평가라 빈 정의가 아직 없다 |
:::warning
세 경로의 공통점은 실패가 조용하다는 것이다. Spring은 "등록되지 않은 컴포넌트"를 오류로 보지 않는다. 그것이 정상 동작이기 때문이다.
:::
## 확인 순서
어떤 컴포넌트가 실제로 존재하는지 물을 때는 순서가 있다.
1. 컴포지션 루트의 스캔 범위와 제외 정규식을 읽는다
2. 제외됐다면 `.imports` 파일에서 그 패키지의 소유자를 찾는다
3. 소유자가 있다면 그것이 `@Bean`이나 `@Import`로 대상을 실제로 만드는지 확인한다
4. 조건이 붙어 있다면 그 조건이 언제 평가되는지 확인한다
<!-- body:end -->
@@ -0,0 +1,114 @@
---
kind: CONCEPT
slug: when-conditions-are-evaluated
title: 조건부 빈의 평가 시점 — 파싱 시점과 등록 시점
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:when-conditions-are-evaluated
evidenceCapturedOn: 2026-09-01
assets:
- key: when-conditions-are-evaluated
file: ../../../final/evidence/rendered/when-conditions-are-evaluated.svg
evidence:
- ../../../final/evidence/raw/when-conditions-are-evaluated.txt
source:
- 원본 분석 절은 analysis/05 §14.4 이다.
---
# 조건부 빈의 평가 시점 — 파싱 시점과 등록 시점
같은 `@ConditionalOnBean`이라도 그 클래스가 자동설정으로 등록되는지 `@Import`로 들어오는지에 따라 평가 시점이 다르다. 그 차이가 조건을 항상 거짓으로 만들 수 있다.
## 관계
- **ConditionalOnBean(DataSource)이 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다**
이 개념이 실제로 문제가 된 사례다.
- **ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다**
이 개념에서 나온 확인 규칙이다.
- **ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다**
현재 리비전에 대한 미해결 질문이다.
## 본문
<!-- body:start -->
`@ConditionalOnBean`은 그 클래스가 **언제 평가되는가**에 따라 답이 달라진다. `@AutoConfiguration`으로 등록되면 다른 자동설정 이후에 평가되지만, plain `@Configuration`이 `@Import`로 들어오면 **클래스가 파싱되는 동안 — 대상 빈 정의가 존재하기 전에** 평가된다.
## 등록 방식이 평가 시점을 정한다
:::evidence key="when-conditions-are-evaluated" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 16줄" zoom="true"
:::
## 이 저장소가 그 함정을 밟았다
여덟 빈이 조용히 사라졌다.
## 남은 회피 방법 둘
검증기를 주입받지 않고 직접 생성하기, 그리고 조건을 루트로 올리기.
:::note
현재 리비전의 각 조건부 빈이 어느 시점에 평가되는지는 ConditionEvaluationReport로 확인하지 않았다
:::
## 두 시점
`@ConditionalOnBean`은 "이 타입의 빈 정의가 이미 등록되어 있는가"를 묻는다. 그 질문의 답은 언제 묻느냐에 달라진다.
| 클래스가 들어오는 경로 | 조건이 평가되는 시점 |
|---|---|
| 자동설정 (`.imports`) | 자동설정 순서에 따라 등록 단계에서 |
| `@Import` | 그것을 import 하는 클래스가 파싱될 때 |
| 컴포넌트 스캔 | 스캔 단계에서 |
`@Import`로 들어오는 클래스가 문제다. 부모 설정이 파싱되는 시점에 자식 클래스의 클래스 수준 조건이 함께 평가되는데, 그 시점에는 자동설정이 만들 빈 정의가 아직 존재하지 않는다.
## 이 저장소가 겪은 형태
```java
/**
* <p>This class used to carry {@code @ConditionalOnBean(DataSource.class)}. It is imported by
* {@code PersistenceJpaRootAutoConfiguration} rather than auto-configured, so that condition was
* evaluated while the class was parsed — before the datasource bean definition existed — and was
* therefore false in every real deployment. All eight beans below silently disappeared, and nothing
* reported it because nothing depended on any of them. It surfaced only when the datasource
* validator was finally wired to a caller and the JPA Compose lane answered "No qualifying bean".
*/
```
두 문장이 이 개념의 핵심이다. 조건이 모든 실제 배포에서 거짓이었다는 것, 그리고 아무것도 그것을 보고하지 않았다는 것.
보고되지 않은 이유가 특히 중요하다. 사라진 여덟 빈에 의존하는 것이 없었기 때문이다. 의존이 있었다면 미충족 의존성으로 시끄럽게 실패했을 것이다.
## 해결 방향은 순서가 아니라 제거였다
```java
/**
* <p>The condition is removed rather than reordered: this class is reached only through the JPA
* root, which already carries the master switch, so "is there a datasource" has been answered yes
* by the time it is parsed. A bean here that needs one takes it as a parameter, and a missing
* datasource with the switch on is then a loud failure — which is the outcome that was wanted,
* rather than the layer vanishing.
*/
```
:::tip
조건을 옮기거나 순서를 바꾸는 대신, 그 조건이 이미 답해진 지점으로 도달 경로를 제한하고 조건 자체를 없앴다. 그리고 필요한 의존은 파라미터로 받게 해서, 없을 때 조용히 사라지는 대신 시끄럽게 실패하게 만들었다.
:::
## 확인 방법
정적으로는 두 가지를 본다.
1. 그 클래스가 `.imports`에 있는가, 아니면 다른 클래스가 `@Import` 하는가
2. 조건이 요구하는 빈을 누가 언제 등록하는가
런타임으로는 `debug=true` 부팅의 조건 평가 리포트가 답한다. `Negative matches` 항목의 사유 문자열이 "빈 정의 없음"인지 "타입 자체가 없음"인지를 구별해 준다.
<!-- body:end -->