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,163 @@
---
kind: CASE
slug: a05-f007-transactionprofileregistry
title: 오타를 막는 근거만 남기고, 오타가 들어올 자리를 지웠다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f007-transactionprofileregistry
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f007-transactionprofileregistry-adr
file: ../../../final/evidence/rendered/a05-f007-transactionprofileregistry-adr.svg
- key: a05-f007-transactionprofileregistry
file: ../../../final/evidence/rendered/a05-f007-transactionprofileregistry.svg
evidence:
- ../../../final/evidence/raw/a05-f007-transactionprofileregistry-adr.txt
- ../../../final/evidence/raw/a05-f007-transactionprofileregistry.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §25 다. 프로덕션 참조 0, 전용 단위 테스트만 존재, 비공개 API 패키지, 루트 빈 배선 없음이 그 절의 판정이고 등급은 P3 다. ADR 대조와 형제 레지스트리 비교는 이 기록에서 새로 확인했다. 바로 앞 §24 는 같은 리프에서 트랜잭션 스택이 둘로 갈린 문제를 다룬다.
---
# 오타를 막는 근거만 남기고, 오타가 들어올 자리를 지웠다
프로파일을 이름으로 찾고 등록되지 않은 이름을 거절하는 레지스트리가 남았다. 이름을 건네던 유일한 호출부는 한 커밋에서 지워졌고, 같은 커밋이 그 레지스트리의 javadoc 에서 지워진 애너테이션 이름만 빼고 오타 예시와 논거는 남겼다.
## 관계
- **재시도 구현이 둘이고 정교한 쪽을 아무도 호출하지 않는다**
같은 스택의 다른 마디가 같은 형태로 남은 사례다.
- **legacy compatibility surface의 제거 조건을 세 가지로 고정한다**
이 후보를 그 세 조건에 대 봤다.
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
같은 개념을 가리키는 어휘가 둘 남았을 때 어느 쪽이 죽었는지 표시하라는 규칙이다.
## 문제
이 레지스트리는 등록되지 않은 이름에 기본값을 주지 않고 던진다. javadoc 이 그 이유로 프로파일 이름 오타 하나가 남의 격리 수준과 타임아웃과 재시도 예산을 물려받는 상황을 든다.
그런 방어가 값을 하려면 이름을 건네는 쪽이 있어야 한다.
## 결론
이름을 건네던 곳은 하나였다. 삭제된 인터셉터의 66행이 애너테이션에 적힌 프로파일 이름을 레지스트리에 넘기고, 69행이 그렇게 얻은 프로파일로 코디네이터를 불렀다.
애너테이션 44줄, 인터셉터 109줄, 그 테스트 211줄이 한 커밋에서 함께 지워졌다. 파일 909개를 옮긴 기능 커밋 하나에 이 삭제가 섞여 들어갔다.
같은 커밋이 레지스트리 javadoc 도 한 줄 고쳤다. 오타 예시에서 애너테이션 표기를 빼고 이름만 남겼다. 방어의 근거는 손질해서 남기고, 방어할 대상은 같은 커밋으로 지운 것이다.
지금 세면 자기 파일을 뺀 프로덕션 참조 0, 자동설정 등록 0, 설정 키 0 이다. 패키지는 스캔 범위에 들어가는데 이 클래스에는 스테레오타입 애너테이션이 없다. 남은 참조는 전용 단위 테스트 한 파일의 셋이다.
레지스트리가 담는 타입도 사정이 다르지 않다. TransactionProfile 은 네 파일에 나타나고 공개 포트가 그것을 두 번째 인자로 받는다. 그런데 그 포트를 참조하는 main 파일은 포트 자신과 구현체뿐이고, 자기 파일 밖에서 프로파일을 만드는 main 코드도 없다. 참조가 네 파일 안에서만 돌고 바깥에서 들어오는 길이 없다.
프로덕션 트랜잭션은 다른 타입으로 열린다. 같은 패키지의 SpringTransactionPort 는 애플리케이션 계층 요청 타입을 받는데, 그 파일에서 TransactionProfile 을 부르는 곳이 없다.
그래서 이름 조회가 객체 전달로 대체된 것으로 읽으면 틀린다. 이름을 받던 입력이 사라졌고, 객체를 받는 쪽에도 프로덕션 호출자가 붙지 않았다. 레지스트리는 그 스택에서 가장 먼저 잘려 나간 마디다.
제거 조건 셋 중 둘은 확인된다. 외부 프로덕션 참조가 0 이고 설정 경로가 없다. 세 번째인 대체 경로의 특성화는 확인되지 않는다. 대체 경로가 같은 동작을 다르게 하는 것이 아니라, 이름 조회를 수행하는 프로덕션 경로가 없다.
이런 모양 자체가 버려진 것은 아니다. 문자열 이름을 받아 등록되지 않았으면 던지는 레지스트리를 정렬 필드 매퍼가 지금도 호출한다. 형제로 셀 수 있는 것은 결국 하나다. gRPC 채널 레지스트리는 검증된 값 객체를 키로 쓰고, Mongo 일관성 레지스트리는 enum 을 키로 써서 조회가 실패할 수 없다고 javadoc 이 적는다.
ADR 은 아직 지워진 것을 전제한다. 강제 절이 삭제된 클래스의 상수를 근거로 지목하고, 결정 절은 재시도 어드바이스가 스프링 트랜잭션 어드바이스 바깥에 정렬된다고 적는다. 그 상수 이름은 소스 전체에 없고, JPA 리프 main 에 어드바이저도 없다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 단어 경계 참조 계수, 삭제 커밋의 부모와 diff 열람, 형제 레지스트리의 키 타입 대조, ADR 대조
소스 수정 : x
## 재현 조건
1. 레지스트리의 패키지와 이 리프의 공개 API 패키지를 비교한다.
2. 자기 파일을 뺀 프로덕션 참조와 자동설정 등록과 설정 키를 센다. 스캔 대상 패키지인지, 스테레오타입이 있는지도 본다.
3. 레지스트리가 담는 타입의 참조를 세고, 그 타입을 만드는 코드와 포트를 부르는 코드를 따로 센다.
4. 프로덕션 트랜잭션이 실제로 지나는 포트를 찾아 그 타입을 확인한다.
5. 삭제 커밋의 부모에서 인터셉터를 열고, 같은 커밋의 레지스트리 diff 를 본다.
6. 형제 레지스트리들의 require 키 타입을 읽는다.
7. ADR 의 결정 절과 강제 절이 지목한 것을 소스에서 찾는다.
## 본문
<!-- body:start -->
`TransactionProfileRegistry` 는 이름을 받아 트랜잭션 프로파일을 돌려주고, 등록되지 않은 이름이면 기본값으로 넘어가는 대신 예외를 던진다.
클래스의 javadoc 이 그 완고함의 근거를 적는다. 기본값이 있으면 `"order-wrtie"` 같은 오타가 남의 격리 수준과 타임아웃과 재시도 예산으로 조용히 실행된다는 것이다.
그런 방어는 이름을 건네는 쪽이 있을 때만 값을 한다.
## 이름을 건네던 코드는 한 번 있었고, 지워졌다
:::evidence key="a05-f007-transactionprofileregistry-adr" alt="삭제 커밋의 규모와 지워진 세 파일, 그 커밋의 부모에서 열어 본 인터셉터의 해당 줄, 같은 커밋이 레지스트리 javadoc 에 낸 diff, 문자열 키를 쓰는 형제 레지스트리와 다른 키 타입을 쓰는 둘, 그리고 ADR 의 결정 절과 강제 절 원문을 차례로 출력한 터미널 기록." caption="삭제 규모와 지워진 세 파일 · 삭제 직전의 호출부 두 줄 · 같은 커밋의 javadoc diff · 문자열 키 형제는 하나 · ADR 두 절이 지목한 것 — 43줄 · exit 0" zoom="true"
:::
한 커밋이 애너테이션 44줄과 인터셉터 109줄과 그 테스트 211줄을 지웠다. 909개 파일을 손댄 큰 기능 커밋이고, 이 삭제는 그 안에 있다.
지운 파일을 그 커밋의 부모에서 열면 레지스트리를 어떻게 썼는지 보인다.
```java
41: private final TransactionProfileRegistry profiles;
66: TransactionProfile profile = profiles.require(policy.profile());
69: return coordinator.execute(operation, profile, () -> proceed(invocation), transactionKey);
```
`policy.profile()` 은 애너테이션에 적힌 문자열이다. 오타가 들어올 수 있는 자리가 거기였다.
## 같은 커밋이 근거만 손질해서 남겼다
```text
- * default would mean a typo in {@code @RetryableJpaTransaction(profile = "order-wrtie")} silently
+ * default would mean a typo in a profile name of {@code "order-wrtie"} silently runs with someone
```
지워지는 애너테이션의 이름만 문장에서 빼고, 오타 예시와 논거는 그대로 뒀다. 방어할 대상을 지우는 커밋이 방어의 근거는 문법만 고쳐 남긴 것이다.
## 이름이 사라진 자리를 객체가 넘겨받지도 않았다
:::evidence key="a05-f007-transactionprofileregistry" alt="레지스트리의 패키지 위치와 공개 API 패키지 파일 수, 자기 파일을 제외한 프로덕션 참조와 자동설정 등록과 설정 키의 계수, 컴포넌트 스캔 대상 여부와 스테레오타입 유무, 레지스트리가 담는 타입의 참조 계수와 그 타입을 만드는 코드 수, 포트를 참조하는 파일, 그리고 프로덕션 트랜잭션이 실제로 지나는 포트와 그 타입을 출력한 터미널 기록." caption="구현 패키지 · 프로덕션 참조 0 과 설정 키 0 · 스캔 대상이나 스테레오타입 없음 · 담긴 타입은 네 파일에 있으나 만드는 코드 0 · 실제 경로는 다른 포트 — 37줄 · exit 0" zoom="true"
:::
레지스트리 쪽 수는 전부 0 이다. 자기 파일을 뺀 프로덕션 참조 0, 자동설정 등록 0, 출하 설정의 프로파일 키 0. 리프의 공개 API 패키지에는 파일이 마흔아홉 개 있지만 이것은 그중에 없다. 패키지 자체는 컴포넌트 스캔 대상인데, 이 클래스에 스테레오타입 애너테이션이 없어 후보가 되지 않는다.
레지스트리가 담는 타입 쪽은 수가 다르다. `TransactionProfile` 은 네 파일에서 열한 줄에 나타나고, import 와 javadoc 을 빼면 일곱 줄이 타입 사용이다. 코디네이터, 정의 매퍼, 실행기 구현, 그리고 공개 포트다. 포트는 이름 대신 객체를 받는다.
```java
<T> T execute(PersistenceOperationName operation, TransactionProfile profile, Supplier<T> work);
```
두 번째 인자가 프로파일 객체다. 다만 이 포트를 참조하는 main 파일은 포트 자신과 그 구현체뿐이고, 자기 파일 밖에서 `TransactionProfile` 을 만드는 main 코드도 없다. 네 파일은 서로를 참조할 뿐이고 바깥에서 들어오는 진입점이 없다.
프로덕션 트랜잭션은 같은 패키지의 다른 클래스가 연다. `SpringTransactionPort` 가 애플리케이션 계층의 요청 타입을 받고, 그 파일 안의 `TransactionProfile` 참조는 0 이다.
그러니 이름 조회가 객체 전달로 대체됐다고는 말할 수 없다. 이름을 받던 입력이 사라진 뒤 객체를 받는 쪽에도 호출자가 붙지 않았고, 레지스트리는 그 스택에서 가장 먼저 잘려 나간 마디다.
## 제거 조건 세 가지에 대 보면
두 조건은 확인된다. 외부 프로덕션 참조가 0 이고, 운영자가 켤 수 있는 설정 경로가 없다.
세 번째 조건인 대체 경로의 특성화는 확인되지 않는데, 이유가 규칙이 상정한 것과 다르다. 규칙은 대체 경로가 같은 동작을 증명하지 못하면 지우는 순간 동작이 바뀐다고 말한다. 여기서는 이름 조회를 수행하는 프로덕션 경로가 없다. 지워도 바뀔 동작이 없는 대신, 무엇이 대체했는지도 말할 수 없다.
이 모양이 폐기된 것도 아니다. `SafeSortRegistry` 는 지금도 문자열 이름을 받아 등록되지 않았으면 던지고, 정렬 파라미터를 파싱하는 매퍼가 그것을 호출한다.
형제라 부를 만한 것은 그 하나다. gRPC 채널 레지스트리는 검증된 값 객체를 키로 쓰고, 던지는 이유도 오타가 아니라 아직 설치되지 않았다는 것이다. Mongo 일관성 레지스트리는 enum 을 키로 쓰고, 그 javadoc 이 모든 enum 상수가 언제나 있다고 적는다. 컴파일러가 키를 강제하므로 오타가 들어올 자리가 없다.
## ADR 이 지워진 것을 아직 전제한다
전체 트랜잭션 재시도 ADR 의 Enforcement 절이 두 가지를 근거로 지목한다.
```text
`FullTransactionRetryCoordinatorTest`; `RetryableJpaTransactionInterceptor.DEFAULT_ORDER`.
```
앞의 것은 있다. 뒤의 것은 삭제된 클래스의 상수이고, `DEFAULT_ORDER` 라는 이름은 소스 전체에 한 줄도 없다.
Decision 절도 마찬가지다. 재시도 어드바이스가 스프링 트랜잭션 어드바이스 바깥에 정렬되어 각 시도가 새 트랜잭션을 연다고 적는데, JPA 리프 main 에 어드바이저는 없다. 코드는 정리됐고 결정 기록은 그대로 남았다.
## 확인하지 못한 것
저장소 밖의 리플렉션이나 외부 직접 생성 여부는 소스 검색으로 알 수 없다.
포크가 이름 기반 조회를 다시 붙일 의도인지도 알 수 없다. 테스트 머리글이 계획 문서의 과제 번호와 설계 절을 가리키지만, 그 계획이 지금도 유효한지는 이 저장소가 말하지 않는다.
<!-- body:end -->
@@ -0,0 +1,138 @@
---
kind: CASE
slug: a05-f018-sql
title: 쿼리 이름을 SQL 로 옮기는 두 경로가 양쪽 끝만 있고 가운데가 없다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f018-sql
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f018-sql
file: ../../../final/evidence/rendered/a05-f018-sql.svg
evidence:
- ../../../final/evidence/raw/a05-f018-sql.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §55 의 backlog 안, 「Cross-scope P1/P2 — query SQL naming/observability composition 부재」 항목이다. 검사기의 런타임 등록이 0 이라는 판정, SQL 계층으로 잇는 다리가 확인되지 않았다는 판정, 그리고 최종 판정을 트랜잭션 관측 배선 공백과 함께 관측·설정 범위로 미룬다는 기록이 거기 있다.
- 다리가 확인되지 않았다는 판정은 맞다. `QueryNameContext` 가 이름을 스레드에 묶고 검사기가 그것을 읽지만, 어떤 SessionFactory 도 그 검사기를 설치하지 않으므로 `inspect()` 는 호출되지 않는다. 코드에 있는 것은 다리가 아니라 양쪽 끝이다. 두 번째 경로인 힌트도 마찬가지다.
---
# 쿼리 이름을 SQL 로 옮기는 두 경로가 양쪽 끝만 있고 가운데가 없다
쿼리 이름을 SQL 주석으로 실어 나르는 문장 검사기가 있다. 어떤 SessionFactory 도 그것을 설치하지 않아 `inspect()` 가 호출되지 않는다. 두 번째 경로인 `org.hibernate.comment` 힌트도 그것을 SQL 로 내보내는 설정이 꺼져 있다. 이름을 넣는 프로덕션 클래스 셋은 자기 단위 테스트 밖에서 쓰이지 않는다.
## 관계
- **@Bean이 있다는 것은 조립 증거가 아니다**
구현의 존재를 조립의 증거로 읽는다는 점이 같다.
- **이름은 값이 아니라 registry key다**
쿼리 이름이 등록된 식별자로 쓰이는 맥락이다.
- **카디널리티 경계를 타입으로 표현하기**
그 이름이 메트릭 태그가 되는 경계다.
## 문제
주석에 이름이 실려야 DBA 가 눈앞의 문장을 어느 유스케이스에서 나온 것인지 되짚는다. 검사기 javadoc 이 그 이유를 적고, 없으면 어느 엔드포인트가 이 쿼리를 내는지를 SQL 조각으로 코드베이스를 뒤져 답하게 된다고 덧붙인다.
## 결론
inspect() 는 다 쓰여 있다. 현재 스레드에 묶인 이름을 꺼내 /* 이름 */ 을 앞에 붙이고, 이름에 주석 종료자가 있으면 원본을 돌려준다. 그 분기는 도달하지 않는다. QueryName 의 형식 [a-z][a-z0-9.-]{2,95} 가 이미 * 와 / 를 막고, javadoc 도 그 검사를 additionally 라고 적는다.
단위 테스트는 없다. 검사기에도 컨텍스트에도 테스트 파일이 없다.
SessionFactory 가 설치해 주지 않으면 inspect() 는 불리지 않는다. 네 경로는 저장소 루트에서 확장자 제한 없이 훑어 얻었다. 설정 키 0, HibernatePropertiesCustomizer 와 SessionFactoryBuilder 와 ServiceRegistry 와 Integrator 0, persistence.xml 0, 런타임 리소스의 FQCN 0 이다.
이름을 넣는 쪽은 배선되어 있다. QueryNameContext.with(...) 를 부르는 프로덕션 코드가 셋이다. 그 컨텍스트를 읽는 쪽은 검사기 하나뿐이다.
SQL 로 이름을 옮기는 길이 그것 말고 또 있다. org.hibernate.comment 힌트를 거는 것은 리포지토리 조각 지원과 Querydsl 지원이다. 그 힌트도 스위치 하나에 달려 있는데 저장소에 그 키가 0 이다.
두 경로 다 이름을 넣는 코드와 그것을 읽는 자리는 있는데, 그 사이를 잇는 설치와 스위치가 없다.
넣는 세 클래스도 실행되지 않는다. 각 타입을 쓰는 파일은 자기 자신과 자기 단위 테스트뿐이고, 리포지토리 조각 지원을 상속하는 유일한 코드도 그 테스트 안의 중첩 클래스다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 설치 경로 넷을 저장소 루트에서 계수, 이름을 넣고 읽는 지점 추적, 두 번째 경로의 활성 설정 확인
소스 수정 : x
## 재현 조건
1. 검사기 구현과 javadoc, 그리고 QueryName 의 형식 정규식을 읽는다.
2. 저장소 루트에서 확장자 제한 없이 설치 경로 넷을 각각 센다.
3. 그 클래스의 단위 테스트가 있는지 찾는다.
4. 이름을 넣는 호출과 읽는 호출을 각각 센다.
5. org.hibernate.comment 힌트를 거는 곳과, use_sql_comments 를 켜는 곳을 센다.
6. 넣는 세 클래스를 쓰는 파일과 상속하는 코드를 전부 나열한다.
## 본문
<!-- body:start -->
쿼리 이름이 SQL 주석으로 실려야 DBA 가 `pg_stat_activity` 나 느린 쿼리 로그의 문장을 유스케이스로 되돌릴 수 있다. 검사기 javadoc 이 그 이유를 적는다.
## inspect() 가 하는 일
:::evidence key="a05-f018-sql" alt="문장 검사기의 javadoc 앞부분과 클래스 본문, 쿼리 이름의 형식 정규식, 하이버네이트가 이 구현을 설치하는 경로 넷을 저장소 루트에서 확장자 제한 없이 센 결과와 그 클래스의 단위 테스트 수, 이름을 컨텍스트에 넣는 세 곳과 읽는 곳, 이름을 SQL 로 옮기는 두 번째 경로와 그것을 활성화하는 설정의 수, 그리고 넣는 세 클래스를 쓰는 파일과 상속하는 코드를 출력한 터미널 기록." caption="검사기 javadoc 과 본문 · 이름 형식이 이미 * 와 / 를 막음 · 설치 경로 넷 전부 0 · 단위 테스트 0 · 넣는 곳 셋과 읽는 곳 하나 · 두 번째 경로의 use_sql_comments 0 · 세 클래스는 자기 테스트만 — 69줄 · exit 0" zoom="true"
:::
```java
public String inspect(String sql) {
if (sql == null) {
return null;
}
Optional<QueryName> queryName = QueryNameContext.current();
if (queryName.isEmpty()) {
return sql;
}
String value = queryName.get().value();
if (value.contains(COMMENT_TERMINATOR)) {
return sql;
}
return "/* " + value + " */ " + sql;
}
```
현재 스레드에 묶인 이름을 꺼내 주석으로 붙인다. 이름에 `*/` 가 있으면 원본을 돌려주는 분기가 하나 더 있는데, `QueryName` 의 형식 `[a-z][a-z0-9.-]{2,95}` 가 이미 `*``/` 를 막으므로 도달하지 않는다. javadoc 도 그 검사를 additionally 라고 적는다.
이 클래스에는 단위 테스트가 없다. 컨텍스트 쪽도 없다.
## 검사기를 설치하는 설정이 없다
하이버네이트가 `inspect()` 를 부르려면 SessionFactory 가 이 구현을 설치해야 한다. 설치 경로 넷을 저장소 루트에서 확장자 제한 없이 셌다.
```text
statement_inspector 설정 키 : 0
HibernatePropertiesCustomizer / SessionFactoryBuilder / Integrator : 0
persistence.xml : 0
런타임 리소스의 FQCN : 0
```
## 이름을 컨텍스트에 넣는 세 곳
`JpaStreamExecutor:63`, `JpaKeysetQuerySupport:49`, `JpaRepositoryFragmentSupport:72` 가 작업을 감싸며 이름을 컨텍스트에 넣는다. 그 컨텍스트를 읽는 코드는 검사기 하나다.
이름을 SQL 로 옮기는 경로는 하나가 더 있다. 리포지토리 조각 지원과 Querydsl 지원이 `org.hibernate.comment` 힌트를 건다.
```java
return entityManager.createQuery(jpql, resultType).setHint(COMMENT_HINT, name.value());
```
그 힌트가 SQL 로 나오려면 `hibernate.use_sql_comments` 가 켜져야 한다. 저장소에 그 키는 0 이다.
두 경로 모두 양쪽 끝만 있고 가운데가 없다.
## 세 클래스의 프로덕션 참조
각 타입을 쓰는 파일은 자기 자신과 자기 단위 테스트뿐이다. 리포지토리 조각 지원을 상속하는 유일한 코드도 그 테스트 안의 중첩 클래스다.
스프링 데이터의 조각 규약으로 연결될 여지도 없다. 그 클래스는 인터페이스가 아니라 추상 클래스이고, `repositoryBaseClass``@NoRepositoryBean` 도 저장소에 없다.
이름은 실행 경로에 오르지 않는다.
## 확인하지 못한 것
애플리케이션을 부팅해 실제 문장에 주석이 붙지 않는 것을 관측하지 않았다. 설치 경로와 호출 지점을 센 것까지가 확인 범위다.
<!-- body:end -->
@@ -0,0 +1,157 @@
---
kind: CASE
slug: a06-f003-change-streams-true
title: 거부라고 적힌 처리가 폐기이고, 그 값을 읽는 시작 검사는 켤 방법이 없다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a06-f003-change-streams-true
evidenceCapturedOn: 2026-09-02
body: case-a06-f003-change-streams-true.body.md
assets:
- key: a06-f003-change-streams-true
file: ../../../final/evidence/rendered/a06-f003-change-streams-true.svg
- key: a06-f003-change-streams-true-probe
file: ../../../final/evidence/rendered/a06-f003-change-streams-true-probe.svg
- key: a06-f003-change-streams-true-shipped
file: ../../../final/evidence/rendered/a06-f003-change-streams-true-shipped.svg
evidence:
- ../../../final/evidence/raw/a06-f003-change-streams-true.txt
- ../../../final/evidence/raw/a06-f003-change-streams-true-probe.txt
- ../../../final/evidence/raw/a06-f003-change-streams-true-shipped.txt
source:
- 원본 분석 절은 `analysis/06-adapter-outbound-persistence-mongo.md#L156` 이다.
- 그 절의 판정은 P3 이고, 근거로 변경 스트림 실행체가 애초에 출하되지 않는다는 것을 든다. 같은 문서 `#L1069` 이 이 리비전에서 그 전제를 부정한다. 그래서 P3 에 붙은 "현재 잘못된 동작을 만들지는 않는다"는 그대로 두기 어렵지만, 등급 재조정은 이 기록이 하지 않는다. `#L1069` 의 P2 는 별도 기록이 다룬다.
- 네 호출이 각각 어디서 멈추는지, 그 분기가 내는 문장이 저장소에 몇 번 나오는지, 소비자 조건 다섯의 구현체가 전부 시험 픽스처라는 것, 그리고 세 입력의 바인딩 결과와 단독 서버에서의 두 배선 비교와 복구 정책 판정은 이 기록에서 확인했다.
---
# 거부라고 적힌 처리가 폐기이고, 그 값을 읽는 시작 검사는 켤 방법이 없다
설정 타입의 컴팩트 생성자가 변경 스트림 플래그를 예외 없이 거짓으로 덮어쓴다. 그 자리의 주석은 이 처리를 거부라고 부른다. 거부와 폐기는 운영자에게 다른 사건이다. 하나는 기동이 멈추고 하나는 아무 일도 일어나지 않는다.
## 관계
- **하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다**
그 기록에서는 플래그가 켜져 있어 요구를 만드는데 실행체가 없다.
- **sanitize가 아니라 reject가 기본이다**
이 주석이 따르겠다고 선언한 규칙이다.
- **플래그는 고정 거짓이라 능력 검사를 끄지만, 조립 조건이 아니라서 소비자 빈은 그대로 생성된다**
같은 플래그를 다른 절에서 다룬 기록이다. 등급 재조정은 그 기록이 한다.
## 문제
레코드 컴포넌트는 넷이고 생성자는 넷을 서로 다르게 다룬다. 프로파일은 널이면 빈 맵으로 흡수한다. 필요 보조 노드 수는 널이면 기본값을 넣고 음수면 예외로 거부한다. 형제 불리언인 트랜잭션 플래그는 그대로 보존한다. 변경 스트림 플래그만 예외 없이 거짓이 된다.
## 결론
이 플래그가 실제로 끄는 것은 실행체가 아니라 검사다. 시작 검증기는 변경 스트림이 켜져 있을 때 토폴로지 능력을 확인하는 분기를 갖는다. 그 인자를 넘기는 프로덕션 지점은 자동 구성 한 줄이고, 그 줄이 읽는 값은 생성자가 이미 거짓으로 덮어쓴 뒤다.
시험도 그 분기 본문에 닿지 않는다. 검증기를 만드는 시험은 하나이고 호출은 넷인데, 둘은 거짓을 넘기고, 하나는 앞 단계인 토폴로지 검사에서 먼저 멈추며, 나머지 하나는 복제 셋이라 능력이 안정으로 나온다. 그 분기가 내는 문장은 저장소 안에서 자기 throw 자리 한 곳에만 있고, 그것을 단언하는 시험은 없다.
오플로그가 없는 단독 서버로 확인했다. 출하 배선의 시작 검증은 통과하고, 같은 검증기에 리터럴 참을 넘기면 토폴로지를 지목하는 예외가 난다. 그 뒤 커서를 열면 드라이버가 명령 단계에서 거절하고, 복구 정책은 그것을 실패로 확정하며 프라이머리 장애 조치 런북을 붙인다. 그 런북에 오플로그 없는 토폴로지 항목은 없다.
주석은 이 처리의 근거로 드라이버 쪽 구현이 출하되지 않아 빈이 0 이라는 것을 든다. 그 근거는 이 리비전에서 성립하지 않는다. 다만 그 사실이 바꾸는 것은 원본 절의 등급 판단이고, 이 기록의 관찰은 거부와 폐기의 차이 그대로다.
수정은 둘 중 하나다. 값을 정말로 거부하거나, 플래그를 레코드 컴포넌트에서 빼 존재하지 않는 스위치로 만드는 것이다.
## 검증 환경
OpenJDK : 21.0.12
MongoDB : 8.0.16 단독 서버
확인 방식 : 실제 바인딩에 세 입력 통과, 실제 서버에 시작 검증기와 커서 열기와 복구 정책 실행
소스 수정 : x
## 재현 조건
1. 컴팩트 생성자에서 네 컴포넌트의 처리를 비교하고 변경 스트림 자리의 주석을 읽는다.
2. 시작 검증기의 변경 스트림 분기, 그 인자를 넘기는 곳, 검증기를 만드는 곳을 전수로 센다.
3. 그 분기가 내는 문장이 저장소 어디에 나오는지 센다.
4. 검증기를 만드는 시험의 네 호출이 각각 어디서 멈추는지 읽는다.
5. 오플로그가 없는 단독 서버를 띄운다.
6. 세 입력을 실제 바인딩에 통과시키고 각각의 결과를 읽는다.
7. 같은 서버에 대해 출하 배선과 리터럴 참 배선으로 시작 검증을 각각 돌린다.
8. 같은 클래스로 커서를 열고, 그 실패를 복구 정책에 넘긴다.
## 본문
<!-- body:start -->
설정 타입의 주석은 이 값을 저장하지 않고 거부한다고 적으면서, 근거로 드라이버 쪽 구현이 없다는 것을 든다.
## 네 값 중 하나만 삼켜진다
:::evidence key="a06-f003-change-streams-true" alt="설정 타입의 컴팩트 생성자에서 네 컴포넌트가 각각 흡수·보존·덮어쓰기·예외로 처리되는 구간과 변경 스트림 자리의 주석, 시작 검증기의 변경 스트림 분기, 그 분기가 내는 문장이 저장소에 나오는 곳 전수, changeStreams 라는 이름이 나오는 곳 전수, 검증기를 만드는 곳 전수를 출력한 터미널 기록." caption="프로파일은 빈 맵으로 흡수 · 음수 보조 노드 수는 예외 · 트랜잭션은 보존 · 변경 스트림만 예외 없이 거짓 · 분기가 내는 문장은 자기 throw 자리 한 곳뿐 · 검증기 생성 지점은 프로덕션 1 시험 1 — 43줄 · exit 0" zoom="true"
:::
```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
// it
// would leave an operator believing it took effect, so the value is refused rather than stored:
// zero beans, zero threads, and a `true` that cannot be honoured never becomes one that looks
// honoured.
changeStreams = false;
```
여덟 줄 아래에서 같은 생성자가 음수 보조 노드 수를 예외로 던진다. 거부가 어떤 모양인지 같은 생성자 안에 있고, 이 줄은 그 모양이 아니다. 형제 불리언인 `transactions` 는 손대지 않는다.
## 검증기가 받는 값은 언제나 거짓이다
```java
if (changeStreamsEnabled && !capabilities.isStable(MongoCapability.CHANGE_STREAM)) {
```
좌항을 넘기는 프로덕션 지점은 자동 구성 한 줄뿐이다. 검증기를 만드는 곳은 그 줄과 시험 하나이고, 시험은 리터럴을 넘긴다. 그런데 그 시험의 네 호출 어느 것도 이 `if` 의 본문을 실행하지 않는다.
- 단독 서버에 프로덕션 프로파일을 놓는 호출은 참 둘을 넘기지만, `validate()` 가 능력 검사보다 먼저 부르는 토폴로지 검사에서 멈춘다
- 선언과 실제가 어긋나는 호출은 거짓 둘을 넘긴다
- 트랜잭션만 켜는 호출은 변경 스트림에 거짓을 넘긴다
- 정상 복제 셋 호출은 참을 넘기지만 그 토폴로지에서는 능력이 안정으로 나온다
`if` 가 만드는 문장은 저장소 전체에서 자기 `throw` 자리 한 곳에만 있다.
## 실제 서버에서
:::evidence key="a06-f003-change-streams-true-probe" alt="오플로그가 없는 단독 MongoDB 를 띄우고 세 입력을 실제 바인딩에 통과시킨 결과, 관측 토폴로지와 변경 스트림 능력 판정, 출하 배선과 리터럴 참 배선으로 각각 돌린 시작 검증 결과, 같은 클래스를 직접 만들어 커서를 연 결과, 그리고 그 실패를 복구 정책에 넘긴 판정을 출력한 터미널 기록." caption="단독 서버에서 능력 판정은 isStable=false · 출하 배선의 시작 검증은 통과, 리터럴 참 배선은 토폴로지를 지목하며 거절 · 커서를 열면 드라이버가 40573 으로 거절 · 복구 정책은 FAILED 와 failover 런북 — 20줄 · exit 0" zoom="true"
:::
오플로그가 없는 단독 서버를 띄우고 같은 클래스들을 그대로 썼다. 리터럴 참을 넘긴 배선의 예외 메시지는 이 배포에 없는 것과 필요한 것을 함께 적는다.
```text
change streams are enabled but unavailable here: {reason=topology is STANDALONE, which has no oplog}; a REPLICA_SET, SHARDED or ATLAS topology is required
```
출하 배선에서는 이 예외가 만들어지지 않는다. 같은 클래스를 직접 만들어 커서를 열면 드라이버가 명령 단계에서 거절한다.
```text
Command execution failed on MongoDB server with error 40573 (Location40573): 'The $changeStream stage is only supported on replica sets' on server 127.0.0.1:57017.
```
그 실패를 복구 정책에 넘기면 판정이 나온다.
```text
MongoChangeStreamRecoveryDecision[state=FAILED, autoResume=false, requiredRunbook=docs/mongodb/runbooks/failover.md]
```
정책은 서버 코드가 이력 소실이면 전용 런북을, 라벨이 재개 가능이면 재개를 고르고, 그 밖은 실패로 확정한다. 40573 은 셋째 갈래다. 붙는 런북은 프라이머리 선출과 서버 선택 지연을 다루고, 이 서버에 오플로그가 없다는 경우는 다루지 않는다.
## 주석이 근거로 든 사실
:::evidence key="a06-f003-change-streams-true-shipped" alt="드라이버 쪽 구현을 만드는 빈 위에 겹쳐 있는 조건 넷(모듈 opt-in, 블로킹 템플릿 클래스, 리액티브 템플릿 클래스와 빈), 그 빈이 커서를 여는 호출 사슬, 소비자 빈이 요구하는 조건 다섯, 그 다섯을 구현하는 클래스 전수, 저장소 자신의 배선 시험 네 개, 그리고 이 자동 구성 파일에서 해당 플래그가 나오는 유일한 줄을 출력한 터미널 기록." caption="source 빈의 조건은 모듈 opt-in 과 리액티브 템플릿, 그리고 @ConditionalOnMissingBean · 소비자 조건 다섯의 구현체 열은 전부 시험 픽스처 · 배선 시험이 조립·미조립·미배선을 각각 고정 · 플래그는 검증기 인자 한 줄뿐 — 56줄 · exit 0" zoom="true"
:::
드라이버 쪽 구현에는 빈 선언이 있다. 그 빈의 메서드에 붙은 조건은 `@ConditionalOnMissingBean` 하나지만, 그것을 감싼 중첩 설정 클래스가 리액티브 템플릿을 클래스로도 빈으로도 요구하고, 다시 그 바깥이 모듈 opt-in 을 요구한다. 무조건은 아니고 리액티브 템플릿이 있는 배포에서는 언제나다. 그 조건 넷 어디에도 변경 스트림 플래그는 없다.
소비자 빈의 선언도 있다. 조건이 다섯인데 다섯 다 배포가 공급해야 하는 타입이고, 이 저장소는 다섯 중 어느 것도 빈으로 만들지 않는다. 열 개의 구현체가 전부 시험 안의 `private static final class` 다.
저장소 자신의 배선 시험이 세 상태를 각각 고정한다. 리액티브 템플릿이 없으면 아무것도 만들지 않고, 있으면 source 는 만들고 투영기가 없으면 소비자는 만들지 않고, 다섯을 주면 소비자가 조립된다.
주석이 없다고 적은 넷 가운데 `watch``resumeAfter`/`startAfter` 는 이 클래스에 있고, 커서 수명과 재연결은 같은 패키지의 소비자에 있다.
## 확인하지 못한 것
소비자 빈의 조건 다섯을 공급하는 포크의 배포는 다루지 않았다. 이 저장소 안에서 확인한 것은 다섯 타입의 구현체가 전부 시험 픽스처라는 것, 조건 목록에 이 플래그가 없다는 것, 그리고 저장소 자신의 배선 시험이 그 세 상태를 각각 고정한다는 것이다.
<!-- body:end -->
@@ -0,0 +1,80 @@
---
kind: CASE
slug: grpc-advanced-compat-f01
title: 통합 다리의 메타데이터 조립이 메타데이터 예산을 검사하지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-advanced-compat-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-compat-f01
file: ../../../final/evidence/rendered/grpc-advanced-compat-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-compat-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-compat.md#L120 이다.
module: grpc-advanced-compat
priority: P3
---
# 통합 다리의 메타데이터 조립이 메타데이터 예산을 검사하지 않는다
허용 목록으로 키를 거르지만 값의 크기도, 합계도 보지 않는다. 클래스 javadoc 자신이 예산을 이 정책의 이유 중 하나로 든다 — 흐름의 내부 배관을 네트워크로 보내면 "it counts against the metadata budget." 그리고 같은 저장소의 다른 메타데이터 경로는 예산을 검사한다.
## 문제
허용 목록으로 키를 거르지만 값의 크기도, 합계도 보지 않는다.
클래스 javadoc 자신이 예산을 이 정책의 이유 중 하나로 든다 — 흐름의 내부 배관을 네트워크로 보내면 "it counts against the metadata budget." 그리고 같은 저장소의 다른 메타데이터 경로는 예산을 검사한다.
## 결론
String.valueOf(value) 이므로 헤더 값이 임의의 객체일 때 그 문자열 표현이 그대로 실린다.
Spring Integration 헤더에는 컬렉션이나 도메인 객체가 흔히 들어가므로 값 하나가 클 수 있다.
수정은 이 record 에 GrpcMetadataBudget 를 성분으로 추가하고 metadataFrom 끝에서 검사하는 것이다.
형태가 이미 옆 리프에 있다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcMetadataBudget 참조 26건 전수 검색과 다리의 메타데이터 조립 경로 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-advanced-compat.md#L120 에 있다.
## 본문
<!-- body:start -->
허용 목록으로 키를 거르지만 값의 크기도, 합계도 보지 않는다.
## GrpcMetadataBudget 참조 위치
:::evidence key="grpc-advanced-compat-f01" alt="코드베이스에서 GrpcMetadataBudget 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcMetadataBudget 코드베이스 검색 — 26줄 · exit 0" zoom="true"
:::
## 클래스 javadoc 자신이 예산을 이유로 든다
흐름의 내부 배관을 네트워크로 보내면 "it counts against the metadata budget." 그리고 같은 저장소의 다른 메타데이터 경로는 예산을 검사한다.
## 값 하나가 클 수 있다
`String.valueOf(value)` 이므로 헤더 값이 임의의 객체일 때 그 문자열 표현이 그대로 실린다. Spring Integration 헤더에는 컬렉션이나 도메인 객체가 흔히 들어간다.
## 수정
이 record 에 `GrpcMetadataBudget` 를 성분으로 추가하고 `metadataFrom` 끝에서 검사하는 것이다. 형태가 이미 옆 리프에 있다.
## 확인하지 못한 것
실제 브라우저·프록시·서블릿 컨테이너로 이 다리를 돌리지 않았다. 그 인프라가 없다는 것이 이 가족의 기록이다.
<!-- body:end -->
@@ -0,0 +1,91 @@
---
kind: CASE
slug: grpc-core-api-f01
title: 정책 목록의 가장 강한 성질을 이 저장소에서는 쓸 수 없다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-core-api-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-core-api-f01
file: ../../../final/evidence/rendered/grpc-core-api-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-core-api-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-core-api.md#L140 이다.
module: grpc-core-api
priority: P3
---
# 정책 목록의 가장 강한 성질을 이 저장소에서는 쓸 수 없다
withDescriptorMethods 를 부르는 곳은 이 리프의 테스트 두 줄뿐이다. GrpcMethodPolicyCatalog.builder() 를 부르는 곳은 저장소 전체에서 전부 테스트다.
## 문제
withDescriptorMethods 를 부르는 곳은 이 리프의 테스트 두 줄뿐이다.
GrpcMethodPolicyCatalog.builder() 를 부르는 곳은 저장소 전체에서 전부 테스트다.
## 결론
그리고 그중 어느 것도 서술자 집합을 선언하지 않는다(위 두 줄 제외).
자바독이 그 상태를 미리 서술한다 — 서술자가 없으면 "the catalog is materially weaker … there is nothing to compare a policy's method name against." 그리고 서술자가 없는 이유는 옆 리프에 있다.
grpc-codegen 이 서술자 산출물을 정의하지만 저장소에 protobuf 플러그인이 없어 protoc 이 돌지 않는다.
즉 이름 변경을 잡는 성질은 코드 생성 레인이 켜지기 전까지 성립할 수 없다.
기록하는 이유는 이것이 이 클래스가 존재하는 첫 번째 이유로 적혀 있기 때문이다.
수정은 코드 생성 레인이 생길 때 그 서술자를 목록 조립에 연결하는 것이고, 그때까지는 자바독이 그 조건을 명시하는 편이 낫다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : withDescriptorMethods 와 builder() 호출처 전수 검색으로 production 호출 유무 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-core-api.md#L140 에 있다.
## 본문
<!-- body:start -->
`withDescriptorMethods` 를 부르는 곳은 이 리프의 테스트 두 줄뿐이다.
```
grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:145
grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:175
```
`GrpcMethodPolicyCatalog.builder()` 를 부르는 곳은 저장소 전체에서 전부 테스트이고, 그중 어느 것도 서술자 집합을 선언하지 않는다(위 두 줄 제외).
## withDescriptorMethods 를 부르는 곳
:::evidence key="grpc-core-api-f01" alt="분석 문서 analysis/grpc/grpc-core-api.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-core-api.md 발췌 — 15줄" zoom="true"
:::
## 자바독이 그 상태를 미리 서술한다
서술자가 없으면 "the catalog is materially weaker … there is nothing to compare a policy's method name against."
## 서술자가 없는 이유는 옆 리프에 있다
`grpc-codegen` 이 서술자 산출물을 정의하지만 저장소에 protobuf 플러그인이 없어 `protoc` 이 돌지 않는다. 즉 이름 변경을 잡는 성질은 코드 생성 레인이 켜지기 전까지 성립할 수 없다.
## 기록하는 이유
이것이 이 클래스가 존재하는 첫 번째 이유로 적혀 있다. 수정은 코드 생성 레인이 생길 때 그 서술자를 목록 조립에 연결하는 것이고, 그때까지는 자바독이 그 조건을 명시하는 편이 낫다. P3.
## 확인하지 못한 것
서술자 대조 경로를 실제 스키마로 돌려 보지 않았다. 저장소에 컴파일된 서술자가 없다.
<!-- body:end -->
@@ -0,0 +1,90 @@
---
kind: CASE
slug: grpc-observability-f01
title: queueHighWatermark 는 요구되고 검증되지만 아무도 읽지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-observability-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-observability-f01
file: ../../../final/evidence/rendered/grpc-observability-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-observability-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-observability.md#L197 이다.
module: grpc-observability
priority: P3
---
# queueHighWatermark 는 요구되고 검증되지만 아무도 읽지 않는다
GrpcStreamObservation 의 7성분 중 queueHighWatermark 만 소비자가 없다. tags() 에 없고, GrpcObservationConvention.record(GrpcStreamObservation) 이 등록하는 세 meter(STREAM_LIFETIME·STREAM_MESSAGES·STREAM_FLOW_CONTROL_STALLS) 어디에도 들어가지 않는다.
## 문제
GrpcStreamObservation 의 7성분 중 queueHighWatermark 만 소비자가 없다.
tags() 에 없고, GrpcObservationConvention.record(GrpcStreamObservation) 이 등록하는 세 meter(STREAM_LIFETIME·STREAM_MESSAGES·STREAM_FLOW_CONTROL_STALLS) 어디에도 들어가지 않는다.
## 결론
테스트도 250L 을 넘기고 그 값에 대해 아무것도 단언하지 않는다.
클래스 javadoc 이 "what is recorded instead is …" 로 세 가지를 열거하는데 그 목록에도 없다.
즉 서술과 구현은 일치하고, 어긋난 것은 필수 생성자 인자라는 점이다.
호출자는 측정해서 넘겨야 하고 그 값은 버려진다.
수정은 둘 중 하나다 — STREAM_QUEUE_HIGH_WATERMARK gauge/counter 를 추가하거나, 성분에서 뺀다.
큐 최고 수위는 소비자 지연의 직접 지표이므로 전자가 이 클래스의 목적에 맞는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcStreamObservation 참조 5건 검색과 record 오버로드가 등록하는 meter 목록 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-observability.md#L197 에 있다.
## 본문
<!-- body:start -->
`GrpcStreamObservation` 의 7성분 중 `queueHighWatermark` 만 소비자가 없다.
```
GrpcStreamObservation.java:23 long queueHighWatermark, ← 선언
GrpcStreamObservation.java:31 … || queueHighWatermark < 0 ← 검증
그 외 저장소 전체 매치 0
```
## GrpcStreamObservation 참조 위치
:::evidence key="grpc-observability-f01" alt="코드베이스에서 GrpcStreamObservation 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcStreamObservation 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 세 meter 어디에도 들어가지 않는다
`tags()` 에 없고, `GrpcObservationConvention.record(GrpcStreamObservation)` 이 등록하는 `STREAM_LIFETIME`·`STREAM_MESSAGES`·`STREAM_FLOW_CONTROL_STALLS` 어디에도 없다. 테스트도 `250L` 을 넘기고 그 값에 대해 아무것도 단언하지 않는다.
## 서술과 구현은 일치한다
클래스 javadoc 이 "what is recorded instead is …" 로 세 가지를 열거하는데 그 목록에도 없다. 어긋난 것은 **필수 생성자 인자**라는 점이다 — 호출자는 측정해서 넘겨야 하고 그 값은 버려진다.
## 수정
`STREAM_QUEUE_HIGH_WATERMARK` gauge/counter 를 추가하거나, 성분에서 뺀다. 큐 최고 수위는 소비자 지연의 직접 지표이므로 전자가 이 클래스의 목적에 맞는다.
## 확인하지 못한 것
이 리프를 실제 MeterRegistry 에 배선해 돌린 적이 없다. 배선 자체가 없으므로 런타임 관측이 불가능하다.
<!-- body:end -->
@@ -0,0 +1,82 @@
---
kind: CASE
slug: grpc-policy-f07
title: clearAfterTask 는 합법 값이 하나뿐인 성분이고, 아무도 읽지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-policy-f07
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-policy-f07
file: ../../../final/evidence/rendered/grpc-policy-f07.svg
evidence:
- ../../../final/evidence/raw/grpc-policy-f07.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-policy.md#L324 이다.
module: grpc-policy
priority: P3
---
# clearAfterTask 는 합법 값이 하나뿐인 성분이고, 아무도 읽지 않는다
false 를 무조건 거부하므로 이 성분이 가질 수 있는 값은 true 하나다. 그리고 저장소 전체에서 clearAfterTask() 를 읽는 production 코드가 없다 — 호출처는 이 생성자의 가드와 테스트의 단언 한 줄뿐이다.
## 문제
false 를 무조건 거부하므로 이 성분이 가질 수 있는 값은 true 하나다.
그리고 저장소 전체에서 clearAfterTask() 를 읽는 production 코드가 없다 — 호출처는 이 생성자의 가드와 테스트의 단언 한 줄뿐이다.
## 결론
읽지 않아도 되는 이유는 GrpcContextBinder 가 옳게 쓰였기 때문이다.
runWith·callWith·wrap 이 전부 finally 에서 detach 한다.
불변식이 이미 구조로 지켜진다.
그래서 이 성분은 설정처럼 보이지만 설정이 아니다.
읽는 사람은 정책으로 끌 수 있는 것이라고 읽고, 테스트는 그 가드를 시험한다.
수정은 성분을 지우고 javadoc 에 "always cleared" 를 남기는 것이다.
그러면 backgroundWork()·stable() 이 인자 하나가 되고, 불변식은 검증이 아니라 구조가 된다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcContextBinder 참조 11건 검색과 생성자 가드가 허용하는 값 범위 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-policy.md#L324 에 있다.
## 본문
<!-- body:start -->
`false` 를 무조건 거부하므로 이 성분이 가질 수 있는 값은 `true` 하나다. 그리고 저장소 전체에서 `clearAfterTask()` 를 읽는 production 코드가 없다 — 호출처는 이 생성자의 가드와 테스트의 단언 한 줄뿐이다.
## GrpcContextBinder 참조 위치
:::evidence key="grpc-policy-f07" alt="코드베이스에서 GrpcContextBinder 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcContextBinder 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 읽지 않아도 되는 이유
`GrpcContextBinder` 가 옳게 쓰였다 — `runWith`·`callWith`·`wrap` 이 전부 `finally` 에서 detach 한다. 불변식이 이미 구조로 지켜진다.
## 그래서 설정처럼 보이지만 설정이 아니다
읽는 사람은 정책으로 끌 수 있는 것이라고 읽고, 테스트는 그 가드를 시험한다. 수정은 성분을 지우고 javadoc 에 "always cleared" 를 남기는 것이다. 그러면 `backgroundWork()`·`stable()` 이 인자 하나가 되고, 불변식은 검증이 아니라 구조가 된다.
## 확인하지 못한 것
이 성분을 false 로 만들어 동작 차이를 관측하지 않았다. 생성자가 false 를 무조건 거부한다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,78 @@
---
kind: CASE
slug: grpc-server-f03
title: 빌더 경로에서 순서 규칙 넷 중 셋이 발화할 수 없다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-server-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-server-f03
file: ../../../final/evidence/rendered/grpc-server-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-server-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-server.md#L196 이다.
module: grpc-server
priority: P3
---
# 빌더 경로에서 순서 규칙 넷 중 셋이 발화할 수 없다
byStage 는 EnumMap 이므로 keySet() 은 언제나 열거형 선언 순서다. 그리고 stage(...) 가 같은 단계의 두 번째 등록을 이미 거부한다.
## 문제
byStage 는 EnumMap 이므로 keySet() 은 언제나 열거형 선언 순서다.
그리고 stage(...) 가 같은 단계의 두 번째 등록을 이미 거부한다.
## 결론
따라서 빌더가 만드는 목록에서는 중복도, 역순도, 예외 경계가 최외곽이 아닌 경우도 발생할 수 없다.
발화 가능한 규칙은 필수 단계 누락 하나다.
결함은 아니다 — 나머지 셋은 violations(List) 를 직접 부르는 외부 호출자를 위한 것이고, 테스트가 그 경로로 셋을 모두 확인한다.
기록하는 이유는 빌더를 쓰는 조립 코드가 그 셋의 보호를 받는다고 읽기 쉽기 때문이다.
실제 보호는 자료구조가 준다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : EnumMap 의 keySet 순서 보장과 stage 등록 가드가 이미 막는 경우의 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-server.md#L196 에 있다.
## 본문
<!-- body:start -->
`byStage``EnumMap` 이므로 `keySet()` 은 언제나 열거형 선언 순서다. 그리고 `stage(...)` 가 같은 단계의 두 번째 등록을 이미 거부한다.
## byStage 가 EnumMap 이다
:::evidence key="grpc-server-f03" alt="분석 문서 analysis/grpc/grpc-server.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-server.md 발췌 — 15줄" zoom="true"
:::
## 빌더 경로에서는 셋이 발생할 수 없다
중복도, 역순도, 예외 경계가 최외곽이 아닌 경우도 발생할 수 없다. 발화 가능한 규칙은 필수 단계 누락 하나다.
## 결함은 아니다
나머지 셋은 `violations(List)` 를 직접 부르는 외부 호출자를 위한 것이고, 테스트가 그 경로로 셋을 모두 확인한다. 기록하는 이유는 빌더를 쓰는 조립 코드가 그 셋의 보호를 받는다고 읽기 쉽기 때문이다 — 실제 보호는 자료구조가 준다.
## 확인하지 못한 것
빌더 경로로 네 규칙을 실제로 발화시켜 보지 않았다. 자료구조의 순서 보장과 선행 가드로 판정했다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: grpc-spring-boot-starter-f01
title: 시작 검증기가 시작 시 실행되지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-spring-boot-starter-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-spring-boot-starter-f01
file: ../../../final/evidence/rendered/grpc-spring-boot-starter-f01.svg
- key: grpc-spring-boot-starter-f01-diagram
file: ../../../final/assets/diagrams/grpc-spring-boot-starter-f01.svg
evidence:
- ../../../final/evidence/raw/grpc-spring-boot-starter-f01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-spring-boot-starter.md#L183 이다.
module: grpc-spring-boot-starter
priority: P2
---
# 시작 검증기가 시작 시 실행되지 않는다
GrpcPlatformStartupValidator 를 이름으로 부르는 파일은 둘뿐이다 — 자기 자신과 자기 테스트. GrpcPlatformAutoConfiguration 은 빈 9개를 만들고 requireValid 를 부르지 않는다.
## 문제
GrpcPlatformStartupValidator 를 이름으로 부르는 파일은 둘뿐이다 — 자기 자신과 자기 테스트.
GrpcPlatformAutoConfiguration 은 빈 9개를 만들고 requireValid 를 부르지 않는다.
## 결론
초기화 콜백도, @PostConstruct 도, ApplicationRunner 도 없다.
그래서 클래스 javadoc 이 약속한 성질이 성립하지 않는다 — "Refuses to start on a configuration that would be wrong in a way nobody would notice." 지금은 그 설정으로 그냥 시작한다.
검증기가 유일한 소비자인 설정 키가 넷이다.
transport — production 이 아닌 전송을 거부할 곳이 없다.
게다가 자동 설정은 이 값을 보지 않고 GrpcServerProfile.stableNetty(...) 를 하드코딩한다(§17.2).
tls-enabled · trust-all-certificates — 배포 환경의 TLS 바닥을 강제할 곳이 없다.
operation-ledger-enabled — 멱등 키 필수 메서드가 원장 없이 열리는 것을 막을 곳이 없다.
같은 저장소가 이 형태를 두 번 기록했다 — WebPlatformStartupValidator 가 시작 시 실행되지 않고, BrokerAclManifest 의 시작 자기점검이 없다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcPlatformStartupValidator 참조 15건 검색과 자동 설정이 만드는 빈 목록 대조
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-spring-boot-starter.md#L183 에 있다.
## 본문
<!-- body:start -->
`GrpcPlatformStartupValidator` 를 이름으로 부르는 파일은 둘뿐이다 — 자기 자신과 자기 테스트.
## 검증기가 도는 데 빠진 것
:::evidence key="grpc-spring-boot-starter-f01-diagram" alt="설정 프로퍼티 셋만 검증기 입력 안에 놓이고 모듈 id 집합 생산자와 requireValid 호출 지점이 바깥에 빗금으로 놓인다" caption="검증기가 도는 데 빠진 것" zoom="false"
:::
`GrpcPlatformAutoConfiguration` 은 빈 9개를 만들고 `requireValid` 를 부르지 않는다. 초기화 콜백도, `@PostConstruct` 도, `ApplicationRunner` 도 없다. 그래서 클래스 javadoc 이 약속한 성질이 성립하지 않는다 — "Refuses to start on a configuration that would be wrong in a way nobody would notice."
## GrpcPlatformStartupValidator 참조 위치
:::evidence key="grpc-spring-boot-starter-f01" alt="코드베이스에서 GrpcPlatformStartupValidator 를 검색한 출력 15줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcPlatformStartupValidator 코드베이스 검색 — 15줄 · exit 0" zoom="true"
:::
## 함께 사라지는 설정 키 넷
`transport` — production 이 아닌 전송을 거부할 곳이 없다(게다가 자동 설정은 이 값을 보지 않고 `GrpcServerProfile.stableNetty(...)` 를 하드코딩한다, §17.2). `tls-enabled` · `trust-all-certificates` — 배포 환경의 TLS 바닥을 강제할 곳이 없다. `operation-ledger-enabled` — 멱등 키 필수 메서드가 원장 없이 열리는 것을 막을 곳이 없다.
## 정본이 저장소 안에 둘 있다
messaging 의 `StartupProfileValidation``InitializingBean.afterPropertiesSet` 으로 돌려 그 문제를 이미 한 번 해결했고, fileserver 는 `attestMapping()` 을 app-bootstrap 의 `@Bean` 으로 연결했다. 반대로 같은 저장소가 이 형태를 두 번 기록했다 — `WebPlatformStartupValidator` 가 시작 시 실행되지 않고, `BrokerAclManifest` 의 시작 자기점검이 없다.
## 왜 배선되지 않았는지가 서명에 보인다
`violations` 는 넷을 받고 그중 셋에 생산자가 없다. 특히 마지막은 "스타터가 해석한 모듈 id 집합" 인데 그것을 실행 중에 산출하는 코드가 없다. P2.
## 확인하지 못한 것
스타터를 실제 애플리케이션에 올려 컨텍스트를 세우지 않았다. build-only 이고 이 스타터를 의존하는 모듈이 저장소에 없다.
<!-- body:end -->
@@ -0,0 +1,74 @@
---
kind: CASE
slug: grpc-spring-boot-starter-f03
title: default-unary-deadline 은 읽는 코드가 저장소에 없다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-spring-boot-starter-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-spring-boot-starter-f03
file: ../../../final/evidence/rendered/grpc-spring-boot-starter-f03.svg
evidence:
- ../../../final/evidence/raw/grpc-spring-boot-starter-f03.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-spring-boot-starter.md#L236 이다.
module: grpc-spring-boot-starter
priority: P3
---
# default-unary-deadline 은 읽는 코드가 저장소에 없다
getDefaultUnaryDeadline() 의 호출자가 0 이다. 검증기도 이 값을 쓰지 않는다 — 검증기가 보는 것은 정책 목록의 policy.deadline().usable() 이고 그 값이 0 이면 위반을 낸다.
## 문제
getDefaultUnaryDeadline() 의 호출자가 0 이다.
검증기도 이 값을 쓰지 않는다 — 검증기가 보는 것은 정책 목록의 policy.deadline().usable() 이고 그 값이 0 이면 위반을 낸다.
## 결론
즉 자바독이 말하는 "선언하지 않은 메서드에 적용되는 기본 마감" 을 적용하는 코드가 없다.
ignoreUnknownFields = false 라서 이 키를 설정하는 것은 성공하고 아무 효과가 없다.
수정은 그 기본값을 실제로 적용하는 지점을 만들거나(정책 목록 조립 시), 필드를 제거하는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : getDefaultUnaryDeadline 호출처 검색과 검증기가 실제로 읽는 값 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-spring-boot-starter.md#L236 에 있다.
## 본문
<!-- body:start -->
`getDefaultUnaryDeadline()` 의 호출자가 0 이다. 검증기도 이 값을 쓰지 않는다 — 검증기가 보는 것은 정책 목록의 `policy.deadline().usable()` 이고 그 값이 0 이면 위반을 낸다.
## 검증기가 실제로 보는 값
:::evidence key="grpc-spring-boot-starter-f03" alt="분석 문서 analysis/grpc/grpc-spring-boot-starter.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/grpc/grpc-spring-boot-starter.md 발췌 — 15줄" zoom="true"
:::
## 자바독이 말하는 기본 마감을 적용하는 코드가 없다
"선언하지 않은 메서드에 적용되는 기본 마감" 이다. `ignoreUnknownFields = false` 라서 이 키를 설정하는 것은 성공하고 아무 효과가 없다.
## 수정
그 기본값을 실제로 적용하는 지점을 만들거나(정책 목록 조립 시), 필드를 제거하는 것이다.
## 확인하지 못한 것
이 값을 바꿔 동작 차이가 없음을 실행으로 확인하지 않았다. 호출자가 0 이라는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,82 @@
---
kind: CASE
slug: grpc-testkit-f05
title: 계약 스위트 둘이 결과를 만드는 코드를 갖지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:grpc-testkit-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-testkit-f05
file: ../../../final/evidence/rendered/grpc-testkit-f05.svg
evidence:
- ../../../final/evidence/raw/grpc-testkit-f05.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-testkit.md#L245 이다.
module: grpc-testkit
priority: P3
---
# 계약 스위트 둘이 결과를 만드는 코드를 갖지 않는다
GrpcUnaryReliabilityContract 와 GrpcServerStreamingContract 는 순수 평가기다 — List<Result> 를 받아 위반을 돌려준다. 시나리오 정의(단항 3 · 스트리밍 5)와 그 정합성 검사는 훌륭하다.
## 문제
GrpcUnaryReliabilityContract 와 GrpcServerStreamingContract 는 순수 평가기다 — List<Result> 를 받아 위반을 돌려준다.
시나리오 정의(단항 3 · 스트리밍 5)와 그 정합성 검사는 훌륭하다.
## 결론
스위트가 자기 커버리지를 열거하고, 돌지 않은 시나리오를 침묵이 아니라 위반으로 만든다.
빠진 것은 그 시나리오를 돌리는 쪽이다.
GrpcUnaryContractResult·GrpcStreamingContractResult 를 만드는 코드는 저장소 전체에서 두 테스트뿐이고, 둘 다 리터럴로 만든다.
그래서 "이 플랫폼은 비멱등 변경을 재시도하지 않는다" 를 뒷받침하는 것은, 그 문장을 리터럴로 적은 뒤 평가기가 그것을 읽고 위반이 없다고 답하는 절차다.
평가기의 산술은 옳고, 대상이 관측이 아니다.
in-process 픽스처(§1)는 이 시나리오들을 돌릴 재료를 이미 갖고 있다 — 인터셉터를 끼운 서버, 상태 매핑, 스트리밍 핸들러.
수정은 픽스처 위에서 세 시나리오를 실행해 attempts·businessInvocations 를 세는 러너를 두는 것이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : GrpcUnaryReliabilityContract 참조 10건 검색과 결과를 만드는 코드의 존재 여부 확인
소스 수정 : x
## 재현 조건
원문은 analysis/grpc/grpc-testkit.md#L245 에 있다.
## 본문
<!-- body:start -->
`GrpcUnaryReliabilityContract``GrpcServerStreamingContract` 는 순수 평가기다 — `List<Result>` 를 받아 위반을 돌려준다. 시나리오 정의(단항 3 · 스트리밍 5)와 그 정합성 검사는 훌륭하다. 스위트가 자기 커버리지를 열거하고, 돌지 않은 시나리오를 침묵이 아니라 위반으로 만든다.
## GrpcUnaryReliabilityContract 참조 위치
:::evidence key="grpc-testkit-f05" alt="코드베이스에서 GrpcUnaryReliabilityContract 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcUnaryReliabilityContract 코드베이스 검색 — 10줄 · exit 0" zoom="true"
:::
## 빠진 것은 그 시나리오를 돌리는 쪽이다
`GrpcUnaryContractResult`·`GrpcStreamingContractResult` 를 만드는 코드는 저장소 전체에서 두 테스트뿐이고, 둘 다 리터럴로 만든다. 그래서 "이 플랫폼은 비멱등 변경을 재시도하지 않는다" 를 뒷받침하는 것은, 그 문장을 리터럴로 적은 뒤 평가기가 그것을 읽고 위반이 없다고 답하는 절차다.
## 평가기의 산술은 옳고 대상이 관측이 아니다
in-process 픽스처(§1)는 이 시나리오들을 돌릴 재료를 이미 갖고 있다 — 인터셉터를 끼운 서버, 상태 매핑, 스트리밍 핸들러. 수정은 픽스처 위에서 세 시나리오를 실행해 `attempts`·`businessInvocations` 를 세는 러너를 두는 것이다.
## 확인하지 못한 것
두 계약 스위트를 실제 결과로 돌려 보지 않았다. 순수 평가기이고 입력을 만드는 코드가 없다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,68 @@
---
kind: CASE
slug: messaging-admin-api-f03
title: TopologyManagementMode 가 어디에도 연결되어 있지 않다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-api-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-f03
file: ../../../final/evidence/rendered/messaging-admin-api-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L952 이다.
module: messaging-admin-api
priority: P3
---
# TopologyManagementMode 가 어디에도 연결되어 있지 않다
자기 선언과 테스트 4건이 전부다. 이 enum 을 읽는 프로덕션 코드도, 이것으로 매핑되는 설정 프로퍼티도 없다(EVD-302).
## 문제
자기 선언과 테스트 4건이 전부다.
이 enum 을 읽는 프로덕션 코드도, 이것으로 매핑되는 설정 프로퍼티도 없다(EVD-302).
## 결론
두 선택지가 있다: 실제로 배선하거나(선언된 토폴로지 관리 모드를 설정에서 읽고 requireSafeFor(isProduction) 를 기동 시 호출), 제거한다.
지금 상태는 "규칙이 코드에 있다" 는 인상만 준다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : TopologyManagementMode 참조 5건 검색으로 프로덕션 읽기와 설정 프로퍼티 매핑 유무 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-api.md#L952 에 있다.
## 본문
<!-- body:start -->
자기 선언과 테스트 4건이 전부다. 이 enum 을 읽는 프로덕션 코드도, 이것으로 매핑되는 설정 프로퍼티도 없다(`EVD-302`).
## TopologyManagementMode 참조 위치
:::evidence key="messaging-admin-api-f03" alt="코드베이스에서 TopologyManagementMode 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TopologyManagementMode 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 두 선택지
실제로 배선하거나(선언된 토폴로지 관리 모드를 설정에서 읽고 `requireSafeFor(isProduction)` 를 기동 시 호출), 제거한다. 지금 상태는 "규칙이 코드에 있다" 는 인상만 준다.
## 확인하지 못한 것
부팅된 컨텍스트에서 이 enum 이 어떤 경로로도 읽히지 않는 것을 런타임으로 확인하지 않았다. 참조 전수로 판정했다.
<!-- body:end -->
@@ -0,0 +1,74 @@
---
kind: CASE
slug: messaging-admin-api-f04
title: 운영자용 표면 전체에 프로덕션 소비자가 없다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-api-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-f04
file: ../../../final/evidence/rendered/messaging-admin-api-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L956 이다.
module: messaging-admin-api
priority: P3
---
# 운영자용 표면 전체에 프로덕션 소비자가 없다
ReplayPlan.describeImpact, RedrivePlan.describeImpact, ReplayResult.fellShortOfTheEstimate, RedriveResult.isFullyAccounted, AdminOperationLease.isResumption — 다섯 개가 전부 테스트에서만 호출된다(EVD-302). 이것들은 잉여 코드가 아니라 아직 소비자가 없는 잘 설계된 표면이다.
## 문제
ReplayPlan.describeImpact, RedrivePlan.describeImpact, ReplayResult.fellShortOfTheEstimate, RedriveResult.isFullyAccounted, AdminOperationLease.isResumption — 다섯 개가 전부 테스트에서만 호출된다(EVD-302).
이것들은 잉여 코드가 아니라 아직 소비자가 없는 잘 설계된 표면이다.
## 결론
describeImpact 의 javadoc 이 "operator-facing" 이라고 쓰고 ApprovedPlanExecutionTest.aReplayIntoTheLiveGroupSaysSoInCapitals 가 대문자 LIVE 까지 검증한다.
문제는 그 문자열이 도달할 화면이 없다는 것이다.
admin API·CLI 계층을 만들 때 이 다섯이 그 계층의 명세라는 점을 문서에 남겨 두는 것이 낫다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : ReplayPlan 참조 9건 검색으로 다섯 표면의 호출처가 테스트뿐임을 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-api.md#L956 에 있다.
## 본문
<!-- body:start -->
`ReplayPlan.describeImpact`, `RedrivePlan.describeImpact`, `ReplayResult.fellShortOfTheEstimate`, `RedriveResult.isFullyAccounted`, `AdminOperationLease.isResumption` — 다섯 개가 전부 테스트에서만 호출된다(`EVD-302`).
## ReplayPlan 참조 위치
:::evidence key="messaging-admin-api-f04" alt="코드베이스에서 ReplayPlan 를 검색한 출력 9줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ReplayPlan 코드베이스 검색 — 9줄 · exit 0" zoom="true"
:::
## 잉여 코드가 아니라 아직 소비자가 없는 표면이다
`describeImpact` 의 javadoc 이 "operator-facing" 이라고 쓰고 `ApprovedPlanExecutionTest.aReplayIntoTheLiveGroupSaysSoInCapitals` 가 대문자 `LIVE` 까지 검증한다. 문제는 그 문자열이 도달할 화면이 없다는 것이다.
## 남겨 둘 것
admin API·CLI 계층을 만들 때 이 다섯이 그 계층의 명세라는 점을 문서에 남겨 두는 것이 낫다.
## 확인하지 못한 것
운영자 도구가 이 저장소 밖에 존재하는지 확인할 수 없었다. 저장소 안의 참조로만 판정했다.
<!-- body:end -->
@@ -0,0 +1,66 @@
---
kind: CASE
slug: messaging-admin-api-f06
title: messaging-policy 의존이 import 0건이다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-api-f06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-f06
file: ../../../final/evidence/rendered/messaging-admin-api-f06.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-f06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L968 이다.
module: messaging-admin-api
priority: P3
---
# messaging-policy 의존이 import 0건이다
선언만 남아 있다. 제거 후보.
## 문제
선언만 남아 있다.
제거 후보.
## 결론
선언만 남아 있다.
제거 후보.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 선언된 의존에 대한 패키지 이름 import 전수 검색
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-api.md#L968 에 있다.
## 본문
<!-- body:start -->
`messaging-policy` 의존이 선언만 남아 있고 import 는 0건이다.
## 선언만 남은 의존
:::evidence key="messaging-admin-api-f06" alt="분석 문서 analysis/messaging/messaging-admin-api.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-admin-api.md 발췌 — 15줄" zoom="true"
:::
## 제거 후보
## 확인하지 못한 것
의존을 제거하고 빌드를 돌려 보지 않았다. import 0 건으로 판정했으므로 간접 사용은 배제하지 못했다.
<!-- body:end -->
@@ -0,0 +1,77 @@
---
kind: CASE
slug: messaging-admin-runtime-f03
title: 오케스트레이터가 어디에서도 실행되지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-runtime-f03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-f03
file: ../../../final/evidence/rendered/messaging-admin-runtime-f03.svg
- key: messaging-admin-runtime-f03-diagram
file: ../../../final/assets/diagrams/messaging-admin-runtime-f03.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-f03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L918 이다.
module: messaging-admin-runtime
priority: P2
---
# 오케스트레이터가 어디에서도 실행되지 않는다
DefaultMessagingAdminService 257줄과 ReplayService 99줄이 프로덕션에서도 테스트에서도 인스턴스화되지 않는다(EVD-307). 검사 순서·저널 시퀀스·실패 시 재던짐·실패 코드 정제가 전부 미검증이다.
## 문제
DefaultMessagingAdminService 257줄과 ReplayService 99줄이 프로덕션에서도 테스트에서도 인스턴스화되지 않는다(EVD-307).
검사 순서·저널 시퀀스·실패 시 재던짐·실패 코드 정제가 전부 미검증이다.
## 결론
DefaultMessagingAdminService 의 생성자는 10개 인자를 받고 그중 8개가 SPI 또는 Supplier 이므로, 대역으로 조립하는 테스트를 쓰는 비용은 낮다.
§12.1(a)의 회귀 테스트도 이 층에서 쓰는 것이 자연스럽다 — 저널·리스·리드라이브 루프가 함께 도는 것이 결함이 나타나는 조건이기 때문이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : DefaultMessagingAdminService 참조 2건 검색으로 프로덕션·테스트 양쪽 인스턴스화 지점 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-runtime.md#L918 에 있다.
## 본문
<!-- body:start -->
`DefaultMessagingAdminService` 257줄과 `ReplayService` 99줄이 프로덕션에서도 테스트에서도 인스턴스화되지 않는다(`EVD-307`).
## 실행되지 않는 계층
:::evidence key="messaging-admin-runtime-f03-diagram" alt="SPI 각각의 단위 테스트만 검증된 범위 안에 놓이고 검사 순서와 저널 시퀀스, 실패 재던짐과 코드 정제가 바깥에 빗금으로 놓인다" caption="실행되지 않는 계층" zoom="false"
:::
검사 순서·저널 시퀀스·실패 시 재던짐·실패 코드 정제가 전부 미검증이다.
## DefaultMessagingAdminService 참조 위치
:::evidence key="messaging-admin-runtime-f03" alt="코드베이스에서 DefaultMessagingAdminService 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultMessagingAdminService 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## 대역으로 조립하는 비용이 낮다
`DefaultMessagingAdminService` 의 생성자는 10개 인자를 받고 그중 8개가 SPI 또는 `Supplier` 다. §12.1(a)의 회귀 테스트도 이 층에서 쓰는 것이 자연스럽다 — 저널·리스·리드라이브 루프가 함께 도는 것이 결함이 나타나는 조건이기 때문이다.
## 확인하지 못한 것
부팅된 컨텍스트에서 admin 평면을 켜고 빈 그래프를 관측하지 않았다. 런타임 관측을 수행하지 않았다.
<!-- body:end -->
@@ -0,0 +1,68 @@
---
kind: CASE
slug: messaging-admin-runtime-f04
title: public 인터페이스를 패키지 밖에서 구현할 수 없다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-runtime-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-f04
file: ../../../final/evidence/rendered/messaging-admin-runtime-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L924 이다.
module: messaging-admin-runtime
priority: P3
---
# public 인터페이스를 패키지 밖에서 구현할 수 없다
RedriveEstimator(public)의 반환 타입 RedriveEstimate 가 package-private 이다(EVD-308). DefaultMessagingAdminService 의 public 생성자가 그 인터페이스를 요구하므로, 외부 조립이 불가능하다.
## 문제
RedriveEstimator(public)의 반환 타입 RedriveEstimate 가 package-private 이다(EVD-308).
DefaultMessagingAdminService 의 public 생성자가 그 인터페이스를 요구하므로, 외부 조립이 불가능하다.
## 결론
RedriveEstimate 를 public 으로 올리는 것이 최소 수정이다.
더 나은 방향은 DefaultMessagingAdminService 밖의 최상위 record 로 꺼내는 것 — 지금은 오케스트레이터의 내부 타입이 SPI 계약의 일부가 되어 있다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : RedriveEstimator 참조 3건 검색과 반환 타입 및 생성자 인자의 접근 제한자 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-runtime.md#L924 에 있다.
## 본문
<!-- body:start -->
`RedriveEstimator`(public)의 반환 타입 `RedriveEstimate` 가 package-private 이다(`EVD-308`). `DefaultMessagingAdminService` 의 public 생성자가 그 인터페이스를 요구하므로, 외부 조립이 불가능하다.
## RedriveEstimator 참조 위치
:::evidence key="messaging-admin-runtime-f04" alt="코드베이스에서 RedriveEstimator 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RedriveEstimator 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 최소 수정과 더 나은 방향
`RedriveEstimate` 를 public 으로 올리는 것이 최소 수정이다. 더 나은 방향은 `DefaultMessagingAdminService` 밖의 최상위 record 로 꺼내는 것 — 지금은 오케스트레이터의 내부 타입이 SPI 계약의 일부가 되어 있다.
## 확인하지 못한 것
패키지 밖에서 실제로 구현을 시도해 컴파일 실패를 관측하지 않았다. 접근 제한자 조합으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,66 @@
---
kind: CASE
slug: messaging-admin-runtime-f09
title: 선언된 의존 6개 중 3개가 import 0건
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-admin-runtime-f09
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-runtime-f09
file: ../../../final/evidence/rendered/messaging-admin-runtime-f09.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-runtime-f09.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-runtime.md#L957 이다.
module: messaging-admin-runtime
priority: P3
---
# 선언된 의존 6개 중 3개가 import 0건
messaging-policy, messaging-transport-spi, messaging-security. 제거 후보.
## 문제
messaging-policy, messaging-transport-spi, messaging-security.
제거 후보.
## 결론
messaging-policy, messaging-transport-spi, messaging-security.
제거 후보.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 선언된 의존 6개에 대한 패키지 이름 import 전수 검색
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-admin-runtime.md#L957 에 있다.
## 본문
<!-- body:start -->
선언된 의존 6개 중 3개가 import 0건이다 — `messaging-policy`, `messaging-transport-spi`, `messaging-security`.
## import 0 건인 세 의존
:::evidence key="messaging-admin-runtime-f09" alt="분석 문서 analysis/messaging/messaging-admin-runtime.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-admin-runtime.md 발췌 — 15줄" zoom="true"
:::
## 제거 후보
## 확인하지 못한 것
세 의존을 제거하고 빌드를 돌려 보지 않았다. import 0 건으로 판정했으므로 리플렉션이나 문자열을 통한 간접 사용은 배제하지 못했다.
<!-- body:end -->
@@ -0,0 +1,90 @@
---
kind: CASE
slug: messaging-claim-check-f01
title: 배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-claim-check-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-claim-check-f01
file: ../../../final/evidence/rendered/messaging-claim-check-f01.svg
- key: messaging-claim-check-f01-diagram
file: ../../../final/assets/diagrams/messaging-claim-check-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-claim-check-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-claim-check.md#L507 이다.
module: messaging-claim-check
priority: P2
---
# 배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다
여섯 타입 전부 leaf 밖 참조 0, ClaimCheckStore 구현이 테스트 fake뿐, 조립 0건. 그런데 runtime_memberships가 ["app-bootstrap"]이고 starter의 allowed_dependencies에 포함된다.
## 관계
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 튜닝 값이 두 계층에 있으면 어느 쪽이 이기는지 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
여섯 타입 전부 leaf 밖 참조 0, ClaimCheckStore 구현이 테스트 fake뿐, 조립 0건.
그런데 runtime_memberships가 ["app-bootstrap"]이고 starter의 allowed_dependencies에 포함된다.
## 결론
그리고 messaging-policy의 PayloadLimitGuard가 상한 초과 payload를 거절하며 "payload of %d bytes exceeds the %d byte limit for %s; use claim check"라고 안내한다.
운영자가 상한 초과 오류를 보고 안내대로 claim check를 켜려 해도 켤 것이 없다 — 저장소 구현도, bean도, 오프로드를 부르는 발행 경로도 없다.
그리고 DestinationProfile이 claimCheckThresholdBytes를 선언하고 검증까지 하므로 설정 표면은 존재한다.
설정할 수 있고 아무 효과가 없는 값이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : ClaimCheckStore 참조 6건 검색과 runtime_memberships 값 및 다른 리프의 오류 메시지 안내 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-claim-check.md#L507 에 있다.
## 본문
<!-- body:start -->
여섯 타입 전부 leaf 밖 참조 0, `ClaimCheckStore` 구현이 테스트 fake뿐, 조립 0건. 그런데 `runtime_memberships``["app-bootstrap"]`이고 starter의 `allowed_dependencies`에 포함된다.
## 안내가 가리키는 빈자리
:::evidence key="messaging-claim-check-f01-diagram" alt="설정 표면과 오류 메시지 안내가 운영자가 켤 수 있는 것 안에 놓이고 저장소 구현과 bean 이 바깥에 빗금으로 놓인다" caption="안내가 가리키는 빈자리" zoom="false"
:::
## ClaimCheckStore 참조 위치
:::evidence key="messaging-claim-check-f01" alt="코드베이스에서 ClaimCheckStore 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClaimCheckStore 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 다른 곳의 에러 메시지가 이 경로를 권한다
`messaging-policy``PayloadLimitGuard`가 상한 초과 payload를 거절하며 `"payload of %d bytes exceeds the %d byte limit for %s; use claim check"`라고 안내한다. 운영자가 그 안내대로 claim check를 켜려 해도 켤 것이 없다 — 저장소 구현도, bean도, 오프로드를 부르는 발행 경로도 없다.
## 설정 표면은 존재한다
`DestinationProfile``claimCheckThresholdBytes`를 선언하고 검증까지 한다. 설정할 수 있고 아무 효과가 없는 값이다.
## 확인하지 못한 것
ClaimCheckStore 를 구현할 계획이 있는지 확인할 수 없었다. objectstorage 어댑터가 후보이지만 두 리프가 registry 에서 연결되지 않는다.
<!-- body:end -->
@@ -0,0 +1,94 @@
---
kind: CASE
slug: messaging-cloudevents-f02
title: 배포 아티팩트가 싣지만 아무도 부르지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-cloudevents-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-cloudevents-f02
file: ../../../final/evidence/rendered/messaging-cloudevents-f02.svg
- key: messaging-cloudevents-f02-diagram
file: ../../../final/assets/diagrams/messaging-cloudevents-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-cloudevents-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-cloudevents.md#L529 이다.
module: messaging-cloudevents
priority: P2
---
# 배포 아티팩트가 싣지만 아무도 부르지 않는다
세 타입의 leaf 밖 참조가 0인데 runtime_memberships가 ["app-bootstrap"]이다. messaging-spring-boot-starter의 의존 목록에 있어 cloudevents-api·cloudevents-core 두 jar가 런타임 classpath에 오른다.
## 관계
- **왕복이라 부르는 테스트는 무엇을 보존하지 않는지도 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **문서가 범위를 제한하면 그 제한을 누가 강제하는지 같이 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
세 타입의 leaf 밖 참조가 0인데 runtime_memberships가 ["app-bootstrap"]이다.
messaging-spring-boot-starter의 의존 목록에 있어 cloudevents-api·cloudevents-core 두 jar가 런타임 classpath에 오른다.
## 결론
starter에 CloudEventMapper를 만드는 @Bean이 없다.
형제 Avro·Protobuf는 소비자 0과 membership []이 일치하는 정합적 incubating 상태다.
이 leaf만 어긋난다.
오늘 실행되는 코드가 없으므로 사고는 아니지만, 아티팩트 크기와 "이 의존성이 왜 있지"의 조사 비용이 남는다.
그리고 support-matrix.md:23이 "모든 messaging leaf가 unwired"라고 적고 있어 문서에서도 이 사실을 알 수 없다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : CloudEventMapper 참조 3건 검색과 배포 아티팩트에 실리는 jar·타입 목록 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-cloudevents.md#L529 에 있다.
## 본문
<!-- body:start -->
세 타입의 leaf 밖 참조가 0인데 `runtime_memberships``["app-bootstrap"]`이다.
## 싣고 쓰지 않는 구조
:::evidence key="messaging-cloudevents-f02-diagram" alt="cloudevents 두 jar 와 leaf 세 타입이 배포 아티팩트 안에 놓이고 CloudEventMapper 빈이 바깥에 빗금으로 놓인다" caption="싣고 쓰지 않는 구조" zoom="false"
:::
`messaging-spring-boot-starter`의 의존 목록에 있어 `cloudevents-api`·`cloudevents-core` 두 jar가 런타임 classpath에 오른다. starter에 `CloudEventMapper`를 만드는 `@Bean`이 없다.
## CloudEventMapper 참조 위치
:::evidence key="messaging-cloudevents-f02" alt="코드베이스에서 CloudEventMapper 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CloudEventMapper 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 형제 둘은 정합적이다
Avro·Protobuf는 소비자 0과 membership `[]`이 일치하는 incubating 상태다. 이 leaf만 어긋난다.
## 오늘 사고는 아니고 조사 비용이 남는다
실행되는 코드가 없으므로 사고는 아니지만, 아티팩트 크기와 "이 의존성이 왜 있지"의 조사 비용이 남는다. 그리고 `support-matrix.md:23`이 "모든 messaging leaf가 unwired"라고 적고 있어 문서에서도 이 사실을 알 수 없다.
## 확인하지 못한 것
이 리프가 starter 의존 목록에 들어간 시점과 이유를 확인할 수 없었다. 커밋이 대량 커밋 4개뿐이다.
<!-- body:end -->
@@ -0,0 +1,100 @@
---
kind: CASE
slug: messaging-inbox-jdbc-postgresql-f01
title: bounded purge가 구현돼 있고 호출되지 않아, cleanup이 스스로 막겠다고 한 장애를 일으킨다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-inbox-jdbc-postgresql-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-inbox-jdbc-postgresql-f01
file: ../../../final/evidence/rendered/messaging-inbox-jdbc-postgresql-f01.svg
- key: messaging-inbox-jdbc-postgresql-f01-diagram
file: ../../../final/assets/diagrams/messaging-inbox-jdbc-postgresql-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-inbox-jdbc-postgresql-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L647 이다.
module: messaging-inbox-jdbc-postgresql
priority: P1
---
# bounded purge가 구현돼 있고 호출되지 않아, cleanup이 스스로 막겠다고 한 장애를 일으킨다
InboxRepository·OutboxRepository 둘 다 purge*Before(Instant, int) 오버로드를 선언하고, JdbcInboxRepository:141·JdbcOutboxRepository:486이 LIMIT + FOR UPDATE SKIP LOCKED로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 선언 2 + 구현 2 + 테스트 fake override 5이고 호출 지점이 0이다.
## 관계
- **컬럼 폭은 애플리케이션 검증과 짝을 이룬다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 안전 규칙은 한 공식과 한 강제 시점을 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
InboxRepository·OutboxRepository 둘 다 purge*Before(Instant, int) 오버로드를 선언하고, JdbcInboxRepository:141·JdbcOutboxRepository:486이 LIMIT + FOR UPDATE SKIP LOCKED로 구현한다.
저장소 전체에서 그 시그니처가 등장하는 9곳은 선언 2 + 구현 2 + 테스트 fake override 5이고 호출 지점이 0이다.
## 결론
InboxCleanupJob:56과 OutboxCleanupJob:50이 무제한 오버로드를 부른다.
InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000은 자기 선언 한 줄만 존재한다.
InboxCleanupJob의 javadoc이 스스로 적는다 — "A single unbounded DELETE over a table that has been accumulating for weeks holds locks long enough to block the very reservations the inbox exists to serve, so the cleanup would cause the outage it is meant to prevent." 실행되는 코드가 정확히 그 문장이 서술하는 동작이다.
OutboxRepository의 bounded 오버로드 javadoc은 한 발 더 나간다 — "The cleanup jobs describe themselves as bounded by batch size; this is the parameter that makes that true." 그 파라미터를 아무도 넘기지 않는다.
그리고 두 leaf가 동일한 형태로 그렇다.
왜 P1인가.
두 leaf 다 runtime_memberships: ["app-bootstrap"]이고 두 cleanup job이 starter에서 bean으로 만들어진다(MessagingReliabilityAutoConfiguration의 inboxCleanupJob·outboxCleanupJob). 즉 출하 구성에서 실행되는 경로이며, 백로그가 쌓인 뒤 첫 스윕에서 발현한다. 다른 미배선 발견들과 성격이 다르다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : InboxRepository 참조 20건 검색으로 bounded 시그니처가 등장하는 9곳을 선언·구현·호출로 분류
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-inbox-jdbc-postgresql.md#L647 에 있다.
## 본문
<!-- body:start -->
`InboxRepository`·`OutboxRepository` 둘 다 `purge*Before(Instant, int)` 오버로드를 선언하고, `JdbcInboxRepository:141`·`JdbcOutboxRepository:486``LIMIT` + `FOR UPDATE SKIP LOCKED`로 구현한다. 저장소 전체에서 그 시그니처가 등장하는 9곳은 **선언 2 + 구현 2 + 테스트 fake override 5**이고 **호출 지점이 0**이다. `InboxCleanupJob:56``OutboxCleanupJob:50`이 무제한 오버로드를 부른다.
## 정리가 일으키는 장애
:::evidence key="messaging-inbox-jdbc-postgresql-f01-diagram" alt="쌓인 백로그가 무제한 DELETE 와 락 장기 보유를 지나 예약이 막히는 결과로 이어진다" caption="정리가 일으키는 장애" zoom="false"
:::
## InboxRepository 참조 위치
:::evidence key="messaging-inbox-jdbc-postgresql-f01" alt="코드베이스에서 InboxRepository 를 검색한 출력 20줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="InboxRepository 코드베이스 검색 — 20줄 · exit 0" zoom="true"
:::
## javadoc 자신이 이 동작을 서술한다
`InboxCleanupJob`의 javadoc — "A single unbounded DELETE over a table that has been accumulating for weeks holds locks long enough to block the very reservations the inbox exists to serve, so the cleanup would cause the outage it is meant to prevent." 실행되는 코드가 정확히 그 문장이 서술하는 동작이다. `OutboxRepository`의 bounded 오버로드 javadoc은 한 발 더 나간다 — "The cleanup jobs describe themselves as bounded by batch size; **this is the parameter that makes that true**." 그 파라미터를 아무도 넘기지 않는다. `InboxCleanupJob.DEFAULT_BATCH_SIZE = 1_000`은 자기 선언 한 줄만 존재한다.
## 왜 P1인가
두 leaf 다 `runtime_memberships: ["app-bootstrap"]`이고 두 cleanup job이 starter에서 bean으로 만들어진다(`MessagingReliabilityAutoConfiguration``inboxCleanupJob`·`outboxCleanupJob`). 즉 **출하 구성에서 실행되는 경로**이며, 백로그가 쌓인 뒤 첫 스윕에서 발현한다. 다른 미배선 발견들과 성격이 다르다.
## 후보
두 job이 bounded 오버로드에 배치 크기를 넘기게 한다 — `InboxCleanupJob`은 이미 `DEFAULT_BATCH_SIZE`를 갖고 있다.
## 확인하지 못한 것
무제한 DELETE 가 실제 규모의 테이블에서 얼마나 오래 락을 잡는지 측정하지 않았다.
<!-- body:end -->
@@ -0,0 +1,84 @@
---
kind: CASE
slug: messaging-observability-f02
title: 관측 구현이 조립되지 않고, 그 재료 둘만 bean으로 존재한다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-observability-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-observability-f02
file: ../../../final/evidence/rendered/messaging-observability-f02.svg
- key: messaging-observability-f02-diagram
file: ../../../final/assets/diagrams/messaging-observability-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-observability-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-observability.md#L682 이다.
module: messaging-observability
priority: P2
---
# 관측 구현이 조립되지 않고, 그 재료 둘만 bean으로 존재한다
MessagingMetrics는 MessagingObservation의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다. starter는 그 생성자 인자 둘(MessagingRedactor:253, CardinalityGuard:264)을 bean으로 만들고 MessagingMetrics bean은 만들지 않는다.
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
MessagingMetrics는 MessagingObservation의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다.
starter는 그 생성자 인자 둘(MessagingRedactor:253, CardinalityGuard:264)을 bean으로 만들고 MessagingMetrics bean은 만들지 않는다.
## 결론
DefaultMessagePublisher는 NO_OBSERVATION을 쓰는 6인자 생성자로 조립된다.
재료·구현·seam·호출부가 전부 있고 조립 한 줄이 없다.
그리고 두 재료 bean은 주입처가 0이므로 컨텍스트에 앉아만 있다 — bean 존재 검사는 통과한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : MessagingMetrics 참조 16건 검색과 starter 가 만드는 bean 목록 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-observability.md#L682 에 있다.
## 본문
<!-- body:start -->
`MessagingMetrics``MessagingObservation`의 유일한 구현이고 저장소 전체에서 자기 테스트에서만 생성된다.
## 재료와 조립의 거리
:::evidence key="messaging-observability-f02-diagram" alt="MessagingRedactor 와 CardinalityGuard 가 starter 가 만드는 bean 안에 놓이고 MessagingMetrics 가 바깥에 빗금으로 놓인다" caption="재료와 조립의 거리" zoom="false"
:::
starter는 그 생성자 인자 둘(`MessagingRedactor:253`, `CardinalityGuard:264`)을 bean으로 만들고 `MessagingMetrics` bean은 만들지 않는다.
## MessagingMetrics 참조 위치
:::evidence key="messaging-observability-f02" alt="코드베이스에서 MessagingMetrics 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingMetrics 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## 조립 한 줄이 없다
`DefaultMessagePublisher``NO_OBSERVATION`을 쓰는 6인자 생성자로 조립된다. 재료·구현·seam·호출부가 전부 있다. 그리고 두 재료 bean은 주입처가 0이므로 컨텍스트에 앉아만 있다 — bean 존재 검사는 통과한다.
## 확인하지 못한 것
이 bean 이 없는 것이 미완인지 확장점인지 확인하지 못했다. 저장소 안에 답이 없다.
<!-- body:end -->
@@ -0,0 +1,95 @@
---
kind: CASE
slug: messaging-outbox-jdbc-postgresql-f01
title: 정리 작업이 무제한 DELETE 를 쏘고, 그것을 막는 오버로드는 호출되지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-outbox-jdbc-postgresql-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-outbox-jdbc-postgresql-f01
file: ../../../final/evidence/rendered/messaging-outbox-jdbc-postgresql-f01.svg
- key: messaging-outbox-jdbc-postgresql-f01-diagram
file: ../../../final/assets/diagrams/messaging-outbox-jdbc-postgresql-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-outbox-jdbc-postgresql-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L876 이다.
module: messaging-outbox-jdbc-postgresql
priority: P1
---
# 정리 작업이 무제한 DELETE 를 쏘고, 그것을 막는 오버로드는 호출되지 않는다
OutboxCleanupJob:50 과 InboxCleanupJob:56 이 무제한 오버로드를 부른다. bounded 오버로드(purgePublishedBefore(Instant, int) / purgeProcessedBefore(Instant, int))는 두 포트에 선언되고 두 구현에 구현되어 있으며 호출부가 0건이다(EVD-294, EVD-311).
## 문제
OutboxCleanupJob:50 과 InboxCleanupJob:56 이 무제한 오버로드를 부른다.
bounded 오버로드(purgePublishedBefore(Instant, int) / purgeProcessedBefore(Instant, int))는 두 포트에 선언되고 두 구현에 구현되어 있으며 호출부가 0건이다(EVD-294, EVD-311).
## 결론
두 잡 모두 starter 빈이지만 스케줄러는 등록되지 않으며, 그것은 의도된 설계다(EVD-316).
즉 기본 배포에서는 아무 일도 일어나지 않고, 애플리케이션이 문서 지시대로 잡을 스케줄하는 순간 무제한 DELETE 가 발동한다.
잠재 결함이지 상시 결함이 아니다.
bounded 구현의 주석이 결과를 명시한다: "An unbounded DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay and the business writes behind retention." 3일치 백로그가 쌓인 테이블에서 이것은 릴레이 정지와 비즈니스 쓰기 정체를 뜻한다.
두 리프 모두 runtime_memberships: ["app-bootstrap"] 이고 두 잡 모두 starter 빈이다.
수정은 한 줄이다 — purgePublishedBefore(cutoff, batchLimit).
maxBatches 가 그제서야 의미를 갖는다.
배치 크기는 새 파라미터가 필요하고, OutboxProperties.batchSize(100)를 재사용하거나 별도 값을 둔다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : OutboxProperties 참조 28건 검색과 두 오버로드의 SQL·호출자·starter 배선 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L876 에 있다.
## 본문
<!-- body:start -->
`OutboxCleanupJob:50``InboxCleanupJob:56` 이 무제한 오버로드를 부른다. bounded 오버로드(`purgePublishedBefore(Instant, int)` / `purgeProcessedBefore(Instant, int)`)는 두 포트에 선언되고 두 구현에 구현되어 있으며 호출부가 **0건**이다(`EVD-294`, `EVD-311`).
## 두 형태의 락 구간
:::evidence key="messaging-outbox-jdbc-postgresql-f01-diagram" alt="호출되는 오버로드 쪽에 무제한 DELETE 와 전체 백로그 락이 빗금으로 놓이고 호출되지 않는 오버로드 쪽에 LIMIT 배치와 배치 단위 락이 놓인다" caption="두 형태의 락 구간" zoom="false"
:::
## OutboxProperties 참조 위치
:::evidence key="messaging-outbox-jdbc-postgresql-f01" alt="코드베이스에서 OutboxProperties 를 검색한 출력 28줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxProperties 코드베이스 검색 — 28줄 · exit 0" zoom="true"
:::
## 잠재 결함이지 상시 결함이 아니다
두 잡 모두 starter 빈이지만 스케줄러는 등록되지 않으며, 그것은 의도된 설계다(`EVD-316`). 즉 기본 배포에서는 아무 일도 일어나지 않고, 애플리케이션이 문서 지시대로 잡을 스케줄하는 순간 무제한 DELETE 가 발동한다.
## bounded 구현의 주석이 결과를 명시한다
"An unbounded DELETE holds locks and writes WAL in proportion to the whole backlog, which stalls the relay and the business writes behind retention." 3일치 백로그가 쌓인 테이블에서 이것은 릴레이 정지와 비즈니스 쓰기 정체를 뜻한다. 두 리프 모두 `runtime_memberships: ["app-bootstrap"]` 이다.
## 수정은 한 줄이고 대역도 함께 고쳐야 한다
`purgePublishedBefore(cutoff, batchLimit)` 로 바꾸면 `maxBatches` 가 그제서야 의미를 갖는다. 배치 크기는 `OutboxProperties.batchSize`(100)를 재사용하거나 별도 값을 둔다. 그리고 회귀 테스트가 성립하려면 `RecordingRepository` 를 고쳐야 한다 — 현재 대역의 bounded 구현은 `Math.min(unbounded(), limit)` 로 전부 지우고 숫자만 깎는다.
## 확인하지 못한 것
백로그가 쌓인 실제 테이블에서 무제한 DELETE 의 락 보유 시간을 측정하지 않았다. 두 SQL 과 호출부 부재로 도출했다.
<!-- body:end -->
@@ -0,0 +1,81 @@
---
kind: CASE
slug: messaging-outbox-jdbc-postgresql-f04
title: 두 릴레이 상호배제가 기동에서 강제되지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-outbox-jdbc-postgresql-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-outbox-jdbc-postgresql-f04
file: ../../../final/evidence/rendered/messaging-outbox-jdbc-postgresql-f04.svg
- key: messaging-outbox-jdbc-postgresql-f04-diagram
file: ../../../final/assets/diagrams/messaging-outbox-jdbc-postgresql-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-outbox-jdbc-postgresql-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L909 이다.
module: messaging-outbox-jdbc-postgresql
priority: P2
---
# 두 릴레이 상호배제가 기동에서 강제되지 않는다
DebeziumOutboxProfile.requireExactlyOneRelay(...) 는 프로덕션 호출부가 0건이다. 클래스 javadoc 은 "the incompatibility is therefore enforced at startup instead of documented" 라고 쓴다.
## 문제
DebeziumOutboxProfile.requireExactlyOneRelay(...) 는 프로덕션 호출부가 0건이다.
클래스 javadoc 은 "the incompatibility is therefore enforced at startup instead of documented" 라고 쓴다.
## 결론
properties 파일도 같은 경고를 반복한다("Enable this OR the in-process polling relay, never both").
같은 리프에 정확히 이 형태를 고친 선례가 있다 — OutboxRelayWorker 가 "nothing ever called runOnce" 를 고치고 MessagingOutboxRelayLifecycle 로 배선까지 마쳤다.
같은 방식으로 MessagingReliabilityAutoConfiguration 에 프로필 빈과 InitializingBean 검사를 두면 된다.
배선하려면 CDC 모드를 선택할 프로퍼티도 필요하다 — 지금은 DebeziumOutboxProfile 을 만드는 설정 경로 자체가 없다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : OutboxRelayWorker 참조 17건 검색과 상호배제 검사 메서드의 프로덕션 호출부 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-outbox-jdbc-postgresql.md#L909 에 있다.
## 본문
<!-- body:start -->
`DebeziumOutboxProfile.requireExactlyOneRelay(...)` 는 프로덕션 호출부가 0건이다. 클래스 javadoc 은 "the incompatibility is therefore enforced at startup instead of documented" 라고 쓴다.
## 배제가 강제되지 않는 자리
:::evidence key="messaging-outbox-jdbc-postgresql-f04-diagram" alt="문서의 경고만 기동에서 강제되는 것 안에 놓이고 requireExactlyOneRelay 호출이 바깥에 빗금으로 놓인다" caption="배제가 강제되지 않는 자리" zoom="false"
:::
properties 파일도 같은 경고를 반복한다("Enable this OR the in-process polling relay, never both").
## OutboxRelayWorker 참조 위치
:::evidence key="messaging-outbox-jdbc-postgresql-f04" alt="코드베이스에서 OutboxRelayWorker 를 검색한 출력 17줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxRelayWorker 코드베이스 검색 — 17줄 · exit 0" zoom="true"
:::
## 같은 리프에 선례가 있다
`OutboxRelayWorker` 가 "nothing ever called `runOnce`" 를 고치고 `MessagingOutboxRelayLifecycle` 로 배선까지 마쳤다. 같은 방식으로 `MessagingReliabilityAutoConfiguration` 에 프로필 빈과 `InitializingBean` 검사를 두면 된다. 배선하려면 CDC 모드를 선택할 프로퍼티도 필요하다 — 지금은 `DebeziumOutboxProfile` 을 만드는 설정 경로 자체가 없다.
## 확인하지 못한 것
두 릴레이를 동시에 켠 배포를 만들어 관측하지 않았다. 검사 메서드의 호출부가 0 건이라는 것으로 도출했다.
<!-- body:end -->
@@ -0,0 +1,82 @@
---
kind: CASE
slug: messaging-rabbit-f04
title: 능력 상수의 delayedDelivery 가 무조건 참이고, 그 지연을 제공할 토폴로지는 조립되지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-rabbit-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-rabbit-f04
file: ../../../final/evidence/rendered/messaging-rabbit-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-rabbit-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-rabbit.md#L307 이다.
module: messaging-rabbit
priority: P3
---
# 능력 상수의 delayedDelivery 가 무조건 참이고, 그 지연을 제공할 토폴로지는 조립되지 않는다
그런데 지연을 실제로 만드는 것은 RabbitRetryQueueTopology 이고, 그 클래스는 자기 파일과 시험 하나 밖에서 참조되지 않는다. 어떤 production 코드도 그 큐를 선언하지 않는다.
## 문제
그런데 지연을 실제로 만드는 것은 RabbitRetryQueueTopology 이고, 그 클래스는 자기 파일과 시험 하나 밖에서 참조되지 않는다.
어떤 production 코드도 그 큐를 선언하지 않는다.
## 결론
그리고 그 클래스의 javadoc 이 이 지연의 성질을 정확히 적는다.
즉 제공되는 것은 "메시지별 지연" 이 아니라 "재시도 큐 하나당 TTL 하나" 다.
능력 모델에는 그 구분을 표현하는 자리가 없고, 상수는 프로파일과 무관하게 참을 답한다.
Kafka 는 같은 칸을 false 로 둔다.
그래서 이 플래그의 두 값이 "지연 있음/없음" 이 아니라 "지연을 흉내낼 토폴로지를 선언할 수 있음/없음" 을 뜻하게 된다.
수정은 능력을 전송 상수가 아니라 목적지의 재시도 큐 선언에서 파생시키는 것이다.
이 리프가 조립되지 않는 동안에는 P3 이고, RabbitChannelPublisher 구현이 생기는 날 함께 봐야 한다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : RabbitRetryQueueTopology 참조 4건 검색으로 그 큐를 선언하는 production 코드 유무 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-rabbit.md#L307 에 있다.
## 본문
<!-- body:start -->
능력 상수의 `delayedDelivery` 가 무조건 참이다. 그런데 지연을 실제로 만드는 것은 `RabbitRetryQueueTopology` 이고, 그 클래스는 자기 파일과 시험 하나 밖에서 참조되지 않는다. 어떤 production 코드도 그 큐를 선언하지 않는다.
## RabbitRetryQueueTopology 참조 위치
:::evidence key="messaging-rabbit-f04" alt="코드베이스에서 RabbitRetryQueueTopology 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RabbitRetryQueueTopology 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 제공되는 것은 메시지별 지연이 아니다
그 클래스의 javadoc 이 이 지연의 성질을 정확히 적는다 — "재시도 큐 하나당 TTL 하나" 다. 능력 모델에는 그 구분을 표현하는 자리가 없고, 상수는 프로파일과 무관하게 참을 답한다. Kafka 는 같은 칸을 `false` 로 둔다.
## 플래그의 두 값이 다른 뜻이 된다
"지연 있음/없음" 이 아니라 "지연을 흉내낼 토폴로지를 선언할 수 있음/없음" 이다. 수정은 능력을 전송 상수가 아니라 목적지의 재시도 큐 선언에서 파생시키는 것이다. 이 리프가 조립되지 않는 동안에는 P3 이고, `RabbitChannelPublisher` 구현이 생기는 날 함께 봐야 한다.
## 확인하지 못한 것
지연 재시도 큐 토폴로지를 실제로 선언해 보지 않았다. 참조가 자기 파일과 시험 하나뿐이라는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,92 @@
---
kind: CASE
slug: messaging-reliability-api-f02
title: fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-reliability-api-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-reliability-api-f02
file: ../../../final/evidence/rendered/messaging-reliability-api-f02.svg
- key: messaging-reliability-api-f02-diagram
file: ../../../final/assets/diagrams/messaging-reliability-api-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-reliability-api-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-reliability-api.md#L698 이다.
module: messaging-reliability-api
priority: P2
---
# fencing token 경로가 실제 데이터베이스에 대해 실행되지 않는다
OutboxRelay는 claimBatch/lease 기반 전이만 쓴다. OutboxPostgresIT는 leaseBatch/MessageId 기반 전이만 쓴다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
OutboxRelay는 claimBatch/lease 기반 전이만 쓴다.
OutboxPostgresIT는 leaseBatch/MessageId 기반 전이만 쓴다.
## 결론
신세대를 쓰는 다른 테스트는 InMemoryOutboxRepository와 RecordingRepository — SQL이 없는 fake다.
fencing의 정확성은 구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다.
OutboxTransitionResult.STALE_LEASE는 "its update matches zero rows"에서 나오고, 그것은 SQL의 성질이지 Java의 성질이 아니다.
in-memory fake는 그 SQL을 실행하지 않는다.
즉 이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : OutboxRelay 참조 27건 검색과 릴레이·컨테이너 테스트가 각각 쓰는 전이 세대 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-reliability-api.md#L698 에 있다.
## 본문
<!-- body:start -->
`OutboxRelay``claimBatch`/lease 기반 전이만 쓴다. `OutboxPostgresIT``leaseBatch`/`MessageId` 기반 전이만 쓴다.
## 검증이 닿은 범위
:::evidence key="messaging-reliability-api-f02-diagram" alt="in-memory fake 만 펜싱 경로를 실행하는 것 안에 놓이고 실 데이터베이스 위의 펜싱 경로가 바깥에 빗금으로 놓인다" caption="검증이 닿은 범위" zoom="false"
:::
신세대를 쓰는 다른 테스트는 `InMemoryOutboxRepository``RecordingRepository` — SQL이 없는 fake다.
## OutboxRelay 참조 위치
:::evidence key="messaging-reliability-api-f02" alt="코드베이스에서 OutboxRelay 를 검색한 출력 27줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxRelay 코드베이스 검색 — 27줄 · exit 0" zoom="true"
:::
## fencing 의 정확성은 SQL 의 성질이다
구현의 조건부 UPDATE가 영향 행 수를 정확히 세는지에 달려 있다. `OutboxTransitionResult.STALE_LEASE`는 "its update matches zero rows"에서 나오고, in-memory fake는 그 SQL을 실행하지 않는다. 즉 **이중 발행을 막는 장치가 그것을 검증할 수 있는 유일한 환경에서 실행되지 않는다.**
## 확인하지 못한 것
fencing token SQL 이 실제 PostgreSQL 에서 정확한지 확인하지 못했다. 그것을 검증할 레인이 다른 세대를 쓴다.
<!-- body:end -->
@@ -0,0 +1,104 @@
---
kind: CASE
slug: messaging-runtime-core-f01
title: 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-runtime-core-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-runtime-core-f01
file: ../../../final/evidence/rendered/messaging-runtime-core-f01.svg
- key: messaging-runtime-core-f01-diagram
file: ../../../final/assets/diagrams/messaging-runtime-core-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-runtime-core-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-runtime-core.md#L710 이다.
module: messaging-runtime-core
priority: P2
---
# 관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다
DefaultMessagePublisher가 모든 발행 결과를 observation.recordPublish(...)로 기록하고, 관측을 "constructor argument rather than an optional decorator"로 받는다. MessagingMetrics가 MessagingObservation을 구현한다.
## 관계
- **만들어 두고 흘리지 않는 진단값은 진단이 아니다**
같은 분석 리프에서 끌어낸 규칙이다.
- **증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
- **`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
DefaultMessagePublisher가 모든 발행 결과를 observation.recordPublish(...)로 기록하고, 관측을 "constructor argument rather than an optional decorator"로 받는다.
MessagingMetrics가 MessagingObservation을 구현한다.
## 결론
그런데 출하 조립(MessagingCoreAutoConfiguration:446)은 6인자 생성자를 써서 NO_OBSERVATION을 넣고, MessagingMetrics는 저장소 전체에서 자기 테스트에서만 생성된다.
starter는 MessagingMetrics의 협력자 둘(MessagingRedactor:253, CardinalityGuard:264)을 bean으로 만든다.
이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — "an unobserved publish path is how 'the dashboards were empty during the incident' happens".
그리고 같은 javadoc이 이전 결함을 "bean은 있고 호출 경로가 없었다"로 기록한다.
지금은 반대다 — 호출 경로가 있고 bean이 없다.
관측 결과는 같다.
고침이 간극을 닫은 게 아니라 반대편으로 옮겼다.
"decorator가 아니라 생성자 인자"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : DefaultMessagePublisher 참조 22건 검색과 출하 조립이 고르는 생성자의 인자 수 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-runtime-core.md#L710 에 있다.
## 본문
<!-- body:start -->
`DefaultMessagePublisher`가 모든 발행 결과를 `observation.recordPublish(...)`로 기록하고, 관측을 "constructor argument rather than an optional decorator"로 받는다. `MessagingMetrics``MessagingObservation`을 구현한다.
## 조립이 되돌린 것
:::evidence key="messaging-runtime-core-f01-diagram" alt="MessagingMetrics 구현과 recordPublish 호출부와 생성자 인자 자리가 갖춰진 것 안에 놓이고 출하 조립의 6인자 생성자가 바깥에 빗금으로 놓인다" caption="조립이 되돌린 것" zoom="false"
:::
그런데 출하 조립(`MessagingCoreAutoConfiguration:446`)은 **6인자 생성자**를 써서 `NO_OBSERVATION`을 넣고, `MessagingMetrics`는 저장소 전체에서 자기 테스트에서만 생성된다.
## DefaultMessagePublisher 참조 위치
:::evidence key="messaging-runtime-core-f01" alt="코드베이스에서 DefaultMessagePublisher 를 검색한 출력 22줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultMessagePublisher 코드베이스 검색 — 22줄 · exit 0" zoom="true"
:::
## 협력자 둘은 bean 으로 만들어진다
starter가 `MessagingRedactor:253`, `CardinalityGuard:264` 를 만든다. 이 필드의 javadoc이 정확히 이 상황을 막으려고 쓰였다 — "an unobserved publish path is how 'the dashboards were empty during the incident' happens".
## 고침이 간극을 반대편으로 옮겼다
같은 javadoc이 이전 결함을 "bean은 있고 호출 경로가 없었다"로 기록한다. 지금은 반대다 — 호출 경로가 있고 bean이 없다. 관측 결과는 같다. "decorator가 아니라 생성자 인자"라는 선택도 막지 못했는데, 인자를 기본값으로 채우는 짧은 생성자가 함께 있기 때문이다.
## 확인하지 못한 것
6인자 생성자 선택이 의도인지 확인할 수 없었다. 커밋이 대량 커밋 4개뿐이고 이 선택을 설명하는 기록이 없다.
<!-- body:end -->
@@ -0,0 +1,84 @@
---
kind: CASE
slug: messaging-runtime-core-f02
title: 소비 오케스트레이터가 조립되지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-runtime-core-f02
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-runtime-core-f02
file: ../../../final/evidence/rendered/messaging-runtime-core-f02.svg
- key: messaging-runtime-core-f02-diagram
file: ../../../final/assets/diagrams/messaging-runtime-core-f02.svg
evidence:
- ../../../final/evidence/raw/messaging-runtime-core-f02.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-runtime-core.md#L719 이다.
module: messaging-runtime-core
priority: P2
---
# 소비 오케스트레이터가 조립되지 않는다
DefaultDeliveryProcessor는 leaf 밖 참조 0, src/main 생성 0, src/test 생성 1이다. 이 클래스가 고친 문제("각 어댑터가 retry/dead-letter의 뜻을 각자 결정")가 배선 없이는 그대로 남는다.
## 관계
- **만들어 두고 흘리지 않는 진단값은 진단이 아니다**
같은 분석 리프에서 끌어낸 규칙이다.
- **증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
- **`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
DefaultDeliveryProcessor는 leaf 밖 참조 0, src/main 생성 0, src/test 생성 1이다.
이 클래스가 고친 문제("각 어댑터가 retry/dead-letter의 뜻을 각자 결정")가 배선 없이는 그대로 남는다.
## 결론
그리고 DeclaredDestinationAccess가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : DefaultDeliveryProcessor 참조 7건 검색으로 leaf 밖 참조와 src/main 생성 수 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-runtime-core.md#L719 에 있다.
## 본문
<!-- body:start -->
`DefaultDeliveryProcessor`는 leaf 밖 참조 0, `src/main` 생성 0, `src/test` 생성 1이다.
## 경로가 열리지 않는 이유
:::evidence key="messaging-runtime-core-f02-diagram" alt="발행 경로만 조립되는 것 안에 놓이고 DefaultDeliveryProcessor 가 바깥에 빗금으로 놓인다" caption="경로가 열리지 않는 이유" zoom="false"
:::
## DefaultDeliveryProcessor 참조 위치
:::evidence key="messaging-runtime-core-f02" alt="코드베이스에서 DefaultDeliveryProcessor 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultDeliveryProcessor 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 이 클래스가 고친 문제가 그대로 남는다
"각 어댑터가 retry/dead-letter의 뜻을 각자 결정" 이다. 그리고 `DeclaredDestinationAccess`가 consume 권한을 빈 집합으로 두는 것과 정합적이다 — 기본 구성은 소비를 상정하지 않는다.
## 확인하지 못한 것
파생 프로젝트가 이 오케스트레이터를 직접 조립하는지 확인할 방법이 이 저장소 안에 없다.
<!-- body:end -->
@@ -0,0 +1,90 @@
---
kind: CASE
slug: messaging-schema-api-f01
title: 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-schema-api-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-api-f01
file: ../../../final/evidence/rendered/messaging-schema-api-f01.svg
- key: messaging-schema-api-f01-diagram
file: ../../../final/assets/diagrams/messaging-schema-api-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-api-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-api.md#L494 이다.
module: messaging-schema-api
priority: P2
---
# 포맷 독립 진화 규칙이 호출되지 않고, 그것이 막으려던 중복이 실제로 생겼다
SchemaCompatibilityValidator의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다. 동시에 AvroCompatibilityGate가 isTransitive를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.
## 관계
- **port 계약은 동시성 요구를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **도달성 판정은 단어가 아니라 import로 확인한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
SchemaCompatibilityValidator의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다.
동시에 AvroCompatibilityGate가 isTransitive를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.
## 결론
오늘은 7개 모드 전부에서 두 구현의 결과가 같다(NONE_EXPERIMENTAL은 gate의 early return이 가린다).
그러나 enum에 값이 하나 추가되면 허용목록은 "검사 안 함", 거부목록은 "양방향 검사"로 반대 방향 기본값을 갖는다.
그리고 requireProductionMode — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 는 호출되는 곳이 없다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : SchemaCompatibilityValidator 참조 11건 검색과 Avro 게이트의 중복 구현 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-schema-api.md#L494 에 있다.
## 본문
<!-- body:start -->
`SchemaCompatibilityValidator`의 저장소 전체 참조가 자기 선언과 자기 테스트뿐이다.
## 규칙이 막지 못한 것
:::evidence key="messaging-schema-api-f01-diagram" alt="자기 테스트만 규칙을 부르는 것 안에 놓이고 production 호출자가 바깥에 빗금으로 놓인다" caption="규칙이 막지 못한 것" zoom="false"
:::
동시에 `AvroCompatibilityGate``isTransitive`를 글자 그대로 복사했고 방향 판정 둘은 허용목록/거부목록으로 형태가 반대다.
## SchemaCompatibilityValidator 참조 위치
:::evidence key="messaging-schema-api-f01" alt="코드베이스에서 SchemaCompatibilityValidator 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SchemaCompatibilityValidator 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 오늘은 결과가 같다
7개 모드 전부에서 두 구현의 결과가 같다(`NONE_EXPERIMENTAL`은 gate의 early return이 가린다). 그러나 enum에 값이 하나 추가되면 허용목록은 "검사 안 함", 거부목록은 "양방향 검사"로 **반대 방향** 기본값을 갖는다.
## 게이트 하나도 호출되지 않는다
`requireProductionMode` — 검사 없는 스키마가 보존 로그를 뒷받침하는 것을 막는 게이트 — 를 부르는 곳이 없다.
## 확인하지 못한 것
port 구현의 스레드 안전성 요구를 관측할 대상이 없다. javadoc 에 없고 이 저장소에 production 구현이 없다.
<!-- body:end -->
@@ -0,0 +1,81 @@
---
kind: CASE
slug: messaging-security-f04
title: 종료 시 자격증명 소거가 호출되지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-security-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-security-f04
file: ../../../final/evidence/rendered/messaging-security-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-security-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-security.md#L661 이다.
module: messaging-security
priority: P3
---
# 종료 시 자격증명 소거가 호출되지 않는다
CredentialRuntimeRegistry.clearAll()의 javadoc이 "Clears every held credential, for shutdown"이라고 하고, 호출자가 저장소에 없다. 이 leaf 전체가 "비밀이 힙에 남지 않게 한다"를 목적으로 하고(char[], clear(), 회전 시 즉시 소거), 종료 경로에서 그 마지막 단계가 빠져 있다.
## 관계
- **배선된 게이트는 자기 leaf 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **같은 술어가 두 타입에 있으면 하나가 다른 하나를 부른다**
같은 분석 리프에서 끌어낸 규칙이다.
- **가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **맵 갱신 함수 안에서 I/O를 하면 그 지연이 락 범위가 된다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
CredentialRuntimeRegistry.clearAll()의 javadoc이 "Clears every held credential, for shutdown"이라고 하고, 호출자가 저장소에 없다.
이 leaf 전체가 "비밀이 힙에 남지 않게 한다"를 목적으로 하고(char[], clear(), 회전 시 즉시 소거), 종료 경로에서 그 마지막 단계가 빠져 있다.
## 결론
프로세스가 끝나면 힙도 사라지지만, 종료가 느리거나 힙 덤프가 뜨는 경우가 정확히 이 통제가 노리는 상황이다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : clearAll 호출자 전수 검색과 종료 계약의 자격증명 소거 단계 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-security.md#L661 에 있다.
## 본문
<!-- body:start -->
`CredentialRuntimeRegistry.clearAll()`의 javadoc이 "Clears every held credential, for shutdown"이라고 하고, 호출자가 저장소에 없다.
## clearAll() 의 javadoc 과 호출자 수
:::evidence key="messaging-security-f04" alt="분석 문서 analysis/messaging/messaging-security.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-security.md 발췌 — 15줄" zoom="true"
:::
## 이 leaf 전체의 목적에서 마지막 단계가 빠졌다
"비밀이 힙에 남지 않게 한다"를 위해 `char[]`, `clear()`, 회전 시 즉시 소거를 두었는데 종료 경로가 비어 있다.
## 프로세스가 끝나면 힙도 사라지지만
종료가 느리거나 힙 덤프가 뜨는 경우가 정확히 이 통제가 노리는 상황이다.
## 확인하지 못한 것
힙 덤프로 자격증명 잔존을 확인하지 않았다. 호출자 부재로 판정했다.
<!-- body:end -->
@@ -0,0 +1,96 @@
---
kind: CASE
slug: messaging-spring-boot-starter-f01
title: 같은 자동 설정 안에서 검증기 하나만 감싸이지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-spring-boot-starter-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-boot-starter-f01
file: ../../../final/evidence/rendered/messaging-spring-boot-starter-f01.svg
- key: messaging-spring-boot-starter-f01-diagram
file: ../../../final/assets/diagrams/messaging-spring-boot-starter-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-boot-starter-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-boot-starter.md#L281 이다.
module: messaging-spring-boot-starter
priority: P2
---
# 같은 자동 설정 안에서 검증기 하나만 감싸이지 않는다
KafkaMessagingAutoConfiguration 은 검증기 셋을 만든다. KafkaTransactionProfileValidator 에는 대응하는 StartupProfileValidation 이 없다.
## 관계
- **검증기는 발행이 아니라 주입이 강제다**
같은 분석 리프에서 끌어낸 규칙이다.
## 문제
KafkaMessagingAutoConfiguration 은 검증기 셋을 만든다.
KafkaTransactionProfileValidator 에는 대응하는 StartupProfileValidation 이 없다.
## 결론
즉 컨텍스트가 그 검증기를 발행하고 아무도 주입하지 않는다 — StartupProfileValidation 의 javadoc 이 서술한 이전 상태와 정확히 같은 형태다.
RabbitMessagingAutoConfiguration 은 검증기 하나이고 그것을 감싼다.
그러므로 이 가족에서 감싸이지 않은 검증기는 이 하나다.
트랜잭션 프로파일 검증이 무엇을 막는지는 그 클래스가 안다 — 비트랜잭션 생산자 위의 정확히 한 번 주장 같은 조합이다.
그 검증이 지금 돌지 않는다.
수정은 한 블록이다.
같은 파일의 kafkaProfileStartupValidation 형태를 복사해 세 번째 검증기를 감싼다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : KafkaMessagingAutoConfiguration 참조 6건 검색과 세 검증기의 감싸기 여부 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-spring-boot-starter.md#L281 에 있다.
## 본문
<!-- body:start -->
`KafkaMessagingAutoConfiguration` 은 검증기 셋을 만든다. `KafkaTransactionProfileValidator` 에는 대응하는 `StartupProfileValidation` 이 없다.
## 한 곳만 빠진 감싸기
:::evidence key="messaging-spring-boot-starter-f01-diagram" alt="KafkaProfileValidator 와 RabbitProfileValidator 가 감싸인 것 안에 놓이고 KafkaTransactionProfileValidator 가 바깥에 빗금으로 놓인다" caption="한 곳만 빠진 감싸기" zoom="false"
:::
즉 컨텍스트가 그 검증기를 발행하고 아무도 주입하지 않는다 — `StartupProfileValidation` 의 javadoc 이 서술한 이전 상태와 정확히 같은 형태다.
## KafkaMessagingAutoConfiguration 참조 위치
:::evidence key="messaging-spring-boot-starter-f01" alt="코드베이스에서 KafkaMessagingAutoConfiguration 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="KafkaMessagingAutoConfiguration 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 이 가족에서 감싸이지 않은 검증기는 이 하나다
`RabbitMessagingAutoConfiguration` 은 검증기 하나이고 그것을 감싼다. 트랜잭션 프로파일 검증이 무엇을 막는지는 그 클래스가 안다 — 비트랜잭션 생산자 위의 정확히 한 번 주장 같은 조합이다.
## 수정은 한 블록이다
같은 파일의 `kafkaProfileStartupValidation` 형태를 복사해 세 번째 검증기를 감싼다.
## 확인하지 못한 것
TLS·SASL 을 요구하는 실제 브로커에 붙여 재현하지 않았다. 조립되는 설정 맵 성분과 보안 설정기가 만드는 성분의 교집합이 0 이라는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,95 @@
---
kind: CASE
slug: messaging-testkit-f01
title: FaultController 의 5개 중 2개가 구현만 3벌 있고 호출부가 0건이다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-testkit-f01
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-f01
file: ../../../final/evidence/rendered/messaging-testkit-f01.svg
- key: messaging-testkit-f01-diagram
file: ../../../final/assets/diagrams/messaging-testkit-f01.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-f01.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L940 이다.
module: messaging-testkit
priority: P2
---
# FaultController 의 5개 중 2개가 구현만 3벌 있고 호출부가 0건이다
rejectPublish() 와 reset() 은 인터페이스에 선언되어 세 하니스가 전부 구현하지만, 계약 스위트를 포함해 어디에서도 호출되지 않는다(EVD-299). rejectPublish 는 심지어 세 하니스의 publish() 경로에 완전히 배선되어 있다(KafkaContractHarness:119, RabbitContractHarness:85, InMemoryMessagingHarness:66) — 켜는 스위치만 아무도 누르지 않는다.
## 문제
rejectPublish() 와 reset() 은 인터페이스에 선언되어 세 하니스가 전부 구현하지만, 계약 스위트를 포함해 어디에서도 호출되지 않는다(EVD-299).
rejectPublish 는 심지어 세 하니스의 publish() 경로에 완전히 배선되어 있다(KafkaContractHarness:119, RabbitContractHarness:85, InMemoryMessagingHarness:66) — 켜는 스위치만 아무도 누르지 않는다.
## 결론
이것이 단순한 미사용 코드가 아닌 이유: 미사용 경로가 틀린 값을 인코딩하고 있다.
InMemoryMessagingHarness 에서 rejectPublish 는 rejected("BROKER_REJECTED", …) 를 돌려주고, 그 헬퍼는 PublishEvidence.notTransmitted() 를 쓴다(:184-193).
TransmissionEvidence.NOT_TRANSMITTED 의 javadoc 은 "Nothing was written to the broker connection." 이다.
그런데 FaultController.rejectPublish 의 javadoc 은 "refused outright by the broker" 다 — 브로커가 거절하려면 바이트가 나갔어야 하므로 TRANSMITTED 여야 한다.
이 플랫폼은 전송 증거를 세 값으로 구분하는 것을 핵심 가치로 삼는데, 유일하게 실행되지 않는 경로에 그 구분의 오류가 들어 있다.
connection-refused 시나리오(유일하게 증거가 없는 시나리오, Expectation.REJECTED)와 이 미사용 결함이 같은 빈칸을 가리킨다.
둘 중 하나를 택해야 한다: 계약에 rejectsWhenBrokerRefusesBeforeTransmission 를 추가하고 전송 증거를 바로잡거나, rejectPublish 를 인터페이스에서 제거해 세 하니스의 구현 부담을 없애거나.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : FaultController 참조 19건 검색과 세 하니스의 구현·배선 지점 대비 호출부 수 확인
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-testkit.md#L940 에 있다.
## 본문
<!-- body:start -->
`rejectPublish()``reset()` 은 인터페이스에 선언되어 세 하니스가 전부 구현하지만, 계약 스위트를 포함해 어디에서도 호출되지 않는다(`EVD-299`).
## 구현만 있는 고장 종류
:::evidence key="messaging-testkit-f01-diagram" alt="세 하니스의 구현과 publish 경로 배선이 고장 종류에 있는 것 안에 놓이고 호출부가 바깥에 빗금으로 놓인다" caption="구현만 있는 고장 종류" zoom="false"
:::
`rejectPublish` 는 심지어 세 하니스의 `publish()` 경로에 완전히 배선되어 있다(`KafkaContractHarness:119`, `RabbitContractHarness:85`, `InMemoryMessagingHarness:66`) — 켜는 스위치만 아무도 누르지 않는다.
## FaultController 참조 위치
:::evidence key="messaging-testkit-f01" alt="코드베이스에서 FaultController 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FaultController 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 미사용 경로가 틀린 값을 인코딩하고 있다
`InMemoryMessagingHarness` 에서 `rejectPublish``rejected("BROKER_REJECTED", …)` 를 돌려주고, 그 헬퍼는 `PublishEvidence.notTransmitted()` 를 쓴다(`:184-193`). `TransmissionEvidence.NOT_TRANSMITTED` 의 javadoc 은 "Nothing was written to the broker connection." 인데, `FaultController.rejectPublish` 의 javadoc 은 "refused outright by **the broker**" 다 — 브로커가 거절하려면 바이트가 나갔어야 하므로 `TRANSMITTED` 여야 한다.
## 유일하게 실행되지 않는 경로에 그 구분의 오류가 있다
이 플랫폼은 전송 증거를 세 값으로 구분하는 것을 핵심 가치로 삼는다. `connection-refused` 시나리오(유일하게 증거가 없는 시나리오, `Expectation.REJECTED`)와 이 미사용 결함이 같은 빈칸을 가리킨다. 둘 중 하나를 택해야 한다 — 계약에 `rejectsWhenBrokerRefusesBeforeTransmission` 를 추가하고 전송 증거를 바로잡거나, `rejectPublish` 를 인터페이스에서 제거해 세 하니스의 구현 부담을 없애거나.
## reset 은 별개다
세 구현 모두 결함 플래그를 one-shot 으로 소비하므로(`consumeXxx` 가 읽고 즉시 false) 리셋이 필요 없는 구조다. 계약이 테스트마다 새 하니스를 만드는 것도 같은 이유다. 제거 후보다.
## 확인하지 못한 것
두 고장을 실제로 주입해 계약 스위트가 어떻게 반응하는지 관측하지 않았다. 호출부가 0 건이라는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,72 @@
---
kind: CASE
slug: messaging-testkit-f04
title: 1 MiB 한도가 PayloadPolicy 를 두고 리터럴로 재선언된다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-testkit-f04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-f04
file: ../../../final/evidence/rendered/messaging-testkit-f04.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-f04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L966 이다.
module: messaging-testkit
priority: P3
---
# 1 MiB 한도가 PayloadPolicy 를 두고 리터럴로 재선언된다
PayloadPolicy.DEFAULT_MAX_BYTES = 1_048_576 이 정본인데 같은 값이 최소 8곳에 다시 있고(§12.3(b)), 그중 둘이 이 리프다(InMemoryMessagingHarness:31, ContractMessage:50). messaging-testkit 은 api project(':messaging:messaging-policy') 를 이미 선언하고 있으므로 import 한 줄이면 된다.
## 문제
PayloadPolicy.DEFAULT_MAX_BYTES = 1_048_576 이 정본인데 같은 값이 최소 8곳에 다시 있고(§12.3(b)), 그중 둘이 이 리프다(InMemoryMessagingHarness:31, ContractMessage:50).
messaging-testkit 은 api project(':messaging:messaging-policy') 를 이미 선언하고 있으므로 import 한 줄이면 된다.
## 결론
지금은 messaging-policy 의존이 import 0건이라 선언만 남아 있는데(§12.4(b)), 이 자리가 그 의존이 실제로 쓰여야 할 곳이다.
ContractMessage.oversized() 의 1_048_577 은 PayloadPolicy.DEFAULT_MAX_BYTES + 1 로 쓰면 "한도 바로 위 한 바이트" 라는 의도가 코드에 드러난다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : PayloadPolicy 참조 25건 검색과 같은 값이 리터럴로 재선언된 지점 집계
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-testkit.md#L966 에 있다.
## 본문
<!-- body:start -->
`PayloadPolicy.DEFAULT_MAX_BYTES = 1_048_576` 이 정본인데 같은 값이 최소 8곳에 다시 있고(§12.3(b)), 그중 둘이 이 리프다(`InMemoryMessagingHarness:31`, `ContractMessage:50`).
## PayloadPolicy 참조 위치
:::evidence key="messaging-testkit-f04" alt="코드베이스에서 PayloadPolicy 를 검색한 출력 25줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PayloadPolicy 코드베이스 검색 — 25줄 · exit 0" zoom="true"
:::
## import 한 줄이면 된다
`messaging-testkit``api project(':messaging:messaging-policy')` 를 이미 선언하고 있다. 지금은 그 의존이 import 0건이라 선언만 남아 있는데(§12.4(b)), 이 자리가 그 의존이 실제로 쓰여야 할 곳이다.
## 의도가 코드에 드러나게 하는 방법
`ContractMessage.oversized()``1_048_577``PayloadPolicy.DEFAULT_MAX_BYTES + 1` 로 쓰면 "한도 바로 위 한 바이트" 가 읽힌다.
## 확인하지 못한 것
리터럴을 정본 상수로 바꿔 빌드를 돌려 보지 않았다. 값이 같다는 것과 의존 선언이 이미 존재한다는 것으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,68 @@
---
kind: CASE
slug: messaging-testkit-f05
title: messaging-transport-spi 의존이 import 0건이다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-testkit-f05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-f05
file: ../../../final/evidence/rendered/messaging-testkit-f05.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-f05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L972 이다.
module: messaging-testkit
priority: P3
---
# messaging-transport-spi 의존이 import 0건이다
policy 와 달리 transport-spi 는 쓸 자리가 보이지 않는다. 제거 후보.
## 문제
policy 와 달리 transport-spi 는 쓸 자리가 보이지 않는다.
제거 후보.
## 결론
policy 와 달리 transport-spi 는 쓸 자리가 보이지 않는다.
제거 후보.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : 선언된 의존에 대한 패키지 이름 import 전수 검색
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-testkit.md#L972 에 있다.
## 본문
<!-- body:start -->
`messaging-transport-spi` 의존이 import 0건이다.
## import 0 건인 의존
:::evidence key="messaging-testkit-f05" alt="분석 문서 analysis/messaging/messaging-testkit.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-testkit.md 발췌 — 15줄" zoom="true"
:::
## policy 와 다르다
policy 쪽은 쓸 자리가 분명한데(§P3의 1 MiB 한도) transport-spi 는 쓸 자리가 보이지 않는다. 제거 후보.
## 확인하지 못한 것
의존을 제거하고 빌드를 돌려 보지 않았다. import 0 건으로 판정했으므로 간접 사용은 배제하지 못했다.
<!-- body:end -->
@@ -0,0 +1,66 @@
---
kind: CASE
slug: messaging-testkit-f06
title: BrokerFailureMatrix.adapters() 는 호출부가 0건이다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:messaging-testkit-f06
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-f06
file: ../../../final/evidence/rendered/messaging-testkit-f06.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-f06.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L976 이다.
module: messaging-testkit
priority: P3
---
# BrokerFailureMatrix.adapters() 는 호출부가 0건이다
public 메서드이나 아무도 쓰지 않는다. 이 리프의 다른 public 표면은 전부 소비자가 있다.
## 문제
public 메서드이나 아무도 쓰지 않는다.
이 리프의 다른 public 표면은 전부 소비자가 있다.
## 결론
제거하거나, 진단용이라면 그렇게 적는다.
## 검증 환경
OpenJDK : 21.0.12 java -version 으로 확인
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
확인 방식 : adapters() 호출부 검색과 이 리프의 다른 public 표면의 소비자 유무 대조
소스 수정 : x
## 재현 조건
원문은 analysis/messaging/messaging-testkit.md#L976 에 있다.
## 본문
<!-- body:start -->
`BrokerFailureMatrix.adapters()` 는 public 메서드이나 아무도 쓰지 않는다.
## adapters() 의 호출부
:::evidence key="messaging-testkit-f06" alt="분석 문서 analysis/messaging/messaging-testkit.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-testkit.md 발췌 — 15줄" zoom="true"
:::
## 이 리프의 다른 public 표면은 전부 소비자가 있다
제거하거나, 진단용이라면 그렇게 적는다.
## 확인하지 못한 것
리플렉션이나 서비스 로더로 부르는 형태는 배제하지 못했다. 이름 기반 검색으로만 확인했다.
<!-- body:end -->
@@ -0,0 +1,103 @@
---
kind: CASE
slug: startup-validator-is-the-only-reader-of-four-keys
title: 시작 검증기가 유일한 소비자인 설정 키 넷이 아무것도 게이트하지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:startup-validator-is-the-only-reader-of-four-keys
evidenceCapturedOn: 2026-09-01
body: case-startup-validator-is-the-only-reader-of-four-keys.body.md
assets:
- key: startup-validator-is-the-only-reader-of-four-keys
file: ../../../final/evidence/rendered/startup-validator-is-the-only-reader-of-four-keys.svg
evidence:
- ../../../final/evidence/raw/startup-validator-is-the-only-reader-of-four-keys.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-spring-boot-starter.md#L139 이다.
---
# 시작 검증기가 유일한 소비자인 설정 키 넷이 아무것도 게이트하지 않는다
검증기를 이름으로 부르는 파일은 자기 자신과 자기 테스트뿐이다. 자동 설정은 빈 아홉을 만들고 검증을 부르지 않는다. 그리고 그 검증기가 유일한 소비자인 설정 키가 넷이다.
## 관계
- **시작 검증기가 시작 시 실행되지 않는다**
다른 가족의 같은 형태다.
- **검증기는 발행이 아니라 주입이 강제다**
이 사례에서 뽑은 규칙이다.
- **같은 자동 설정 안에서 검증기 하나만 감싸이지 않아 트랜잭션 조건이 검사되지 않는다**
같은 통독에서 나온 짝이다.
## 문제
시작 검증기의 자바독이 선정 기준을 적는다.
여기 담긴 모든 규칙은 실행 시 증상이 침묵이거나 오귀인인 실수라는 것이다. 마감 없는 단항 메서드는 클라이언트 자신의 마감까지 매달리고, 무제한 실행기는 과부하를 무제한 지연으로 바꾸고, 운영의 전체 신뢰는 전송 보안을 보고하면서 제공하지 않고, 운영의 반사 공개는 스키마를 게시하고, 원장 없는 키 필수 메서드는 지킬 수 없는 멱등 키를 받아들인다는 것이다.
그리고 어느 것도 연기 테스트를 실패시키지 않는다는 것이다.
## 결론
그 검증이 돌지 않는다.
검증기를 이름으로 부르는 파일이 둘뿐이다. 자기 자신과 자기 테스트다.
자동 설정은 실행기 프로파일과 서버 프로파일과 승인 제어기와 상태 레지스트리와 반사 정책과 관리 노출 정책과 배수 정책과 문맥 결속기와 오류 사상기, 아홉 빈을 만든다. 검증 호출이 없고 초기화 콜백도 없다.
그래서 클래스 자바독이 약속한 성질이 성립하지 않는다. 아무도 눈치채지 못할 방식으로 틀린 설정에서 시작을 거부한다는 것이다. 지금은 그냥 시작한다.
함께 사라지는 것이 있다.
검증기가 유일한 소비자인 설정 키가 넷이다. 전송과 TLS 사용 여부와 전체 신뢰와 원장 활성화다.
전송 키는 자동 설정이 아예 보지 않는다. 서버 프로파일 팩토리가 안정 전송을 하드코딩한다.
TLS 두 키는 배포 환경의 바닥을 강제할 곳이 없다.
원장 키는 멱등 키 필수 메서드가 원장 없이 열리는 것을 막을 곳이 없다.
같은 저장소가 정본을 둘 갖고 있다. 메시징 가족은 시작 프로파일 검증을 초기화 콜백으로 돌려 이 문제를 이미 한 번 해결했고, 파일 서버 하위 트리는 증명 메서드를 부트스트랩의 빈으로 연결했다.
## 검증 환경
Spring Boot : 4.0.8
확인 방식 : 호출자 전수 검색과 설정 키별 소비자 계수
소스 수정 : x
## 재현 조건
원문은 document-detail 의 analysis/grpc/grpc-spring-boot-starter.md 에 있다.
1. 검증기 클래스 이름을 저장소 전체에서 검색한다.
2. 자동 설정의 빈 목록을 읽고 검증 호출이 있는지 본다.
3. 설정 속성의 각 접근자를 저장소 전체에서 검색한다.
4. 검증기 밖에 소비자가 없는 키를 가려낸다.
5. 서버 프로파일 팩토리가 전송 키를 읽는지 확인한다.
## 본문
<!-- body:start -->
검증기를 이름으로 부르는 파일은 자기 자신과 자기 테스트뿐이다. 자동 설정은 빈 아홉 개를 만들고 `requireValid` 를 부르지 않으며 초기화 콜백도 없다.
## StartupProfileValidation 참조 위치
:::evidence key="startup-validator-is-the-only-reader-of-four-keys" alt="코드베이스에서 StartupProfileValidation 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="StartupProfileValidation 코드베이스 검색 — 4줄 · exit 0" zoom="true"
:::
## 그 검증기가 유일한 소비자인 키 넷
`transport`·`tls-enabled`·`trust-all-certificates`·`operation-ledger-enabled`. 따라서 운영 환경의 TLS 바닥도, 비운영 전송 거부도, 멱등 키 필수 메서드의 원장 요구도 강제되지 않는다.
## 같은 저장소가 정본을 둘 갖고 있다
messaging 의 `StartupProfileValidation` 과 fileserver 의 증명 호출이다.
## 확인하지 못한 것
스타터를 애플리케이션에 올려 컨텍스트를 세우지 않았다. 빌드 전용 리프라 그 배포가 없다.
<!-- body:end -->
@@ -0,0 +1,101 @@
---
kind: CASE
slug: validator-declared-and-never-injected
title: 같은 자동 설정 안에서 검증기 하나만 감싸이지 않아 트랜잭션 조건이 검사되지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:validator-declared-and-never-injected
evidenceCapturedOn: 2026-09-01
body: case-validator-declared-and-never-injected.body.md
assets:
- key: validator-declared-and-never-injected
file: ../../../final/evidence/rendered/validator-declared-and-never-injected.svg
evidence:
- ../../../final/evidence/raw/validator-declared-and-never-injected.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-boot-starter.md#L148 이다.
---
# 같은 자동 설정 안에서 검증기 하나만 감싸이지 않아 트랜잭션 조건이 검사되지 않는다
한 자동 설정이 검증기 둘을 만든다. 하나는 시작 검증 도우미로 감싸여 컨텍스트 구성 중에 돌고, 다른 하나는 빈으로 발행만 된다. 그 도우미의 자바독이 서술한 이전 결함이 정확히 그 형태다.
## 관계
- **시작 검증기가 유일한 소비자인 설정 키 넷이 아무것도 게이트하지 않는다**
같은 통독에서 나온 짝이다.
- **검증기는 발행이 아니라 주입이 강제다**
이 사례에서 뽑은 규칙이다.
- **능력 상수가 프로파일 조건보다 오래 살아서 중복 제거 없는 목적지가 가드를 통과한다**
같은 어댑터 계열의 짝이 되는 사례다.
## 문제
시작 검증 도우미가 이 가족에서 이미 한 번 고쳐진 결함을 자바독에 기록한다.
브로커 두 종과 보안 검증기가 전부 빈이었는데 아무 데도 주입되지 않았다는 것이다. 컨텍스트가 브로커마다 검증기를 발행하고 아무것도 검증하지 않았다는 것이다.
그 수정이 적용된 뒤의 상태를 확인했다.
## 결론
같은 자동 설정 안에서 하나가 빠져 있다.
브로커 프로파일 검증기는 도우미로 감싸여 초기화 콜백에서 돈다.
같은 파일의 트랜잭션 프로파일 검증기는 빈으로 발행만 된다. 대응하는 도우미 선언이 없다.
다른 브로커의 자동 설정은 검증기가 하나뿐이고 그것을 감싼다. 그러므로 이 가족에서 감싸이지 않은 검증기는 이 하나다.
그 검증기가 무엇을 막는지는 자기 자바독이 적는다.
트랜잭션 식별자 접두와 멱등 생산자와 모든 복제 확인과 수동 오프셋 커밋을 요구하고, 마지막 규칙이 핵심이다. 부수효과가 데이터베이스에 사는 목적지가 브로커 트랜잭션을 함께 선언하면, 팀이 트랜잭션이라는 낱말을 두 번 읽고 전체 경로가 원자적이라고 결론짓는다는 것이다. 두 절반은 여전히 갈라질 수 있다.
그 검증이 지금 돌지 않는다.
그리고 같은 어댑터의 능력 선언은 브로커 트랜잭션을 무조건 참으로 답한다. 두 겹이 함께 비어 있다.
여기서 리프 경계를 넘어야만 보이는 것이 하나 있다. 검증기는 어댑터 리프가 소유하고, 그것을 시작 시 부르는 배선은 스타터가 소유한다. 어느 쪽 문서도 혼자서는 이 검증이 실행되지 않는다는 것을 말할 수 없다.
## 검증 환경
Spring Boot : 4.0.8
확인 방식 : 자동 설정의 빈 선언 대조와 형제 자동 설정 비교
소스 수정 : x
## 재현 조건
원문은 document-detail 의 analysis/messaging/messaging-spring-boot-starter.md 에 있다.
1. 시작 검증 도우미의 자바독을 읽는다.
2. 브로커 자동 설정의 빈 선언을 순서대로 읽는다.
3. 각 검증기에 대응하는 도우미 선언이 있는지 확인한다.
4. 다른 브로커의 자동 설정과 비교한다.
5. 감싸이지 않은 검증기가 무엇을 요구하는지 읽는다.
## 본문
<!-- body:start -->
`KafkaProfileValidator``StartupProfileValidation` 으로 감싸여 `afterPropertiesSet` 에서 돌고, 같은 파일의 `KafkaTransactionProfileValidator` 는 빈으로 발행만 된다. Rabbit 쪽은 하나뿐인 검증기를 감싼다.
## KafkaProfileValidator 참조 위치
:::evidence key="validator-declared-and-never-injected" alt="코드베이스에서 KafkaProfileValidator 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="KafkaProfileValidator 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 검사되지 않는 네 조건
트랜잭션 식별자 접두·멱등 생산자·`acks=all`·수동 커밋 요구가 시작 시 검사되지 않고, 어댑터는 `brokerTransaction=true` 를 무조건 답한다.
## 어느 문서도 혼자서는 이 사실을 말할 수 없다
검증기의 절반은 `messaging-kafka` 가, 배선의 절반은 스타터가 소유한다.
## 확인하지 못한 것
조건을 어긴 프로파일로 컨텍스트를 세워 검증이 돌지 않는 것을 재현하지 않았다.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: QUESTION
slug: messaging-core-api-f01
title: 선언된 핸들러 계약이 배선된 것과 다르다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:messaging-core-api-f01
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-core-api.md#L835
---
# 선언된 핸들러 계약이 배선된 것과 다르다
공개 API 가 먼저 보여 주는 핸들러 인터페이스를 구현해도 아무 데도 꽂히지 않는다. 실제 배선은 다른 타입을 받는다.
## 사실
MessageHandler<T>(delivery/MessageHandler.java:14)의 저장소 전체 참조가 0 이다. git grep -n -w MessageHandler -- src ':!src/messaging/messaging-core-api' 가 exit 1 이다.
핸들러 결과를 정산으로 바꾸는 유일한 지점 DefaultDeliveryProcessor 는 Function<MessageEnvelope<EncodedMessage>, HandleResult> 를 받는다(:40,47).
MessageDelivery 가 빠지면서 deliveryAttempt · redelivered · handlerDeadline · shutdownRequested 가 핸들러에 도달할 수 없다. DeliveryContext javadoc 이 설명하는 graceful drain 협력은 현재 배선으로 성립하지 않는다.
## 미지수
저장소 밖에 이 계약의 소비자가 있는가. 세 선택지 모두 그 답에 걸린다.
## 선택지
DefaultDeliveryProcessor 시그니처를 계약에 맞춘다
MessageDelivery 를 조립해야 하므로 DeliveryContext 생성 책임이 runtime 으로 간다.
두 타입을 이 leaf 에서 제거한다
실제 계약만 남고 공개 API 가 배선과 일치한다.
확장점임을 명시한다
파생 프로젝트가 구현하는 자리라면 javadoc 과 support-matrix.md 에 그 사실을 적는다.
## 다음 검증
git grep -n -w MessageHandler 와 DefaultDeliveryProcessor.java:40,47 로 현재 배선은 확정된다. 저장소 밖 소비자의 존재는 이 저장소 안에서 확인할 수 없다.
@@ -0,0 +1,46 @@
---
kind: QUESTION
slug: messaging-core-api-f03
title: 12개 예외가 선언만 되어 있다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:messaging-core-api-f03
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-core-api.md#L862
---
# 12개 예외가 선언만 되어 있다
23개 구체 예외 중 12개가 leaf 밖에서 한 번도 참조되지 않는다. 깨지는 것은 없고, 대신 정리 비용이 계속 커진다.
## 사실
23개 구체 예외 중 12개가 leaf 밖 참조 0 이다(§6.2 표). 원문 근거는 evidence/raw/269 §B 이다.
MessagePublishAmbiguousException 처럼 설계의 중심 개념에 이름을 준 타입이 던져지지 않으면, 그 개념이 실제로 어떤 경로로 표현되는지(결과 record)를 읽는 사람이 스스로 알아내야 한다.
src/messaging/CLAUDE.md:44 가 "새 public 타입은 그 모듈의 계약이다. 삭제·시그니처 변경은 breaking change 로 취급한다" 라고 적는다. 그래서 나중에 정리하는 비용이 시간에 비례해 커진다.
## 미지수
저장소 밖 소비자가 있는가. 같은 leaf 의 핸들러 계약 질문과 같은 미지수를 공유한다.
## 선택지
어댑터가 결과 record 대신 예외를 던질 지점을 정한다
선언과 실행이 만난다.
미사용 예외를 제거한다
CLAUDE.md:44 의 breaking change 규칙을 지금 한 번 치른다.
파생 프로젝트용 어휘임을 명시한다
제거하지 않는 이유가 코드 옆에 남는다.
## 다음 검증
evidence/raw/269 §B 재실행으로 참조 수는 다시 셀 수 있다. 저장소 밖 소비자 여부는 그 재실행으로 답해지지 않는다.
@@ -0,0 +1,54 @@
---
kind: QUESTION
slug: messaging-kafka-share-experimental-f03
title: 형제 어댑터 넷이 구현하는 SPI를 이 leaf만 구현하지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:messaging-kafka-share-experimental-f03
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-kafka-share-experimental.md#L485
---
# 형제 어댑터 넷이 구현하는 SPI를 이 leaf만 구현하지 않는다
capability 선언은 있는데 그것을 읽어 갈 통로가 없다. 이 leaf 만 `MessagingTransport` 를 구현하지 않기 때문이다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
같은 분석 리프에서 끌어낸 규칙이다.
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 사실
KafkaMessagingTransport · RabbitMessagingTransport · PulsarMessagingTransport · NatsJetStreamTransport 가 전부 MessagingTransport 를 구현한다. 이 leaf 는 TransportConsumerRegistration 만 부분 구현한다.
KafkaShareWorkQueueCapability 가 존재하는 이유는 "shared validators refuse … before a message is ever produced" 다. 그것이 실현되려면 MessagingTransport.capabilities(DestinationName) 를 통해 값이 전달돼야 한다. 그 인터페이스를 구현하지 않으므로 capability 는 아무도 읽지 않는 상수다.
두 experimental 형제(pulsar, nats)는 구현한다. "experimental 이라서" 가 이유가 되지 않는다.
## 미지수
이 leaf 를 완성할 것인가. 그 판정이 §17 첫 항목에 걸려 있고, 완성 여부가 정해지기 전에는 SPI 구현 여부도 정할 수 없다.
## 선택지
MessagingTransport 를 구현한다
capability 가 실제 검증기에 도달하고 형제 넷과 형태가 같아진다.
capability 전달 경로를 따로 정한다
구현하지 않기로 하면 그 상수를 누가 읽는지가 정해져야 한다.
## 다음 검증
git grep -n 'implements MessagingTransport' -- 'src/messaging/**/*.java' 로 형제들의 구현 상태는 확정된다. 완성 여부의 결정은 저장소 안의 사실로 닫히지 않는다.
@@ -0,0 +1,54 @@
---
kind: QUESTION
slug: messaging-policy-f02
title: 출하 컨텍스트가 발행은 하고 소비는 하지 못한다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:messaging-policy-f02
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-policy.md#L790
---
# 출하 컨텍스트가 발행은 하고 소비는 하지 못한다
소비 경로의 여덟 클래스가 `src/main` 어디에서도 생성되지 않는다. 발행 경로는 생성된다. 이것이 미완인지 의도된 확장점인지가 정해지지 않았다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 사실
KafkaConsumerRegistrar · RabbitConsumerRegistrar · KafkaBatchConsumerRegistrar · RabbitBatchConsumerRegistrar · DefaultDeliveryProcessor · KafkaRetryExecutor · KafkaDeadLetterPublisher · RabbitDeadLetterPublisher 가 전부 src/main 생성 0 이다.
대조군인 발행 경로는 생성된다 — DefaultMessagePublisher 와 TransportMessagingRuntime 이 MessagingCoreAutoConfiguration:446,476 에서 만들어진다.
이것이 messaging-policy 의 두 축이 미배선인 근본 원인이고, analysis/messaging/messaging-core-api.md §12.1 이 관측한 MessageHandler<T> 참조 0 의 조립 쪽 설명이다.
docs/messaging/support-matrix.md 의 브로커 등급표가 소비 측 보장(순서 · 정산 · 재시도)을 서술하는데, 그 보장을 수행할 코드가 조립되지 않는다.
## 미지수
소비 경로 조립이 미완인가, 파생 프로젝트의 조립 책임으로 남긴 확장점인가. 이 질문의 소유는 이 leaf 가 아니라 cross-scope 또는 messaging-spring-boot-starter 쪽이다.
## 선택지
소비자 등록을 자동설정에 추가한다
지원 매트릭스가 서술하는 소비 측 보장이 실행 가능해진다.
파생 프로젝트의 조립 책임임을 문서화한다
현 상태를 유지하되 지원 매트릭스가 그 경계를 밝힌다.
## 다음 검증
evidence/raw/281 §F 를 재실행하면 생성 0 은 다시 확정된다. 의도 여부는 그 재실행으로 답해지지 않는다.
@@ -0,0 +1,52 @@
---
kind: QUESTION
slug: messaging-reliability-api-f03
title: dual-write의 답이라고 선언한 진입점에 구현이 없다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:messaging-reliability-api-f03
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-reliability-api.md#L707
---
# dual-write의 답이라고 선언한 진입점에 구현이 없다
Outbox 절반이 "릴레이가 읽는 쪽" 만 배선돼 있고 "애플리케이션이 쓰는 쪽" 이 비어 있다.
## 관계
- **계약만 담는 leaf도 계약의 거절 조건은 자기 레인에서 검증한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **record의 `equals`를 좁히면 이유를 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **호출 컨텍스트가 계약이면 그 컨텍스트를 검증할 수단을 함께 정한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 사실
ReliableMessagePublisher 가 구현 0, 참조 0 이다. git grep -n -E 'implements .*ReliableMessagePublisher' -- src 가 아무것도 돌려주지 않는다. javadoc 은 "This is the answer to the dual-write problem" 이라고 적는다.
OutboxRepository.append 가 있으므로 outbox 에 행을 넣을 방법 자체가 없는 것은 아니다. 다만 그 포트는 저장소 계약이고, ReliableMessagePublisher 는 애플리케이션이 저장소를 직접 만지지 않게 하려고 존재한다.
애플리케이션은 ArchUnit 규칙 때문에 이 leaf 를 참조할 수 없다. 그래서 브리지 어댑터가 필요한데 그것이 없다.
## 미지수
두 outbox 모델 중 어느 쪽이 정본인가. 이 질문은 application-core 와 cross-scope 가 함께 답한다.
## 선택지
브리지 어댑터를 만든다
애플리케이션이 ArchUnit 규칙을 지키면서 이 진입점에 도달한다.
애플리케이션 outbox 모델을 정본으로 삼는다
이 인터페이스를 제거하거나 파생 프로젝트의 확장점임을 명시한다.
## 다음 검증
구현 부재는 evidence/raw/289 §B · §C · §D 로 확정된다. 어느 모델이 정본인가는 이 leaf 안에서 답해지지 않는다.
@@ -0,0 +1,48 @@
---
kind: QUESTION
slug: messaging-schema-json-f03
title: 빈 registry로 조립되면 모든 메시지가 거절된다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:messaging-schema-json-f03
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-schema-json.md#L481
---
# 빈 registry로 조립되면 모든 메시지가 거절된다
기본값이 빈 registry 이므로 계약 bean 이 없으면 codec 이 시작에 성공하고 첫 publish 에서 실패한다. 그런 조립이 실제로 발생하는지가 이 leaf 밖에 있다.
## 관계
- **실패 코드는 운영자의 다음 행동이 갈리는 지점마다 나눈다**
같은 분석 리프에서 끌어낸 규칙이다.
## 사실
contracts.getIfAvailable(MessageContracts::none) 이 기본값이므로 MessageContracts bean 이 없으면 빈 registry 로 codec 이 만들어진다(MessagingCoreAutoConfiguration:362-365).
그 codec 은 시작에 성공하고 첫 publish 에서 UNKNOWN_MESSAGE_TYPE 으로 실패한다.
messaging-core-api 계열의 다른 leaf 에서 관측된 것과 같은 형태다 — "시작은 하고 첫 쓰기에서 실패한다".
## 미지수
MessageContracts 의 production 구현이 존재하는가. 존재하지 않으면 기본 조립이 곧 이 상태다.
## 선택지
계약 bean 이 없을 때 시작을 거부한다
실패가 첫 publish 가 아니라 부팅으로 옮겨 간다.
빈 registry 를 유효한 조립으로 유지한다
그때는 그 조합이 무엇을 뜻하는지가 문서에 있어야 한다.
## 다음 검증
messaging-spring-boot-starter leaf 에서 MessageContracts production 구현의 존재 여부를 확인한다. 그 leaf SSOT 가 답을 갖는다.
@@ -0,0 +1,60 @@
---
kind: REFERENCE
slug: a-validator-is-enforced-by-injection
title: 검증기는 발행이 아니라 주입이 강제다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:a-validator-is-enforced-by-injection
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 검증기는 발행이 아니라 주입이 강제다
## 목적
검증기를 빈으로 만든 것을 그 검증이 돈다는 증거로 읽어, 아무것도 검사하지 않는 컨텍스트를 검사되는 컨텍스트로 착각하는 것을 막는다.
## 규칙
1. 검증기의 존재와 실행은 다른 사실이다
컨테이너가 발행한 객체는 누군가 그것을 부를 때만 판정을 만든다. 발행 자체는 아무 판정도 아니다.
2. 실행 지점을 이름으로 지목할 수 없으면 돌지 않는다고 본다
초기화 콜백이든 조립 코드의 호출이든, 그 지점을 파일과 줄로 댈 수 없으면 검증은 없는 것이다.
3. 검증은 컨텍스트가 만들어지는 중에 실패해야 한다
응용 이벤트로 늦추면 실패 시점에 이미 빈이 다 만들어져 있고, 원인이 된 설정 객체가 스택에서 사라진다.
4. 검증기가 유일한 소비자인 설정 키를 센다
그 수가 곧 검증이 돌지 않을 때 조용해지는 설정의 수다.
5. 검증기가 여럿이면 각각의 실행 지점을 따로 확인한다
같은 파일 안에서 하나만 감싸이는 형태가 실제로 나타난다.
## 적용 조건
시작 시점에 설정을 판정하는 모든 검증기
자동 설정이 만드는 정책·프로파일·능력 객체
## 예외
의도적으로 호출자에게 판정을 맡기는 순수 함수형 규칙 객체는 여기 해당하지 않는다. 다만 그 경우 호출자가 어디인지가 자바독에 있어야 한다.
## 예시
한 가족이 이 결함을 이미 한 번 겪고 고쳤다. 브로커마다 검증기를 발행하면서 아무 데도 주입하지 않아 컨텍스트가 아무것도 검증하지 않았고, 수정은 검증기를 초기화 콜백을 구현한 얇은 타입으로 감싸는 것이었다. 그 타입의 자바독이 이전 상태를 기록으로 남긴다.
같은 형태가 네 곳에 남아 있다. 플랫폼 시작 검증기의 호출자가 0 이고, 같은 자동 설정 안에서 검증기 하나만 감싸이지 않고, 두 아키텍처 규칙이 저장소 소스에 적용되지 않는다.
## 관계
- **같은 자동 설정 안에서 검증기 하나만 감싸이지 않아 트랜잭션 조건이 검사되지 않는다**
이 규칙이 나온 사례다.
- **시작 검증기가 유일한 소비자인 설정 키 넷이 아무것도 게이트하지 않는다**
규칙 4 가 나온 사례다.
- **시작 검증기가 시작 시 실행되지 않는다**
다른 가족의 같은 형태다.
@@ -0,0 +1,50 @@
---
kind: REFERENCE
slug: messaging-claim-check-f04
title: leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-claim-check-f04
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-claim-check.md#L534
---
# leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다
## 관계
- **배포 아티팩트가 싣지만 아무도 부르지 않고, 다른 곳의 에러 메시지가 이 경로를 권한다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **예외 승격이 에러 코드 문자열 접미사에 의존한다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
## 목적
테스트가 클래스 단위로 붙지 않으면 어떤 결정이 검증되지 않았는지를 셀 수 없다. messaging-claim-check 의 테스트 세 개가 guard·resolver·policy 를 겨냥하고 publisher 를 겨냥하는 것이 없어서, publisher 가 혼자 소유한 결정들이 통째로 미검증으로 남았다.
## 규칙
1. leaf 의 public 클래스 목록과 테스트 클래스 목록을 나란히 놓는다
짝이 없는 이름이 곧 미검증 표면이다.
2. 그 클래스가 혼자 소유한 결정을 센다
publisher 의 경우 오프로드 판정(shouldOffload), 오프로드 시 payload 를 비우는 것, Offloaded 의 양방향 방어 복사, 그리고 "저장이 발행보다 먼저" 라는 순서다. 마지막 것은 publisher 의 계약인데 그것을 확인하는 테스트가 없다.
3. 다른 클래스의 테스트가 대신 덮고 있는지 확인한다
덮고 있다면 그 사실을 적고, 덮지 않으면 테스트를 만든다.
## 적용 조건
leaf 하나가 여러 public 클래스를 갖고 그중 일부만 테스트 이름에 등장하는 자리.
## 예외
테스트가 소비자 leaf 에 있는 경우는 예외로 볼 수 있다. 다만 그때는 어느 레인이 그 계약을 붙드는지가 기록돼 있어야 하고, 여기서는 그런 기록이 없다.
## 예시
find src/test -name '*Test.java' 가 세 개를 돌려주고 그 셋이 guard·resolver·policy 라는 것.
@@ -0,0 +1,48 @@
---
kind: REFERENCE
slug: messaging-kafka-share-experimental-f02
title: 허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-kafka-share-experimental-f02
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-kafka-share-experimental.md#L476
---
# 허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다
## 관계
- **"등록"이 아무것도 등록하지 않고 성공을 반환한다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
## 목적
verifyCleanArchitectureDependencies 는 allowed_dependencies 를 상한으로만 검사한다. 그래서 쓰지 않는 의존이 남아 있어도 초록불이고, build closure 는 실제 필요보다 넓어진다. 여기서는 messaging-kafka 34파일과 그 전이 의존이 함께 딸려 온다.
## 규칙
1. 선언된 의존과 실제 import 를 각각 센다
build.gradle 이 org.apache.kafka:kafka-clients 를 선언하는데 import org.apache.kafka 가 0건이다. registry 가 messaging-policy 와 messaging-kafka 의존을 허용하는데 두 패키지의 import 도 0건이다.
2. 상한 검사만으로는 이 차이가 드러나지 않는다는 것을 안다
허용 목록을 통과했다는 사실은 "선언이 과하지 않다" 를 뜻하지 않는다.
3. 미사용을 잡으려면 import 를 세는 검사를 따로 둔다
그때까지는 선언 옆에 미완 상태임을 적어 둔다.
## 적용 조건
registry 나 build 파일이 의존을 선언하고, 그 선언이 아키텍처 검사의 입력이 되는 모든 leaf.
## 예외
곧 쓰일 예정이라 미리 선언해 둔 경우는 예외가 될 수 있다. 다만 그 의도가 주석에 없으면 읽는 쪽에서는 "이 leaf 가 Kafka 를 쓴다" 는 인상만 남는다.
## 예시
evidence/raw/290 §D 의 import 전수와 build.gradle 의 선언. 확인 방법은 grep -rn 'import org.apache.kafka\|import dev.caskeleton.messaging.policy\|import dev.caskeleton.messaging.kafka\.' src/messaging/messaging-kafka-share-experimental/src 다.
@@ -0,0 +1,52 @@
---
kind: REFERENCE
slug: messaging-runtime-core-f07
title: 만들어 두고 흘리지 않는 진단값은 진단이 아니다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-runtime-core-f07
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-runtime-core.md#L763
---
# 만들어 두고 흘리지 않는 진단값은 진단이 아니다
## 관계
- **관측이 구현·호출부·주입 자리를 모두 갖추고도 출하에서 no-op이다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **소비 오케스트레이터가 조립되지 않는다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **선언된 content type과 실제 인코딩이 조용히 갈라질 수 있다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
## 목적
핸들러가 null 을 반환한 경우와 HandleResult.Retry 를 반환한 경우가 정산 수준에서 구분되지 않는다. 전자는 프로그래밍 오류이고 후자는 정상 흐름인데 같은 requeue 로 끝난다. descriptor 는 만들어졌으나 어디로도 흐르지 않는다.
## 규칙
1. descriptor 를 만드는 코드와 그것을 소비하는 코드를 짝지어 본다
DefaultDeliveryProcessor.missingResult() 가 HANDLER_RETURNED_NOTHING descriptor 를 만든다. result == null 분기는 그것을 쓰지 않고 바로 settlement.requeue(retryDelay) 를 부른다.
2. 소비자가 없으면 둘 중 하나를 고른다
값을 관측이나 로그로 흘리거나, 메서드를 제거한다.
3. 남겨 둘 이유가 있으면 그 이유를 적는다
package-private static 이라 외부 호출자가 생길 수 없다는 점이 판정을 단순하게 만든다.
## 적용 조건
실패 원인을 값으로 표현해 두고 그 값이 정산·로그·메트릭 중 어디로도 나가지 않을 수 있는 자리.
## 예외
SSOT 가 이 규칙의 반례를 적지 않았다. 테스트에서만 쓰는 진단 팩토리라면 그 사실이 이름이나 주석에 있어야 한다.
## 예시
DefaultDeliveryProcessor.java:77-79 의 팩토리와 :146-154 의 null 분기. 확인 방법은 git grep -n 'missingResult' -- src 다.
@@ -0,0 +1,52 @@
---
kind: REFERENCE
slug: messaging-security-f07
title: 배선된 게이트는 자기 leaf 레인에서 검증한다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-security-f07
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-security.md#L688
---
# 배선된 게이트는 자기 leaf 레인에서 검증한다
## 관계
- **종료 시 자격증명 소거가 호출되지 않는다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **같은 TLS posture를 두 클래스가 다른 엄격도로 검사한다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
- **권한 거부가 `AUTHORIZATION`이 아니라 `CONFIGURATION`으로 기록된다**
이 규칙의 근거는 같은 분석 리프의 판정이 소유한다.
## 목적
BrokerTlsPolicy 는 실제로 배선된 클래스다 — 어댑터 둘이 호출한다. 그 네 거절 조건과 허용목록 판정이 이 leaf 의 레인에서 검증되지 않는다. 어댑터 테스트가 간접적으로 지나가더라도 그것은 다른 목표를 가진 레인이다.
## 규칙
1. leaf 의 타입 중 테스트에 이름이 없는 것을 센다
다섯이다. BrokerTlsPolicy · BrokerAclManifest · DestinationAccessPolicy · DestinationAccessValidator · BrokerCredentialProfile.
2. 그중 실제 호출자가 있는 것을 먼저 고른다
배선되지 않은 타입의 미검증과 배선된 게이트의 미검증은 무게가 다르다.
3. 거절 조건과 경계를 겨냥한 테스트를 자기 레인에 둔다
BrokerTlsPolicy 의 네 거절 조건과 허용·거부 경계가 그 대상이다.
## 적용 조건
보안·승인 판정을 수행하고 다른 leaf 가 호출하는 모든 게이트 클래스.
## 예외
다른 레인이 그 게이트를 명시적 목표로 검증하고 그 사실이 기록돼 있으면 예외가 될 수 있다. 어댑터 테스트는 그 조건을 만족하지 않는다.
## 예시
세 테스트 클래스 전수. 확인 방법은 find src/test -name '*Test.java' 가 셋을 돌려준다는 것이다.
@@ -0,0 +1,43 @@
---
kind: REFERENCE
slug: messaging-spring-cloud-stream-bridge-f01
title: 허용 의존 목록은 상한이므로 미사용을 잡지 않는다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-spring-cloud-stream-bridge-f01
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-spring-cloud-stream-bridge.md#L552
---
# 허용 의존 목록은 상한이므로 미사용을 잡지 않는다
## 목적
spring-context 선언이 "이 leaf 가 Spring 과 통합돼 있다" 는 인상을 주는데, main 소스는 Spring 타입을 한 번도 이름 부르지 않는다. 바인더 접촉면 전체가 자체 함수형 인터페이스다.
## 규칙
1. 선언과 import 를 각각 센다
registry 가 messaging-transport-spi 를 허용하고 build.gradle 이 spring-context 를 선언한다. main 소스의 비-JDK import 9개는 전부 messaging-core-api 와 messaging-policy 에서 온다.
2. 검색 결과가 비었는지 확인한다
import dev.caskeleton.messaging.transport 와 import org.springframework 검색이 exit 1 이다.
3. 상한 검사로는 이 차이가 드러나지 않는다는 것을 안다
verifyCleanArchitectureDependencies 는 허용 목록을 상한으로만 본다. 같은 형태가 messaging-kafka-share-experimental 에도 있다 — incubating leaf 둘이 같은 방식으로 미사용 의존을 선언했다.
## 적용 조건
registry 나 build 파일이 의존을 선언하고 그 선언이 아키텍처 검사의 입력이 되는 모든 leaf.
## 예외
완성 시 필요해질 의존을 미리 선언해 둔 경우는 예외가 될 수 있다. 그때는 그 사실이 build.gradle 주석에 있어야 한다.
## 예시
evidence/raw/296 §B 의 import 전수와 두 검색의 exit 1. 확인 방법은 그 §B 를 재실행하는 것이다.
@@ -0,0 +1,43 @@
---
kind: REFERENCE
slug: messaging-spring-cloud-stream-bridge-f05
title: 등록을 받는 컴포넌트는 해제도 제공한다
topic: runtime-reachability-and-composition
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:messaging-spring-cloud-stream-bridge-f05
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
source:
- analysis/messaging/messaging-spring-cloud-stream-bridge.md#L588
---
# 등록을 받는 컴포넌트는 해제도 제공한다
## 목적
바인딩이 재구성되거나 컨텍스트가 종료될 때 맵이 비워지지 않는다. 오늘은 조립되지 않아 무해하다. 같은 가족의 messaging-transport-spi 는 TransportConsumerRegistration 을 AutoCloseable 로 두어 반대편을 이미 갖췄다.
## 규칙
1. 등록 메서드가 있으면 짝이 되는 해제를 찾는다
SpringCloudStreamConsumerBridge 에 unregister 도 close 도 없다. SpringCloudStreamPublisherBridge 도 마찬가지다.
2. 없으면 등록이 프로세스 수명과 같아지는지 확인한다
여기서는 바인딩 재구성이 그 가정을 깬다.
3. unregister(bindingName) 이나 AutoCloseable 중 하나를 둔다
둘 중 어느 쪽이든 해제 시점이 코드에 생긴다.
## 적용 조건
핸들러·리스너·바인딩을 맵에 담아 두는 모든 등록 컴포넌트.
## 예외
등록이 애플리케이션 수명과 정확히 같고 재구성 경로가 없으면 대상이 아니다. 이 브리지는 재구성을 전제하는 자리에 있다.
## 예시
두 클래스의 public 메서드 전수. 확인 방법은 그 목록을 보는 것이다.