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
+126
@@ -0,0 +1,126 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-flag-that-validates-an-unwired-subsystem
|
||||
title: 하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a-flag-that-validates-an-unwired-subsystem
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
assets:
|
||||
- key: a-flag-that-validates-an-unwired-subsystem
|
||||
file: ../../../final/evidence/rendered/a-flag-that-validates-an-unwired-subsystem.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a-flag-that-validates-an-unwired-subsystem.txt
|
||||
source:
|
||||
- 분석 문서는 mongo 어댑터 편 §49 다. 플래그가 그대로 바인딩된다는 것은 같은 문서 §6 의 바인딩 관측값이고, 형제 입력의 처리 차이도 그 절에 있다. 반대쪽 실행체가 출하되어 플래그와 무관하게 조립된다는 것은 §65 와 §68 이다.
|
||||
- 같은 문서 §50 은 둘 다 실행체가 없다고 적는다. 그 줄은 sub-scope 06 시점의 요약이고 §68 이 뒤집었다 — 자동설정의 무조건 빈과 드라이버 호출이 그 근거이며, 위 터미널 출력에서 확인할 수 있다.
|
||||
---
|
||||
|
||||
# 하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다
|
||||
|
||||
mongo 트랜잭션 타입은 어느 것도 빈이 아니고 다른 프로덕션 코드가 부르지도 않는다. 그런데 그것을 켜는 설정 플래그는 살아 있어서, 토폴로지 프로브를 함께 공급한 배포에서는 트랜잭션 지원 여부를 검증받게 만든다. 능력 요구만 만들고 능력을 제공하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
플래그 값이 바인딩된다는 것도 그 하위 시스템이 조립됐다는 증거가 아니다.
|
||||
- **"꺼짐"은 조건의 반복이 아니라 구조여야 한다**
|
||||
같은 설정 클래스가 스위치 둘을 서로 반대 방향으로 하위 시스템과 떼어 놓았다.
|
||||
|
||||
## 문제
|
||||
|
||||
mongo 플랫폼 자동설정에서 트랜잭션 타입과 인과 세션 타입을 찾으면 일치가 각각 0 이다. 트랜잭션 패키지 밖의 프로덕션 참조도 0 이다.
|
||||
|
||||
즉 실행체와 세션 팩토리와 재시도 코디네이터와 인과 세션 실행기 어느 것도 빈이 아니고, 이 어댑터의 다른 프로덕션 코드가 부르지도 않는다.
|
||||
|
||||
그런데 설정의 transactions 플래그는 다르다. 값이 그대로 바인딩되고, 플랫폼 자동설정이 그 값을 시작 검증기에 넘기며, 검증기는 트랜잭션이 켜져 있는데 능력이 Stable 이 아니면 시작을 거부한다.
|
||||
|
||||
## 결론
|
||||
|
||||
프로브를 공급한 배포는 토폴로지가 트랜잭션을 지원하는지 검증받고, 그다음 트랜잭션을 실행할 빈은 하나도 받지 못한다. 플래그가 능력 요구만 만들고 능력을 제공하지 않는다.
|
||||
|
||||
같은 설정 클래스의 change stream 플래그는 반대쪽으로 어긋나 있다. 실행체 쪽 드라이버 코드는 출하되어 빈으로 조립되는데, 플래그는 생성자에서 조용히 거짓이 된다.
|
||||
|
||||
데이터 위험은 없다. 없는 것을 쓸 수는 없기 때문이다. 이 플래그가 무엇을 켜는지 적힌 곳이 없고, 시작 검증이 통과한 것과 실행체가 조립된 것을 구분해 주는 신호도 없다.
|
||||
|
||||
수정은 셋 중 하나다. 트랜잭션 실행체를 조건부 빈으로 조립하거나, 플래그가 무엇을 켜는지를 문서에 적거나, 같은 생성자의 required-secondaries 처럼 값을 예외로 거부하는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
확인 방식 : 정적 도달성 전수 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 트랜잭션 패키지의 타입을 하위 디렉터리까지 나열하고, mongo 플랫폼 자동설정에서 그 타입 참조를 센다.
|
||||
2. 트랜잭션 패키지 밖의 프로덕션 참조를 센다.
|
||||
3. 설정 플래그가 선언되는 지점과 그 값이 시작 검증기로 전달되는 지점, 검증기의 판정을 확인한다.
|
||||
4. 그 검사를 여는 조건과, 이 저장소가 그 조건을 만족시키는지 확인한다.
|
||||
5. 같은 생성자가 형제 입력을 각각 어떻게 처리하는지 확인한다.
|
||||
6. change stream 실행체의 조립 조건과 드라이버 호출을 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`ca-skeleton.persistence-mongo.platform.transactions` 를 참으로 둔 배포가 토폴로지 프로브를 함께 공급하면, 시작할 때 트랜잭션 능력 검사가 돈다. 검사가 통과해도 트랜잭션을 실행할 빈은 하나도 조립되지 않는다.
|
||||
|
||||
## 타입은 스물인데 그것을 부르는 코드가 없다
|
||||
|
||||
:::evidence key="a-flag-that-validates-an-unwired-subsystem" alt="코드베이스에서 트랜잭션 패키지의 타입 스물과 자동설정의 참조 매치 수, 패키지 밖 참조 수, 플래그가 선언되어 검증기 판정에 쓰이는 지점, 그 검사를 여는 프로브 조건과 이 저장소가 프로브를 내지 않는다는 기술, 설정 생성자가 입력마다 다르게 처리하는 세 줄, 반대쪽 실행체가 플래그와 무관하게 조립되는 조건과 드라이버 호출, 그리고 소비자가 요구하는 다섯 포트를 뽑은 출력 70줄. 타입은 스물인데 참조가 0 이고, 검사는 프로브가 있어야 열린다는 것이 그 출력에 그대로 보인다." caption="타입 20 · 참조 0 · 플래그 → 검증기 → 거부 · 검사를 여는 프로브 조건 · 반대쪽의 무조건 조립 — 70줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
트랜잭션 패키지에 타입이 스무 개 있다. 블로킹과 리액티브 양쪽의 실행체와 세션 팩토리, 스코프와 프로파일, 커밋 조정과 재시도 예산을 갖춘 재시도 코디네이터, 그리고 인과 세션 실행기 넷이다. 절반은 계약이고 나머지가 구체 클래스다.
|
||||
|
||||
플랫폼 자동설정에서 `Transaction` 을 세면 0, `CausalSession` 을 세도 0 이다. 트랜잭션 패키지 밖의 main 참조도 0 이다.
|
||||
|
||||
## 플래그는 그 사실과 무관하게 검증까지 간다
|
||||
|
||||
`transactions` 는 설정 record 의 성분이라 값이 그대로 바인딩된다. 자동설정 362행이 그 값을 시작 검증기에 넘기고, 검증기 97행이 그것으로 판정한다 — 트랜잭션이 켜져 있는데 능력이 Stable 이 아니면 시작을 거부한다.
|
||||
|
||||
## 그 검사 자체가 조건부다
|
||||
|
||||
검증기를 돌리는 빈은 `MongoTopologyProbe` 에 조건되어 있다. 그리고 이 저장소는 프로브를 출하하지 않는다 — 그 자리 javadoc 이 직접 적는다. 프로브는 연결을 소유한 조립 루트가 살아 있는 데이터 평면 클라이언트로 만드는 것이고, 그것은 fork 의 결정이라는 것이다.
|
||||
|
||||
프로브 없이 플랫폼 프로파일만 설정한 배포는 별도의 빈이 시작을 거부한다. 그 예외 문구가 이유를 적는다 — 검사가 하필 자기 부재를 보고해야 할 바로 그 빈에 조건되어 있어서, 프로브 없이 시작하면 토폴로지도 Stable API 수준도 자격의 실제 능력도 아무것도 검사하지 않은 채 조용히 지나간다는 것이다.
|
||||
|
||||
그래서 이 플래그가 시작 요구를 만드는 것은 fork 가 프로브와 보안 프로파일과 관리 자격 참조와 스키마 버전 범위를 모두 공급했을 때다. 셰이프 그대로의 이 저장소에서는 검사가 열리지 않는다.
|
||||
|
||||
## 같은 생성자가 입력마다 다르게 처리한다
|
||||
|
||||
설정 record 의 컴팩트 생성자를 보면 처리가 갈린다.
|
||||
|
||||
`profiles` 의 널은 빈 맵으로 흡수한다. `change-streams` 는 무엇이 오든 거짓으로 덮어쓴다. `required-secondaries` 에 음수가 오면 예외를 던져 거부한다.
|
||||
|
||||
`transactions` 는 이 생성자에 아예 등장하지 않는다. 값이 그대로 보존되는 이유가 그것이다.
|
||||
|
||||
덮어쓰기 쪽만 참으로 설정해도 예외도 로그도 발생하지 않는다. 그 줄에 붙은 주석은 값을 무시하면 적용된 것처럼 보이게 되니 저장하지 않고 거부한다고 적는데, 예외로 거부하는 것은 `required-secondaries` 가 하는 일이고 여기서 일어나는 것은 조용한 덮어쓰기다.
|
||||
|
||||
## 강등의 근거로 적힌 사실이 코드와 맞지 않는다
|
||||
|
||||
그 주석에는 드라이버 쪽 소스가 — watch, resumeAfter/startAfter, 커서 수명, 재접속이 — 출하되지 않았다고 이유까지 적혀 있다.
|
||||
|
||||
같은 모듈의 리액티브 소스가 `.changeStream(...)` 과 `.watchCollection(...)` 을 호출하고, 재개 위치에 따라 `startAfter` 와 `resumeAfter` 를 나눠 건다. 자동설정이 그것을 빈으로 조립하는데, 그 경로에서 플래그를 보는 조건은 0 이다. 리액티브 템플릿이 있으면 조립된다.
|
||||
|
||||
그 위의 소비자는 다섯 포트를 요구한다. 무엇을 투영할지, 중복을 어떻게 걸러 낼지, 어디에 투영했다고 기록할지, 저장한 토큰을 어떻게 보호할지, 어느 컬렉션을 볼지다. 다섯 모두 main 에서 빈으로 등록되는 곳이 0 인데, 이쪽은 어긋난 것이 아니다. 플랫폼이 투영기를 지어낼 수 없으니 배포가 주기 전까지 소비자가 서 있는 것이 맞다.
|
||||
|
||||
## 두 스위치가 반대 방향으로 같은 곳에서 끊겼다
|
||||
|
||||
트랜잭션은 스위치가 살아서 요구를 만드는데 그 요구를 갚을 코드가 조립되지 않는다. change stream 은 코드가 조립되는데 스위치가 죽어 있다. 방향은 반대이고 끊긴 자리는 같다.
|
||||
|
||||
## 남는 것은 데이터 위험이 아니다
|
||||
|
||||
없는 것을 쓸 수는 없으니 트랜잭션이 깨질 일은 없다.
|
||||
|
||||
남는 것은 이 플래그가 무엇을 켜는지 적힌 곳이 없다는 것이다. 시작 검증이 통과한 것과 실행체가 조립된 것을 구분해 주는 신호도 없다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
애플리케이션을 부팅해 플래그를 켠 상태의 시작 동작과 빈 목록을 확인하지 않았다. 도달성은 이름 기반 정적 검색으로 판정했으므로, 리플렉션이나 설정으로 조립되는 경로는 배제하지 못했다.
|
||||
|
||||
<!-- body:end -->
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a-validator-that-demands-tls-and-an-assembly-that-omits-it
|
||||
title: 검증기가 운영에 TLS를 요구하고, 실제로 조립되는 생산자에는 그 설정이 없다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a-validator-that-demands-tls-and-an-assembly-that-omits-it
|
||||
evidenceCapturedOn: 2026-09-03
|
||||
assets:
|
||||
- key: a-validator-that-demands-tls-and-an-assembly-that-omits-it
|
||||
file: ../../../final/evidence/rendered/a-validator-that-demands-tls-and-an-assembly-that-omits-it.svg
|
||||
- key: a-validator-that-demands-tls-and-an-assembly-that-omits-it-run
|
||||
file: ../../../final/evidence/rendered/a-validator-that-demands-tls-and-an-assembly-that-omits-it-run.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a-validator-that-demands-tls-and-an-assembly-that-omits-it.txt
|
||||
- ../../../final/evidence/raw/a-validator-that-demands-tls-and-an-assembly-that-omits-it-run.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/messaging/messaging-spring-boot-starter.md 의 §17.1 이다.
|
||||
---
|
||||
|
||||
# 검증기가 운영에 TLS를 요구하고, 실제로 조립되는 생산자에는 그 설정이 없다
|
||||
|
||||
`KafkaProfileValidator` 는 운영으로 선언된 브로커에 TLS 와 브로커 인증이 켜져 있기를 요구하고, 기동 시점에 실제로 실행된다. 그런데 프로덕션에서 만들어지는 `KafkaProducer` 둘 어느 쪽 설정 맵에도 `security.protocol` 이 없어서, 두 맵 모두 `PLAINTEXT` 로 해석된다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
|
||||
`KafkaProfileValidator` 만 읽으면 운영 프로파일에 전송 보안이 강제된 것으로 보인다. `KafkaProducer` 를 만드는 두 코드를 열어야 그 설정 맵에 `security.protocol` 이 없다는 것이 보인다.
|
||||
- **검증기는 발행이 아니라 주입이 강제다**
|
||||
`KafkaSecurityConfigurer` 는 빈 팩토리까지 있지만 그것을 주입받는 프로덕션 코드가 0 건이라 아무 설정 맵에도 닿지 않는다.
|
||||
- **"꺼짐"은 조건의 반복이 아니라 구조여야 한다**
|
||||
기동에서 설정값을 한 번 더 검사해도, 두 조립부가 `security.protocol` 을 설정 맵에 넣지 않으면 프로듀서는 평문 설정으로 만들어진다. 검사 조건을 늘리는 쪽이 아니라 조립 코드가 그 키를 넣어야 막힌다.
|
||||
|
||||
## 문제
|
||||
|
||||
브로커 프로파일이 값으로 표현되고 기동 때 검증된다. 운영 프로파일이면 전송 보안과 브로커 인증이 있어야 한다.
|
||||
|
||||
그 요구가 실제로 걸리는지, 그리고 통과한 뒤 만들어지는 프로듀서가 그 선언대로 붙는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 규칙은 기동 시점에 실제로 실행된다. 그 검증을 통과한 뒤 만들어지는 KafkaProducer 두 곳의 설정 맵에 security.protocol 이 없다.
|
||||
|
||||
규칙은 KafkaProfileValidator:51 과 :55 에 있다. KafkaMessagingAutoConfiguration:55 의 kafkaProfileStartupValidation 이 StartupProfileValidation 을 통해 설정에서 컴파일된 프로파일에 이 검증기를 실행하고, MessagingConfigurationBindingTest:235 가 그 거부를 고정한다.
|
||||
|
||||
같은 애플리케이션이 만드는 KafkaProducer 는 둘이다. KafkaMessagingAutoConfiguration:153 이 키 다섯 개짜리를, KafkaSenderConfig:81 이 아홉 개짜리를 내놓는데 보안 키는 어느 쪽에도 없다. 두 맵을 ProducerConfig 에 그대로 넘겨 읽으면 security.protocol 이 PLAINTEXT 로, sasl.jaas.config 가 널로 나온다.
|
||||
|
||||
보안 값을 설정 맵에 실을 수 있는 코드 자체는 KafkaSecurityConfigurer:80 에 있다. 그 클래스의 빈 팩토리는 KafkaMessagingAutoConfiguration:109 에 있지만 조건이 두 단이고, 그 끝에 있는 CredentialProvider 를 구현하는 main 클래스가 0 건이다. 출하되는 애플리케이션에서는 이 빈이 만들어지지 않고, 만들어졌더라도 주입받는 코드가 없다.
|
||||
|
||||
그래서 배포가 겪는 것은 이렇다. app.messaging.enabled=true 와 app.messaging.broker=kafka 를 켜고 운영 프로파일에 전송 보안과 인증을 선언하면 기동은 통과한다. 만들어진 프로듀서의 설정 맵은 security.protocol 이 PLAINTEXT 로 해석되므로 클라이언트는 평문으로 접속을 시도한다. 설정에 선언한 보안과 프로듀서가 실제로 쓰는 값이 다른데도, 기동에서 예외나 경고가 나오지 않는다.
|
||||
|
||||
판정은 P1 로 원본 분석과 같다. 근거는 셋이다. 이 경로는 미배선 블록이 아니라 환경변수 둘로 켜지는 출하 조립이다. 기동에서 통과하는 검증은 실제 연결이 아니라 설정에 적힌 선언을 본다. 그리고 조립을 확인하는 테스트가 빈이 있는지만 단언하고 설정 맵의 키는 보지 않아서, 이 상태가 테스트 레인에 걸리지 않는다.
|
||||
|
||||
수정은 KafkaMessagingAutoConfiguration.messagingKafkaProducer 와 KafkaSenderConfig.kafkaSeamProducer 가 KafkaSecurityConfigurer.configure 의 결과를 각자의 설정 맵에 넣는 것이다. 그러려면 그 빈이 실제로 만들어져야 하므로 CredentialProvider 구현을 함께 정해야 한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
kafka-clients : 4.1.2
|
||||
확인 방식 : 검증기의 두 규칙과 그 실행 경로 확인, 프로덕션 KafkaProducer 생성 지점 전수와 각 설정 맵의 원문 확인, KafkaSecurityConfigurer 의 빈 조건 사슬과 주입처 계수, 두 설정 맵을 ProducerConfig 에 넘겨 해석된 보안 값 다섯 읽기
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. KafkaProfileValidator 에서 운영 프로파일에 거는 규칙 둘을 읽는다.
|
||||
2. 그 검증기를 설정에서 컴파일된 프로파일에 돌리는 자리와, 그것이 기동의 어느 단계인지 확인한다.
|
||||
3. 그 거부를 고정하는 테스트를 찾는다.
|
||||
4. 두 조립부를 켜는 프로퍼티를 전부 찾고 출하 기본값을 읽는다.
|
||||
5. 프로덕션에서 KafkaProducer 를 만드는 자리를 전부 세고, 각 설정 맵의 원문을 그대로 읽는다.
|
||||
6. security.protocol 을 상수명과 리터럴 양쪽으로 저장소 전체에서 찾는다.
|
||||
7. KafkaSecurityConfigurer.configure 가 자격 종류마다 무엇을 넣고 어디서 던지는지 읽는다.
|
||||
8. 그 클래스의 빈 팩토리에 붙은 조건을 따라가고, 사슬 끝의 인터페이스를 구현하는 main 클래스를 센다.
|
||||
9. 그 빈을 주입받는 코드와 getBean·ObjectProvider·빈 이름 조회를 각각 센다.
|
||||
10. 5에서 읽은 두 맵을 그대로 만들어 ProducerConfig 에 넘기고 보안 값 다섯을 읽는다.
|
||||
11. 조립을 확인하는 테스트가 무엇을 단언하는지, 실 브로커 시험이 어떤 컨테이너에 붙는지 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
이 저장소는 브로커 프로파일을 값으로 표현하고 기동 시점에 검증한다. 그중 운영 프로파일에는 전송 보안과 브로커 인증을 요구하는 규칙 둘이 있다.
|
||||
|
||||
## KafkaProfileValidator 가 운영 프로파일에 요구하는 둘
|
||||
|
||||
:::evidence key="a-validator-that-demands-tls-and-an-assembly-that-omits-it" alt="저장소 루트에서 돌린 정적 검색 출력 102줄. KafkaProfileValidator 51행부터 58행까지의 두 요구가 원문 그대로 나오고, 그 검증기가 InitializingBean 의 afterPropertiesSet 에서 도는 경로와 그 거부를 지키는 테스트가 이어진다. 그다음 두 조립부를 켜는 프로퍼티가 나오는데 플랫폼 루트와 브리지 루트가 각각 messaging enabled 를 조건으로 달고 심 쪽은 broker 프로퍼티를 달며 출하 기본값이 false 다. 이어서 프로덕션에서 KafkaProducer 를 만드는 두 자리의 설정 맵이 주석까지 원문 그대로 실려 있고, security.protocol 을 상수명과 리터럴 양쪽으로 검색한 결과가 KafkaSecurityConfigurer 의 상수 선언과 그 한 줄뿐이다. 그다음 configure 가 자격 종류마다 넣는 값과 던지는 두 분기, 그 클래스가 빈이 되기까지의 조건 사슬과 CredentialProvider 를 구현하는 main 클래스가 0 건이라는 계수, 그 빈을 받는 코드와 getBean·ObjectProvider·빈 이름 조회가 모두 0 건이라는 확인, 그리고 조립 테스트 둘이 단언하는 것과 실 브로커 시험이 붙는 컨테이너가 보인다." caption="두 요구와 그 실행 경로 · 두 프로듀서의 설정 맵 원문 · security.protocol 을 넣는 코드 한 줄 · 조건 사슬과 CredentialProvider 0 건 · 조립 테스트의 단언 — 102줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`KafkaProfileValidator:51` 은 운영 프로파일에 전송 보안이 꺼져 있으면 `IllegalArgumentException` 을 던지고 메시지에 브로커 이름을 붙인다. `:55` 가 브로커 인증에 같은 것을 한다.
|
||||
|
||||
이 검증기는 선언만 있는 것이 아니라 실제로 돈다. `KafkaMessagingAutoConfiguration:55` 의 `kafkaProfileStartupValidation` 이 `StartupProfileValidation` 을 만들고, 그 클래스가 `InitializingBean` 을 구현해 `afterPropertiesSet`(\:43)에서 설정에서 컴파일된 프로파일에 검증기를 건다. `MessagingConfigurationBindingTest:235` 의 `aProductionKafkaBrokerWithoutTransportSecurityFailsStartup` 이 그 거부를 고정한다.
|
||||
|
||||
여기까지만 보면 운영 프로파일로 뜬 배포는 평문으로 브로커에 붙을 수 없다.
|
||||
|
||||
## 조립되는 KafkaProducer 두 곳에 security.protocol 이 없다
|
||||
|
||||
프로덕션에서 `KafkaProducer` 를 만드는 자리는 둘이다.
|
||||
|
||||
`KafkaMessagingAutoConfiguration.messagingKafkaProducer` 가 `:153` 에서 만든다. `:139` 에서 빈 `HashMap` 을 열고 `:140`\~`:152` 에 넣는 것은 `BOOTSTRAP_SERVERS_CONFIG`, 직렬화기 둘, `ACKS_CONFIG`, `ENABLE_IDEMPOTENCE_CONFIG` 다섯이다.
|
||||
|
||||
`KafkaSenderConfig.kafkaSeamProducer` 가 `:81` 에서 만든다. 위 다섯에 `MAX_BLOCK_MS_CONFIG`, `LINGER_MS_CONFIG`, `REQUEST_TIMEOUT_MS_CONFIG`, `DELIVERY_TIMEOUT_MS_CONFIG` 를 더해 아홉이다.
|
||||
|
||||
두 메서드 모두 `@ConditionalOnMissingBean` 이 붙어 있어 채택자가 자기 빈을 내놓지 않으면 이것이 쓰인다. 켜는 프로퍼티는 둘이다. `MessagingPlatformRootAutoConfiguration:28` 과 `MessagingBridgeRootAutoConfiguration:21` 이 각각 `app.messaging.enabled=true` 를 조건으로 달고, 그 안에서 `MessagingProviderSelection:48` 이 `app.messaging.broker=kafka` 에 `KafkaMessagingAutoConfiguration` 을 물리며 `KafkaSenderConfig:38` 이 같은 값을 조건으로 단다. `application.yml:949` 의 출하 기본값은 `enabled: ${APP_MESSAGING_ENABLED:false}` 다.
|
||||
|
||||
두 설정 맵의 원문 어디에도 `security.protocol` 이 없다.
|
||||
|
||||
## 두 설정 맵을 ProducerConfig 에 넘겼을 때 해석되는 보안 값
|
||||
|
||||
:::evidence key="a-validator-that-demands-tls-and-an-assembly-that-omits-it-run" alt="JVM 프로브 출력 19줄. OpenJDK 판이 먼저 찍히고 kafka-clients 판본이 나온다. 두 조립부가 넣는 설정 맵을 그대로 ProducerConfig 에 넘긴 결과가 프로듀서마다 나오는데, 넣은 키 목록과 함께 security.protocol 이 PLAINTEXT 로, ssl.endpoint.identification.algorithm 이 https 로, sasl.mechanism 이 GSSAPI 로, sasl.jaas.config 가 null 로, ssl.enabled.protocols 가 TLSv1.2 와 TLSv1.3 으로 해석된 것이 보인다. 두 프로듀서의 보안 값 다섯이 모두 같다." caption="두 조립 맵을 ProducerConfig 에 넘겨 읽은 보안 값 다섯 — 19줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
두 맵 모두 `security.protocol` 이 `PLAINTEXT` 로 해석된다. 자격 쪽도 비어 있어 `sasl.jaas.config` 가 널이다. 브로커에 붙기 전에 클라이언트가 무엇으로 붙을지는 이 시점에 이미 정해져 있다.
|
||||
|
||||
`KafkaSecurityConfigurer` 가 넣는 값 중 둘은 클라이언트 기본값과 같다. `ssl.endpoint.identification.algorithm` 은 넣지 않아도 `https` 이고, `ssl.enabled.protocols` 의 기본값도 `[TLSv1.2, TLSv1.3]` 이다. 실제로 차이가 나는 것은 `security.protocol` 과 SASL 두 값이다.
|
||||
|
||||
## 보안 값을 만드는 코드는 있고, 그 빈이 만들어지지 않는다
|
||||
|
||||
`security.protocol` 을 넣는 프로덕션 코드는 저장소 전체에서 `KafkaSecurityConfigurer:80` 한 줄이다. 상수명과 리터럴 양쪽으로 찾아도 `:32` 의 상수 선언과 그 한 줄뿐이다.
|
||||
|
||||
같은 메서드가 `:83` 에서 활성 프로토콜 목록을, `:86` 에서 종단 식별 알고리즘을 넣고, 그다음은 자격 종류에 따라 갈린다. SASL/SCRAM 이면 `:92` 가 `SCRAM-SHA-512` 를, 사용자·암호면 `:109` 가 `PLAIN` 을, 상호 TLS 면 `:106` 이 `NONE` 을 넣는다. OAuth2 와 Nkey 는 값을 넣지 않고 `:99` 와 `:113` 에서 던진다.
|
||||
|
||||
이 클래스를 만드는 팩토리는 `KafkaMessagingAutoConfiguration:109` 에 있는데, 조건이 두 단이다. `:107` 이 `@ConditionalOnBean(CredentialRuntimeRegistry.class)` 이고, 그 레지스트리를 내놓는 `MessagingCoreAutoConfiguration:317` 은 `:315` 의 `@ConditionalOnBean(CredentialProvider.class)` 뒤에 있다. 그런데 `CredentialProvider` 를 구현하는 main 클래스가 0 건이다. 유일한 구현은 `CredentialRuntimeRegistryTest:21` 의 시험용 클래스다.
|
||||
|
||||
그래서 출하되는 애플리케이션에서 이 빈은 만들어지지도 않는다. 만들어졌다 해도 받을 곳이 없다 — 그것을 파라미터나 필드로 받는 프로덕션 코드가 0 건이고, `getBean` 과 `ObjectProvider` 와 빈 이름 문자열로 가져가는 자리도 0 건이다.
|
||||
|
||||
## 조립 테스트가 설정 맵의 키를 단언하지 않는다
|
||||
|
||||
`MessagingStarterOffContractTest:118` 의 `selectingKafkaAssemblesOnlyKafka` 는 컨텍스트가 실패하지 않았는지, `KafkaMessagingAutoConfiguration` 빈이 하나인지, Rabbit 쪽 빈이 없는지를 단언한다(\:127, \:130). `:172` 의 `aSelectedTransportAssemblesAPublisher` 는 `MessagePublisher` 빈이 하나인지를 본다(\:189). 프로듀서가 어떤 키를 들고 있는지는 어느 쪽도 보지 않는다.
|
||||
|
||||
실 브로커에 붙는 `MessagingLiveRoundTripQualificationTest:66` 은 `new KafkaContainer("apache/kafka:4.1.0")` 를 쓴다. 보안 설정이 하나도 없는 컨테이너다. 그래서 이 테스트가 확인한 왕복은 보안 설정이 없는 브로커와의 왕복이다.
|
||||
|
||||
## 원문과 갈리는 자리
|
||||
|
||||
원문 §17.1 은 조립되는 생산자를 `messagingKafkaProducer` 하나로 적었다. 프로덕션에서 `KafkaProducer` 를 만드는 자리는 둘이고, `KafkaSenderConfig.kafkaSeamProducer` 도 같은 프로퍼티 조건에서 조립되며 그쪽에도 보안 키가 없다.
|
||||
|
||||
원문이 `KafkaSecurityConfigurer` 가 만드는 성분을 다섯으로 센 것도 자격 종류를 하나로 놓았을 때다. `configure` 는 자격 종류마다 다른 SASL 메커니즘을 넣고, 상호 TLS 에서는 `sasl.jaas.config` 없이 `NONE` 만 넣으며, OAuth2 와 Nkey 에서는 아무것도 넣지 않고 던진다.
|
||||
|
||||
원문의 시나리오는 운영자가 `CredentialProvider` 빈을 공급하는 데까지 간다. 그 단계 없이도 이 상태는 성립한다. 검증기가 보는 `tlsEnabled` 는 `app.messaging.brokers.*` 소속이고, 자격을 요구하는 `MessagingCredentialRequirementValidator` 가 보는 것은 `app.messaging.security.*` 소속이라 두 네임스페이스가 다르다. 보안 프로파일을 적지 않으면 자격 공급 없이 통과한다.
|
||||
|
||||
판정과 근거는 원문과 같다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
브로커를 세워 핸드셰이크를 보지 않았다. 읽은 것은 클라이언트가 그 맵에서 어떤 프로토콜로 붙기로 정하는가까지다.
|
||||
|
||||
브로커를 띄워 실제 핸드셰이크를 관측하지 않았다. 확인한 것은 조립부가 만드는 설정 맵을 `ProducerConfig` 에 넘겼을 때 `security.protocol` 이 `PLAINTEXT` 로 해석되는 데까지다.
|
||||
|
||||
<!-- body:end -->
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: a06-f018-changestreams-false
|
||||
title: 플래그는 고정 거짓이라 능력 검사를 끄지만, 조립 조건이 아니라서 소비자 빈은 그대로 생성된다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:a06-f018-changestreams-false
|
||||
evidenceCapturedOn: 2026-09-02
|
||||
body: case-a06-f018-changestreams-false.body.md
|
||||
assets:
|
||||
- key: a06-f018-changestreams-false
|
||||
file: ../../../final/evidence/rendered/a06-f018-changestreams-false.svg
|
||||
- key: a06-f018-changestreams-false-wiring
|
||||
file: ../../../final/evidence/rendered/a06-f018-changestreams-false-wiring.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/a06-f018-changestreams-false.txt
|
||||
- ../../../final/evidence/raw/a06-f018-changestreams-false-wiring.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1069 이다. 등급은 P2 다. 주석의 전제가 더 이상 사실이 아니라는 판정, 형제 불리언과의 대비표, 검증기 분기가 도달 불가라는 사실, 그리고 실패가 장애 조치 런북으로 분류된다는 서술이 그 절에 있다.
|
||||
- 같은 문서 `#L1015` 는 소비자가 도는 조건을 포크가 다섯을 공급하는 경우로 한정한다. 이 저장소 안에서는 그 다섯의 구현이 전부 시험 픽스처다.
|
||||
- 같은 문서 `#L156` 은 같은 코드를 P3 으로 판정하면서 변경 스트림 실행체가 애초에 출하되지 않는다는 것을 근거로 든다. 이 리비전에서 그 근거가 성립하지 않으므로 그 절에 붙은 "현재 잘못된 동작을 만들지는 않는다"는 유지될 수 없고, 실질 등급은 이 절의 P2 로 흡수된다.
|
||||
- 설정 빈이 속성을 받고도 거짓을 보고한다는 것, 그 두 경우의 빈 집합이 같다는 것, 검사 빈 자체에 조건이 있다는 것, 그리고 런북 전체에 단독 서버·오플로그·해당 오류 코드가 없다는 것은 이 기록에서 확인했다.
|
||||
---
|
||||
|
||||
# 플래그는 고정 거짓이라 능력 검사를 끄지만, 조립 조건이 아니라서 소비자 빈은 그대로 생성된다
|
||||
|
||||
설정 주석은 드라이버 쪽 구현이 출하되지 않아 빈이 0 이라는 것을 근거로 플래그를 고정 거짓으로 만든다. 이 리비전에서 그 구현에는 빈 선언이 있고, 배포가 다섯을 공급하면 소비자도 선다. 조립 조건 어디에도 그 플래그는 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **거부라고 적힌 처리가 폐기이고, 그 값을 읽는 시작 검사는 켤 방법이 없다**
|
||||
같은 플래그를 다른 절에서 다룬 기록이다. 거부와 폐기의 차이는 그 기록이 다룬다.
|
||||
- **하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다**
|
||||
형제 불리언 쪽 기록이다. 트랜잭션 계수와 그 결과는 그 기록이 다룬다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
플래그와 조립의 관계를 확인하는 규칙이다.
|
||||
|
||||
## 문제
|
||||
|
||||
시작 검증기에는 인접한 두 분기가 있다. 하나는 트랜잭션이 켜져 있는데 토폴로지가 지원하지 않으면 던지고, 다른 하나는 변경 스트림에 대해 같은 일을 한다. 두 좌항은 같은 설정 타입의 형제 불리언이고 자동 구성의 인접한 두 줄이 넘긴다.
|
||||
|
||||
두 불리언은 같은 검증기에서 서로 다르게 끝난다. 앞의 값은 배포가 넣은 대로 도착하고, 뒤의 값은 컴팩트 생성자가 이미 거짓으로 바꾼 뒤다.
|
||||
|
||||
## 결론
|
||||
|
||||
플래그가 고정 거짓이므로 검증기의 변경 스트림 분기는 실행되지 않는다. 조립은 그와 무관하게 진행된다. 소비자 빈의 조건은 배포가 공급해야 하는 타입 다섯이고, 그 목록에 이 플래그는 없다. 자동 구성 파일 전체에서 그 이름이 나오는 줄은 검증기 인자 하나뿐이다.
|
||||
|
||||
전체 자동 구성을 올린 스프링 컨텍스트로 확인했다. 설정 빈은 change-streams=true 를 받고도 거짓을 보고하고, 그 두 경우의 빈 집합이 같다. 모듈 opt-in 을 켜고 리액티브 템플릿이 있으면 드라이버 쪽 구현 빈은 만들어지고 소비자 빈은 만들어지지 않는다. 다섯을 함께 넣으면 소비자 빈도 만들어진다. opt-in 을 켜지 않으면 셋 다 없다.
|
||||
|
||||
주석은 이 코드가 있기 전 상태를 서술한다. 빈이 0 이고 스레드가 0 이라는 근거는 이 리비전에서 성립하지 않는다.
|
||||
|
||||
남는 것은 능력 검사만 꺼진 상태다. 다만 그 검사가 열리는 조건이 따로 있다. 검사 빈은 토폴로지 프로브를 조건으로 걸고, 보안 프로파일과 관리 자격 참조와 스키마 버전 범위 중 하나라도 없으면 부분 검증 대신 예외로 닫는다. 그리고 검증기는 선언 토폴로지와 실제를 능력 검사보다 먼저 대조한다. 변경 스트림 분기는 그 둘을 통과한 배포에서만 차례를 얻는데, 그 차례가 와도 좌항이 거짓이다.
|
||||
|
||||
그 다음 실패는 커서를 여는 시점의 드라이버 오류다. 복구 정책은 서버 코드가 이력 소실이 아니고 재개 가능 라벨도 아니면 실패로 확정하며 장애 조치 런북을 붙인다. 런북 어디를 봐도 그 세 낱말이 없다. 이 연쇄는 형제 기록이 오플로그 없는 서버에서 실행으로 확인했다.
|
||||
|
||||
수정은 셋 중 하나다. 플래그를 되살려 조립 조건으로 쓰거나, 소비자 빈이 설 때 능력을 기동에서 확인하거나, 최소한 주석을 현재 사실로 고치는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 전체 자동 구성을 올린 스프링 컨텍스트에서 빈 집합과 바인딩 값 비교, 코드베이스 정적 검색
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 시작 검증기의 인접한 두 분기와 그 좌항을 넘기는 두 줄을 읽는다.
|
||||
2. 그 검사를 만드는 빈의 조건과 입력이 빠졌을 때의 처리를 읽는다.
|
||||
3. 컴팩트 생성자의 플래그 강제와 그 주석을 읽는다.
|
||||
4. 소비자 빈에 붙은 조건과 자동 구성 파일에서 그 플래그가 나오는 줄 수를 확인한다.
|
||||
5. MongoPlatformAutoConfiguration 전체와 블로킹·리액티브 템플릿 빈을 등록한 컨텍스트를 ca-skeleton.persistence-mongo.enabled=true 로 띄우고 두 빈과 설정 빈의 유무, 그리고 바인딩된 플래그 값을 읽는다.
|
||||
6. 조건 다섯을 함께 넣고 같은 것을 읽는다.
|
||||
7. 두 경우를 change-streams=true 를 넣은 상태에서 반복한다.
|
||||
8. opt-in 을 켜지 않은 경우와 리액티브 템플릿이 없는 경우를 각각 읽는다.
|
||||
9. 복구 정책의 분류와 그것이 붙이는 런북 전체를 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
시작 검증기에는 능력을 요구하는 분기가 둘 있고, 두 좌항은 같은 설정 타입의 형제 불리언이다.
|
||||
|
||||
## 두 분기가 읽는 값이 오는 자리
|
||||
|
||||
:::evidence key="a06-f018-changestreams-false" alt="시작 검증기의 인접한 두 능력 분기와 그 좌항을 넘기는 자동 구성의 두 줄, 그 검사를 만드는 빈의 조건과 입력이 빠졌을 때 닫는 처리, 컴팩트 생성자의 플래그 강제와 그 주석, 자동 구성 파일에서 그 플래그가 나오는 줄 수, 소비자 빈에 붙은 조건 전체, 복구 정책의 분류 메서드, 그리고 그것이 붙이는 런북의 증상 절 전체와 그 런북에서 단독 서버·오플로그·해당 오류 코드가 나오는 줄 수를 출력한 터미널 기록." caption="두 분기의 좌항은 형제 불리언이고 인접한 두 줄이 넘김 · 검사 빈은 토폴로지 프로브 조건이고 입력이 빠지면 닫음 · 변경 스트림은 생성자에서 고정 거짓이고 자동 구성 파일에 그 이름이 나오는 줄은 1 · 소비자 조건은 @ConditionalOnMissingBean 과 타입 다섯 · 실패는 FAILED 와 장애 조치 런북 · 그 런북에 단독 서버·오플로그·40573 은 0줄 — 91줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```java
|
||||
if (transactionsEnabled && !capabilities.isStable(MongoCapability.TRANSACTION)) {
|
||||
...
|
||||
if (changeStreamsEnabled && !capabilities.isStable(MongoCapability.CHANGE_STREAM)) {
|
||||
```
|
||||
|
||||
자동 구성의 인접한 두 줄이 그 좌항을 넘긴다.
|
||||
|
||||
```java
|
||||
properties.transactions(),
|
||||
properties.changeStreams(),
|
||||
```
|
||||
|
||||
앞의 값은 배포가 넣은 대로 도착한다. 그 값에서 무슨 일이 벌어지는지는 형제 기록이 다룬다. 여기서는 뒤의 값이 이미 거짓이라는 것과, 그런데도 조립은 진행된다는 것만 본다.
|
||||
|
||||
## 조립 조건
|
||||
|
||||
주석이 근거로 든 것은 빈이 0 이라는 사실이다.
|
||||
|
||||
```java
|
||||
// Experimental, and therefore not a switch (MNG-INT-003). The driver-side source — watch,
|
||||
// resumeAfter/startAfter, cursor lifetime, reconnection — is not shipped; what exists is policy
|
||||
// and value objects that do not add up to a running consumer. Accepting the flag and ignoring …
|
||||
...
|
||||
changeStreams = false;
|
||||
```
|
||||
|
||||
소비자 빈에 붙은 조건은 `@ConditionalOnMissingBean` 과 타입 다섯의 `@ConditionalOnBean` 이다.
|
||||
|
||||
```java
|
||||
@org.springframework.boot.autoconfigure.condition.ConditionalOnBean({
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
|
||||
.class
|
||||
})
|
||||
```
|
||||
|
||||
다섯 다 배포가 공급해야 하는 타입이다. 이 플래그는 목록에 없고, 자동 구성 파일 전체에서 그 이름이 나오는 줄은 검증기 인자 하나뿐이다.
|
||||
|
||||
## 컨텍스트를 띄운 결과
|
||||
|
||||
:::evidence key="a06-f018-changestreams-false-wiring" alt="전체 자동 구성을 등록한 스프링 컨텍스트를 모듈 opt-in 없이, opt-in 과 리액티브 템플릿만으로, opt-in 과 배포가 공급해야 하는 다섯을 함께, 그리고 opt-in 과 리액티브 템플릿 없이 각각 띄워 드라이버 쪽 구현 빈과 소비자 빈과 설정 빈의 유무, 그리고 바인딩된 플래그 값을 change-streams 를 넣지 않은 경우와 넣은 경우에 대해 읽은 터미널 기록." caption="opt-in 없으면 셋 다 없음 · opt-in 과 템플릿이면 드라이버 쪽만 섬 · 다섯을 넣으면 소비자도 섬 · 설정 빈은 change-streams=true 를 받고도 changeStreams()=false · 두 경우의 빈 집합이 같음 — 13줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`MongoPlatformAutoConfiguration` 전체를 등록하고 블로킹·리액티브 템플릿을 넣어 띄웠다.
|
||||
|
||||
```text
|
||||
[opt-in, 리액티브 템플릿만]
|
||||
기본 드라이버쪽=있음 소비자=없음 설정빈=있음 changeStreams()=false
|
||||
change-streams=true 드라이버쪽=있음 소비자=없음 설정빈=있음 changeStreams()=false
|
||||
|
||||
[opt-in, 배포가 공급해야 하는 다섯을 함께]
|
||||
기본 드라이버쪽=있음 소비자=있음 설정빈=있음 changeStreams()=false
|
||||
change-streams=true 드라이버쪽=있음 소비자=있음 설정빈=있음 changeStreams()=false
|
||||
```
|
||||
|
||||
설정 빈은 컨텍스트에 있고 속성을 받는다. 받고도 거짓을 보고하므로 조립 조건에 닿기 전에 이미 값이 정해져 있다. 소비자가 서는 조건은 다섯을 공급했는지 하나다.
|
||||
|
||||
모듈 opt-in 이 없으면 세 빈이 모두 만들어지지 않고, opt-in 이 있어도 리액티브 템플릿이 없으면 두 빈이 만들어지지 않는다.
|
||||
|
||||
```text
|
||||
[모듈 opt-in 없이]
|
||||
기본 드라이버쪽=없음 소비자=없음 설정빈=없음
|
||||
|
||||
[opt-in, 리액티브 템플릿 없이]
|
||||
기본 드라이버쪽=없음 소비자=없음 설정빈=있음 changeStreams()=false
|
||||
```
|
||||
|
||||
## 능력 검사가 열리는 조건
|
||||
|
||||
검사가 꺼진 것과 검사가 애초에 만들어지지 않는 것은 다르다. 검사 빈부터 조건이 있다.
|
||||
|
||||
```java
|
||||
@Bean
|
||||
@ConditionalOnBean(MongoTopologyProbe.class)
|
||||
public InitializingBean mongoPlatformStartupCheck(
|
||||
...
|
||||
if (security == null || admin == null || versions == null) {
|
||||
// Fail closed rather than validate a subset. A partial startup check reports success for
|
||||
// the parts nobody supplied, which is the shape the missing wiring already had.
|
||||
```
|
||||
|
||||
토폴로지 프로브가 있어야 만들어지고, 보안 프로파일과 관리 자격 참조와 스키마 버전 범위가 다 있어야 돈다. 그리고 검증기는 능력 검사보다 먼저 선언 토폴로지와 실제를 대조한다. 그 둘을 통과한 배포에서만 변경 스트림 분기가 자기 차례를 얻고, 그 차례에서 좌항이 거짓이다.
|
||||
|
||||
## 그 다음 실패가 가는 곳
|
||||
|
||||
```java
|
||||
if (failure.hasLabel("ResumableChangeStreamError")) {
|
||||
return MongoChangeStreamRecoveryDecision.resume();
|
||||
}
|
||||
return MongoChangeStreamRecoveryDecision.halt(MongoChangeStreamState.FAILED, FAILURE_RUNBOOK);
|
||||
...
|
||||
private static boolean isHistoryLost(int serverCode) {
|
||||
return serverCode == 286 || serverCode == 280;
|
||||
```
|
||||
|
||||
토폴로지가 복제 셋이 아니라는 오류는 286 도 280 도 아니고 재개 가능 라벨도 없으므로 셋째 갈래다. 붙는 런북의 증상 절은 네 항목이고 전부 프라이머리 선출과 서버 선택 지연이다.
|
||||
|
||||
```text
|
||||
- `MongoServerSelectionException` / `MongoConnectionException` spike, then recovery within seconds.
|
||||
- `MongoSdamObservationListener` reports a topology change (primary removed, new primary elected).
|
||||
- `MongoPoolObservationListener` shows checkout wait times rising while server-side command duration
|
||||
stays flat — the wait is topology, not query cost.
|
||||
- Latency spike on writes with no corresponding rise in read latency.
|
||||
```
|
||||
|
||||
증상 절뿐 아니라 그 런북 전체에서 단독 서버도 오플로그도 해당 오류 코드도 나오지 않는다. 이 연쇄를 실제 서버에서 이은 것은 형제 기록이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
다섯을 공급한 포크의 배포를 오플로그 없는 토폴로지에 올려 기동 통과와 커서 열기 실패를 이어서 재현하지는 않았다. 그 연쇄는 형제 기록이 단독 서버에서 실행으로 확인했다. 여기서는 조립 조건과 검사가 열리는 조건까지 확인했다.
|
||||
|
||||
<!-- body:end -->
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: autoconfiguration-in-name-only
|
||||
title: 이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:autoconfiguration-in-name-only
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: autoconfiguration-in-name-only
|
||||
file: ../../../final/evidence/rendered/autoconfiguration-in-name-only.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/autoconfiguration-in-name-only.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05 §14.4 이다.
|
||||
---
|
||||
|
||||
# 이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다
|
||||
|
||||
이름이 AutoConfiguration 으로 끝나는 세 클래스가 실제로는 평범한 팩토리였다. 컴포지션 루트는 그 패키지를 스캔에서 뺐고, 자동설정으로 등록되지도 않았다. 능력 리포트는 세 능력을 Stable 로 보고했고 실행 컨텍스트에는 그중 아무것도 없었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
이름과 위치가 조립을 보장하지 않는다는 사례다.
|
||||
- **조건부 빈의 평가 시점 — 파싱 시점과 등록 시점**
|
||||
같은 클래스에서 이어진 두 번째 결함이 그 개념을 설명한다.
|
||||
- **시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다**
|
||||
같은 형태의 확인 절차다.
|
||||
|
||||
## 문제
|
||||
|
||||
세 클래스가 이름을 AutoConfiguration 으로 끝냈다. 그러나 셋 다 다음을 갖고 있지 않았다.
|
||||
|
||||
@AutoConfiguration 애너테이션
|
||||
@Bean 메서드
|
||||
AutoConfiguration.imports 항목
|
||||
|
||||
동시에 컴포지션 루트는 이 패키지를 컴포넌트 스캔에서 의도적으로 제외한다. 자동설정이 이 패키지에 들어가는 유일한 경로이기 때문이다.
|
||||
|
||||
세 조건이 겹치면 결과는 하나다. 아무도 이 클래스들을 등록하지 않는다.
|
||||
|
||||
## 결론
|
||||
|
||||
능력 리포트는 트랜잭션 재시도와 완료 증거와 관측성을 Stable 로 올려 두었고, 실행 컨텍스트에는 그중 아무것도 없었다.
|
||||
|
||||
이 격차의 위험은 리포트를 읽는 사람에게 있다. 재시도에 의존하는 코드를 배포할 수 있고, 그 재시도는 한 번도 일어나지 않는다. 리포트가 그것을 Stable 이라고 말했기 때문이다.
|
||||
|
||||
수정은 등록을 추가하는 것이었다. 팩토리는 그대로 남았다. 팩토리가 조립 결정을 담고 있고, 자기 컴포지션 루트를 직접 배선하는 애플리케이션은 여전히 그것을 직접 호출할 수 있기 때문이다. 달라진 것은 기본 애플리케이션이 이제 빈을 받는다는 점이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
수정된 형태를 확인하는 절차다.
|
||||
|
||||
1. JpaPlatformRuntimeAutoConfiguration 의 클래스 javadoc 두 번째 문단을 읽는다. 세 클래스가 무엇을 갖고 있지 않았는지 열거되어 있다.
|
||||
2. CaSkeletonApplication 의 AUTO_CONFIGURED_PACKAGES 에서 이 패키지가 제외되는지 확인한다.
|
||||
3. 현재 클래스에 Configuration 애너테이션과 조건들이 붙어 있고 실제 @Bean 을 갖는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
세 클래스가 `...AutoConfiguration`으로 이름 붙었고 plain factory였다 — `@AutoConfiguration`도, `@Bean`도, `.imports` 엔트리도 없었고 합성 루트는 그 패키지를 스캔에서 제외한다.
|
||||
|
||||
## 세 클래스가 갖지 않은 것
|
||||
|
||||
:::evidence key="autoconfiguration-in-name-only" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 16줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 리포트와 컨텍스트가 어긋났다
|
||||
|
||||
capability 리포트는 transaction retry·completion evidence·observability를 Stable로 나열했고 **돌고 있는 컨텍스트에는 그중 아무것도 없었다.** 개발자가 재시도되지 않는 재시도에 의존하는 코드를 배포할 수 있었다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
당시 능력 리포트의 출력을 직접 보지 않았다. 이 기록은 저장소가 javadoc 에 남긴 사후 기록에 근거한다.
|
||||
|
||||
없음 — 수정 후 형태를 코드로 확인했다
|
||||
|
||||
<!-- body:end -->
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: conditionalonbean-evaluated-at-parse-time
|
||||
title: '@ConditionalOnBean(DataSource.class)가 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다'
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:conditionalonbean-evaluated-at-parse-time
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: conditionalonbean-evaluated-at-parse-time
|
||||
file: ../../../final/evidence/rendered/conditionalonbean-evaluated-at-parse-time.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/conditionalonbean-evaluated-at-parse-time.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05 §14.4 이다.
|
||||
---
|
||||
|
||||
# @ConditionalOnBean(DataSource.class)가 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다
|
||||
|
||||
@Import 로 들어오는 설정 클래스에 붙은 @ConditionalOnBean 이 데이터소스 빈 정의가 생기기 전에 평가되어 항상 거짓이었다. 여덟 빈이 조용히 사라졌고, 아무것도 그것을 보고하지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **조건부 빈의 평가 시점 — 파싱 시점과 등록 시점**
|
||||
이 사례가 설명하는 메커니즘이다.
|
||||
- **ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다**
|
||||
이 함정이 현재 리비전에도 남아 있는지에 대한 미해결 질문이다.
|
||||
- **이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다**
|
||||
같은 클래스에서 앞서 일어난 결함이다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 클래스는 예전에 @ConditionalOnBean(DataSource.class) 를 갖고 있었다.
|
||||
|
||||
문제는 이 클래스가 자동설정으로 등록되는 것이 아니라 PersistenceJpaRootAutoConfiguration 이 @Import 로 끌어온다는 점이다. 그래서 조건이 클래스 파싱 시점에 평가된다. 데이터소스 빈 정의가 아직 존재하지 않는 시점이다.
|
||||
|
||||
따라서 조건은 실제 배포 전부에서 거짓이었다.
|
||||
|
||||
## 결론
|
||||
|
||||
여덟 빈이 조용히 사라졌다.
|
||||
|
||||
아무것도 그것을 보고하지 않았다. 그 여덟에 의존하는 것이 없었기 때문이다. 결함이 드러난 것은 데이터소스 검증기가 마침내 호출자에 연결되고 JPA Compose 레인이 적격 빈 없음이라고 답했을 때다.
|
||||
|
||||
수정은 조건의 순서를 바꾸는 것이 아니라 조건을 제거하는 것이었다. 근거는 이렇다. 이 클래스는 JPA 루트를 통해서만 도달하고 그 루트가 이미 마스터 스위치를 갖고 있으므로, 파싱 시점에는 데이터소스가 있느냐는 질문에 이미 예라고 답한 상태다. 데이터소스가 필요한 빈은 그것을 파라미터로 받고, 스위치가 켜진 채 데이터소스가 없으면 시끄러운 실패가 된다. 계층이 사라지는 것보다 그쪽이 원하던 결과다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
수정된 형태를 확인하는 절차다.
|
||||
|
||||
1. JpaPlatformRuntimeAutoConfiguration 의 클래스 javadoc 다섯 번째와 여섯 번째 문단을 읽는다.
|
||||
2. 현재 클래스 애너테이션에 ConditionalOnBean 이 없고 ConditionalOnClass 와 ConditionalOnProperty 만 있는지 확인한다.
|
||||
3. PersistenceJpaRootAutoConfiguration 이 이 클래스를 Import 하는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
이 클래스는 루트가 **import**하지 auto-configure하지 않으므로, 그 조건이 클래스 파싱 중 — datasource 빈 정의가 존재하기 전에 — 평가됐고 따라서 **모든 실제 배포에서 false**였다.
|
||||
|
||||
## 조건이 평가된 시점
|
||||
|
||||
:::evidence key="conditionalonbean-evaluated-at-parse-time" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 16줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 여덟 빈이 조용히 사라졌다
|
||||
|
||||
아무것도 그중 어느 것에도 의존하지 않아 아무것도 보고하지 않았다.
|
||||
|
||||
## 드러난 시점
|
||||
|
||||
datasource validator가 caller에 배선되고 Compose 레인이 "No qualifying bean"이라고 답했을 때다. 같은 함정을 피하려고 루트의 검사가 validator를 주입받지 않고 직접 생성한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
현재 리비전의 다른 조건부 빈들이 각각 어느 시점에 평가되는지 런타임에서 확인하지 않았다. debug 부팅의 조건 평가 리포트가 그것을 답한다.
|
||||
|
||||
현재 리비전에서 재발하지 않는지 ConditionEvaluationReport로 확인하지 않았다
|
||||
|
||||
<!-- body:end -->
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: narrowing-the-scan-orphaned-eight-components
|
||||
title: 넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:narrowing-the-scan-orphaned-eight-components
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: narrowing-the-scan-orphaned-eight-components
|
||||
file: ../../../final/evidence/rendered/narrowing-the-scan-orphaned-eight-components.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/narrowing-the-scan-orphaned-eight-components.txt
|
||||
source:
|
||||
- 원본 분석 절은 analysis/05 §14.2 이다.
|
||||
---
|
||||
|
||||
# 넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다
|
||||
|
||||
컴포지션 루트가 퍼시스턴스 패키지를 스캔에서 뺐다. 그 제외는 옳았지만 나머지 절반이 빠져 있었다. 스캔 컴포넌트로 작성된 여덟 클래스에 아무도 도달하지 않았고, 그중 하나는 트랜잭션 포트의 유일한 구현이었다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다**
|
||||
같은 형태가 웹 리프에서 반복된 사례다.
|
||||
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
|
||||
스캔 제외가 그 규칙을 따른 조치라는 점이 이 사례의 전제다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
스테레오타입이 붙어 있다는 것도 조립 증거가 아니다.
|
||||
|
||||
## 문제
|
||||
|
||||
컴포지션 루트의 컴포넌트 스캔은 퍼시스턴스 어댑터 패키지 전체를 정규식으로 제외한다. javadoc 은 그 제외가 옳다고 명시한다. 선택적 능력을 선택적으로 만드는 것이 그 제외이며, JPA 가 꺼진 배포는 퍼시스턴스 빈을 조립하지 않는다.
|
||||
|
||||
빠진 것은 나머지 절반이다. 이 리프의 여덟 클래스가 스캔 컴포넌트로 작성되어 있었다.
|
||||
|
||||
SpringTransactionPort
|
||||
PersistenceExceptionTranslator
|
||||
StandardSqlStateErrorMapping
|
||||
DomainContextAuditContextPort
|
||||
멱등성 저장소와 그 리퍼
|
||||
outbox 저장소와 그 리퍼
|
||||
|
||||
넓은 스캔이 이들에게 닿지 않게 되자 다른 어떤 것도 닿지 않았다. @Component 와 @Repository 가 붙어 있었지만 실행 중인 어떤 애플리케이션에서도 빈이 아니었다.
|
||||
|
||||
특히 TransactionPort 는 구현이 아예 없는 상태가 됐다. 트랜잭션을 여는 모든 유스케이스가 그것을 열 포트를 갖지 못했다.
|
||||
|
||||
## 결론
|
||||
|
||||
단위 테스트로는 보이지 않았다. 이 클래스들은 각자의 테스트에서 직접 생성되기 때문이다.
|
||||
|
||||
드러난 것은 트랜잭션이 필요한 능력이 실제로 조립됐을 때다. 알림 오케스트레이터가 local-notification-ingest 레인에서 미충족 의존성으로 실패했다.
|
||||
|
||||
수정은 스캔을 복원하되 원래 덮었어야 할 패키지로 좁히고, PersistenceJpaRootAutoConfiguration 을 통해서만 도달하게 만드는 것이었다. 그 루트가 JPA 마스터 스위치를 갖는다. 꺼짐은 여전히 구조적이다.
|
||||
|
||||
두 패키지는 의도적으로 빠져 있다. fileserver 는 자기 능력 스위치로 게이트되고 자기 설정 클래스가 스캔한다. notification 은 전용 파사드가 빈 단위로 명시적으로 조립한다.
|
||||
|
||||
이 패키지들 아래 컴포넌트는 각자의 ConditionalOnProperty 가드를 유지한다. 스캔 대상이 된다는 것은 후보가 된다는 뜻이지 무조건 빈이 된다는 뜻이 아니다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
수정된 형태를 확인하는 절차다.
|
||||
|
||||
1. JpaAdapterComponentsConfig 의 클래스 javadoc 을 읽는다. 여덟 클래스가 이름으로 열거되어 있다.
|
||||
2. CaSkeletonApplication 의 제외 정규식에 퍼시스턴스 패키지가 있는지 확인한다.
|
||||
3. 이 설정 클래스가 PersistenceJpaRootAutoConfiguration 을 통해서만 도달하는지 확인한다.
|
||||
4. 의도적으로 빠진 두 패키지의 대체 조립 경로를 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
합성 루트의 스캔이 persistence 트리를 정규식으로 제외했고 **그 제외는 옳다** — 그것이 optional capability를 optional하게 만든다. 빠진 것은 나머지 절반이다.
|
||||
|
||||
## SpringTransactionPort 참조 위치
|
||||
|
||||
:::evidence key="narrowing-the-scan-orphaned-eight-components" alt="코드베이스에서 SpringTransactionPort 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SpringTransactionPort 코드베이스 검색 — 11줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 여덟 클래스에 아무것도 도달하지 않았다
|
||||
|
||||
`SpringTransactionPort`·`PersistenceExceptionTranslator`·`StandardSqlStateErrorMapping`·`DomainContextAuditContextPort`·idempotency store와 reaper·outbox store와 reaper가 scanned component로 쓰여 있는데, 넓은 스캔이 멈추자 아무것도 도달하지 않았다.
|
||||
|
||||
## 특히 TransactionPort 는 구현이 전혀 없었다
|
||||
|
||||
트랜잭션을 여는 모든 유스케이스가 열 포트를 갖지 못했고, 단위 테스트는 각 클래스를 직접 생성하므로 볼 수 있는 것이 없었다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
당시 실패했던 local-notification-ingest 레인을 이 리비전에서 재실행하지 않았다.
|
||||
|
||||
없음 — 수정된 @ComponentScan 대상 6개를 코드로 확인했다
|
||||
|
||||
<!-- body:end -->
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: observation-downgraded-by-the-composition
|
||||
title: 관측을 필수 생성자 인자로 만든 수정을 조립이 6인자 생성자로 되돌렸다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:observation-downgraded-by-the-composition
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-observation-downgraded-by-the-composition.body.md
|
||||
assets:
|
||||
- key: observation-downgraded-by-the-composition
|
||||
file: ../../../final/evidence/rendered/observation-downgraded-by-the-composition.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/observation-downgraded-by-the-composition.txt
|
||||
- ../../../final/evidence/raw/tl-messaging-observation-noop.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#5-2 · analysis/19 §5.1 이다.
|
||||
---
|
||||
|
||||
# 관측을 필수 생성자 인자로 만든 수정을 조립이 6인자 생성자로 되돌렸다
|
||||
|
||||
발행기는 관측 구현을 필수 인자로 요구하는 생성자를 갖고 있다. 자동설정이 관측 인자가 없는 짧은 생성자를 부르고, 그 생성자는 no-op 관측으로 위임한다. 출하 배포에서 메시징 관측은 아무것도 기록하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
타입이 요구하는 것과 조립이 넘기는 것이 다를 수 있다는 사례다.
|
||||
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
|
||||
발행기만 읽으면 관측이 필수로 보인다.
|
||||
- **outbox가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다**
|
||||
같은 조립 지점에서 나온 다른 공백이다.
|
||||
|
||||
## 문제
|
||||
|
||||
DefaultMessagePublisher 는 생성자가 둘이다.
|
||||
|
||||
7인자 : 관측 인자가 없고, NO_OBSERVATION 으로 위임한다
|
||||
8인자 : 관측을 받고 Objects.requireNonNull 로 검사한다
|
||||
|
||||
8인자 쪽 javadoc 은 그것이 모든 결과를 기록하는 발행기라고 적는다. 7인자 쪽은 주입 가능한 시계를 위한 것이라고 적는다.
|
||||
|
||||
NO_OBSERVATION 은 필드 javadoc 이 그 성격을 밝힌다. 메트릭을 배선하지 않은 배포를 위한 no-op 관측이다. 여섯 개 기록 메서드가 전부 빈 본문이다.
|
||||
|
||||
## 결론
|
||||
|
||||
자동설정이 6인자 호출을 한다.
|
||||
|
||||
MessagingCoreAutoConfiguration 의 messagingPublisher 빈은 여섯 개 협력자만 받아 6인자 생성자를 호출한다. 그 생성자는 다시 7인자로, 7인자는 8인자로 NO_OBSERVATION 을 넣어 위임한다.
|
||||
|
||||
결과적으로 출하 배포의 발행기는 발행도, 전달도, 정산도, 백로그도, 진단도 기록하지 않는다. 관측 인터페이스는 존재하고 구현체도 존재하지만 그 사이를 잇는 조립이 없다.
|
||||
|
||||
이 실패의 성격은 조용하다. 빈은 생성되고 발행은 정상 동작하며 로그에도 신호가 없다. no-op 구현이 명시적으로 존재하기 때문에 널 참조도 예외도 나지 않는다. 관측이 꺼진 것과 관측이 배선되지 않은 것이 런타임에서 구별되지 않는다.
|
||||
|
||||
@ConditionalOnMissingBean 이 붙어 있으므로 애플리케이션이 자기 MessagePublisher 빈을 등록하면 이 자동설정은 물러난다. 그러나 이 저장소의 출하 컴포지션은 그렇게 하지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
확인 방식 : 정적 도달성 확인. 애플리케이션을 부팅하지 않았다
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/tl-messaging-observation-noop.txt 에 있다.
|
||||
|
||||
1. DefaultMessagePublisher 의 생성자 두 개와 NO_OBSERVATION 필드를 읽는다.
|
||||
2. 6인자 호출이 7인자로, 7인자가 8인자로 위임하며 관측 자리에 NO_OBSERVATION 이 들어가는 경로를 확인한다.
|
||||
3. MessagingCoreAutoConfiguration 의 messagingPublisher 빈이 몇 개의 인자로 생성자를 부르는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
관측을 선택적 데코레이터가 아니라 필수 생성자 인자로 만든 수정이 runtime-core에 있고 그 javadoc이 "an unobserved publish path is how 'the dashboards were empty during the incident' happens"로 이유를 적는다.
|
||||
|
||||
## 관측을 필수 인자로 만든 수정
|
||||
|
||||
:::evidence key="observation-downgraded-by-the-composition" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 조립이 그 수정을 되돌린다
|
||||
|
||||
자동설정은 6인자 생성자를 골라 `NO_OBSERVATION`(다섯 메서드 전부 빈 본문)을 주입하고, 방출자 넷은 main 참조 0이며 등록되는 것은 협력자 둘뿐이다.
|
||||
|
||||
## 같은 경로가 예외 메시지를 의도적으로 버린다
|
||||
|
||||
둘이 합쳐지면 진단 흔적이 남지 않는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
애플리케이션을 부팅해 액추에이터에서 messaging 메트릭 시리즈의 부재를 관측하지 않았다. 부팅 한 번이면 확증된다.
|
||||
|
||||
<!-- body:end -->
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: outbox-chain-behind-an-unsatisfiable-condition
|
||||
title: outbox 가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:outbox-chain-behind-an-unsatisfiable-condition
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-outbox-chain-behind-an-unsatisfiable-condition.body.md
|
||||
assets:
|
||||
- key: outbox-chain-behind-an-unsatisfiable-condition
|
||||
file: ../../../final/evidence/rendered/outbox-chain-behind-an-unsatisfiable-condition.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/outbox-chain-behind-an-unsatisfiable-condition.txt
|
||||
- ../../../final/evidence/raw/tl-outbox-unsatisfiable-condition.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#4-3 · analysis/19 §7.1 이다.
|
||||
---
|
||||
|
||||
# outbox 가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다
|
||||
|
||||
messaging 플랫폼의 outbox 사슬은 조건이 참이 될 수 없어 조립되지 않는다. 원인은 조건 결함이 아니라 이 저장소가 outbox 를 두 번 구현했고 다른 쪽을 출하하기 때문이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
두 스택 중 하나만 컨텍스트에 들어간다는 것이 이 규칙의 사례다.
|
||||
- **@ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다**
|
||||
조건이 참이 될 수 없다는 관측이 이 규칙으로 이어진다.
|
||||
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
|
||||
조립하는 쪽을 읽지 않으면 중복을 조건 결함으로 오진한다.
|
||||
- **high-water mark가 본 위치를 뜻해서 재전달된 변경이 영구히 사라졌다**
|
||||
같은 신뢰성 계열에서 조용한 실패가 나타난 다른 사례다.
|
||||
|
||||
## 문제
|
||||
|
||||
messaging 스타터의 MessagingReliabilityAutoConfiguration 은 outbox 와 inbox 운영 빈을 조건부로 등록한다.
|
||||
|
||||
81행 : OutboxRelay 는 OutboxRepository 와 OutboxEnvelopeFactory 빈이 있을 때만
|
||||
106행 : OutboxRelayWorker 는 OutboxRelay 가 있을 때만
|
||||
123행 : MessagingOutboxRelayLifecycle 은 OutboxRelayWorker 가 있을 때만
|
||||
138행 : OutboxCleanupJob 은 OutboxRepository 가 있을 때만
|
||||
167행 : InboxCleanupJob 은 InboxRepository 가 있을 때만
|
||||
|
||||
사슬의 뿌리는 OutboxRepository 와 OutboxEnvelopeFactory 다. 프로덕션 코드 어디에도 그 두 타입의 빈을 만드는 곳이 없다. 따라서 사슬 전체가 조립되지 않고, outbox 와 inbox 리프의 main 파일 19개 2,818 LOC 가 조용히 비어 있다.
|
||||
|
||||
여기까지가 앞선 분석의 판정이었고 그것은 사실이다. 다시 잰 이유는 스타터의 클래스 javadoc 이 이 조건들을 결함이 아니라 계약으로 서술하기 때문이다. 플랫폼은 그 저장소를 제공할 수 없다고 명시한다. 애플리케이션 자신의 트랜잭션에서 애플리케이션 자신의 데이터소스에 쓰기 때문이다.
|
||||
|
||||
그렇다면 남는 질문은 하나다. 이 저장소의 출하 애플리케이션은 그 계약을 이행하는가.
|
||||
|
||||
## 결론
|
||||
|
||||
이행하지 않는다. 대신 자기 outbox 를 갖고 있다.
|
||||
|
||||
스택 A 는 배선되어 출하된다.
|
||||
|
||||
포트 : application-core 의 outbox 패키지. OutboxStorePort 와 OutboxAppendPort 와 OutboxMessagePublishPort 와 PublishPendingOutboxEventsUseCase 를 포함해 15개 파일
|
||||
구현 : persistence-jpa 의 OutboxStoreAdapter. @Repository 가 붙어 있어 컴포넌트 스캔으로 컨텍스트에 들어간다
|
||||
구동 : app-bootstrap 의 OutboxConfig 가 PublishPendingOutboxEventsUseCase 를 직접 생성하고, OutboxRelayScheduler 가 @Scheduled 로 5초 간격 폴링한다
|
||||
|
||||
스택 B 는 어둡다.
|
||||
|
||||
포트 : messaging-reliability-api 의 OutboxRepository
|
||||
구현 : JdbcOutboxRepository 2,276 LOC. 스프링 스테레오타입이 없다
|
||||
조립 : MessagingReliabilityAutoConfiguration 81행의 조건 뒤
|
||||
|
||||
측정으로 확정한 것은 이렇다. main 코드에서 JdbcOutboxRepository 나 JdbcInboxRepository 나 OutboxEnvelopeFactory 를 생성하는 곳이 0 이고, app-bootstrap 에서 관련 빈을 만드는 곳도 0 이다. OutboxEnvelopeFactory 를 @Bean 으로 만드는 곳은 스타터의 테스트 하나뿐이다.
|
||||
|
||||
이 차이가 중요한 이유는 수정 방향이 반대이기 때문이다. 조건이 만족되지 않는다고 읽으면 app-bootstrap 에 빈을 등록하는 수정이 된다. outbox 가 둘이라고 읽으면 어느 쪽이 정본인지 먼저 정해야 하는 문제가 된다.
|
||||
|
||||
후자가 맞다. 두 스택은 저장 모델도 다르고 발행 경로도 다르다. 둘을 동시에 켜면 같은 업무 이벤트가 두 테이블에 적히거나 두 번 발행될 수 있다.
|
||||
|
||||
스타터의 javadoc 은 relay 를 자기가 구동하는 이유를 이렇게 적는다. 아무도 relay 를 돌리지 않는 것은 성공을 보고한 업무 트랜잭션 뒤에서 테이블이 차오르는 상황이라는 것이다. 그 경고는 스택 B 의 테이블에는 해당하지 않는다. 그 테이블에 쓰는 코드가 배선되어 있지 않으므로 채워질 일이 없다. 스택 B 는 위험한 것이 아니라 존재하지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
확인 방식 : 정적 도달성 전수 확인. 애플리케이션을 부팅하지 않았다
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/335-two-outboxes-one-wired.txt 에 있다.
|
||||
|
||||
1. MessagingReliabilityAutoConfiguration 의 조건 사슬과 클래스 javadoc 을 읽는다.
|
||||
2. main 소스 전체에서 JdbcOutboxRepository 와 JdbcInboxRepository 와 OutboxEnvelopeFactory 를 생성하는 코드를 찾는다. 일치 0 이다.
|
||||
3. app-bootstrap 의 main 에서 outbox 또는 inbox 저장소 빈을 만드는 곳을 찾는다. 일치 0 이다.
|
||||
4. application-core 의 outbox 패키지와 persistence-jpa 의 OutboxStoreAdapter 스테레오타입, app-bootstrap 의 OutboxConfig 와 OutboxRelayScheduler 를 읽어 다른 스택이 배선되어 있음을 확인한다.
|
||||
|
||||
앞선 사이클의 증거 파일 tl-outbox-unsatisfiable-condition.txt 는 일부 구획에 셸 변수가 전개되지 않은 채 저장돼 있다. 그 구획의 수치는 이번에 다시 쟀다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
이 저장소에는 서로를 모르는 outbox 구현이 둘 있다.
|
||||
|
||||
## 스택 A — 배선되어 출하된다
|
||||
|
||||
`application-core/.../outbox/` 의 포트와 `persistence-jpa` 의 `@Repository OutboxStoreAdapter`, 그리고 `app-bootstrap` 의 `OutboxConfig` 가 `@Scheduled` 로 구동하는 `PublishPendingOutboxEventsUseCase` 다.
|
||||
|
||||
## OutboxConfig 참조 위치
|
||||
|
||||
:::evidence key="outbox-chain-behind-an-unsatisfiable-condition" alt="코드베이스에서 OutboxConfig 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxConfig 코드베이스 검색 — 3줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 스택 B — 조건이 프로덕션에서 참이 되지 않는다
|
||||
|
||||
`messaging-reliability-api` 의 `OutboxRepository` 와 `JdbcOutboxRepository`(2,276 LOC)와 `OutboxRelay` 계열이며, `MessagingReliabilityAutoConfiguration:81` 의 `@ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class})` 뒤에 있다. `JdbcOutboxRepository` 에는 스프링 스테레오타입이 없고 그것을 만드는 `@Bean` 도 main 에 없다.
|
||||
|
||||
## 조건 결함이 아니라 정본이 정해지지 않은 중복이다
|
||||
|
||||
스타터의 javadoc 은 이 조건들을 결함이 아니라 계약으로 서술하고 저장소·팩토리 제공을 애플리케이션 책임으로 둔다. 두 스택은 저장 모델과 발행 경로가 다르므로 동시에 켜면 같은 이벤트가 두 번 적히거나 두 번 발행될 수 있다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
애플리케이션을 부팅해 조건 평가 리포트로 확인하지 않았다. 부팅 한 번이면 두 스택 중 어느 것이 컨텍스트에 들어가는지 직접 관측된다.
|
||||
|
||||
두 스택을 동시에 켰을 때 실제로 이중 기록이 일어나는지 재현하지 않았다. 저장 모델이 다르다는 것은 코드로 확인했으나 그 결과를 관측한 것은 아니다.
|
||||
|
||||
ConditionEvaluationReport로 미충족 사유를 확인하지 않았다
|
||||
|
||||
<!-- body:end -->
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: scan-exclusion-without-an-owner
|
||||
title: 스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:scan-exclusion-without-an-owner
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-scan-exclusion-without-an-owner.body.md
|
||||
assets:
|
||||
- key: scan-exclusion-without-an-owner
|
||||
file: ../../../final/evidence/rendered/scan-exclusion-without-an-owner.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/scan-exclusion-without-an-owner.txt
|
||||
- ../../../final/evidence/raw/tl-web-six-unowned-components.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#3-1 · analysis/14 §8.1, §51 이다.
|
||||
---
|
||||
|
||||
# 스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다
|
||||
|
||||
컴포지션 루트가 웹 플랫폼의 다섯 패키지를 컴포넌트 스캔에서 뺐다. 그 패키지를 소유해야 할 두 자동설정은 등록되어 실행되지만, 그 안의 컴포넌트 여섯 개는 어느 쪽도 만들지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **@Bean이 있다는 것은 조립 증거다가 아니다**
|
||||
자동설정이 등록되고 실행된다는 것과 그것이 무엇을 만드는가는 별개다.
|
||||
- **Spring 조립의 세 경로와 각각이 결정하는 것**
|
||||
스캔 제외와 자동설정 소유가 서로를 보완해야 하는 구조다.
|
||||
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
|
||||
스캔 제외라는 구조적 선택 자체는 이 규칙을 따른 것이다.
|
||||
|
||||
## 문제
|
||||
|
||||
컴포지션 루트는 @SpringBootApplication 대신 @ComponentScan 을 직접 쓰고, 정규식 하나로 자동설정이 소유해야 할 패키지들을 스캔에서 뺀다.
|
||||
|
||||
그 목록에 웹 플랫폼의 다섯 패키지가 들어 있다.
|
||||
|
||||
adapter.inbound.web.mvc.error
|
||||
adapter.inbound.web.mvc.budget
|
||||
adapter.inbound.web.mvc.operation
|
||||
adapter.inbound.web.webflux.error
|
||||
adapter.inbound.web.webflux.operation
|
||||
|
||||
주석은 이 다섯이 목록에 있는 이유를 명시한다. 그 안의 어드바이스와 컨트롤러는 해당 플랫폼 자동설정이 활성일 때만 존재하는 빈을 필요로 하는데, 컴포넌트 스캔은 그 조건과 무관하게 그것들을 찾는다. 그래서 전부 꺼진 배포나 부분 설정 배포가 미충족 의존성으로 기동에 실패했다. 자동설정이 소유하게 하는 것이 컨트롤의 존재를 그 의존성의 존재에 묶는 방법이다.
|
||||
|
||||
의도는 명확하다. 문제는 그 소유가 실제로 일어나는가다.
|
||||
|
||||
## 결론
|
||||
|
||||
두 자동설정은 등록되어 있고 출하 컨텍스트에 도달한다. 그러나 그 안의 컴포넌트 여섯 개를 만들지 않는다.
|
||||
|
||||
스캔에서 뺀 다섯 패키지 안의 컴포넌트와 그 main 참조 수는 이렇다.
|
||||
|
||||
WebMvcProblemExceptionHandler : @RestControllerAdvice @Order, main 참조 0
|
||||
WebFluxProblemExceptionHandler : @RestControllerAdvice @Order, main 참조 0
|
||||
WebMvcBudgetExceptionHandler : @RestControllerAdvice @Order, main 참조 0
|
||||
WebMvcBudgetFilter : 애너테이션 없음, main 참조 0
|
||||
OperationHttpController : @RestController, main 참조 0
|
||||
ReactiveOperationHttpController : @RestController, main 참조 0
|
||||
|
||||
두 자동설정이 만드는 것은 각각 13개와 10개 빈이고, @Import 는 0 이다. 그 23개는 전부 협력자다. 문제 카탈로그, 예산 카탈로그, 검증 예외 매퍼, URI 정책, 요청 ID 필터 같은 것들이며 위 여섯 중 하나도 포함하지 않는다.
|
||||
|
||||
즉 스캔 제외는 소유권을 자동설정으로 옮기려는 조치였는데, 옮겨받을 쪽이 그것을 받지 않았다. 여섯 컴포넌트는 스캔에서도 빠지고 자동설정에도 없다. 어디에도 없다.
|
||||
|
||||
기동은 성공한다. 미충족 의존성으로 실패하던 원래 증상은 사라졌다. 다만 그 컨트롤들도 함께 사라졌다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
확인 방식 : 정적 도달성 확인. 애플리케이션을 부팅하지 않았다
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/tl-web-six-unowned-components.txt 에 있고, 터미널 자산은 evidence/terminal 아래에 있다.
|
||||
|
||||
1. CaSkeletonApplication 의 AUTO_CONFIGURED_PACKAGES 정규식을 읽고 제외 대상 패키지를 확인한다.
|
||||
2. 그 다섯 패키지 안의 스프링 스테레오타입 컴포넌트를 열거한다.
|
||||
3. 각각에 대해 main 소스에서의 참조 수를 센다.
|
||||
4. WebMvcPlatformAutoConfiguration 과 WebFluxPlatformAutoConfiguration 의 @Bean 목록과 @Import 수를 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
합성 루트가 다섯 web 패키지를 스캔에서 제외하고 근거를 "Ownership by auto-configuration is what ties a control's presence to its dependency's"로 적었다.
|
||||
|
||||
## ProblemCatalog 참조 위치
|
||||
|
||||
:::evidence key="scan-exclusion-without-an-owner" alt="코드베이스에서 ProblemCatalog 를 검색한 출력 15줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ProblemCatalog 코드베이스 검색 — 15줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 두 자동설정은 실제로 존재하고 도달한다
|
||||
|
||||
`.imports`에 있고 출하 컨텍스트에 도달하는데, 등록하는 `@Bean` 13개·10개가 전부 협력자이고 제외된 패키지의 컴포넌트 여섯은 어느 쪽도 소유하지 않는다(전부 main 참조 0).
|
||||
|
||||
## 빈은 있고 그것을 쓰는 조언은 빈이 아니다
|
||||
|
||||
`ProblemCatalog`와 `WebProblemFactory`는 빈이고 그것을 쓰는 `@RestControllerAdvice`는 빈이 아니다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
애플리케이션을 부팅해 실제 빈 목록으로 확인하지 않았다. 부팅 한 번이면 여섯 컴포넌트의 부재가 직접 관측된다.
|
||||
|
||||
<!-- body:end -->
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: thirteen-startup-rules-never-run
|
||||
title: 시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:thirteen-startup-rules-never-run
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: thirteen-startup-rules-never-run
|
||||
file: ../../../final/evidence/rendered/thirteen-startup-rules-never-run.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/thirteen-startup-rules-never-run.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#5-3 · analysis/20 §3.1 이다.
|
||||
---
|
||||
|
||||
# 시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다
|
||||
|
||||
gRPC 플랫폼의 시작 검증기는 188줄에 13개 위반 규칙을 담고 있다. 이 검증기를 부르는 것은 자기 테스트뿐이고, 유일한 자동설정 지점은 부르지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다**
|
||||
이 사례에서 끌어낸 확인 절차다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
검증기가 존재한다는 것과 그것이 도는 것은 별개다.
|
||||
|
||||
## 문제
|
||||
|
||||
GrpcPlatformStartupValidator 는 188줄이고 violations 목록에 13개 항목을 추가한다. TLS 요구, 실행기 풀 크기, 채널 프로파일, 자격증명 누출 등을 검사한다.
|
||||
|
||||
이 검증기를 호출하는 곳을 저장소 전체에서 찾으면 자기 테스트 GrpcPlatformStartupValidatorTest 하나뿐이다.
|
||||
|
||||
같은 패키지의 GrpcPlatformAutoConfiguration 은 106줄이고 @Bean 이 9개인데, 그중 어느 것도 이 검증기를 부르지 않는다.
|
||||
|
||||
## 결론
|
||||
|
||||
검증기는 정확하고 잘 테스트되어 있으며 돌지 않는다.
|
||||
|
||||
이 판정은 gRPC 블록 전체가 어떤 런타임 컴포지션에도 속하지 않는다는 더 큰 사실 안에 있다. 그 상태는 저장소가 문서로 인정하고 있으며 결함이 아니다. 다만 이 검증기의 경우, 블록이 배포되기 시작하는 날에도 자동으로 돌기 시작하지는 않는다는 점이 남는다. 조립 지점이 그것을 부르지 않기 때문이다.
|
||||
|
||||
13개 규칙은 테스트로 고정되어 있으므로 회귀는 잡힌다. 잡히지 않는 것은 그 규칙이 실행 시점에 적용되는가다.
|
||||
|
||||
확인 절차로 일반화하면 이렇다. 시작 검증기가 실제로 도는지는 그 능력에 자동설정 루트가 있고 그 루트가 검증기를 부르는지와 일치한다. 검증기 파일의 존재나 그 테스트의 통과는 답이 아니다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
Gradle : 9.0.0
|
||||
Spring Boot : 4.0.8
|
||||
확인 방식 : 정적 도달성 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/267-grpc-family-reachability.txt 에 있다.
|
||||
|
||||
1. GrpcPlatformStartupValidator 의 줄 수와 violations 추가 지점 수를 센다.
|
||||
2. 저장소 전체에서 이 타입의 참조를 찾고 main 과 test 를 구분한다.
|
||||
3. GrpcPlatformAutoConfiguration 의 @Bean 목록에서 검증기 호출이 있는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
validator가 5개 그룹 13개 규칙을 갖고(transport·security 4 / executor 2 / methods 4 / channels 2 / advanced isolation 1) javadoc이 그 13개를 고른 기준을 "None of them fails a smoke test"로 적는다.
|
||||
|
||||
## 다섯 그룹 13개 규칙
|
||||
|
||||
:::evidence key="thirteen-startup-rules-never-run" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 유일한 조립 지점이 부르지 않는다
|
||||
|
||||
자동설정은 `@Bean` 9개를 만들면서 이 validator를 부르지 않고, static 메서드라 빈이 될 수도 없다.
|
||||
|
||||
## 두 개의 강제가 이 하나를 통해서만 성립한다
|
||||
|
||||
CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 startup을 거부한다"와 §2.2의 runtime 강제 둘 다이므로, 둘 다 실행되지 않는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
gRPC 블록이 어떤 배포에도 포함되지 않으므로 런타임 관측은 불가능하다. 이 판정은 정적 도달성에 근거한다.
|
||||
|
||||
build-only 가족이라 부팅 확인이 불가능하다 — 채택 시점에만 관측 가능
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user