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
+147
@@ -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 -->
|
||||
+114
@@ -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 -->
|
||||
Reference in New Issue
Block a user