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,65 @@
# 사람이 읽고 정해야 남는 것
자료가 뒷받침하는 것은 다 채웠다. 아래는 자료에 없어서 쓰면 지어내는 것이 된다.
하나를 끝내면 이 파일에서 지운다.
## 관계가 비어 있다
`root-tree.md` 는 949개 노드 중 374개에만 관계를 적었다. 나머지 575개 중 204개는 트리가
적은 규칙 — 「근거 사건은 같은 `source` 의 finding 이 소유한다」 — 으로 채웠다. 남은 것은
같은 분석 리프에 짝이 될 기록이 없는 경우다.
Studio 는 Decision 만 관계를 필수로 요구한다. 비어도 게시된다. 다만 관계 없는 기록은 다른
기록에서 도달할 수 없다.
- case 125건
- concept 239건
- reference 5건
- question 2건
## Question 에 가정·제약이 없다
이 칸은 확인하지 않고 전제한 것을 적는 자리다. 원본이 적지 않았으므로 지어내지 않는다.
그 질문을 다시 읽고 무엇을 전제했는지 정해야 채워진다.
- 가정 없음 14건
- tech-log-studio/contract-domain-and-bounds/question/openquestion-messaging-claim-check-f05.md
- tech-log-studio/declaration-and-document-drift/question/openquestion-messaging-inbox-jdbc-postgresql-f04.md
- tech-log-studio/drift-direction/question/openquestion-widen-doc-contract-assertions.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-core-api-f01.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-core-api-f03.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-kafka-share-experimental-f03.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-policy-f02.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-reliability-api-f03.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-schema-json-f03.md
- tech-log-studio/schema-and-data-contracts/question/openquestion-messaging-cloudevents-f04.md
- tech-log-studio/schema-and-data-contracts/question/openquestion-messaging-schema-protobuf-f03.md
- tech-log-studio/security-policy-enforcement/question/openquestion-messaging-security-f03.md
- tech-log-studio/transport-and-provider-semantics/question/openquestion-messaging-observability-f03.md
- tech-log-studio/transport-and-provider-semantics/question/openquestion-messaging-spring-cloud-stream-bridge-f02.md
- 제약 없음 14건
- tech-log-studio/contract-domain-and-bounds/question/openquestion-messaging-claim-check-f05.md
- tech-log-studio/declaration-and-document-drift/question/openquestion-messaging-inbox-jdbc-postgresql-f04.md
- tech-log-studio/drift-direction/question/openquestion-widen-doc-contract-assertions.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-core-api-f01.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-core-api-f03.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-kafka-share-experimental-f03.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-policy-f02.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-reliability-api-f03.md
- tech-log-studio/runtime-reachability-and-composition/question/openquestion-messaging-schema-json-f03.md
- tech-log-studio/schema-and-data-contracts/question/openquestion-messaging-cloudevents-f04.md
- tech-log-studio/schema-and-data-contracts/question/openquestion-messaging-schema-protobuf-f03.md
- tech-log-studio/security-policy-enforcement/question/openquestion-messaging-security-f03.md
- tech-log-studio/transport-and-provider-semantics/question/openquestion-messaging-observability-f03.md
- tech-log-studio/transport-and-provider-semantics/question/openquestion-messaging-spring-cloud-stream-bridge-f02.md
- 선택지 없음 1건
- tech-log-studio/drift-direction/question/openquestion-widen-doc-contract-assertions.md
## 평문 칸에 백틱이 있다
본문을 뺀 모든 칸은 평문이라 백틱이 글자 그대로 보인다. 368개 칸이 그렇다. 지우면 되지만
식별자를 어떻게 드러낼지는 칸마다 다르므로 한 번에 훑고 정한다.
```bash
grep -rn '^## \(문제\|결론\|검증 환경\|재현 조건\|목적\|규칙\)' -A5 tech-log-studio/*/*/*.md | grep '`'
```
@@ -0,0 +1,12 @@
[
{
"code": "LOW_HEADING_SIGNATURE_DIVERSITY",
"severity": "warning",
"message": "본문 402개가 heading signature 12종만 사용함(3.0%). 고정 목차 template 반복 여부를 검토한다.",
"examples": [
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a-red-test-misread-as-a-product-defect.body.md",
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a-build-gate-that-is-not-in-the-build.body.md",
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a-release-gate-with-no-evidence-producer.body.md"
]
}
]
@@ -0,0 +1,12 @@
[
{
"code": "LOW_HEADING_SIGNATURE_DIVERSITY",
"severity": "warning",
"message": "본문 386개가 heading signature 18종만 사용함(4.7%). 고정 목차 template 반복 여부를 검토한다.",
"examples": [
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-inbound-graphql-c09.body.md",
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-inbound-graphql-c07.body.md",
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-inbound-graphql-c03.body.md"
]
}
]
@@ -0,0 +1,610 @@
{
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-fileserver-c01.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 63자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-fileserver-c05.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 67자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-httpclient-c09.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 56자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c03.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 58자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c04.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 57자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c05.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c12.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 72자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c16.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 56자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c19.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 57자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c25.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 62자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c26.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 62자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c27.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 74자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c28.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 58자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c29.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c30.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 65자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c31.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 76자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c35.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 76자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c36.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 66자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c37.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 81자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c47.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c51.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 73자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-persistence-jpa-c53.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 62자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-support-c02.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 56자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-adapter-outbound-support-c04.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 72자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-application-core-c03.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 64자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-application-core-c06.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 60자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-application-core-c08.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 70자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-application-core-c09.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/concept/concept-messaging-policy-c07.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 57자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a-five-second-string-that-broke-every-prod-deploy.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 58자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a01-f002-idfactory-newid.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 60자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f004-retrydecision-reason.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 60자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f005-jparetrypolicy.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f006-stable.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 68자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f007-transactionprofileregistry.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 69자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f013-specificationpolicy-specification-unrestricted.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 64자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f014-collection-fetch-pagination.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 66자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f022-stable.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 63자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f025-filequotaservice-commit.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 58자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f027-maximum-attempts.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 70자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f029-for-update-skip-locked.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 65자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a05-f033-jpa-flyway-migration.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 69자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a06-f016-flamingock.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 58자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a06-f018-changestreams-false.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 58자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a14-f005-publicpaths-restrictedpathrule.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 56자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-a17-f002-stomp.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a03-f001.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a04-f002.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 73자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a04-f004.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 57자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a05-f024.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 58자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a05-f028.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 61자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a05-f030.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 72자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a05-f031.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 61자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a05-f034.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 69자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a06-f002.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 57자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a06-f008.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 62자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a06-f027.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 66자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a16-f001.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 56자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-analysis-finding-a19-f013.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 62자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-autoconfiguration-in-name-only.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 61자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-conditionalonbean-evaluated-at-parse-time.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 57자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-grpc-proto-contract-f01.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 57자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-messaging-inbox-jdbc-postgresql-f01.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-messaging-kafka-f01.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 69자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-messaging-nats-experimental-f01.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 65자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-messaging-nats-experimental-f03.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 72자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-messaging-rabbit-f04.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 58자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-messaging-reliability-api-f08.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 61자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-messaging-spring-boot-starter-f02.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 57자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-readme-said-no-beans-there-are-eight.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 60자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-the-public-surface-contract-test-lives-outside-the-family.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/case/case-trust-policy-lives-in-nginx-not-in-the-code.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 12,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/openquestion/openquestion-a05-f003-capabilitysupport-constraints.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 56자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/openquestion/openquestion-analysis-finding-a02-f003.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 59자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/openquestion/openquestion-analysis-finding-a02-f005.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 67자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
],
"/shared/Tech-Log-Document/clean-architecture-backend-template/openquestion/openquestion-analysis-finding-a04-f006.md": [
{
"code": "LONG_TITLE",
"severity": "info",
"line": 13,
"message": "제목이 57자임; 사건/메커니즘 이름으로 줄일 수 있는지 검토"
}
]
}
@@ -0,0 +1,184 @@
{
"schemaVersion": 1,
"project": "clean-architecture-backend-template",
"reviewedAt": "2026-09-01T15:43:48Z",
"note": "검토를 마치고 유지하기로 한 editorial finding 의 원장이다. 감사 도구는 여기 있는 항목을 ACCEPTED 로, 없는 항목을 UNREVIEWED 로 보고한다. 제목이 바뀌면 titleSha256 이 어긋나 그 항목은 다시 UNREVIEWED 가 된다 — 한 번 승인했다고 영원히 넘어가지 않는다. 제목을 다시 써서 60자 아래로 내려간 항목은 가리킬 발견이 없으므로 여기서 지운다 — 낡은 항목은 tools/editorial_quality/detect_stale_exceptions.py 가 찾는다.",
"criteria": {
"PROTECTED_IDENTIFIER": "길이가 보호 대상 식별자(코드 심볼 · 설정 키 · SQL 구문 · 고유명)에서 온다",
"PAIRED_CONTRAST": "사건 자체가 두 사실의 대비이고, 한쪽을 빼면 사건이 사라진다",
"ENUMERATION_IS_THE_EVENT": "열거된 항목 자체가 관측의 실질이다",
"TECHNICAL_QUALIFIER": "수치나 위치 같은 기술적 한정어가 결함의 규모·범위를 정한다"
},
"exceptions": {
"LONG_TITLE": {
"a-digest-that-covered-who-but-not-what": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "덮은 것과 덮지 않은 것의 대비가 digest 결함의 정의다.",
"path": "case/case-a-digest-that-covered-who-but-not-what.md",
"title": "transition digest가 \"누가·언제\"만 덮고 \"무엇\"을 덮지 않아 다른 전이를 같다고 보고했다",
"titleSha256": "b3fc797d1c1ba2153d91cef24a0b120b215f9caf2e85976967196d585a711629"
},
"a-five-second-string-that-broke-every-prod-deploy": {
"status": "ACCEPTED",
"category": "PROTECTED_IDENTIFIER",
"reason": "connection-timeout: 5s 는 설정 키와 값 그대로다. 그 문자열이 사건의 원인이다.",
"path": "case/case-a-five-second-string-that-broke-every-prod-deploy.md",
"title": "connection-timeout: 5s가 모든 prod 배포를 시작 실패시켰고 local만 통과했다",
"titleSha256": "b3e9857acb4647200bc420b861507cf22389bf9b05b5eed35a905afe15ef6e54"
},
"a16-f004-graphqloperationnamepolicy": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "배선된 쪽과 미배선 쪽 중 정책 타입을 쓰는 것이 미배선 쪽이라는 역전이 사건이다.",
"path": "case/case-a16-f004-graphqloperationnamepolicy.md",
"title": "연산 이름 정책의 두 구현 중 하나만 배선되고, 미배선 쪽만 GraphQlOperationNamePolicy를 쓴다",
"titleSha256": "d72780971c6a7742d779b1b6df1600edd6ee83b8c14f4059b987a983bd8175c3"
},
"a17-f002-stomp": {
"status": "ACCEPTED",
"category": "ENUMERATION_IS_THE_EVENT",
"reason": "연결 티켓·origin 정책·메시지 권한·연결 예산 네 통제의 열거가 실질이고, 뒤 절이 그중 일부의 예외를 정한다.",
"path": "case/case-a17-f002-stomp.md",
"title": "연결 티켓·origin 정책·메시지 권한·연결 예산이 요청 경로 밖이고, 그중 일부는 STOMP 어댑터가 다른 방식으로 대체한다",
"titleSha256": "19246d62702500370586a97fd1bb90dce46e5c277ee3498773f1433e3233c5f7"
},
"a19-f003-messaging-reliability-api": {
"status": "ACCEPTED",
"category": "TECHNICAL_QUALIFIER",
"reason": "main 13파일 · 817 LOC · 테스트 0개 라는 세 수치의 비율이 관측 자체다. 수치를 빼면 남는 것이 없다.",
"path": "case/case-a19-f003-messaging-reliability-api.md",
"title": "messaging-reliability-api는 main 13파일 · 817 LOC에 테스트가 0개다",
"titleSha256": "d94de5ec6f540c39653aa918da71f98fe4975b8609140889143911fb76f4ff50"
},
"analysis-finding-a06-f027": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "실제로 차단하는 것과 없는 것의 대비가 검증 지형 관측 자체다.",
"path": "case/case-analysis-finding-a06-f027.md",
"title": "release gate가 실제로 차단하는 것은 hermetic test 3개이고, mongo용 CI workflow는 없다",
"titleSha256": "1e576f89ecf30db51c9b63a612d7fc3588fd631531fbcc5362818874fb7463f7"
},
"analysis-finding-a08-f004": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "경로 기반이라는 것과 그것을 지키는 사전검사가 스스로 근사라고 적었다는 인용의 대비다.",
"path": "case/case-analysis-finding-a08-f004.md",
"title": "발행 rename만 경로 기반이고, 그것을 지키는 것은 이 모듈이 \"근사에 불과하다\"고 적은 사전검사다",
"titleSha256": "ed49f9e7783938fc6810385172713197c4c3aa4f51405125e2fa5346dca44f32"
},
"analysis-finding-a13-f010": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "FCM 만 규칙 밖이라는 것과 그 FCM 이 두 계약 집합 어디에도 없다는 것이 함께여야 사각지대가 성립한다.",
"path": "case/case-analysis-finding-a13-f010.md",
"title": "FCM만 \"커밋 후 응답 손실 = ambiguous\" 규칙 밖에 있고, 그 FCM이 두 계약 집합 어디에도 없다",
"titleSha256": "4b12e9c90a97c8df82f6ccc5fa6336cabf14618675e78b1e187d94ca18cf0eac"
},
"analysis-finding-a16-f001": {
"status": "ACCEPTED",
"category": "ENUMERATION_IS_THE_EVENT",
"reason": "스키마 조립·계약 정체성·해시 사슬 셋이 통째로 미배선이라는 열거가 규모를 정하고, 액추에이터 엔드포인트가 네 번째다.",
"path": "case/case-analysis-finding-a16-f001.md",
"title": "스키마 조립·계약 정체성·해시 사슬이 통째로 미배선이고, 그것을 발행할 액추에이터 엔드포인트도 등록되지 않는다",
"titleSha256": "e5f6e1f12deaa7f333a81801998a0ed09245ce00dbfaac04b4b7e76249456d58"
},
"autoconfiguration-in-name-only": {
"status": "ACCEPTED",
"category": "TECHNICAL_QUALIFIER",
"reason": "세 클래스라는 수와 capability 리포트에 Stable 로 올랐다는 결과가 사건의 두 축이다.",
"path": "case/case-autoconfiguration-in-name-only.md",
"title": "이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다",
"titleSha256": "fed765b2bcafabffa436bbb66b658d38be4889bf0a29428fe3ce577666a01a42"
},
"conditionalonbean-evaluated-at-parse-time": {
"status": "ACCEPTED",
"category": "PROTECTED_IDENTIFIER",
"reason": "@ConditionalOnBean(DataSource.class) 는 애너테이션 표기 그대로가 원인이다. 인자 타입까지가 조건이다.",
"path": "case/case-conditionalonbean-evaluated-at-parse-time.md",
"title": "@ConditionalOnBean(DataSource.class)가 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다",
"titleSha256": "2f89d628a8e05f3943701661fc17a44b77d6b2a131943fb5a11a5927b7ece67e"
},
"grpc-proto-contract-f01": {
"status": "ACCEPTED",
"category": "PROTECTED_IDENTIFIER",
"reason": "reserved 2 to 5; 는 오탐을 만드는 원문 구문이고 RESERVED_HISTORY 는 위반 코드다. 둘 다 그대로여야 재현된다.",
"path": "case/case-grpc-proto-contract-f01.md",
"title": "reserved 2 to 5; 범위가 개별 숫자로만 수집되어 RESERVED_HISTORY 오탐이 된다",
"titleSha256": "ddb7fe8063fa09028571f1a8647c86b29e73bfc042bcabfaf7c013e4c6d6a2f0"
},
"messaging-inbox-jdbc-postgresql-f01": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "구현돼 있다는 것과 호출되지 않는다는 것의 대비이고, 뒤 절이 그 결과다.",
"path": "case/case-messaging-inbox-jdbc-postgresql-f01.md",
"title": "bounded purge가 구현돼 있고 호출되지 않아, cleanup이 스스로 막겠다고 한 장애를 일으킨다",
"titleSha256": "11e1b5262a9f7739bdd6872a67886b4874d00c121ee2d004d71ae40408d303ab"
},
"messaging-nats-experimental-f01": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "무조건 참인 선언과 창이 있을 때만 일어나는 실제 동작의 대비다. 조건절을 빼면 결함이 성립하지 않는다.",
"path": "case/case-messaging-nats-experimental-f01.md",
"title": "deduplicatedPublish 를 무조건 참으로 선언하는데 실제 중복 제거는 프로파일에 창이 있을 때만 일어난다",
"titleSha256": "d88fd462e81989a10308e98413fc5c0496662ce130606e4d25a947cbacc397a9"
},
"messaging-rabbit-f04": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "능력 상수가 참이라는 것과 그 지연을 제공할 토폴로지가 없다는 것의 대비다.",
"path": "case/case-messaging-rabbit-f04.md",
"title": "능력 상수의 delayedDelivery 가 무조건 참이고, 그 지연을 제공할 토폴로지는 조립되지 않는다",
"titleSha256": "2ff213b705421e39147bad47dbcf8b3ed0dc22fa76daa1227fa62787041e7c34"
},
"messaging-reliability-api-f08": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "두 오버로드를 나란히 노출한다는 것과 호출자가 무제한 쪽을 골랐다는 것이 원인과 결과다.",
"path": "case/case-messaging-reliability-api-f08.md",
"title": "포트가 bounded/unbounded purge 두 오버로드를 나란히 노출하고, 호출자가 무제한 쪽을 고른다",
"titleSha256": "d569175440aa856b591bd4e607f2830320fe877691c9dfc2660888afd53f4af7"
},
"messaging-spring-boot-starter-f02": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "아무도 공급하지 않는 빈 뒤에 있다는 것과 사슬이 자기 클래스 안을 가리킨다는 것이 함께여야 자기참조 구조가 드러난다.",
"path": "case/case-messaging-spring-boot-starter-f02.md",
"title": "출고되는 신뢰성 체인 전체가 아무도 공급하지 않는 빈 뒤에 있고, 그 사슬이 자기 클래스 안을 가리킨다",
"titleSha256": "3c751db7f8344dd35c7f13610b20a384e88e53c6b1d7eab162787773ca881192"
},
"readme-said-no-beans-there-are-eight": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "README 인용과 실제 bean 여덟이 대비 자체다. 한쪽만 남기면 사건이 사라진다.",
"path": "case/case-readme-said-no-beans-there-are-eight.md",
"title": "README가 \"노출된 setting도 bean도 없다\"고 적은 능력에 production bean 여덟이 있다",
"titleSha256": "c1f78904149820c311d1b1882827d808eb0e282e305606062f69dd5f93710f00"
},
"the-public-surface-contract-test-lives-outside-the-family": {
"status": "ACCEPTED",
"category": "PROTECTED_IDENTIFIER",
"reason": "MessagingPublicSurfaceContractTest 한 이름이 제목의 절반이고, app-bootstrap 이 실제 소재다.",
"path": "case/case-the-public-surface-contract-test-lives-outside-the-family.md",
"title": "MessagingPublicSurfaceContractTest가 가족 밖(app-bootstrap)에 있다",
"titleSha256": "fcf1375b7b32bea7173dcb0ab7d9e407e0c8a58cef56a12575dedaad420828d1"
},
"the-transport-and-the-validator-answer-differently": {
"status": "ACCEPTED",
"category": "PAIRED_CONTRAST",
"reason": "두 쪽이 다르게 답한다는 것과 런타임이 쓰는 쪽이 틀렸다는 것이 함께 있어야 심각도가 정해진다.",
"path": "case/case-the-transport-and-the-validator-answer-differently.md",
"title": "같은 어댑터의 능력을 전송과 검증기가 다르게 답하고, 런타임이 쓰는 쪽이 record 의 의미와 어긋난다",
"titleSha256": "092faa5727dcae07b8486a17ddb1f6ff64c1141f33fedac9085641bd3e0edb74"
},
"trust-policy-lives-in-nginx-not-in-the-code": {
"status": "ACCEPTED",
"category": "TECHNICAL_QUALIFIER",
"reason": "421 LOC 이 배선되지 않은 정책의 규모이고, Nginx 라는 실제 판정 위치가 대비의 반대쪽이다.",
"path": "case/case-trust-policy-lives-in-nginx-not-in-the-code.md",
"title": "forwarded 헤더 신뢰 판정이 Nginx에만 있고 Java 정책 421 LOC은 배선되지 않았다",
"titleSha256": "1d2ef0398997a68c91b2712b2a55af15b2cf2a25f5720e6cfdbc01e092950a14"
}
}
}
}
@@ -0,0 +1,75 @@
# Editorial / Visual Quality Audit — 2026-09-01 (초기 스냅숏)
:::warning
**상태: HISTORICAL_SNAPSHOT**
기준 시점: semantic editorial pass **이전**. 이 문서는 현재 완료 상태를 나타내지 않는다.
아래의 `lastEditorialAt: None` · `EDITORIAL_PENDING` · Concept signature 18종 ·
Case signature 12종 · diagram 217 · LONG_TITLE 76 은 전부 그 시점의 값이고 지금은 다르다.
숫자를 덮어쓰지 않고 그대로 둔다 — 어디서 출발했는지가 이 문서의 쓸모다.
현재 상태의 정본은 두 곳이다.
- `_meta/editorial/final-editorial-validation-2026-09-01.md` — 최종 검증 결과
- `_meta/editorial/semantic-pass-progress.md``Correction pass` 절 — 무엇을 왜 고쳤는지
:::
## 상태
- technical records: **949** — CASE 402 · CONCEPT 386 · REFERENCE 105 · OPEN QUESTION 31 · DECISION 25
- rich body: **788** — Concept 386 · Case 402
- `lastEditorialAt`: `None`
- `lastValidationAt`: `None`
- 판정: **EDITORIAL_PENDING**. 생성 완료와 편집 완료를 같은 상태로 보지 않는다.
## 이번에 즉시 수정한 것
- terminal evidence 788개를 raw evidence hash 확인 후 새 renderer로 재생성했다. 긴 줄은 `<tspan>`으로 시각적 wrap하며 raw 문자는 바꾸지 않는다.
- explanatory SVG 217개와 terminal SVG 788개는 새 SVG validator를 통과했다.
- Concept의 고정 첫 절 `무엇을 해결하는가` 383개를 제거했다.
- Case의 고정 첫 절 `무엇이 일어났나` 402개를 제거했다.
- Concept main record의 중복 `무엇을 해결하는가` 357개를 제거했다.
- provenance/asset 설명 boilerplate 1,656개를 body에서 제거했다. 이 정보는 asset/meta와 source provenance가 소유한다.
- 분석자 경험처럼 보이던 `처음에는 … 의심했다/후보로 봤다` Concept 3개를 source anchor를 다시 읽고 객관적인 메커니즘 서술로 교정했다.
## 개별 문서 audit
- flagged artifacts: **76**
- warning: **0**
- info: **76**
- code counts: `{'LONG_TITLE': 76}`
현재 warning은 0이다. `LONG_TITLE`은 자동 수정 대상이 아니라 편집 검토 신호다.
## corpus 구조 audit
### Concept
- LOW_HEADING_SIGNATURE_DIVERSITY: 본문 386개가 heading signature 18종만 사용함(4.7%). 고정 목차 template 반복 여부를 검토한다.
### Case
- LOW_HEADING_SIGNATURE_DIVERSITY: 본문 402개가 heading signature 12종만 사용함(3.0%). 고정 목차 template 반복 여부를 검토한다.
고정 첫 절과 반복 boilerplate는 제거했지만, 전체 목차 signature가 Concept 18종 / Case 12종에 집중되어 있다. 이 문제는 제목을 기계적으로 바꾸는 방식으로 해결하지 않는다. 다음 editorial pass에서 각 문서의 실제 메커니즘·사건을 읽고 내용 기반 절 이름으로 재구성한다.
## visual audit
- explanatory diagrams: 217
- terminal evidence SVG: 788
- XML/viewBox/title/desc/role/script/external-reference/text-overflow validator: **PASS**
- explanatory diagram layout signatures: **10종**
- most common signatures: [(('880', '276', 4, 4, 2, 1), 80), (('880', '366', 4, 4, 3, 1), 56), (('880', '186', 4, 4, 1, 1), 28), (('880', '276', 5, 5, 2, 1), 18), (('880', '186', 3, 3, 1, 1), 16)]
기계적 SVG 결함은 현재 막혔다. 다만 217개 다이어그램의 layout signature가 10종에 집중된 것은 시각 편집 문제다. 앞으로는 `designing-tech-log-visuals` Skill에서 boundary/sequence/state/data-flow/failure/before-after 중 논지에 맞는 visual grammar를 먼저 고르고, 그림 quota를 두지 않는다.
## 완료 조건
`lastEditorialAt`은 아직 채우지 않는다. 다음 단계에서 Concept/Case body를 topic/module 단위로 읽으면서 낮은 heading-signature 다양성을 해소하고, 76개 긴 제목 후보를 실제 문맥 기준으로 검토한 뒤 corpus audit를 다시 실행한다. 단순 정규식 치환으로 PASS를 만들지 않는다.
## state integrity
- record/body hash 또는 line mismatch: **0**
@@ -0,0 +1,158 @@
# Final Editorial Validation — 2026-09-01
**상태: COMPLETE.** 이 문서가 `clean-architecture-backend-template` Tech Log 의 편집 완료 상태를
나타내는 정본이다. 다른 editorial artifact 는 전부 이 문서를 가리킨다.
검증 시각 `2026-09-01T15:48:45Z`. `_meta/state.json``lastEditorialAt` ·
`lastValidationAt` 과 같은 값이다.
같은 디렉터리의 다른 문서와의 관계는 이렇다.
| 문서 | 성격 |
|---|---|
| `editorial-visual-audit-2026-09-01.md` | pass **이전**의 초기 audit. `HISTORICAL_SNAPSHOT` |
| `editorial-audit-2026-09-01.json` | 같은 시점의 도구 원본 출력(LONG_TITLE 76). 가공하지 않는다 |
| `concept-structure-audit-2026-09-01.json` · `case-structure-audit-2026-09-01.json` | 같은 시점의 구조 audit 원본(signature 18종 · 12종) |
| `semantic-pass-progress.md` | 작업 로그. 앞부분은 진행 중 스냅숏, 하단 `Correction pass` 가 정본 |
| `svg-semantic-review-2026-09-01.md` | 33개 후보의 시각 semantic 검토 판정 |
| `editorial-exceptions.json` | 검토를 마치고 유지하기로 한 finding 의 원장 |
| **이 문서** | **최종 검증 수치** |
`.json` 은 도구가 그대로 뱉은 출력이라 안에 표시를 넣지 않는다 — 넣으면 더 이상 도구
출력이 아니다. 어느 세대인지는 파일명의 날짜와 이 표가 말한다.
---
## Corpus
```text
records = 949
CASE = 402
CONCEPT = 386
REFERENCE = 105
OPEN QUESTION = 31
DECISION = 25
rich body = 788 (CONCEPT 386 · CASE 402)
```
## Editorial
```text
corpus structural findings = 0
concept bodies=386 records=386 findings=0
case bodies=402 records=402 findings=0
reference / openquestion / decision records=105 / 31 / 25 findings=0
main-record repeated explanatory prose = 0
LONG_TITLE candidates = 60
reviewed = 60
accepted = 60
unreviewed = 0
stale exceptions = 0
```
LONG_TITLE 60건은 미해결 finding 이 아니다. `editorial-exceptions.json` 이 60건 각각에 대해
승인 사유를 기록한다.
| category | 수 | 뜻 |
|---|---:|---|
| `PROTECTED_IDENTIFIER` | 36 | 길이가 코드 심볼 · 설정 키 · SQL 구문 · 고유명에서 온다 |
| `PAIRED_CONTRAST` | 17 | 사건 자체가 두 사실의 대비다 |
| `TECHNICAL_QUALIFIER` | 4 | 수치나 위치가 결함의 규모를 정한다 |
| `ENUMERATION_IS_THE_EVENT` | 3 | 열거된 항목 자체가 관측의 실질이다 |
승인은 영구 침묵이 아니다. 각 항목이 승인 당시 제목의 `titleSha256` 을 들고 있어서, 제목이
바뀌면 그 항목은 다시 `UNREVIEWED` 가 된다. 승인한 finding 이 더 이상 나오지 않으면
`STALE_EXCEPTION` 으로 보고된다. 새 기록이 55자를 넘는 제목을 갖고 원장에 없으면 그대로
`UNREVIEWED` 로 나타난다.
## References
```text
source/evidence files checked = 1737
invalid references = 0
```
## Visual
```text
explanatory SVG = 181
terminal SVG = 788
mechanical validation = PASS (diagram · terminal)
semantic negative-edge candidates reviewed = 33
redesigned = 3
accepted = 30
coordinate precision violation = 0
```
## Integrity
```text
record hash mismatch = 0
body hash mismatch = 0
diagram svg hash mismatch = 0
raw evidence hash mismatch = 0
line count mismatch = 0
```
## Upstream
```text
crossScopeAnalysis sha state 538e4e72cf033a9e262d5bbccd4914ccb4f2aea405432efd034b719c64cd25ff
crossScopeAnalysis sha file 538e4e72cf033a9e262d5bbccd4914ccb4f2aea405432efd034b719c64cd25ff
crossScopeAnalysis lines state 552 file 552
→ MATCH
```
`verify_pipeline.py` 가 매 실행마다 이 대조를 수행한다 (`upstream document metadata: matches files`).
## Tests
```text
PYTHONPATH=tools python3 -m unittest discover -s tools/tests
Ran 98 tests
OK
python3 tools/verify_pipeline.py /shared
PIPELINE VERIFICATION: PASS
- required paths: 67
- analysis queue contract: valid
- root-tree contract: present
- upstream document metadata: matches files
- forbidden legacy dependency: absent
```
---
## 완료 판정 명령
이 상태를 재확인하는 한 줄은 다음이다. `--fail-on-unreviewed` 가 붙어야 완료 판정이 된다 —
`findings == 0` 이 아니라 `unreviewed == 0` 이 조건이기 때문이다.
```bash
python3 tools/editorial_quality/audit_korean_tech_writing.py \
clean-architecture-backend-template/concept \
clean-architecture-backend-template/case \
clean-architecture-backend-template/reference \
clean-architecture-backend-template/openquestion \
clean-architecture-backend-template/decision \
--exceptions clean-architecture-backend-template/_meta/editorial/editorial-exceptions.json \
--fail-on-unreviewed
```
## state 의 완료 의미
`_meta/state.json``lastEditorialAt` · `lastValidationAt` 은 "편집 pass 가 끝났고 그 시점에
검증이 돌았다" 를 뜻하며, 그 날짜의 closure artifact 가 결과를 소유한다.
`editorialStatus` 같은 상태 필드는 **추가하지 않았다.** `_templates/project/_meta/state.json`
이 상태의 계약이고, 필드를 하나 추가하면 그 템플릿과 그것으로 만들어질 모든 프로젝트의 계약이
바뀐다. 이번 closure 가 요구하는 범위를 넘는다. 완료 여부는 이 문서와 위 명령의 exit code 로
읽는다. 이 규칙은 `humanizing-korean-tech-writing` Skill 의
`Audit artifacts have generations; say which one is current` 절에 적혀 있다.
@@ -0,0 +1,319 @@
# Semantic Editorial Pass — 진행 기록
**현재 상태: COMPLETE**
:::warning
이 문서는 작업 로그다. 아래 여러 절은 **작업 당시의 incremental progress snapshot** 이다.
`진행 중` 표기, 중간 count, 이전 audit 결과는 historical record 이며 현재 상태를 뜻하지 않는다.
예를 들어 `state-machines-and-ownership — 78/119 (진행 중)` 은 그 시점의 값이고, 그 topic 은
CONCEPT 386건 완료 시점에 끝났다.
정본은 두 곳이다.
- 이 문서 하단의 `Correction pass`
- `_meta/editorial/final-editorial-validation-2026-09-01.md` — 최종 검증 수치
:::
시작 2026-09-01. 대상 949 records. 우선순위 CONCEPT -> CASE -> REFERENCE/QUESTION/DECISION.
## Historical progress snapshot — 이번 pass 에서 함께 고친 것
- 생성 과정에서 문장이 절 경계에서 잘려 나간 body 를 원본 source anchor 로 복원한다. 말줄임(…)으로 끝난 문장, 목록이 한 줄로 뭉개진 문단, 코드 스팬 중간에서 끊긴 문장이 대상이다.
- 절 제목을 그 문서가 실제로 설명하는 메커니즘·사건·경계 이름으로 바꾼다. 고정 목차를 새로 만들지 않는다.
- 제목이 SSOT 절 머리글(모듈의 정체와 경계, 실패 경로와 복구/번역 등)이거나 분석 번호((8.2) 등)이거나 분석 과정형(Confirmed —)이면 그 문서의 메커니즘 이름으로 바꾼다.
- 그림은 논지에 맞는 visual grammar 로 다시 그리거나, 논지와 무관하면 제거한다.
## Historical progress snapshot — 완료 topic (CONCEPT)
- result-and-failure-algebra — 10/10
- query-and-pagination-models — 6/6
- observability-models — 7/7
- identity-and-value-contracts — 8/8
- capability-and-disclosure-models — 11/11
- security-and-trust-boundaries — 20/20
## Historical progress snapshot — 그림 처리 기록
- 재설계: adapter-inbound-graphql-c10, adapter-outbound-cache-redis-c03, adapter-outbound-support-c02, application-core-c01, messaging-testkit-c04, adapter-inbound-web-c01, adapter-outbound-objectstorage-c01, adapter-outbound-persistence-jpa-c24, grpc-advanced-diagnostics-c02, adapter-inbound-web-c03, adapter-inbound-web-c04, application-core-c02, grpc-advanced-diagnostics-c01, grpc-observability-c01, grpc-spring-boot-starter-c01
- 제거: grpc-core-api-c01(기록의 논지와 다른 그림), app-bootstrap-c01(표로 읽는 것이 명확), adapter-outbound-notification-c04(같음), grpc-advanced-bootstrap-c03(같음)
- admission-budget-and-backpressure — 21/21
- composition-and-lifecycle-models — 26/26
- transaction-and-consistency-models — 27/27
- state-machines-and-ownership — 78/119 (진행 중)
## CONCEPT 386건 완료 (2026-09-01)
- 일반 heading(구조 / 명령이 보여 주는 것 / 어떻게 움직이는가 / 무엇이 불변식인가 / 어디까지가 경계인가 / 왜 그렇게 되나 / 무엇이 남는가 / 실패하면 어떤 상태가 되는가 / 근거 절 원문)을 남긴 CONCEPT body 0건.
- audit_corpus_structure.py concept: bodies=386 findings=0.
- state.json 과 frontmatter/H1 사이 title 불일치 132건을 state.json 쪽(보호 대상 식별자가 살아 있는 쪽)으로 정본화. 단 test-names-that-assert-what-their-bodies-do-not 은 본문이 네 사례를 열거하므로 "테스트 넷" 으로 확정하고 state.json 을 고쳤다.
- diagram 217 → 181. 제거 36건은 (a) 내용이 목록/표라서 그림이 정보를 더하지 않는 경우, (b) alt 가 다른 기록의 주장을 그리고 있던 경우다.
- CASE 402건은 ca0..ca30 청크로 진행한다.
## CASE 402건 완료 (2026-09-01)
- ca0..ca30 청크 전량 편집. 일반 heading 잔존 0건.
- audit_corpus_structure.py case: bodies=402 findings=0.
- CASE 구간에서 diagram 재설계 다수, 제거 다수. 최종 diagram 수는 pass 종료 시 재보고.
- 다음: REFERENCE 105 / OPEN QUESTION 31 / DECISION 25 (본문 파일 없음, record .md 만 편집).
## Semantic editorial pass — 1차 (2026-09-01, 이후 정정됨)
:::warning
아래는 1차 pass 당시의 스냅숏이다. 두 수치가 틀렸고 correction pass 에서 정정했다 —
`20개 이상 문서에서 동일한 문단 = 0` 은 body 만 센 것이고 main record 를 세지 않았으며,
테스트 수는 `test_verify_pipeline` 한 파일만 돈 결과다. 최신 상태는 이 문서 끝의
"Correction pass" 절을 본다.
:::
전체 949건 편집을 마쳤다. `lastEditorialAt` / `lastValidationAt` = `2026-09-01T10:39:26Z`.
### 구조 다양성
| 지표 | 이전 | 이후 |
|---|---|---|
| CONCEPT heading signature | 18 / 386 (4.7%) | 382 / 386 (99.0%) |
| CASE heading signature | 12 / 402 (3.0%) | 400 / 402 (99.5%) |
| CASE 서로 다른 `##` heading | — | 1,191 |
| CONCEPT 서로 다른 `##` heading | — | 1,054 |
| 20개 이상 body 에서 동일한 문단 | 다수 | 0 |
| 20개 이상 main record 에서 동일한 문장 | 측정하지 않음 | 측정하지 않음 — correction pass 가 163건을 찾았다 |
`## 출처` 는 provenance 절이므로 전 기록이 공유한다. 그 다음으로 빈도가 높은 것은
CASE `## 수정` 37건(9.2%), CONCEPT `## 이 기록이 다루는 파일 범위` 34건(8.8%) 이다.
`audit_corpus_structure.py` 의 FIRST_HEADING_DOMINANCE(80%) · LOW_HEADING_SIGNATURE_DIVERSITY(15%)
· REPEATED_BOILERPLATE(20) 세 규칙 모두 findings=0.
### 이번 회차에 마지막으로 처리한 것
- CASE ca29 · ca30 청크 25건. CASE 402건 전량 완료.
- REFERENCE 40건: `목적`/`규칙`/`적용 조건`/`예외`/`근거` 가 전부 boilerplate 였던 기록을
각 source anchor 의 SSOT finding(사실·근거·왜 문제인가·확인 방법)으로 다시 썼다.
- QUESTION 13건: `미지수`("현재 근거로는 답이 닫히지 않는다")와 `다음 검증` boilerplate 를
각 기록의 실제 미지수와 확인 방법으로 바꾸고, SSOT 의 후보를 `선택지` 절로 옮겼다.
- `## 원본 판정이 무엇을 적었나` 206건(CASE 186 · CONCEPT 20)을 각 기록이 인용하는
근거가 무엇을 보여 주는지로 개별 명명했다. 편집 과정에서 이 heading 자체가
두 번째 template 이 된 것을 계측으로 발견해 되돌린 것이다.
- 제목: LONG_TITLE 후보를 전수 검토했다. 9건을 줄였고 60건은 유지했다.
### 제목 판정
줄인 것 9건.
- `analysis-finding-a03-f003``legacy compatibility surface의 제거 조건을 세 가지로 고정한다`
(다른 두 기록의 `## 관계` 가 이미 이 이름으로 이 규칙을 가리키고 있었다. 끊긴 링크가 붙었다)
- `a05-f003-capabilitysupport-constraints``CapabilitySupport.constraints 의 경계가 타입에 없다`
- `analysis-finding-a04-f006``cache-redis 와 httpclient 의 support 간선이 죽었는지 확정되지 않았다`
- `a06-f003-change-streams-true`(88자) → 두 절 중 중복(`거부되지 않고`/`조용히 버려지며`)을 뺐다
- `a13-f002-authentication-failed-resumehealthy`(77자) → 한 절로 줄였다
- `analysis-finding-a19-f013`(75자) → 인용 문장을 본문에 남기고 제목은 사건 이름으로 바꿨다
- `a14-f005-publicpaths-restrictedpathrule`(73자) → 뒤 절이 앞 절을 일반어로 반복하고 있었다
- `messaging-nats-experimental-f03`(72자) → 제목에 두 문장이 들어 있었다
- `messaging-kafka-f01`(69자) → 세 절 중 사건을 이루는 두 절만 남겼다
유지한 60건은 길이가 (a) `MessagingPublicSurfaceContractTest` · `GrpcSerializedStreamWriter` 같은
보호 대상 식별자, (b) 사건 자체가 두 사실의 대조인 경우(문서 X · 코드 Y), (c) 열거가 실질인 경우
(`연결 티켓·origin 정책·메시지 권한·연결 예산`)에서 온다. 줄이면 사실이 빠진다.
### 시각 자료
- diagram 217 → 181. 제거 36건은 내용이 목록/표라 그림이 정보를 더하지 않거나,
alt 가 다른 기록의 주장을 그리고 있던 경우다.
- terminal SVG 788건은 손대지 않았다.
- `validate_svg.py assets/svg --kind diagram` PASS, `assets/terminal --kind terminal` PASS.
### 최종 검증
| 명령 | 결과 |
|---|---|
| `PYTHONPATH=tools python3 -m unittest tools.tests.test_verify_pipeline` | OK (38 tests — 한 파일만. 전체 discover 아님) |
| `python3 tools/verify_pipeline.py /shared` | PASS |
| `audit_korean_tech_writing.py concept` | scanned=772 findings=0 |
| `audit_korean_tech_writing.py case` | scanned=804 findings=60 (LONG_TITLE, 전건 검토 후 유지) |
| `audit_korean_tech_writing.py reference` | scanned=105 findings=0 |
| `audit_korean_tech_writing.py openquestion` | scanned=31 findings=0 |
| `audit_korean_tech_writing.py decision` | scanned=25 findings=0 |
| `audit_corpus_structure.py concept` | bodies=386 findings=0 |
| `audit_corpus_structure.py case` | bodies=402 findings=0 |
| `validate_svg.py assets/svg --kind diagram` | PASS |
| `validate_svg.py assets/terminal --kind terminal` | PASS |
| record/body SHA ↔ `_meta/state.json` | 949건 전건 일치 |
### 남은 editorial warning
CASE LONG_TITLE 60건뿐이다. 위 판정대로 유지 결정이며, 규칙을 다시 적용할 때는
길이가 아니라 "제목이 사건을 이름 짓는가"로 판정한다.
### 편집 중 발견해 고친 사실 오류 하나
`case-an-unselectable-broker-listed-with-features``## 출처` 가 존재하지 않는 절
`analysis/messaging/messaging-rabbit.md §17` 을 가리키고 있었다. SSOT 를 grep 해
`analysis/19-messaging-platform.md` §6.2 가 그 finding 의 실제 소유자임을 확인하고 고쳤다.
---
# Correction pass (2026-09-01)
독립 재검증에서 나온 결함만 고치고, 같은 형태가 다시 완료 판정을 통과하지 못하도록 validation
contract 를 보강한 회차다. 949건을 다시 쓰지 않았다. `lastEditorialAt` · `lastValidationAt` =
`2026-09-01T11:32:12Z`.
## 1. main record boilerplate 163건
1차 pass 의 audit 은 `*.body.md` 만 glob 했다. main record 는 대상 밖이었고, 그래서 다음 두 문장이
CASE 163건과 158건에 그대로 남아 있었다.
```
확인 방식 : 원본 분석 절의 판정을 옮겼고, 이 기록에 붙은 자산의 명령만 이번 회차에 실행했다
이 노드는 canonical SSOT 의 판정을 그대로 옮긴 것이다. 그 SSOT 의 §16 이 해당 finding 에 대해 실행하지 못한 검증을 소유한다.
```
둘 다 기록이 아니라 생성 파이프라인을 설명하는 문장이다. 163건 전부를 owning SSOT 43개의 §16
(확인하지 못한 것)과 각 기록에 붙은 자산으로 다시 썼다.
- `확인 방식` — 그 기록을 실제로 무엇이 확정했는지. 예: `GrpcAdmissionController 참조 16건 전수
검색과 두 클래스의 공개 표면 대조`, `HeaderValue 참조 26건 검색과 파서의 종료 지점 탐색 코드
확인. jshell 리플렉션으로 왕복 손상을 런타임 재현`.
- `확인하지 못한 것` — owning SSOT §16 이 그 finding 에 대해 소유한 실제 미검증 범위. SSOT 가
그 finding 을 닫아 둔 한 건(`messaging-outbox-jdbc-postgresql-f03`)만 `없음 — …` 으로 적었고,
읽지 않고 `없음` 을 쓴 기록은 없다.
고정 필드(`## 요약` · `## 문제` · `## 결론` · `## 검증 환경` · `## 재현 조건` ·
`## 확인하지 못한 것`)는 record contract 이므로 건드리지 않았다. 공통 환경값
(`OpenJDK : 21.0.12` · `Gradle : 9.0.0` · `소스 수정 : x`)도 그대로 두었다.
덤으로 같은 형태 하나를 더 찾아 고쳤다 — CASE 5건과 그 body 5건이
`가족 문서의 판정을 옮긴 것이다. 실행 확인은 그 문서의 §확인하지 못한 것이 소유한다.` 를 갖고
있었는데, `analysis/19-messaging-platform.md` 에는 그런 절이 없다. 끊긴 포인터였다.
## 2. corpus audit 의 근본 원인
`audit_corpus_structure.py` 를 두 층으로 나눴다.
| 층 | 대상 | 검사 |
|---|---|---|
| rich body | `*.body.md` | heading signature · 첫 heading 편중 · 반복 문단 |
| main record | `*.md` (body 제외) | **section 내부 설명형 prose 반복만** |
main record 층은 고정 schema heading 을 검사하지 않는다. CASE 의 여섯 절이 100% 같은 것은 계약이
동작하는 것이지 결함이 아니다. 대신 `키 : 값` 줄의 **값이 값인지 설명인지**를 가른다 —
한국어 종결형이 붙거나 문장이 둘 이상이면 설명으로 본다. `21.0.12 java -version 으로 확인` 은
값이고, `원본 분석 절의 판정을 옮겼고 …` 는 설명이다.
`## 관계` · `## 근거 기록` · `## 출처` 아래의 `-` 링크 줄은 대상에서 뺐다. 그 줄은 다른 기록의
제목을 그대로 반복해야 링크가 성립한다 — 참조이지 설명이 아니다. 링크 아래의 설명 줄은 그대로
검사한다.
regression test: `tools/tests/test_main_record_boilerplate.py` 8건.
- 고정 CASE heading 이 100% 같아도 fail 하지 않는다
- 공통 Java/Gradle 값만 같아도 fail 하지 않는다
- 같은 설명 문장이 20개 이상 main record 에 반복되면 fail 한다
- 19건이면 fail 하지 않는다 (threshold 경계)
- 관계 링크 줄은 반복해도 fail 하지 않는다
## 3. 잘못된 evidence reference 4건
owning SSOT 를 다시 읽고 정본으로 고쳤다. 없는 파일을 새로 만들지 않았다.
| 기록 | 잘못된 참조 | 정본 |
|---|---|---|
| `a-resumed-redrive-skips-what-it-could-not-move` | `306-redrive-resume-skips-failed-items.txt` | `306-redrive-resume-skips-unmoved.txt` (EVD-306) |
| `an-authorization-denial-recorded-as-a-configuration-error` | `287-messaging-security-access-path.txt` | `287-messaging-security-duplicate-checks.txt` §C (EVD-287) |
| `blocking-means-startup-fails-and-nothing-runs-it` | `302-messaging-admin-service-unwired.txt` | `302-admin-api-topology-guarantee-unwired.txt` (EVD-302) |
| `the-forgeable-approval-survived-on-the-irreversible-half` | `307-destructive-approval-not-verified.txt` | `308-admin-runtime-api-and-dependency-defects.txt` (EVD-308) |
넷째는 접두사 정정이 아니다. `Approved` 가 `VerifiedApproval` 이 아니라 `AdminApproval` 을 담는다는
사실은 `analysis/messaging/messaging-admin-runtime.md` 의 §14 표에서 EVD-**308** 에 귀속돼 있다
(EVD-307 이 소유하는 것은 구현 0건 쪽이다).
## 4. source/evidence reference validator
`tools/validate_source_references.py` 를 추가하고 `verify_pipeline.py` 의 REQUIRED_PATHS 에 등록했다.
| 참조 형태 | 규칙 |
|---|---|
| `analysis/foo.md` | 파일 존재 |
| `analysis/foo.md#L123` | 파일 존재 + line 범위 유효 |
| `evidence/raw/<full-name>.txt` | **정확히 그 파일** 존재. 접두사가 같은 이웃으로 대체하지 않는다 |
| `evidence/raw/287` | unique prefix 로 정확히 하나에 해석 |
두 evidence 형태를 비대칭으로 둔 것이 이 도구의 요점이다. 전체 파일명을 적은 것은 그 파일에 대한
단언이므로, 없으면 FAIL 이어야 한다. 번호만 적은 shorthand 는 evidence id 를 가리키므로 접두사
해석을 허용한다.
regression test: `tools/tests/test_source_reference_validation.py` 8건 — 이번 네 오류의 형태가
전부 포함돼 있다.
## 5. SVG semantic defect
정상 방향 화살표는 실재하는 관계를 뜻하고, 도착 상자를 빗금 치는 것으로 그 뜻이 뒤집히지 않는다.
33개 후보를 전수 검토해 3건을 재설계하고 30건을 유지했다. 상세는
`_meta/editorial/svg-semantic-review-2026-09-01.md`.
| 판정 | 수 |
|---|---:|
| 재설계 | 3 |
| 유지 — 들어오는 전이는 실재하고 부재는 나가는 쪽 | 3 |
| 유지 — 빗금 상자에 화살표가 닿지 않음 | 2 |
| 유지 — 빗금 상자 없음, 화살표가 전부 실재 흐름 | 25 |
규칙을 `designing-tech-log-visuals` 의 SKILL hard gate · editorial rules · review checklist 세 곳에
명문화하고, 그 셋이 존재하는지 검사하는 테스트를 붙였다.
layout signature 수는 목표로 삼지 않았다. 재설계 3건은 화살표가 기록과 반대를 말했기 때문에
고친 것이고, 나머지 30건은 grammar 가 generic 하다는 이유로 건드리지 않았다.
## 6. SVG 좌표 직렬화
설명용 SVG 40개의 좌표 340개가 소수점 둘 이상을 갖고 있었다. 소수점 한 자리로 정규화했고
(기하 변화 최대 0.05px), 생성기와 `validate_svg.py`(`COORDINATE_PRECISION`) 양쪽에 규칙을 넣었다.
터미널 SVG 는 대상이 아니다 — 원래 정수 좌표만 쓴다.
## 7. cross-scope metadata
`document-detail/.../state.json` 의 `crossScopeAnalysis` 가 재생성 이전 값을 들고 있었다.
```
sha256 481b3728… → 538e4e72…
lines 428 → 552
```
이것을 놓친 이유는 어떤 verifier 도 기록된 수치를 파일과 대조하지 않았기 때문이다.
`verify_pipeline.py` 에 `_verify_upstream_document_metadata` 를 추가했다 — state.json 안에서 `path`
와 `sha256` 을 함께 가진 모든 블록(crossScopeAnalysis · finalDocument · rootTree · candidateLedger ·
sourceManifest)을 실제 파일과 대조한다. regression test 5건.
## 8. LONG_TITLE 60건
길이만 보고 줄이지 않았다. 네 조건(독립 사건 둘 혼재 · 본문에 내려도 되는 설명 · 같은 뜻 반복 ·
분석 과정 문장)으로 60건을 다시 훑었고 해당하는 것이 없었다. 남은 길이는 보호 대상 식별자
(`MessagingPublicSurfaceContractTest` · `GrpcSerializedStreamWriter`), 사건 자체가 두 사실의 대조인
경우(문서 X · 코드 Y), 열거가 실질인 경우에서 온다.
## 9. 최종 검증 (fresh)
| 항목 | 결과 |
|---|---|
| records 총수 | 949 (CASE 402 · CONCEPT 386 · REFERENCE 105 · QUESTION 31 · DECISION 25) |
| record hash mismatch | 0 |
| body hash mismatch | 0 |
| diagram svg hash mismatch | 0 |
| raw evidence hash mismatch | 0 |
| line count mismatch | 0 |
| invalid source reference | 0 |
| repeated main-record prose | 0 |
| `unittest discover -s tools/tests` | **Ran 80 tests — OK** |
| `verify_pipeline.py /shared` | PASS (required paths 67 · upstream document metadata matches files) |
| `audit_korean_tech_writing.py` (5개 디렉터리 1,737 파일) | findings 60 — 전부 LONG_TITLE |
| `audit_corpus_structure.py concept` | bodies 386 · records 386 · findings 0 |
| `audit_corpus_structure.py case` | bodies 402 · records 402 · findings 0 |
| `audit_corpus_structure.py reference / openquestion / decision` | records 105 / 31 / 25 · findings 0 |
| `validate_svg.py assets/svg --kind diagram` | PASS (181개) |
| `validate_svg.py assets/terminal --kind terminal` | PASS (788개) |
| `validate_source_references.py` (신규) | findings 0 |
| crossScope metadata | 실제 파일과 일치 |
남은 editorial warning 은 CASE LONG_TITLE 60건뿐이며, §8 의 판정대로 유지한다.
@@ -0,0 +1,157 @@
# SVG semantic review — 2026-09-01
기계 검증(`validate_svg.py`)은 통과하지만 그림이 기록의 주장과 반대를 말하는지 확인한 회차다.
`validate_svg.py` 는 라벨 길이 · 문장형 · 상자 수 · 뷰포트만 본다. **화살표가 무엇을 뜻하는지는
보지 않는다.** 그래서 이번 검토는 사람이 했다.
## 검토 모집단
`<desc>` 가 부재(없 · 않 · 미배선 · 도달하지 · 연결되지 · 0건 · 존재하지 · 못하 · 빠진 · 비어 ·
아니 · 끊)를 말하면서 정상 방향 화살표도 가진 설명용 SVG **33개**. 181개 중 그 조건에 걸리는
전부이며, 나머지 148개는 부재를 말하지 않으므로 이 회차의 대상이 아니다.
33개를 일괄 치환하지 않았다. 각각에 대해 다음 다섯을 대조했다.
- 기록의 주장(`## 요약` · 본문)
- owning SSOT 의 해당 finding
- SVG 의 `<desc>`
- 보이는 라벨
- 화살표의 방향과 도착 지점
## 판정 기준
정상 방향 화살표는 **실재하는 호출 · 의존 · 전이 · 데이터 흐름 · 도달성**을 뜻한다. 그래서
도착 상자가 빗금이라는 사실만으로 화살표의 뜻이 뒤집히지 않는다. 그림이 결함인 경우는
**화살표가 가리키는 관계 자체가 없을 때**다.
반대로, 들어오는 전이는 실재하고 기록이 말하는 부재가 **나가는** 전이인 경우가 있다. 상태
기계의 종착 상태가 그 형태다. 그 경우 빗금 상자로 들어가는 화살표는 정확하다.
## 결과
| 판정 | 수 |
|---|---:|
| 재설계 | 3 |
| 유지 — 들어오는 전이는 실재하고 부재는 나가는 쪽 | 3 |
| 유지 — 빗금 상자에 화살표가 닿지 않음 | 2 |
| 유지 — 빗금 상자가 없고 화살표가 전부 실재 흐름 | 25 |
## 재설계 3건
### `adapter-inbound-graphql-c04.svg`
기록의 주장은 "요청 계층만 배선되고, 네 파생 계층은 메서드로 존재하되 프로덕션 호출자가 0" 이다.
기존 그림은 배선된 요청 데드라인에서 네 파생 계층으로 정상 화살표 넷을 그리고 그 위에
`파생 없음` 이라는 글자를 얹었다. 화살표가 글자를 이긴다.
고친 형태는 화살표를 **하나도 그리지 않는다.** 네 계층은 `파생 없음 — 프로덕션 호출자 0` 이라고
이름 붙인 별도 영역 안에 놓인다.
### `adapter-inbound-graphql-c10.svg`
주장은 "자동설정이 참조하는 두 타입에서 실제로 닿는 패키지는 dataloader 하나이고, fetch ·
pagination · mutation 41개 파일은 어떤 배선 경로에도 없다" 이다. 기존 그림은 네 갈래 전부에
정상 화살표를 그렸다.
고친 형태는 `dataloader` 에만 화살표를 두고, 나머지 셋은 `배선 경로 없음 — 화살표 없음` 영역에
놓는다.
### `adapter-inbound-websocket-c01.svg`
주장은 "설정 접두사 셋 중 `backend.websocket` 만 그것을 읽어 조립하는 `@Configuration` 이 없고,
그 접두사가 규정하는 범위가 main 169 파일 중 약 90개로 가장 넓다" 이다. 기존 그림은 세 갈래에
모두 정상 화살표를 그려, 접두사가 소비자에 닿는다는 뜻과 닿지 않는다는 뜻이 한 그림 안에서
충돌했다.
고친 형태는 소비자가 있는 두 접두사에만 화살표를 두고, `backend.websocket`
`읽는 Configuration 없음` 영역에 화살표 없이 놓는다.
## 유지 3건 — 들어오는 전이는 실재한다
### `adapter-outbound-objectstorage-c05.svg` · `adapter-outbound-fileserver-c03.svg` 계열
`<desc>` 가 "그 네 상태에서 **나오는** 전이는 없다" 라고 적는다. 그림의 화살표 넷은 정상 사슬에서
그 상태로 **들어가는** 전이이고, 그것은 실재한다. 부재는 반대 방향이며 `<desc>` 가 방향을 밝힌다.
정확한 그림이다.
### `adapter-outbound-persistence-jpa-c51.svg`
주장은 "Stable persistence unit 의 스캔 문자열 목록에 experimental package 가 **이미 들어 있다**" 이다.
화살표는 그 목록이 그 항목을 담는다는 실재하는 포함 관계이고, 빗금은 부재가 아니라
바로 아래 라벨이 말하는 `조건 없음` 을 표시한다.
### `messaging-kafka-c02.svg`
주장은 "연속 구간만 커밋되고 빈 자리 뒤는 완료돼 있어도 보류된다" 이다. 화살표는 완료 표시 맵이
두 구간으로 갈리는 실재하는 분할이고, 빗금 상자는 없는 것이 아니라 `보류` 상태다. 라벨이
`빈 자리 뒤 구간 — 보류` 로 그것을 말한다.
## 유지 2건 — 빗금 상자에 화살표가 닿지 않는다
`grpc-policy-f02.svg``messaging-nats-experimental-f01.svg` 는 빗금 상자를 갖지만 어떤 화살표도
그 안으로 들어가지 않는다. 이미 이번 회차가 정한 규칙을 지키는 형태다.
## 유지 25건 — 빗금 상자가 없다
나머지 25개의 `<desc>` 에 나오는 부정 표현은 부재가 아니라 **규칙이나 순서**를 말한다 —
"반대 방향 의존이 없다", "뒤 규칙이 앞 규칙이 금지한 것을 다시 허용할 수 없다",
"검증되지 않은 설정을 쓰는 bean 이 생기지 않는다" 같은 형태다. 화살표는 전부 실재하는 흐름이고
빗금 상자 자체가 없다.
## 규칙을 어디에 고정했나
이 판정 기준을 `designing-tech-log-visuals` 에 명문화했다.
- `SKILL.md` — hard gate 한 줄
- `references/diagram-editorial-rules.md` — "A normal arrow asserts that the relation exists" 절과,
부재를 그리는 세 형태(no edge · separated region · 명시적으로 라벨된 broken/crossed edge)
- `references/review-checklist.md` — 검토 항목 한 줄
- `tools/tests/test_writing_quality_contracts.py` — 위 셋이 실제로 존재하는지 검사하는 테스트
## 전 파일 분류
| SVG | 화살표 | 빗금 | 빗금 진입 | 판정 |
|---|---:|---:|---:|---|
| `adapter-inbound-graphql-c04.svg` | 0 | 4 | 0 | 재설계 |
| `adapter-inbound-graphql-c10.svg` | 1 | 3 | 0 | 재설계 |
| `adapter-inbound-web-c01.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-inbound-websocket-c01.svg` | 2 | 1 | 0 | 재설계 |
| `adapter-outbound-cache-redis-c01.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-cache-redis-c03.svg` | 1 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-cache-redis-c08.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-cache-redis-c12.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-cache-redis-c13.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-fileserver-c03.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-httpclient-c04.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-objectstorage-c05.svg` | 4 | 4 | 4 | 유지 — 들어오는 전이는 실재 |
| `adapter-outbound-persistence-jpa-c02.svg` | 1 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-persistence-jpa-c24.svg` | 1 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-persistence-jpa-c26.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-persistence-jpa-c51.svg` | 2 | 1 | 1 | 유지 — 들어오는 전이는 실재 |
| `adapter-outbound-persistence-mongo-c03.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `adapter-outbound-support-c02.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `application-core-c01.svg` | 1 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `application-core-c04.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `commit-evidence-phases.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `grpc-advanced-resilience-c01.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `grpc-policy-f02.svg` | 3 | 1 | 0 | 유지 — 빗금 상자에 화살표 없음 |
| `messaging-claim-check-c01.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `messaging-inbox-jdbc-postgresql-c06.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `messaging-kafka-c02.svg` | 2 | 1 | 1 | 유지 — 들어오는 전이는 실재 |
| `messaging-nats-experimental-f01.svg` | 3 | 1 | 0 | 유지 — 빗금 상자에 화살표 없음 |
| `messaging-policy-c03.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `messaging-pulsar-experimental-c01.svg` | 1 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `messaging-rabbit-c01.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `messaging-security-c03.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `messaging-testkit-c04.svg` | 2 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
| `messaging-transport-spi-c03.svg` | 3 | 0 | 0 | 유지 — 빗금 없음, 화살표가 전부 실재 흐름 |
## 좌표 직렬화
같은 회차에 정리했다. 설명용 SVG 40개의 좌표 340개가 소수점 둘 이상을 갖고 있었다 —
`225.33333333333334` · `440.00000000000006` 같은 형태다. 렌더링은 같지만 생성기가 float repr 을
그대로 속성에 넣었다는 뜻이고, 같은 그림의 두 판을 diff 할 때 읽히지 않는다.
소수점 한 자리로 정규화했다(기하 변화 최대 0.05px). 그리고 규칙을 두 곳에 고정했다 —
생성기가 좌표를 직렬화할 때 한 자리로 자르고, `validate_svg.py``COORDINATE_PRECISION` 으로
설명용 SVG 를 검사한다. 터미널 SVG 는 대상이 아니다(원래 정수 좌표만 쓴다).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,65 @@
# Tech Log 초안 리뷰 체크리스트 — cycle 2 재실행
실행일 : 2026-09-01 (cycle 2 downstream 재생성 후)
대상 : clean-architecture-backend-template · 레코드 333건 (CONCEPT 21 · CASE 217 · REFERENCE 53 · QUESTION 17 · DECISION 25)
근거 root tree : root-tree.md (노드 334 · 생성 차단 1)
소스 리비전 : 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
## 이번 실행이 이전 실행과 다른 점
첫 실행(같은 날, 레코드 326건)에서 유일하게 열려 있던 항목은 upstream 상태였다 —
document-detail 이 cycle 2 재분석 중이라 analysisStatus 가 IN_PROGRESS 였고 downstream 셋이 NOT_STARTED 였다.
그 항목이 닫혔다.
## Provenance
- [x] **upstream 전부 COMPLETE** — analysisStatus COMPLETE · 스코프 61 COMPLETE + 1 EXCLUDED ·
crossScopeAnalysis / finalDocument / rootTree 전부 COMPLETE · reanalysis.completedAt 기록됨
- [x] 23개 리프가 canonical SSOT 를 갖고 FULL_READ 로 전환됐다. STRUCTURAL_ONLY 잔여 0.
- [x] 레코드 333건 전부 root tree 노드에 slug 로 대응. 미대응 0.
- [x] readiness READY/OPEN 만 생성. NEEDS_DECISION 노드 1건은 decision-evidence 부재로 차단 유지.
- [x] 레코드 sourceRevision 333건 전부 root tree 리비전과 일치.
- [x] root tree 노드 334건 전부 source 앵커 보유. 참조 파일 290건 중 결측 0.
- [x] 라인 앵커 전부 헤딩 착지. 어긋남 0.
## Record kind
- [x] kind ↔ 노드 kind 일치 333/333 · 디렉터리 배치 일치
- [x] bodyMarkdown 은 CASE·CONCEPT 만 사용
- [x] DECISION 25건 전부 근거 기록 보유 · OPEN QUESTION 17건 전부 미지수와 다음 검증 보유
## Facts / Body / Relations
- [x] 비본문 필드의 마크다운 문법 유입 0 · 제목 중복 0
- [x] body-syntax 화이트리스트 위반 0
- [x] 관계 설명 누락 0 · root tree 끊긴 relations 0
## downstream 재생성 요약
| 산출물 | 변경 |
|---|---|
| `analysis/99-cross-scope.md` | 306 → 428줄. §1.2(전수 통독) 신설 · §3.2·§3.5 확장 · §3.7(전송 계열 가정) 신설 · §4 6~9항 · §6 |
| `final/document.md` | 1,680 → 1,765줄. §14.2 신설 · §5.5 확장 · §8.2 9~13항 · 헤더 표 |
| `root-tree.md` | 4,097 → 4,174줄. 노드 327 → 334 |
| `candidate-ledger.json` | 340/291 → 347/298 |
| 레코드 | 326 → 333 |
## 새로 낸 노드 7건
| 노드 | Topic |
|---|---|
| `case:assigned-id-turns-claim-into-upsert` | state-ownership-and-concurrency |
| `case:complete-drain-rolls-back-a-rotation` | 〃 |
| `case:startup-validator-is-the-only-reader-of-four-keys` | runtime-reachability-and-composition |
| `case:validator-declared-and-never-injected` | 〃 |
| `reference:a-validator-is-enforced-by-injection` | 〃 |
| `case:capability-constant-outlives-its-condition` | transport-and-provider-semantics |
| `case:ipv4-only-mask-passes-every-other-form` | security-policy-enforcement |
통독의 새 finding 52건 중 노드로 낸 것은 7건이다. 나머지는 리프 SSOT §17 에 남는다 —
기존 REFERENCE 가 이미 그 규칙을 갖고 있거나(예: `atomic-type-is-not-atomicity`),
같은 사건의 다른 인스턴스여서 노드를 늘릴 이유가 없는 경우다.
## 미결
없다. 다음 단계는 `humanizing-korean-tech-writing` 편집 패스이며, 체크리스트가 별도 패스로 규정한 것이다.
@@ -0,0 +1,56 @@
# Tech Log 초안 리뷰 체크리스트 실행 결과
실행일 : 2026-09-01
대상 : clean-architecture-backend-template · 레코드 326건 (CONCEPT 21 · CASE 211 · REFERENCE 52 · QUESTION 17 · DECISION 25)
근거 root tree : /shared/document-detail/clean-architecture-backend-template/root-tree.md (노드 327 · 생성 차단 1)
소스 리비전 : 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
## Provenance
- [x] 레코드 326건 전부 root tree 노드에 slug 로 대응한다. 미대응 0.
- [x] readiness 가 READY 또는 OPEN 인 노드만 생성됐다. NEEDS_DECISION 노드 1건(`widen-doc-contract-assertions`)은 decision-evidence 부재로 차단 유지.
- [x] 레코드 sourceRevision 326건 전부 root tree 리비전과 일치.
- [x] root tree 노드 327건 전부 source 앵커를 갖는다. 참조 파일 283건 중 결측 0 (codebase 경로 1건 포함 확인).
- [x] 라인 앵커 155건 전부 헤딩에 정확히 착지. 어긋남 0.
- [ ] **upstream 상태가 COMPLETE 가 아니다.** document-detail state.json 이 cycle 2 재분석 중이다 — analysisStatus IN_PROGRESS, 스코프 COMPLETE 38 / IN_PROGRESS 23 / EXCLUDED 1, crossScopeAnalysis·finalDocument·rootTree 는 NOT_STARTED(stale). 상세는 아래 "미결 항목".
## Record kind
- [x] 레코드 kind 가 root tree 노드 kind 와 326건 전부 일치.
- [x] 디렉터리 배치가 kind 와 일치.
- [x] bodyMarkdown 은 CASE·CONCEPT 만 사용. 다른 kind 의 body 참조 0.
- [x] DECISION 25건 전부 근거 기록 1건 이상 보유.
- [x] OPEN QUESTION 17건 전부 미지수와 다음 검증을 갖는다.
## Facts
- [x] 비본문 필드에 백틱·파이프 등 마크다운 문법 유입 0 (평문 규약 준수).
- [x] 제목 중복 0.
- [x] 각 레코드가 검증 환경·재현 조건·확인하지 못한 것을 분리해 적는다. 실행하지 않은 것은 실행하지 않았다고 적었다.
## Concept / Case body
- [x] body-syntax 화이트리스트 위반 0 — raw HTML·각주·체크박스·중첩 리스트·중첩 인용·취소선·링크 타이틀 모두 검출 0.
- [x] body 파일 참조 결측 0.
## Relations
- [x] 관계 항목 전부가 제목 나열이 아니라 설명 줄을 동반한다. 설명 없는 관계 0.
- [x] root tree 끊긴 relations 0.
## 이번 실행에서 고친 것
1. `a19-f022-messagingpublicsurfacecontracttest` — 레저 앵커가 §9.3(문서 계약 테스트 커버리지)에 착지해 §9.3 내용으로 초안이 작성됐다. 그 절은 이미 `doc-contract-test-boundary-predicted-the-drift` 가 담당한다. 노드 제목이 가리키는 §9.5(공개 표면 계약 테스트 위치)로 레코드를 다시 썼다.
2. root tree 라인 앵커 4건 정정 — `analysis-finding-a19-f018` L911→L926, `analysis-finding-a19-f019` L960→L975, `a19-f020-messaging-admin-api` L982→L997, `a19-f022-...` L1099→L1114.
## 미결 항목 (편집 패스 전에 판단 필요)
document-detail 파이프라인이 cycle 2 재분석 중이다.
- 사유(state.json): 23개 리프의 production 구현이 cycle 1 에서 STRUCTURAL_ONLY 였고, full-read 게이트를 통과할 때까지 downstream 합성을 무효로 둔다.
- 대상: messaging 5개(kafka·rabbit·pulsar-exp·nats-exp·spring-boot-starter), grpc 18개 전부.
- 리비전은 그대로다 — baselineRevision == targetRevision, changedPaths 없음. 즉 코드가 바뀐 것이 아니라 읽기 깊이를 올리는 재분석이다.
- 영향 범위: 이 23개 리프에서 나온 레코드는 가족 통합 문서 `analysis/19-messaging-platform.md`·`analysis/20-grpc-platform.md`(역할 INTEGRATION_ONLY)를 근거로 한다. 리프별 canonical SSOT(`analysis/messaging/*.md`·`analysis/grpc/*.md`)는 파일로 존재하지만 상태가 IN_PROGRESS 다.
- 그 밖의 레코드(38개 COMPLETE 스코프 근거)는 이 항목의 영향을 받지 않는다.
체크리스트의 "owning registered leaf 가 자기 canonical module SSOT 를 갖고, 초안이 가족/최종 요약에만 근거하지 않는다" 항목은 이 23개 리프에 대해서는 재분석 완료 후 재확인이 필요하다.
@@ -0,0 +1,63 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c04
title: 다섯 예산 계층 중 요청 계층만 배선돼 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c04
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c04.svg
- key: adapter-inbound-graphql-c04-diagram
file: ../../../final/assets/diagrams/adapter-inbound-graphql-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c04.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L354 이다.
module: adapter-inbound-graphql
---
# 다섯 예산 계층 중 요청 계층만 배선돼 있다
설계 §10이 다섯 계층을 정의하고 `GraphQlDeadlinePropagator`가 그 파생을 담는다. 실제로 배선된 것은 요청 계층 하나이고, 나머지 파생 메서드는 프로덕션 호출자가 없다.
## 본문
<!-- body:start -->
설계 §10이 다섯 계층을 정의하고 `GraphQlDeadlinePropagator`가 그 파생을 담는다. 실제 강제 상태는 이렇다.
| 계층 | 파생 지점 | 배선 |
|---|---|---|
| 전송 핸드셰이크 | — | (이 sub-scope 밖) |
| **요청** | `GraphQlPlatformWebInterceptor:135``GraphQlDeadline.after(policy.maxExecutionTime(), clock)` | **예** |
| 리졸버 | `GraphQlDeadlinePropagator.resolverBudget(...)` | 아니오 |
| DataLoader 배치 | `GraphQlDeadlinePropagator.dataLoaderBatchTimeout(...)` | 아니오 |
| 다운스트림(DB/HTTP) | `GraphQlDeadlinePropagator.downstreamDeadline(...)` | 아니오 |
| 구독 연결 | `GraphQlDeadlinePropagator.subscriptionDeadline(...)` | 아니오 |
## 요청 예산에서 파생되는 계층
:::evidence key="adapter-inbound-graphql-c04-diagram" alt="배선된 요청 데드라인 상자에서 나가는 화살표가 없고, 네 파생 계층이 파생 없음 이라고 이름 붙은 별도 영역 안에 빗금으로 놓인 구조" caption="요청 예산에서 파생되는 계층" zoom="false"
:::
## 참조가 갇혀 있는 범위
`GraphQlTimeoutPolicy``GraphQlResolverBudget`의 main 참조자를 전수하면 전부 `execution` 패키지 안(그리고 미배선 클러스터 안)이다.
```text
GraphQlTimeoutPolicy <- GraphQlRequestCancelledException, GraphQlDeadlinePropagator, GraphQlResolverBudget
GraphQlResolverBudget <- GraphQlResolverDescriptor, GraphQlResolverCatalog, GraphQlDeadlinePropagator, GraphQlExecutionProfileValidator
```
§12.1.
## GraphQlDeadlinePropagator 참조 위치
:::evidence key="adapter-inbound-graphql-c04" alt="코드베이스에서 GraphQlDeadlinePropagator 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlDeadlinePropagator 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c05
title: 요청 데드라인이 실제로 실행을 끊는 경로가 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c05
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c05.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L376 이다.
module: adapter-inbound-graphql
---
# 요청 데드라인이 실제로 실행을 끊는 경로가 있다
`GraphQlCancellation`(93)이 세 곳에서 쓰인다. 요청 계층의 데드라인은 만들어지기만 하는 것이 아니라 실행을 실제로 끊는다.
## 본문
<!-- body:start -->
`GraphQlCancellation`(93)은 `cost/GraphQlRuntimeBudgetTracker` · `advanced/incremental` · `advanced/subscription` 세 곳에서 쓰인다.
## GraphQlCancellation 참조 위치
:::evidence key="adapter-inbound-graphql-c05" alt="코드베이스에서 GraphQlCancellation 를 검색한 출력 30줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlCancellation 코드베이스 검색 — 30줄 · exit 0" zoom="true"
:::
## 요청 계층이 완결돼 있다는 뜻
요청 데드라인이 실제로 실행을 끊는 경로가 존재한다는 뜻이고, `GraphQlRequestContext.withDeadline`의 단조 조이기와 함께 요청 계층은 완결돼 있다.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c05
title: 예산 게이트 프로퍼티가 자바 한 줄에만 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c05
file: ../../../final/evidence/rendered/adapter-inbound-web-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c05.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L666 이다.
module: adapter-inbound-web
---
# 예산 게이트 프로퍼티가 자바 한 줄에만 있다
`backend.web.budgets`를 저장소 전체에서 찾으면 자바 한 줄뿐이다. 어떤 `application.yml`에도 없고 `matchIfMissing`도 없으므로 이 핸들러는 기본 꺼짐이다.
## 본문
<!-- body:start -->
`backend.web.budgets`를 저장소 전체에서 찾으면 자바 한 줄뿐이다.
```text
main/.../mvc/budget/WebMvcBudgetExceptionHandler.java:40:@ConditionalOnProperty(prefix = "backend.web.budgets", name = "enabled", havingValue = "true")
```
어떤 `application.yml`에도 `backend.web.budgets`가 없고 `matchIfMissing`도 없으므로 이 핸들러는 **기본 꺼짐**이다.
## BudgetProblemMapper 참조 위치
:::evidence key="adapter-inbound-web-c05" alt="코드베이스에서 BudgetProblemMapper 를 검색한 출력 18줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="BudgetProblemMapper 코드베이스 검색 — 18줄 · exit 0" zoom="true"
:::
## 켜더라도 필요한 빈이 없다
그 생성자가 요구하는 `BudgetProblemMapper` 빈을 선언하는 코드가 main·app-bootstrap 어디에도 없다 — 참조자는 두 필터와 이 핸들러 자신뿐이고, 셋 다 빈 정의가 아니다.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c14
title: forwarded 헤더를 해석하는 쪽은 피어를 검사하지 않는다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c14
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c14
file: ../../../final/evidence/rendered/adapter-inbound-web-c14.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c14.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L1098 이다.
module: adapter-inbound-web
---
# forwarded 헤더를 해석하는 쪽은 피어를 검사하지 않는다
신뢰 프록시 판정을 담은 `proxy` 패키지 421 LOC이 프로덕션 조립에 들어가지 않는다. 실제로 forwarded 헤더를 해석하는 것은 Spring Boot가 등록하는 필터이고, 그것은 피어가 신뢰된 프록시인지 검사하지 않는다.
## 본문
<!-- body:start -->
신뢰 프록시 판정을 담은 타입들의 참조를 세면 프로덕션 경로가 없다.
```text
TrustedProxyPolicy 6 test 1 testkit
NormalizedForwardedHeaders 4 main 8 test <- main 참조자는 proxy 패키지 내부
ForwardedHeaderSanitizer 2 test 1 testkit
```
`proxy` 패키지 421 LOC이 프로덕션 조립에 들어가지 않는다.
## 실제로 헤더를 해석하는 쪽
실제로 forwarded 헤더를 해석하는 것은 Spring Boot의 `server.forward-headers-strategy=framework`(app-bootstrap `application.yml:321` 기본값)가 등록하는 `ForwardedHeaderFilter`/`ForwardedHeaderTransformer`이고, 그것은 **피어가 신뢰된 프록시인지 검사하지 않는다**. §32.2.
## 분석 원문의 참조 집계
:::evidence key="adapter-inbound-web-c14" alt="분석 문서 analysis/14-adapter-inbound-web.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/14-adapter-inbound-web.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c18
title: fileserver 매핑 검증은 회로가 닫혀 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c18
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c18
file: ../../../final/evidence/rendered/adapter-inbound-web-c18.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c18.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L1336 이다.
module: adapter-inbound-web
---
# fileserver 매핑 검증은 회로가 닫혀 있다
`attestMapping`의 선언·구현·호출이 모두 존재하고, 그 구현을 만드는 자동설정도 있다. 이 leaf의 다른 sub-scope와 달리 회로가 닫혀 있다.
## 본문
<!-- body:start -->
`attestMapping`의 선언·구현·호출이 모두 존재한다.
```text
main/.../nginx/DefaultNginxInternalUriMapper.java:41 (구현)
main/.../nginx/NginxInternalUriMapper.java:32 (선언)
BOOT:autoconfigure/fileserver/FileserverStartupConfiguration.java:87 uriMapper.attestMapping()
```
## 빈을 만드는 자동설정
`FileserverPlatformAutoConfiguration``DefaultNginxInternalUriMapper`(`:215-216`) · `NginxDownloadStrategy`(`:221-223`) · `FileserverRequestContextFactory`(`:159-161`)를 만든다. 회로 닫힘.
## FileserverPlatformAutoConfiguration 참조 위치
:::evidence key="adapter-inbound-web-c18" alt="코드베이스에서 FileserverPlatformAutoConfiguration 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FileserverPlatformAutoConfiguration 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,45 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c04
title: 대칭 검사기 자신을 검사하는 메타 테스트가 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c04
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c04.svg
- key: adapter-outbound-cache-redis-c04-diagram
file: ../../../final/assets/diagrams/adapter-outbound-cache-redis-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c04.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L245 이다.
module: adapter-outbound-cache-redis
---
# 대칭 검사기 자신을 검사하는 메타 테스트가 있다
`ReactiveRedisOperations`는 "Mirrors `RedisOperations` method for method"라고 주장하고 `ApiParityTest`가 그것을 반사로 강제한다. 그 위에 검사기가 고장 나 항상 통과하는 상태를 잡는 메타 테스트가 하나 더 있다.
## 본문
<!-- body:start -->
`ReactiveRedisOperations`는 "Mirrors `RedisOperations` method for method"라고 주장한다. `ApiParityTest`가 그것을 반사로 강제한다 — `PAIRS` 맵에 14쌍의 sync/reactive 인터페이스를 놓고 `everySyncOperationHasReactiveCounterpart`, `everyTypedSurfaceIsInParity`, `theTwoEntryPointsExposeTheSameStructureAccessors`, `everyReactiveMethodReturnsAPublisher`를 돌린다. 두 facade의 접근자 12개는 실제로 동일하다(diff 공백).
## 검사기에 대한 메타 검사
:::evidence key="adapter-outbound-cache-redis-c04-diagram" alt="두 진입점 인터페이스와 대칭 검사와 메타 테스트가 위에서 아래로 쌓이고 검사 방향 화살표가 아래로 그려진 구조" caption="검사기에 대한 메타 검사" zoom="false"
:::
두 가지가 특히 좋다. 첫째, **예외가 이유와 함께 목록에서 빠져 있다** — Pub/Sub은 sync가 핸들러+closeable subscription이고 reactive는 publisher 자신이 전달하며 취소로 구독을 끊으므로 "different shapes on purpose, so mechanical parity would be the wrong check for them". 둘째, `theInspectorDetectsADivergentReturnShape`라는 **검사기에 대한 메타 test**가 있다 — 대칭 검사기가 고장 나 항상 통과하는 상태를 잡는다.
## ReactiveRedisOperations 참조 위치
:::evidence key="adapter-outbound-cache-redis-c04" alt="코드베이스에서 ReactiveRedisOperations 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ReactiveRedisOperations 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c05
title: 렌더된 키 문자열을 받는 API가 없다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c05
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c05.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L324 이다.
module: adapter-outbound-cache-redis
---
# 렌더된 키 문자열을 받는 API가 없다
`QualifiedRedisKey`의 javadoc이 이 계층의 규칙이다 — 이미 렌더된 키 문자열을 받는 API가 없으므로 네임스페이스·슬롯·크기 규칙을 우회할 수 없다. 구조가 그것을 강제한다.
## 본문
<!-- body:start -->
`QualifiedRedisKey`의 javadoc이 이 계층의 규칙이다 — "This is the only key shape the SDK accepts. **There is no API that takes an already rendered key string**, so namespace, slot, and size rules cannot be bypassed."
## 타입이 강제하는 형태
`RedisTypedKey`는 9종만 허용하는 sealed interface고(`ValueKey`·`HashKey`·`ListKey`·`SetKey`·`SortedSetKey`·`BitmapKey`·`HyperLogLogKey`·`GeoKey`·`StreamKey`), 전부 `QualifiedRedisKey` + 코덱으로 구성된다. `QualifiedRedisKey``RedisNamespace`(토큰 3개) + `RedisKeyName`(entity 토큰 + identifier) + 선택적 `RedisSlotTag`다. 그리고 `RedisKeyRenderer`가 **중괄호를 쓰는 유일한 장소**라서 Cluster 해시 태그가 "the tag and nothing else"를 덮는다.
## 분석 원문의 규칙 서술
:::evidence key="adapter-outbound-cache-redis-c05" alt="분석 문서 analysis/10-adapter-outbound-cache-redis.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/10-adapter-outbound-cache-redis.md 발췌 — 15줄" zoom="true"
:::
## 이 검사가 PII 방지의 완결이 아니라고 적는다
`RedisKeyRules`의 자기 한정도 정직하다 — 규칙은 "mechanical"이며 "Values that are indistinguishable from an ordinary surrogate identifier, such as a bare digit string, cannot be rejected here; those must be fingerprinted by the caller before they become a key part."
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c12
title: 탈출구가 두 겹의 사전 승인으로 닫혀 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c12
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c12
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c12.svg
- key: adapter-outbound-cache-redis-c12-diagram
file: ../../../final/assets/diagrams/adapter-outbound-cache-redis-c12.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c12.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L684 이다.
module: adapter-outbound-cache-redis
---
# 탈출구가 두 겹의 사전 승인으로 닫혀 있다
`RedisRawGateway`에는 `execute(String, byte[]...)`가 없다. 원시 명령은 정책 카탈로그의 분류와 배포의 승인 등록 둘 다를 통과해야 하고, 어느 쪽도 요청 시점에 결정되지 않는다.
## 본문
<!-- body:start -->
`RedisRawGateway`의 javadoc이 존재 이유와 한계를 함께 적는다 — "There is no `execute(String, byte[]...)` here or anywhere else in the SDK. The escape hatch exists because **some commands genuinely have no typed form worth building**, not because arbitrary command execution is acceptable; every one of them is named, bounded, and audited before it can be sent."
## 원시 명령이 지나야 하는 두 문
:::evidence key="adapter-outbound-cache-redis-c12-diagram" alt="정책 카탈로그 분류에서 배포 승인 등록으로, 다시 원시 게이트웨이로 이어지는 왼쪽에서 오른쪽 흐름" caption="원시 명령이 지나야 하는 두 문" zoom="false"
:::
승인이 **두 개의 독립된 문**을 모두 통과해야 한다(`RawCommandApprovals`).
1. 명령이 정책 카탈로그에서 `RAW_ONLY`로 분류돼 있어야 한다 — "the organization's decision about which commands may ever leave through this door"
1. 배포가 그 명령에 대한 승인(`ApprovedRawCommand`)을 등록해야 한다
"Neither alone is enough, and neither is decided at request time." 그리고 R3/R4는 어느 쪽이든 거부된다.
## RedisRawGateway 참조 위치
:::evidence key="adapter-outbound-cache-redis-c12" alt="코드베이스에서 RedisRawGateway 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RedisRawGateway 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 승인이 배포 산출물인 이유
`ApprovedRawCommand`**배포 산출물**이다 — 명령 identity, 최대 인자 수, 요청/응답 바이트 상한, 타임아웃, 응답 디코더를 프로세스 시작 전에 고정한다. 토큰은 `RawCommandApprovals`만 발급하고, 검증은 (a) 토큰 타입이 내부 record인지, (b) **발급 레지스트리 인스턴스가 같은지**(`issued.origin != this`), (c) 정책 id가 일치하는지, (d) 제시된 승인이 등록된 것과 같은지 넷을 본다.
## 키 위치를 모르면 기본이 거부다
**`RawMovableKeys`가 이 패키지에서 가장 흥미롭다.** movable key spec(예: `SORT`)은 키 위치를 인자 목록이 결정하므로 정적으로 알 수 없고, 그러면 네임스페이스 검사를 할 수 없다. 기본은 여전히 거부다. 예외로 `SORT`/`SORT_RO` 파서 하나가 등록돼 있는데, 그 설계가 명시적이다 — "a parser that knows **exactly one command shape** and refuses everything else."
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-outbound-fileserver-c06
title: 거부 메시지가 역할 모델을 설명하지 않는다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-fileserver-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-fileserver-c06
file: ../../../final/evidence/rendered/adapter-outbound-fileserver-c06.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-fileserver-c06.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L507 이다.
module: adapter-outbound-fileserver
---
# 거부 메시지가 역할 모델을 설명하지 않는다
`RoleBasedFileAccessPolicy`는 열 개 연산을 READ/WRITE/ADMIN 세 계층으로 접고, 거부 메시지는 필요한 역할도 주체의 역할도 말하지 않는다.
## 본문
<!-- body:start -->
`RoleBasedFileAccessPolicy`는 열 개 연산을 READ/WRITE/ADMIN 세 계층으로 접는다. 근거가 적혀 있다 — 연산별 역할 맵은 `COPY`를 주고 `CREATE`를 안 주는 조합을 허용하는데 "a copy creates a file"이므로 제한처럼 보이고 제한이 아니다.
## admin이 write를 상속하지 않는다
삭제할 수 있다는 이유로 force-delete까지 되면 감사되는 관리 평면이 일반 데이터 평면으로 도달 가능해진다. 빈 admin 역할 집합은 생성자가 거부한다("would leave the management plane unreachable rather than protected").
## RoleBasedFileAccessPolicy 참조 위치
:::evidence key="adapter-outbound-fileserver-c06" alt="코드베이스에서 RoleBasedFileAccessPolicy 를 검색한 출력 12줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RoleBasedFileAccessPolicy 코드베이스 검색 — 12줄 · exit 0" zoom="true"
:::
## 거부 메시지가 담지 않는 것
거부 메시지는 필요한 역할도 주체의 역할도 말하지 않는다 — "a denial that reported what was missing would turn every 403 into a readable description of the role model". `LocalStorageFailures`의 어떤 메시지도 경로·마운트·루트를 담지 않고, 감사 어댑터가 쓰는 필드는 전부 지문·코드·불투명 식별자다.
## 이름 자체가 장치인 클래스
`UnenforcedFileAccessPolicy`의 설계도 기록할 만하다. 이름 자체가 장치다 — composition root가 **타입 이름으로 매치해** production startup을 거부한다. "A permissive default that looked like a real policy would ship as one."
<!-- body:end -->
@@ -0,0 +1,56 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c01
title: 닿지 않는 설정을 무시하지 않고 거부한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c01
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c01.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L90 이다.
module: adapter-outbound-httpclient
---
# 닿지 않는 설정을 무시하지 않고 거부한다
`ClientProfileValidator`는 바인딩은 되지만 어떤 전송에도 닿지 않는 설정을 무시하지 않고 거부한다. 앞선 열 개 모듈에서 반복해 발견한 "선언되었으나 아무것도 하지 않는 설정" 패턴을 이 모듈은 명시적 거부로 처리한다.
## 본문
<!-- body:start -->
이 저장소에서 본 가장 조밀한 설정 검증기다. `validate(profile, environment)`가 11개 검사 그룹을 돌리고 결과를 정렬해 "a configuration error reports deterministically across runs and machines"를 보장한다.
## 소비자가 없던 설정을 거부로 바꾼 이유
특히 이 leaf에서만 보이는 태도가 하나 있다 — **바인딩은 되지만 어떤 전송에도 닿지 않는 설정을 무시하지 않고 거부한다.**
> "Three of them had no consumer anywhere: `timeout.dns`, `proxy.credential-provider` and `proxy.import-ambient-no-proxy`. An operator who set a DNS timeout believed resolution was bounded and it was not; one who named a proxy credential provider believed the proxy was authenticated and it was not… **the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing.**"
기본값은 통과시키므로 "only a deliberate, unmet request fails"다. 같은 논리가 관측 설정에도 적용된다 — `full-url-recording`은 아무도 읽지 않았고 `body-logging`은 actuator 보고에만 닿았다. "Leaving them that way is the worse of the two failure modes — an operator who set them believed the platform was recording full URLs or bodies, and an operator who left them false had no assurance that it was not." 지금은 production에서 둘 다 거부된다.
## ClientProfileValidator 참조 위치
:::evidence key="adapter-outbound-httpclient-c01" alt="코드베이스에서 ClientProfileValidator 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClientProfileValidator 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 나머지 검사가 막는 조용한 다운그레이드
- **`REACTIVE_REDIRECT_UNSUPPORTED`** — 엔진 리다이렉트는 모든 전송에서 꺼져 있고 hop별 재검증을 하는 coordinator는 블로킹 스택에만 있다. 리액티브 프로파일이 redirect를 켜면 "the caller received the 302 as an ordinary response and read its empty body as the answer." 거부가 정직한 결과다 — "a configured guarantee that silently does nothing is worse than one the platform declines to offer."
- **`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`** — `ProtocolIntent`가 "H2를 선호"와 "H2를 요구"를 구분한다. JDK 클라이언트는 `HTTP_2`를 선호로 다뤄 조용히 HTTP/1.1로 협상하고 Apache classic은 HTTP/1.1 전용이라, `HTTP_2`만 선언한 프로파일이 "ran happily over HTTP/1.1, and nothing anywhere said so."
- **`TLS_PROTOCOL_SET_REQUIRED`** — 빈 집합이 통과하면 JVM 기본값이 선택되어, "a profile that meant to pin a TLS floor got whatever the platform default happened to be."
- **`DYNAMIC_TARGET_PROXY_UNSUPPORTED`** — 포워드 프록시는 호스트명을 자기 쪽에서 다시 해석하므로 "The SSRF defence would be present, correct, and bypassed."
- **`RETRY_POLICY_CONTRADICTS_ATTEMPTS`** — `policy`를 실행 경로에서 아무도 읽지 않아 "the actuator could report `retryPolicy: none` for a profile that was retrying three times."
## 이 검증기는 실제로 조립돼 있다
`app-bootstrap``HttpClientStartupValidator:37`이 이 검증기를 생성한다(`168-...` §8.1). 이 leaf는 앞선 cache-redis와 달리 **실제로 조립돼 있다** — app-bootstrap에 이 leaf를 위한 auto-configuration 12개가 있다.
<!-- body:end -->
@@ -0,0 +1,56 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c05
title: 열린 회로가 토큰과 permit을 쓰기 전에 거절한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c05
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c05.svg
- key: adapter-outbound-httpclient-c05-diagram
file: ../../../final/assets/diagrams/adapter-outbound-httpclient-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c05.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L323 이다.
module: adapter-outbound-httpclient
---
# 열린 회로가 토큰과 permit을 쓰기 전에 거절한다
`AttemptResiliencePipeline`이 물리 시도마다 Circuit Breaker → Rate Limiter → Bulkhead → HTTP 호출을 고정 순서로 적용하고 역순으로 해제한다. 순서는 장식이 아니다.
## 본문
<!-- body:start -->
`AttemptResiliencePipeline`이 물리 시도마다 **Circuit Breaker → Rate Limiter → Bulkhead → HTTP 호출**을 고정 순서로 적용하고 역순으로 해제한다.
## 시도마다 지나는 가드 순서
:::evidence key="adapter-outbound-httpclient-c05-diagram" alt="회로 차단기와 요금 제한기와 벌크헤드와 HTTP 호출이 왼쪽에서 오른쪽으로 이어지고 화살표에 허가와 토큰과 permit 이 붙은 구조" caption="시도마다 지나는 가드 순서" zoom="false"
:::
> "The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead permit is spent, otherwise **a dead upstream keeps consuming the quota and concurrency that healthy upstreams need.**"
> "A local rejection (rate limiter or bulkhead) is deliberately *not* recorded as a circuit error: the upstream never saw the request, and **counting our own back-pressure as upstream failure would open the breaker on a healthy dependency.**"
## 브레이커가 503을 보지 못하던 이력
이전에는 원시 전송만 파이프라인 안에서 돌고 응답→예외 매핑이 밖에서 일어나서 "a 503 completed the call normally, the breaker recorded a success, and **an upstream that answered nothing but 503 never opened its circuit. The thing the breaker is for was the one thing it could not see.**" 지금은 `remoteFailure` 분류기가 반환값을 보고 브레이커에 알린다.
## AttemptResiliencePipeline 참조 위치
:::evidence key="adapter-outbound-httpclient-c05" alt="코드베이스에서 AttemptResiliencePipeline 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AttemptResiliencePipeline 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## 규칙을 덮는 테스트와 프로토콜 증거
test 47개가 이 규칙들을 촘촘히 덮는다 — `appliesCircuitThenRateLimiterThenBulkheadPerAttempt`, `openCircuitDoesNotConsumeRateOrBulkheadPermit`, `bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError`, `answeredStatusesDoNotRetryANonIdempotentOperation`, `deniesOneShotBodyEvenForPut`, `honorsRetryAfterOnlyInsideDeadline`, `protocolProofOfNonProcessingWinsOverEverything`, `streamAfterGoAwayLastIdIsPeerNotProcessed` 등.
`Http2ProtocolEvidence`는 프로토콜 수준 증거를 다룬다 — `REFUSED_STREAM`과 GOAWAY의 last-stream-id보다 큰 스트림 id는 **피어가 처리하지 않았음의 증명**이라 `NOT_SENT`로 승격되고, 그 이하 id의 리셋은 여전히 모호하다(`streamAtOrBelowGoAwayLastIdStaysAmbiguous`, `aBareStreamResetProvesNothing`).
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c08
title: 전송의 선언이 프로파일보다 약하면 startup이 실패한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c08
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c08.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c08.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L573 이다.
module: adapter-outbound-httpclient
---
# 전송의 선언이 프로파일보다 약하면 startup이 실패한다
`TransportCapabilityValidator`가 프로파일이 요구하는 것과 전송이 선언한 것을 대조해 부족분을 이름으로 모아 거부한다. 메시지는 프로파일 설정과 능력 이름만 담고 URL·주소·비밀은 담지 않는다.
## 본문
<!-- body:start -->
`TransportCapabilityValidator`가 프로파일이 요구하는 것과 전송이 선언한 것을 대조해 부족분을 이름으로 모아 거부한다 — 프로토콜, route pool, 유계 pending 큐, proxy, mutual TLS, 동적 대상 안정성. 메시지는 "profile settings and capability names only — never a URL, address, or secret."
## 능력이 데이터로 선언된다
`ReactiveTransportCapabilities.reactorNetty()`는 9개 능력을 전부 `true`로, `jettyHttp3Experimental()`은 route pool·유계 큐·DNS 핀·동적 안정성을 `false`로 선언한다. HTTP/3는 `compileOnly` 의존이라 클래스가 없으면 `Http3CapabilityReport`가 전송을 거부한다 — "the failure mode is a startup error rather than a `NoClassDefFoundError` mid-call"(§0).
## TransportCapabilityValidator 참조 위치
:::evidence key="adapter-outbound-httpclient-c08" alt="코드베이스에서 TransportCapabilityValidator 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TransportCapabilityValidator 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## testkit이 별도 source set인 이유
계약을 담은 클래스 35개(`BlockingTransportContract`·`ReactiveTransportContract`·`RetrySafetyContract`·`ResourceLifecycleContract`·`ObservabilityContract`·`DynamicTargetSecurityContract`)를 test·performance·jmh 세 lane이 공유한다. `NettyLeakDetectionExtension`은 leak detector 레벨을 **믿지 않고 확인한다** — "asserts the level rather than trusting the flag reached the forked JVM"(§0).
## 성능 lane이 재지 않는 것
성능 lane 7개는 자원 상한을 검증한다 — `PoolSaturationPerformanceTest`·`RetryStormBudgetTest`·`RuntimeRotationDrainTest`·`OAuthRefreshContentionTest`·`LargeBodyResourceTest`·`Http2StreamSaturationTest`. §15에서 남긴 질문(`ObjectBody.replayability()`의 반사 비용을 재는 lane이 있는가)의 답은 **없다** — 풀·재시도·회전·토큰 경합·본문 크기·H2 스트림을 재고 본문 재생 가능성 판정 비용은 재지 않는다.
<!-- body:end -->
@@ -0,0 +1,50 @@
---
kind: CONCEPT
slug: adapter-outbound-objectstorage-c02
title: 레거시 경로 셋이 서로 다른 스위치로 서로를 배제한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-objectstorage-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-objectstorage-c02
file: ../../../final/evidence/rendered/adapter-outbound-objectstorage-c02.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-objectstorage-c02.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L92 이다.
module: adapter-outbound-objectstorage
---
# 레거시 경로 셋이 서로 다른 스위치로 서로를 배제한다
폐기 경로가 셋인데 서로 다른 스위치를 쓰고 서로를 배제한다. 겹침은 컴파일러가 거부한다.
## 본문
<!-- body:start -->
폐기 경로가 셋인데 서로 다른 스위치를 쓰고 서로를 배제한다.
| 경로 | 스위치 | 성격 |
|---|---|---|
| 선호 임시 활성화 | `app.object-storage.legacy.enabled=true` + 명시적 backend | `ObjectStoragePort`(whole-`byte[]`) 노출 |
| 구 alias | `ca-skeleton.objectstorage.*` | `LegacyObjectStorageActivationGuard` 조건, canonical과 혼용 시 실패 |
| 채택(adoption) | `app.object-storage.legacy-adoption.enabled=true` | raw locator 유지보수 전용, 별도 config 클래스 |
## 겹침을 거부하는 지점
`ObjectStorageBindingCompiler.rejectLegacyOverlap`가 legacy filesystem 루트와 canonical provider 루트가 **어느 방향으로든 포함 관계**면 거부한다.
## ObjectStorageBindingCompiler 참조 위치
:::evidence key="adapter-outbound-objectstorage-c02" alt="코드베이스에서 ObjectStorageBindingCompiler 를 검색한 출력 22줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ObjectStorageBindingCompiler 코드베이스 검색 — 22줄 · exit 0" zoom="true"
:::
## 채택 모드가 추가로 요구하는 것
`LegacyObjectAdoptionSettings``APPLY` 모드일 때 검토된 manifest 경로와 64자리 SHA-256을 요구하고, batch size 11000, timeout 5분 이내를 강제한다. legacy runtime은 `AutoCloseable` holder로 감싸 S3 client 수명을 정확히 소유하고, `@Bean(destroyMethod = "close")`로 등록된다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: application-core-c08
title: 검증 권한과 삭제 권한을 분리한 staged lifecycle
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c08
file: ../../../final/evidence/rendered/application-core-c08.svg
evidence:
- ../../../final/evidence/raw/application-core-c08.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L190 이다.
module: application-core
---
# 검증 권한과 삭제 권한을 분리한 staged lifecycle
semantic objectstorage API는 provider/filesystem type을 노출하지 않고, lifecycle을 staged → verified → published로 분리하며, scanner 권한과 purge 권한을 나눠 검증 주체가 임의 삭제까지 할 수 없게 한다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
semantic objectstorage API는 provider/filesystem type을 노출하지 않는다. object identity/reference는 prefix + check digit를 포함한 opaque routed representation이고 redacted rendering을 제공한다. tampered/cross-prefix reference를 거부한다.
## content I/O가 무한 루프로 가지 않는 이유
content I/O는 bounded pull/push callback context와 budget/cancellation/chunk contract를 사용하며 callback lifetime 밖에서 context를 재사용할 수 없다. zero-progress가 무한 loop로 이어지지 않도록 bounded 후 실패한다.
## FullContentIdentity 참조 위치
:::evidence key="application-core-c08" alt="코드베이스에서 FullContentIdentity 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FullContentIdentity 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 검증 주체가 삭제까지 하지 못하게 나눈다
lifecycle은 staged -> verified -> published를 분리한다. scanner verdict는 exact stage/version/operation/policy revision에 결합되고 publish/cleanup mutation은 exact-version/fencing을 요구한다. scanner 권한과 purge 권한은 분리돼 검증 주체가 임의 삭제까지 할 수 없게 한다.
## 상한과 신원 요구
transient bearer grant는 URI/header를 redaction하고 TTL은 최대 24시간으로 제한한다. multipart part count는 1..10000이고 completion은 expected content identity를 요구한다. `FullContentIdentity`는 SHA-256 기반으로 ETag를 content identity로 오인하지 않는다.
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: grpc-advanced-bootstrap-c01
title: 능력을 하나씩 등급 매기는 것이 설계다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-bootstrap-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-bootstrap-c01
file: ../../../final/evidence/rendered/grpc-advanced-bootstrap-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-bootstrap-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-bootstrap.md#L53 이다.
module: grpc-advanced-bootstrap
---
# 능력을 하나씩 등급 매기는 것이 설계다
능력 15종을 한 깃발로 묶지 않고 각각 등급을 매긴다. 그렇게 하지 않으면 gRPC-Web을 켜는 결정과 xDS를 켜는 결정이 같은 결정이 된다.
## 본문
<!-- body:start -->
능력을 하나씩 등급 매기는 것이 설계다.
> "Bundling them under one 'advanced' flag makes enabling gRPC-Web — a compatibility bridge with a proxy in front of it — the same decision as enabling xDS, which brings a control plane and its outage modes. They are not the same decision, and a single switch is how the second one gets made by accident."
| 등급 | 시작 가능 | production 별도 승인 |
|---|---|---|
| `ADVANCED_STABLE` | 예 | 아니오 |
| `EXPERIMENTAL` | 예 | **예** |
| `WATCH` | 아니오 | — |
| `DISABLED` | 아니오 | — |
기본 등급 분포는 `ADVANCED_STABLE` 11, `EXPERIMENTAL` 3(`HEDGING`·`CUSTOM_LOAD_BALANCER`·`XDS`), `WATCH` 1(`EDITION_2026`)이다.
## EXPERIMENTAL에 두 번째 승인을 요구하는 근거
> "The flag says somebody wanted the feature; the approval says somebody accepted that its failure modes are not fully characterised, which is a different person's decision on most teams."
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-advanced-bootstrap-c01" alt="코드베이스에서 파일 목록을 만든 출력 9줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 9줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: grpc-advanced-streaming-c01
title: 상한 없는 request(n)은 단계만 늘린 무제한 버퍼링이다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-streaming-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-streaming-c01
file: ../../../final/evidence/rendered/grpc-advanced-streaming-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-streaming-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-streaming.md#L86 이다.
module: grpc-advanced-streaming
---
# 상한 없는 request(n)은 단계만 늘린 무제한 버퍼링이다
수동 흐름 제어는 승인이 record의 필드이고 거짓이면 생성자가 거부한다. 수요 상한과 교착 감시가 필수다.
## 본문
<!-- body:start -->
승인이 record 의 필드이고 거짓이면 생성자가 거부한다.
> "Approval is a field because this capability is granted per method, not per service. A method that reads a large result set benefits; the one next to it does not, and enabling both because they share a service is how the second one acquires a bug nobody was looking for."
수요 상한과 교착 감시가 필수다 — 상한 없는 `request(n)` 은 단계만 늘린 무제한 버퍼링이다.
## 감시견이 비교하는 두 시각
감시견은 잠들지 않고 두 시각을 비교한다 — 마지막으로 수요를 요청한 때와 마지막으로 메시지가 움직인 때. 둘 다 시간 제한만큼 멈춰 있으면 교착이다.
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-advanced-streaming-c01" alt="코드베이스에서 파일 목록을 만든 출력 14줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 14줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: messaging-admin-api-c04
title: 실행 경로에서 같은 검사가 세 지점에 겹친다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-api-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-c04
file: ../../../final/evidence/rendered/messaging-admin-api-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L493 이다.
module: messaging-admin-api
---
# 실행 경로에서 같은 검사가 세 지점에 겹친다
계획·승인 발급·실행 세 경로 중 실행 경로에서 검사가 `verify` / `Approved*Plan` 생성자 / guard 세 지점에 걸쳐 겹친다. 방어적 중복이다.
## 본문
<!-- body:start -->
실행 경로가 셋으로 나뉜다.
- **경로 A — 계획** (승인 불필요, dry run 무료)
- **경로 B — 승인 발급** (이 리프 밖, 변경관리 시스템)
- **경로 C — 실행**
## ApprovalVerifier 참조 위치
:::evidence key="messaging-admin-api-c04" alt="코드베이스에서 ApprovalVerifier 를 검색한 출력 29줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ApprovalVerifier 코드베이스 검색 — 29줄 · exit 0" zoom="true"
:::
## 실행 경로에서 검사가 겹치는 세 지점
경로 C 에서 검사가 세 지점(`verify` / `Approved*Plan` / guard)에 걸쳐 겹친다. `ApprovalVerifier` javadoc 이 그 이유를 설명한다. 서명 검증이 통과하면 나머지는 이미 보장되지만, `ApprovedReplayPlan` 생성자와 guard 가 같은 것을 다시 본다. 방어적 중복이며 §12.3(a) 에서 다시 다룬다.
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: CONCEPT
slug: messaging-policy-c05
title: 배치 상한이 개수와 바이트 두 축인 이유
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c05
file: ../../../final/evidence/rendered/messaging-policy-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L423 이다.
module: messaging-policy
---
# 배치 상한이 개수와 바이트 두 축인 이유
개수 상한만으로는 큰 메시지 몇 개가 브로커 프레임을 넘고, 바이트 상한만으로는 아주 많은 작은 메시지가 요청 타임아웃을 넘는다. `checkBatch`가 각 항목에 `checkPayload`도 부르므로 셋이 함께 적용된다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**배치 상한이 두 축인 이유**가 적혀 있다.
```java
// PayloadLimitGuard.java:16-18
* <p>Batches are limited by count <em>and</em> bytes. A count limit alone lets a handful of large
* messages exceed the broker's frame; a byte limit alone lets a huge number of tiny messages exceed
* its request timeout.
```
`checkBatch`가 각 항목에 대해 `checkPayload`도 부르므로 **개별 상한 · 개수 상한 · 총합 상한** 셋이 함께 적용된다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-policy-c05" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 프로파일 검증 실패가 MessagingException 밖인 이유
프로파일 검증 실패는 `IllegalArgumentException`이다 — `MessagingException` 계층 밖이다. 시작 시점의 구성 오류이지 메시지 실패가 아니므로 일관적이다. 다만 `MessagingConfigurationException`("Raised at startup wherever possible")이 존재하는데 쓰이지 않는다 — §17의 P3.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: messaging-schema-avro-c07
title: 바이트 상한이 잘못된 공격에 적용돼 있었다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-schema-avro-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-avro-c07
file: ../../../final/evidence/rendered/messaging-schema-avro-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-avro-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-avro.md#L457 이다.
module: messaging-schema-avro
---
# 바이트 상한이 잘못된 공격에 적용돼 있었다
테스트 클래스 javadoc이 세 결함을 보존한다. 세 번째는 배열 원소 수 주장을 신뢰하고 할당하던 상태이고, 그때 유일하게 있던 방어가 바이트 상한이었다.
## 관계
- **모드 enum을 분기 조건으로 쓰면 각 분기에 테스트를 둔다**
같은 분석 리프에서 끌어낸 규칙이다.
- **컬렉션 순서가 계약이면 양쪽에서 같은 방향으로 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 판단 단위로 정하고 구현 단위로 정하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
테스트 클래스 javadoc이 세 결함을 보존한다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `AvroRegistryBoundsTest` javadoc | 중첩 맵에 `Map.copyOf`(얕은 복사) | 호출자가 생성 후 스키마 교체 가능 → Avro는 실패하지 않고 그럴듯한 쓰레기를 만듦 |
| `AvroRegistryBoundsTest` javadoc | `decodeEvolved`에 크기 검사 없음 | producer가 앞서 나간 뒤 **모든 메시지**가 지나는 경로가 무제한 입력을 수용 |
| `AvroHostileInputTest` javadoc | 배열 원소 수 주장을 신뢰하고 할당 | 5바이트로 4억 원소 배열 → `OutOfMemoryError`, codec이 분류할 수 없는 실패, consumer 스레드에서 프로세스 사망 |
## 세 번째가 형태상 흥미로운 이유
**바이트 상한이라는 올바른 도구가 잘못된 공격에 적용되어 있었다.** 테스트 javadoc이 그것을 한 문장으로 적는다: "The byte limit is the wrong instrument for this attack and was the only one in place."
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-schema-avro-c07" alt="코드베이스에서 파일 목록을 만든 출력 2줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 2줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,76 @@
---
kind: CONCEPT
slug: messaging-spring-cloud-stream-bridge-c03
title: 보장에 의존하는 순간 브리지를 거절한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-spring-cloud-stream-bridge-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-cloud-stream-bridge-c03
file: ../../../final/evidence/rendered/messaging-spring-cloud-stream-bridge-c03.svg
- key: messaging-spring-cloud-stream-bridge-c03-diagram
file: ../../../final/assets/diagrams/messaging-spring-cloud-stream-bridge-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-cloud-stream-bridge-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-cloud-stream-bridge.md#L146 이다.
module: messaging-spring-cloud-stream-bridge
---
# 보장에 의존하는 순간 브리지를 거절한다
브리지는 상호운용을 위해 존재하고 그 위험은 구체적이다 — Stream이 자기 binder 설정을 소유하므로 목적지 프로파일이 모르는 직렬화기·오류 처리·확인 모드를 바인딩이 조용히 얻을 수 있다. 그래서 플랫폼의 보장에 의존하지 않는 목적지만 허용한다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **등록을 받는 컴포넌트는 해제도 제공한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **함께 읽히는 두 맵은 한 값으로 묶는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
브리지의 위험이 무엇인지 javadoc이 먼저 적는다.
> "The bridge exists for interoperability with existing Spring Cloud Stream bindings, and its risk is specific: Stream owns its own binder configuration, so a binding can quietly acquire its own serializer, its own error handling, and its own acknowledgement mode — none of which the destination profile knows about."
**세 거절이 `DestinationProfile`의 세 필드를 직접 본다.**
| 조건 | 코드 |
|---|---|
| `profile.isOrdered()``orderingScope != NONE` | `STREAM_BRIDGE_ORDERING_UNSUPPORTED` |
| `profile.retry().mode() != RetryMode.NONE` | `STREAM_BRIDGE_RETRY_UNSUPPORTED` |
| `profile.deadLetter().enabled()` | `STREAM_BRIDGE_DLQ_UNSUPPORTED` |
## 브리지가 허용되는 범위
:::evidence key="messaging-spring-cloud-stream-bridge-c03-diagram" alt="가드 경계 안에 순서 없음과 재시도 없음과 DLQ 없음 세 조건이 들어 있고 production 목적지가 경계 밖 점선 상자로 놓인 구조" caption="브리지가 허용되는 범위" zoom="false"
:::
세 코드 전부 `MessagingConfigurationException`이고 안정 코드를 갖는다 — `messaging-kafka-share-experimental`이 두 거절에 다른 예외 타입을 쓴 것(그쪽 §17)과 대비된다. `!enabled`도 같은 예외 타입이다. 에러 메시지가 **두 선택지를 명시한다** — "remove it from the binding or move the destination to the native adapter". 무엇을 하라고만 하지 않고 어느 쪽을 포기할지를 준다.
## DestinationProfile 참조 위치
:::evidence key="messaging-spring-cloud-stream-bridge-c03" alt="코드베이스에서 DestinationProfile 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DestinationProfile 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 네 번째 게이트
**production 목적지는 무조건 거절한다.** guard의 세 조건을 통과한 목적지(순서 없음·재시도 없음·DLQ 없음)라도 production이면 막는다. 바인딩 이름 패턴 `[a-zA-Z][a-zA-Z0-9-]{0,63}` — 언더스코어와 점을 배제한다.
## gaps()가 플래그가 아니라 문장을 만드는 이유
**"nothing at runtime will show it"**이 이 record가 존재하는 이유다. 네 boolean과 두 factory: `gaps()`가 각 `false`마다 **문장 하나**를 만든다. **각 문장이 결과까지 적는다** — "indistinguishable", "loses the message". 상태 플래그가 아니라 운영자가 읽는 진술이다. `isFullyGuaranteed()``gaps().isEmpty()`다 — 매 호출마다 네 문장을 다시 만든다. 성능 문제는 아니지만 순수 조회가 문자열을 할당한다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: shared-contract-c02
title: Permission의 정규화는 문법 제한이 아니다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:shared-contract-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: shared-contract-c02
file: ../../../final/evidence/rendered/shared-contract-c02.svg
evidence:
- ../../../final/evidence/raw/shared-contract-c02.txt
source:
- 원본 분석 절은 analysis/02-shared-contract.md#L69 이다.
module: shared-contract
---
# Permission의 정규화는 문법 제한이 아니다
`Permission`은 정확히 한 개의 colon으로 `resource:action`을 분리하고 trim/lowercase normalization을 수행한다. 그러나 component 내부 character set은 제한하지 않는다.
## 본문
<!-- body:start -->
`Permission`은 정확히 한 개의 colon으로 `resource:action`을 분리하고 trim/lowercase normalization을 수행한다. 테스트는 mixed case, surrounding whitespace, blank component, 0/2+ colon을 검증한다.
## Permission 참조 위치
:::evidence key="shared-contract-c02" alt="코드베이스에서 Permission 를 검색한 출력 21줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="Permission 코드베이스 검색 — 21줄 · exit 0" zoom="true"
:::
## 정규화와 문법의 차이
source는 component 내부 character set을 제한하지 않는다. 즉 "lowercase colon-delimited"는 normalization 결과이지 `[a-z0-9-]+` 같은 strict grammar는 아니다. 현재 test 역시 이를 요구하지 않으므로 observed contract로만 기록한다.
<!-- body:end -->
@@ -0,0 +1,126 @@
---
kind: CASE
slug: a-flag-that-validates-an-unwired-subsystem
title: 하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-flag-that-validates-an-unwired-subsystem
evidenceCapturedOn: 2026-09-02
assets:
- key: a-flag-that-validates-an-unwired-subsystem
file: ../../../final/evidence/rendered/a-flag-that-validates-an-unwired-subsystem.svg
evidence:
- ../../../final/evidence/raw/a-flag-that-validates-an-unwired-subsystem.txt
source:
- 분석 문서는 mongo 어댑터 편 §49 다. 플래그가 그대로 바인딩된다는 것은 같은 문서 §6 의 바인딩 관측값이고, 형제 입력의 처리 차이도 그 절에 있다. 반대쪽 실행체가 출하되어 플래그와 무관하게 조립된다는 것은 §65 와 §68 이다.
- 같은 문서 §50 은 둘 다 실행체가 없다고 적는다. 그 줄은 sub-scope 06 시점의 요약이고 §68 이 뒤집었다 — 자동설정의 무조건 빈과 드라이버 호출이 그 근거이며, 위 터미널 출력에서 확인할 수 있다.
---
# 하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다
mongo 트랜잭션 타입은 어느 것도 빈이 아니고 다른 프로덕션 코드가 부르지도 않는다. 그런데 그것을 켜는 설정 플래그는 살아 있어서, 토폴로지 프로브를 함께 공급한 배포에서는 트랜잭션 지원 여부를 검증받게 만든다. 능력 요구만 만들고 능력을 제공하지 않는다.
## 관계
- **@Bean이 있다는 것은 조립 증거가 아니다**
플래그 값이 바인딩된다는 것도 그 하위 시스템이 조립됐다는 증거가 아니다.
- **"꺼짐"은 조건의 반복이 아니라 구조여야 한다**
같은 설정 클래스가 스위치 둘을 서로 반대 방향으로 하위 시스템과 떼어 놓았다.
## 문제
mongo 플랫폼 자동설정에서 트랜잭션 타입과 인과 세션 타입을 찾으면 일치가 각각 0 이다. 트랜잭션 패키지 밖의 프로덕션 참조도 0 이다.
즉 실행체와 세션 팩토리와 재시도 코디네이터와 인과 세션 실행기 어느 것도 빈이 아니고, 이 어댑터의 다른 프로덕션 코드가 부르지도 않는다.
그런데 설정의 transactions 플래그는 다르다. 값이 그대로 바인딩되고, 플랫폼 자동설정이 그 값을 시작 검증기에 넘기며, 검증기는 트랜잭션이 켜져 있는데 능력이 Stable 이 아니면 시작을 거부한다.
## 결론
프로브를 공급한 배포는 토폴로지가 트랜잭션을 지원하는지 검증받고, 그다음 트랜잭션을 실행할 빈은 하나도 받지 못한다. 플래그가 능력 요구만 만들고 능력을 제공하지 않는다.
같은 설정 클래스의 change stream 플래그는 반대쪽으로 어긋나 있다. 실행체 쪽 드라이버 코드는 출하되어 빈으로 조립되는데, 플래그는 생성자에서 조용히 거짓이 된다.
데이터 위험은 없다. 없는 것을 쓸 수는 없기 때문이다. 이 플래그가 무엇을 켜는지 적힌 곳이 없고, 시작 검증이 통과한 것과 실행체가 조립된 것을 구분해 주는 신호도 없다.
수정은 셋 중 하나다. 트랜잭션 실행체를 조건부 빈으로 조립하거나, 플래그가 무엇을 켜는지를 문서에 적거나, 같은 생성자의 required-secondaries 처럼 값을 예외로 거부하는 것이다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
확인 방식 : 정적 도달성 전수 확인
소스 수정 : x
## 재현 조건
1. 트랜잭션 패키지의 타입을 하위 디렉터리까지 나열하고, mongo 플랫폼 자동설정에서 그 타입 참조를 센다.
2. 트랜잭션 패키지 밖의 프로덕션 참조를 센다.
3. 설정 플래그가 선언되는 지점과 그 값이 시작 검증기로 전달되는 지점, 검증기의 판정을 확인한다.
4. 그 검사를 여는 조건과, 이 저장소가 그 조건을 만족시키는지 확인한다.
5. 같은 생성자가 형제 입력을 각각 어떻게 처리하는지 확인한다.
6. change stream 실행체의 조립 조건과 드라이버 호출을 확인한다.
## 본문
<!-- body:start -->
`ca-skeleton.persistence-mongo.platform.transactions` 를 참으로 둔 배포가 토폴로지 프로브를 함께 공급하면, 시작할 때 트랜잭션 능력 검사가 돈다. 검사가 통과해도 트랜잭션을 실행할 빈은 하나도 조립되지 않는다.
## 타입은 스물인데 그것을 부르는 코드가 없다
:::evidence key="a-flag-that-validates-an-unwired-subsystem" alt="코드베이스에서 트랜잭션 패키지의 타입 스물과 자동설정의 참조 매치 수, 패키지 밖 참조 수, 플래그가 선언되어 검증기 판정에 쓰이는 지점, 그 검사를 여는 프로브 조건과 이 저장소가 프로브를 내지 않는다는 기술, 설정 생성자가 입력마다 다르게 처리하는 세 줄, 반대쪽 실행체가 플래그와 무관하게 조립되는 조건과 드라이버 호출, 그리고 소비자가 요구하는 다섯 포트를 뽑은 출력 70줄. 타입은 스물인데 참조가 0 이고, 검사는 프로브가 있어야 열린다는 것이 그 출력에 그대로 보인다." caption="타입 20 · 참조 0 · 플래그 → 검증기 → 거부 · 검사를 여는 프로브 조건 · 반대쪽의 무조건 조립 — 70줄 · exit 0" zoom="true"
:::
트랜잭션 패키지에 타입이 스무 개 있다. 블로킹과 리액티브 양쪽의 실행체와 세션 팩토리, 스코프와 프로파일, 커밋 조정과 재시도 예산을 갖춘 재시도 코디네이터, 그리고 인과 세션 실행기 넷이다. 절반은 계약이고 나머지가 구체 클래스다.
플랫폼 자동설정에서 `Transaction` 을 세면 0, `CausalSession` 을 세도 0 이다. 트랜잭션 패키지 밖의 main 참조도 0 이다.
## 플래그는 그 사실과 무관하게 검증까지 간다
`transactions` 는 설정 record 의 성분이라 값이 그대로 바인딩된다. 자동설정 362행이 그 값을 시작 검증기에 넘기고, 검증기 97행이 그것으로 판정한다 — 트랜잭션이 켜져 있는데 능력이 Stable 이 아니면 시작을 거부한다.
## 그 검사 자체가 조건부다
검증기를 돌리는 빈은 `MongoTopologyProbe` 에 조건되어 있다. 그리고 이 저장소는 프로브를 출하하지 않는다 — 그 자리 javadoc 이 직접 적는다. 프로브는 연결을 소유한 조립 루트가 살아 있는 데이터 평면 클라이언트로 만드는 것이고, 그것은 fork 의 결정이라는 것이다.
프로브 없이 플랫폼 프로파일만 설정한 배포는 별도의 빈이 시작을 거부한다. 그 예외 문구가 이유를 적는다 — 검사가 하필 자기 부재를 보고해야 할 바로 그 빈에 조건되어 있어서, 프로브 없이 시작하면 토폴로지도 Stable API 수준도 자격의 실제 능력도 아무것도 검사하지 않은 채 조용히 지나간다는 것이다.
그래서 이 플래그가 시작 요구를 만드는 것은 fork 가 프로브와 보안 프로파일과 관리 자격 참조와 스키마 버전 범위를 모두 공급했을 때다. 셰이프 그대로의 이 저장소에서는 검사가 열리지 않는다.
## 같은 생성자가 입력마다 다르게 처리한다
설정 record 의 컴팩트 생성자를 보면 처리가 갈린다.
`profiles` 의 널은 빈 맵으로 흡수한다. `change-streams` 는 무엇이 오든 거짓으로 덮어쓴다. `required-secondaries` 에 음수가 오면 예외를 던져 거부한다.
`transactions` 는 이 생성자에 아예 등장하지 않는다. 값이 그대로 보존되는 이유가 그것이다.
덮어쓰기 쪽만 참으로 설정해도 예외도 로그도 발생하지 않는다. 그 줄에 붙은 주석은 값을 무시하면 적용된 것처럼 보이게 되니 저장하지 않고 거부한다고 적는데, 예외로 거부하는 것은 `required-secondaries` 가 하는 일이고 여기서 일어나는 것은 조용한 덮어쓰기다.
## 강등의 근거로 적힌 사실이 코드와 맞지 않는다
그 주석에는 드라이버 쪽 소스가 — watch, resumeAfter/startAfter, 커서 수명, 재접속이 — 출하되지 않았다고 이유까지 적혀 있다.
같은 모듈의 리액티브 소스가 `.changeStream(...)``.watchCollection(...)` 을 호출하고, 재개 위치에 따라 `startAfter``resumeAfter` 를 나눠 건다. 자동설정이 그것을 빈으로 조립하는데, 그 경로에서 플래그를 보는 조건은 0 이다. 리액티브 템플릿이 있으면 조립된다.
그 위의 소비자는 다섯 포트를 요구한다. 무엇을 투영할지, 중복을 어떻게 걸러 낼지, 어디에 투영했다고 기록할지, 저장한 토큰을 어떻게 보호할지, 어느 컬렉션을 볼지다. 다섯 모두 main 에서 빈으로 등록되는 곳이 0 인데, 이쪽은 어긋난 것이 아니다. 플랫폼이 투영기를 지어낼 수 없으니 배포가 주기 전까지 소비자가 서 있는 것이 맞다.
## 두 스위치가 반대 방향으로 같은 곳에서 끊겼다
트랜잭션은 스위치가 살아서 요구를 만드는데 그 요구를 갚을 코드가 조립되지 않는다. change stream 은 코드가 조립되는데 스위치가 죽어 있다. 방향은 반대이고 끊긴 자리는 같다.
## 남는 것은 데이터 위험이 아니다
없는 것을 쓸 수는 없으니 트랜잭션이 깨질 일은 없다.
남는 것은 이 플래그가 무엇을 켜는지 적힌 곳이 없다는 것이다. 시작 검증이 통과한 것과 실행체가 조립된 것을 구분해 주는 신호도 없다.
## 확인하지 못한 것
애플리케이션을 부팅해 플래그를 켠 상태의 시작 동작과 빈 목록을 확인하지 않았다. 도달성은 이름 기반 정적 검색으로 판정했으므로, 리플렉션이나 설정으로 조립되는 경로는 배제하지 못했다.
<!-- body:end -->
@@ -0,0 +1,149 @@
---
kind: CASE
slug: a-validator-that-demands-tls-and-an-assembly-that-omits-it
title: 검증기가 운영에 TLS를 요구하고, 실제로 조립되는 생산자에는 그 설정이 없다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-validator-that-demands-tls-and-an-assembly-that-omits-it
evidenceCapturedOn: 2026-09-03
assets:
- key: a-validator-that-demands-tls-and-an-assembly-that-omits-it
file: ../../../final/evidence/rendered/a-validator-that-demands-tls-and-an-assembly-that-omits-it.svg
- key: a-validator-that-demands-tls-and-an-assembly-that-omits-it-run
file: ../../../final/evidence/rendered/a-validator-that-demands-tls-and-an-assembly-that-omits-it-run.svg
evidence:
- ../../../final/evidence/raw/a-validator-that-demands-tls-and-an-assembly-that-omits-it.txt
- ../../../final/evidence/raw/a-validator-that-demands-tls-and-an-assembly-that-omits-it-run.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-boot-starter.md 의 §17.1 이다.
---
# 검증기가 운영에 TLS를 요구하고, 실제로 조립되는 생산자에는 그 설정이 없다
`KafkaProfileValidator` 는 운영으로 선언된 브로커에 TLS 와 브로커 인증이 켜져 있기를 요구하고, 기동 시점에 실제로 실행된다. 그런데 프로덕션에서 만들어지는 `KafkaProducer` 둘 어느 쪽 설정 맵에도 `security.protocol` 이 없어서, 두 맵 모두 `PLAINTEXT` 로 해석된다.
## 관계
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
`KafkaProfileValidator` 만 읽으면 운영 프로파일에 전송 보안이 강제된 것으로 보인다. `KafkaProducer` 를 만드는 두 코드를 열어야 그 설정 맵에 `security.protocol` 이 없다는 것이 보인다.
- **검증기는 발행이 아니라 주입이 강제다**
`KafkaSecurityConfigurer` 는 빈 팩토리까지 있지만 그것을 주입받는 프로덕션 코드가 0 건이라 아무 설정 맵에도 닿지 않는다.
- **"꺼짐"은 조건의 반복이 아니라 구조여야 한다**
기동에서 설정값을 한 번 더 검사해도, 두 조립부가 `security.protocol` 을 설정 맵에 넣지 않으면 프로듀서는 평문 설정으로 만들어진다. 검사 조건을 늘리는 쪽이 아니라 조립 코드가 그 키를 넣어야 막힌다.
## 문제
브로커 프로파일이 값으로 표현되고 기동 때 검증된다. 운영 프로파일이면 전송 보안과 브로커 인증이 있어야 한다.
그 요구가 실제로 걸리는지, 그리고 통과한 뒤 만들어지는 프로듀서가 그 선언대로 붙는지 확인했다.
## 결론
두 규칙은 기동 시점에 실제로 실행된다. 그 검증을 통과한 뒤 만들어지는 KafkaProducer 두 곳의 설정 맵에 security.protocol 이 없다.
규칙은 KafkaProfileValidator:51 과 :55 에 있다. KafkaMessagingAutoConfiguration:55 의 kafkaProfileStartupValidation 이 StartupProfileValidation 을 통해 설정에서 컴파일된 프로파일에 이 검증기를 실행하고, MessagingConfigurationBindingTest:235 가 그 거부를 고정한다.
같은 애플리케이션이 만드는 KafkaProducer 는 둘이다. KafkaMessagingAutoConfiguration:153 이 키 다섯 개짜리를, KafkaSenderConfig:81 이 아홉 개짜리를 내놓는데 보안 키는 어느 쪽에도 없다. 두 맵을 ProducerConfig 에 그대로 넘겨 읽으면 security.protocol 이 PLAINTEXT 로, sasl.jaas.config 가 널로 나온다.
보안 값을 설정 맵에 실을 수 있는 코드 자체는 KafkaSecurityConfigurer:80 에 있다. 그 클래스의 빈 팩토리는 KafkaMessagingAutoConfiguration:109 에 있지만 조건이 두 단이고, 그 끝에 있는 CredentialProvider 를 구현하는 main 클래스가 0 건이다. 출하되는 애플리케이션에서는 이 빈이 만들어지지 않고, 만들어졌더라도 주입받는 코드가 없다.
그래서 배포가 겪는 것은 이렇다. app.messaging.enabled=true 와 app.messaging.broker=kafka 를 켜고 운영 프로파일에 전송 보안과 인증을 선언하면 기동은 통과한다. 만들어진 프로듀서의 설정 맵은 security.protocol 이 PLAINTEXT 로 해석되므로 클라이언트는 평문으로 접속을 시도한다. 설정에 선언한 보안과 프로듀서가 실제로 쓰는 값이 다른데도, 기동에서 예외나 경고가 나오지 않는다.
판정은 P1 로 원본 분석과 같다. 근거는 셋이다. 이 경로는 미배선 블록이 아니라 환경변수 둘로 켜지는 출하 조립이다. 기동에서 통과하는 검증은 실제 연결이 아니라 설정에 적힌 선언을 본다. 그리고 조립을 확인하는 테스트가 빈이 있는지만 단언하고 설정 맵의 키는 보지 않아서, 이 상태가 테스트 레인에 걸리지 않는다.
수정은 KafkaMessagingAutoConfiguration.messagingKafkaProducer 와 KafkaSenderConfig.kafkaSeamProducer 가 KafkaSecurityConfigurer.configure 의 결과를 각자의 설정 맵에 넣는 것이다. 그러려면 그 빈이 실제로 만들어져야 하므로 CredentialProvider 구현을 함께 정해야 한다.
## 검증 환경
OpenJDK : 21.0.12
kafka-clients : 4.1.2
확인 방식 : 검증기의 두 규칙과 그 실행 경로 확인, 프로덕션 KafkaProducer 생성 지점 전수와 각 설정 맵의 원문 확인, KafkaSecurityConfigurer 의 빈 조건 사슬과 주입처 계수, 두 설정 맵을 ProducerConfig 에 넘겨 해석된 보안 값 다섯 읽기
소스 수정 : x
## 재현 조건
1. KafkaProfileValidator 에서 운영 프로파일에 거는 규칙 둘을 읽는다.
2. 그 검증기를 설정에서 컴파일된 프로파일에 돌리는 자리와, 그것이 기동의 어느 단계인지 확인한다.
3. 그 거부를 고정하는 테스트를 찾는다.
4. 두 조립부를 켜는 프로퍼티를 전부 찾고 출하 기본값을 읽는다.
5. 프로덕션에서 KafkaProducer 를 만드는 자리를 전부 세고, 각 설정 맵의 원문을 그대로 읽는다.
6. security.protocol 을 상수명과 리터럴 양쪽으로 저장소 전체에서 찾는다.
7. KafkaSecurityConfigurer.configure 가 자격 종류마다 무엇을 넣고 어디서 던지는지 읽는다.
8. 그 클래스의 빈 팩토리에 붙은 조건을 따라가고, 사슬 끝의 인터페이스를 구현하는 main 클래스를 센다.
9. 그 빈을 주입받는 코드와 getBean·ObjectProvider·빈 이름 조회를 각각 센다.
10. 5에서 읽은 두 맵을 그대로 만들어 ProducerConfig 에 넘기고 보안 값 다섯을 읽는다.
11. 조립을 확인하는 테스트가 무엇을 단언하는지, 실 브로커 시험이 어떤 컨테이너에 붙는지 읽는다.
## 본문
<!-- body:start -->
이 저장소는 브로커 프로파일을 값으로 표현하고 기동 시점에 검증한다. 그중 운영 프로파일에는 전송 보안과 브로커 인증을 요구하는 규칙 둘이 있다.
## KafkaProfileValidator 가 운영 프로파일에 요구하는 둘
:::evidence key="a-validator-that-demands-tls-and-an-assembly-that-omits-it" alt="저장소 루트에서 돌린 정적 검색 출력 102줄. KafkaProfileValidator 51행부터 58행까지의 두 요구가 원문 그대로 나오고, 그 검증기가 InitializingBean 의 afterPropertiesSet 에서 도는 경로와 그 거부를 지키는 테스트가 이어진다. 그다음 두 조립부를 켜는 프로퍼티가 나오는데 플랫폼 루트와 브리지 루트가 각각 messaging enabled 를 조건으로 달고 심 쪽은 broker 프로퍼티를 달며 출하 기본값이 false 다. 이어서 프로덕션에서 KafkaProducer 를 만드는 두 자리의 설정 맵이 주석까지 원문 그대로 실려 있고, security.protocol 을 상수명과 리터럴 양쪽으로 검색한 결과가 KafkaSecurityConfigurer 의 상수 선언과 그 한 줄뿐이다. 그다음 configure 가 자격 종류마다 넣는 값과 던지는 두 분기, 그 클래스가 빈이 되기까지의 조건 사슬과 CredentialProvider 를 구현하는 main 클래스가 0 건이라는 계수, 그 빈을 받는 코드와 getBean·ObjectProvider·빈 이름 조회가 모두 0 건이라는 확인, 그리고 조립 테스트 둘이 단언하는 것과 실 브로커 시험이 붙는 컨테이너가 보인다." caption="두 요구와 그 실행 경로 · 두 프로듀서의 설정 맵 원문 · security.protocol 을 넣는 코드 한 줄 · 조건 사슬과 CredentialProvider 0 건 · 조립 테스트의 단언 — 102줄 · exit 0" zoom="true"
:::
`KafkaProfileValidator:51` 은 운영 프로파일에 전송 보안이 꺼져 있으면 `IllegalArgumentException` 을 던지고 메시지에 브로커 이름을 붙인다. `:55` 가 브로커 인증에 같은 것을 한다.
이 검증기는 선언만 있는 것이 아니라 실제로 돈다. `KafkaMessagingAutoConfiguration:55``kafkaProfileStartupValidation``StartupProfileValidation` 을 만들고, 그 클래스가 `InitializingBean` 을 구현해 `afterPropertiesSet`(\:43)에서 설정에서 컴파일된 프로파일에 검증기를 건다. `MessagingConfigurationBindingTest:235``aProductionKafkaBrokerWithoutTransportSecurityFailsStartup` 이 그 거부를 고정한다.
여기까지만 보면 운영 프로파일로 뜬 배포는 평문으로 브로커에 붙을 수 없다.
## 조립되는 KafkaProducer 두 곳에 security.protocol 이 없다
프로덕션에서 `KafkaProducer` 를 만드는 자리는 둘이다.
`KafkaMessagingAutoConfiguration.messagingKafkaProducer``:153` 에서 만든다. `:139` 에서 빈 `HashMap` 을 열고 `:140`\~`:152` 에 넣는 것은 `BOOTSTRAP_SERVERS_CONFIG`, 직렬화기 둘, `ACKS_CONFIG`, `ENABLE_IDEMPOTENCE_CONFIG` 다섯이다.
`KafkaSenderConfig.kafkaSeamProducer``:81` 에서 만든다. 위 다섯에 `MAX_BLOCK_MS_CONFIG`, `LINGER_MS_CONFIG`, `REQUEST_TIMEOUT_MS_CONFIG`, `DELIVERY_TIMEOUT_MS_CONFIG` 를 더해 아홉이다.
두 메서드 모두 `@ConditionalOnMissingBean` 이 붙어 있어 채택자가 자기 빈을 내놓지 않으면 이것이 쓰인다. 켜는 프로퍼티는 둘이다. `MessagingPlatformRootAutoConfiguration:28``MessagingBridgeRootAutoConfiguration:21` 이 각각 `app.messaging.enabled=true` 를 조건으로 달고, 그 안에서 `MessagingProviderSelection:48``app.messaging.broker=kafka``KafkaMessagingAutoConfiguration` 을 물리며 `KafkaSenderConfig:38` 이 같은 값을 조건으로 단다. `application.yml:949` 의 출하 기본값은 `enabled: ${APP_MESSAGING_ENABLED:false}` 다.
두 설정 맵의 원문 어디에도 `security.protocol` 이 없다.
## 두 설정 맵을 ProducerConfig 에 넘겼을 때 해석되는 보안 값
:::evidence key="a-validator-that-demands-tls-and-an-assembly-that-omits-it-run" alt="JVM 프로브 출력 19줄. OpenJDK 판이 먼저 찍히고 kafka-clients 판본이 나온다. 두 조립부가 넣는 설정 맵을 그대로 ProducerConfig 에 넘긴 결과가 프로듀서마다 나오는데, 넣은 키 목록과 함께 security.protocol 이 PLAINTEXT 로, ssl.endpoint.identification.algorithm 이 https 로, sasl.mechanism 이 GSSAPI 로, sasl.jaas.config 가 null 로, ssl.enabled.protocols 가 TLSv1.2 와 TLSv1.3 으로 해석된 것이 보인다. 두 프로듀서의 보안 값 다섯이 모두 같다." caption="두 조립 맵을 ProducerConfig 에 넘겨 읽은 보안 값 다섯 — 19줄 · exit 0" zoom="true"
:::
두 맵 모두 `security.protocol``PLAINTEXT` 로 해석된다. 자격 쪽도 비어 있어 `sasl.jaas.config` 가 널이다. 브로커에 붙기 전에 클라이언트가 무엇으로 붙을지는 이 시점에 이미 정해져 있다.
`KafkaSecurityConfigurer` 가 넣는 값 중 둘은 클라이언트 기본값과 같다. `ssl.endpoint.identification.algorithm` 은 넣지 않아도 `https` 이고, `ssl.enabled.protocols` 의 기본값도 `[TLSv1.2, TLSv1.3]` 이다. 실제로 차이가 나는 것은 `security.protocol` 과 SASL 두 값이다.
## 보안 값을 만드는 코드는 있고, 그 빈이 만들어지지 않는다
`security.protocol` 을 넣는 프로덕션 코드는 저장소 전체에서 `KafkaSecurityConfigurer:80` 한 줄이다. 상수명과 리터럴 양쪽으로 찾아도 `:32` 의 상수 선언과 그 한 줄뿐이다.
같은 메서드가 `:83` 에서 활성 프로토콜 목록을, `:86` 에서 종단 식별 알고리즘을 넣고, 그다음은 자격 종류에 따라 갈린다. SASL/SCRAM 이면 `:92``SCRAM-SHA-512` 를, 사용자·암호면 `:109``PLAIN` 을, 상호 TLS 면 `:106``NONE` 을 넣는다. OAuth2 와 Nkey 는 값을 넣지 않고 `:99``:113` 에서 던진다.
이 클래스를 만드는 팩토리는 `KafkaMessagingAutoConfiguration:109` 에 있는데, 조건이 두 단이다. `:107``@ConditionalOnBean(CredentialRuntimeRegistry.class)` 이고, 그 레지스트리를 내놓는 `MessagingCoreAutoConfiguration:317``:315``@ConditionalOnBean(CredentialProvider.class)` 뒤에 있다. 그런데 `CredentialProvider` 를 구현하는 main 클래스가 0 건이다. 유일한 구현은 `CredentialRuntimeRegistryTest:21` 의 시험용 클래스다.
그래서 출하되는 애플리케이션에서 이 빈은 만들어지지도 않는다. 만들어졌다 해도 받을 곳이 없다 — 그것을 파라미터나 필드로 받는 프로덕션 코드가 0 건이고, `getBean``ObjectProvider` 와 빈 이름 문자열로 가져가는 자리도 0 건이다.
## 조립 테스트가 설정 맵의 키를 단언하지 않는다
`MessagingStarterOffContractTest:118``selectingKafkaAssemblesOnlyKafka` 는 컨텍스트가 실패하지 않았는지, `KafkaMessagingAutoConfiguration` 빈이 하나인지, Rabbit 쪽 빈이 없는지를 단언한다(\:127, \:130). `:172``aSelectedTransportAssemblesAPublisher``MessagePublisher` 빈이 하나인지를 본다(\:189). 프로듀서가 어떤 키를 들고 있는지는 어느 쪽도 보지 않는다.
실 브로커에 붙는 `MessagingLiveRoundTripQualificationTest:66``new KafkaContainer("apache/kafka:4.1.0")` 를 쓴다. 보안 설정이 하나도 없는 컨테이너다. 그래서 이 테스트가 확인한 왕복은 보안 설정이 없는 브로커와의 왕복이다.
## 원문과 갈리는 자리
원문 §17.1 은 조립되는 생산자를 `messagingKafkaProducer` 하나로 적었다. 프로덕션에서 `KafkaProducer` 를 만드는 자리는 둘이고, `KafkaSenderConfig.kafkaSeamProducer` 도 같은 프로퍼티 조건에서 조립되며 그쪽에도 보안 키가 없다.
원문이 `KafkaSecurityConfigurer` 가 만드는 성분을 다섯으로 센 것도 자격 종류를 하나로 놓았을 때다. `configure` 는 자격 종류마다 다른 SASL 메커니즘을 넣고, 상호 TLS 에서는 `sasl.jaas.config` 없이 `NONE` 만 넣으며, OAuth2 와 Nkey 에서는 아무것도 넣지 않고 던진다.
원문의 시나리오는 운영자가 `CredentialProvider` 빈을 공급하는 데까지 간다. 그 단계 없이도 이 상태는 성립한다. 검증기가 보는 `tlsEnabled``app.messaging.brokers.*` 소속이고, 자격을 요구하는 `MessagingCredentialRequirementValidator` 가 보는 것은 `app.messaging.security.*` 소속이라 두 네임스페이스가 다르다. 보안 프로파일을 적지 않으면 자격 공급 없이 통과한다.
판정과 근거는 원문과 같다.
## 확인하지 못한 것
브로커를 세워 핸드셰이크를 보지 않았다. 읽은 것은 클라이언트가 그 맵에서 어떤 프로토콜로 붙기로 정하는가까지다.
브로커를 띄워 실제 핸드셰이크를 관측하지 않았다. 확인한 것은 조립부가 만드는 설정 맵을 `ProducerConfig` 에 넘겼을 때 `security.protocol``PLAINTEXT` 로 해석되는 데까지다.
<!-- body:end -->
@@ -0,0 +1,204 @@
---
kind: CASE
slug: a06-f018-changestreams-false
title: 플래그는 고정 거짓이라 능력 검사를 끄지만, 조립 조건이 아니라서 소비자 빈은 그대로 생성된다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a06-f018-changestreams-false
evidenceCapturedOn: 2026-09-02
body: case-a06-f018-changestreams-false.body.md
assets:
- key: a06-f018-changestreams-false
file: ../../../final/evidence/rendered/a06-f018-changestreams-false.svg
- key: a06-f018-changestreams-false-wiring
file: ../../../final/evidence/rendered/a06-f018-changestreams-false-wiring.svg
evidence:
- ../../../final/evidence/raw/a06-f018-changestreams-false.txt
- ../../../final/evidence/raw/a06-f018-changestreams-false-wiring.txt
source:
- 원본 분석 절은 analysis/06-adapter-outbound-persistence-mongo.md#L1069 이다. 등급은 P2 다. 주석의 전제가 더 이상 사실이 아니라는 판정, 형제 불리언과의 대비표, 검증기 분기가 도달 불가라는 사실, 그리고 실패가 장애 조치 런북으로 분류된다는 서술이 그 절에 있다.
- 같은 문서 `#L1015` 는 소비자가 도는 조건을 포크가 다섯을 공급하는 경우로 한정한다. 이 저장소 안에서는 그 다섯의 구현이 전부 시험 픽스처다.
- 같은 문서 `#L156` 은 같은 코드를 P3 으로 판정하면서 변경 스트림 실행체가 애초에 출하되지 않는다는 것을 근거로 든다. 이 리비전에서 그 근거가 성립하지 않으므로 그 절에 붙은 "현재 잘못된 동작을 만들지는 않는다"는 유지될 수 없고, 실질 등급은 이 절의 P2 로 흡수된다.
- 설정 빈이 속성을 받고도 거짓을 보고한다는 것, 그 두 경우의 빈 집합이 같다는 것, 검사 빈 자체에 조건이 있다는 것, 그리고 런북 전체에 단독 서버·오플로그·해당 오류 코드가 없다는 것은 이 기록에서 확인했다.
---
# 플래그는 고정 거짓이라 능력 검사를 끄지만, 조립 조건이 아니라서 소비자 빈은 그대로 생성된다
설정 주석은 드라이버 쪽 구현이 출하되지 않아 빈이 0 이라는 것을 근거로 플래그를 고정 거짓으로 만든다. 이 리비전에서 그 구현에는 빈 선언이 있고, 배포가 다섯을 공급하면 소비자도 선다. 조립 조건 어디에도 그 플래그는 없다.
## 관계
- **거부라고 적힌 처리가 폐기이고, 그 값을 읽는 시작 검사는 켤 방법이 없다**
같은 플래그를 다른 절에서 다룬 기록이다. 거부와 폐기의 차이는 그 기록이 다룬다.
- **하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다**
형제 불리언 쪽 기록이다. 트랜잭션 계수와 그 결과는 그 기록이 다룬다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
플래그와 조립의 관계를 확인하는 규칙이다.
## 문제
시작 검증기에는 인접한 두 분기가 있다. 하나는 트랜잭션이 켜져 있는데 토폴로지가 지원하지 않으면 던지고, 다른 하나는 변경 스트림에 대해 같은 일을 한다. 두 좌항은 같은 설정 타입의 형제 불리언이고 자동 구성의 인접한 두 줄이 넘긴다.
두 불리언은 같은 검증기에서 서로 다르게 끝난다. 앞의 값은 배포가 넣은 대로 도착하고, 뒤의 값은 컴팩트 생성자가 이미 거짓으로 바꾼 뒤다.
## 결론
플래그가 고정 거짓이므로 검증기의 변경 스트림 분기는 실행되지 않는다. 조립은 그와 무관하게 진행된다. 소비자 빈의 조건은 배포가 공급해야 하는 타입 다섯이고, 그 목록에 이 플래그는 없다. 자동 구성 파일 전체에서 그 이름이 나오는 줄은 검증기 인자 하나뿐이다.
전체 자동 구성을 올린 스프링 컨텍스트로 확인했다. 설정 빈은 change-streams=true 를 받고도 거짓을 보고하고, 그 두 경우의 빈 집합이 같다. 모듈 opt-in 을 켜고 리액티브 템플릿이 있으면 드라이버 쪽 구현 빈은 만들어지고 소비자 빈은 만들어지지 않는다. 다섯을 함께 넣으면 소비자 빈도 만들어진다. opt-in 을 켜지 않으면 셋 다 없다.
주석은 이 코드가 있기 전 상태를 서술한다. 빈이 0 이고 스레드가 0 이라는 근거는 이 리비전에서 성립하지 않는다.
남는 것은 능력 검사만 꺼진 상태다. 다만 그 검사가 열리는 조건이 따로 있다. 검사 빈은 토폴로지 프로브를 조건으로 걸고, 보안 프로파일과 관리 자격 참조와 스키마 버전 범위 중 하나라도 없으면 부분 검증 대신 예외로 닫는다. 그리고 검증기는 선언 토폴로지와 실제를 능력 검사보다 먼저 대조한다. 변경 스트림 분기는 그 둘을 통과한 배포에서만 차례를 얻는데, 그 차례가 와도 좌항이 거짓이다.
그 다음 실패는 커서를 여는 시점의 드라이버 오류다. 복구 정책은 서버 코드가 이력 소실이 아니고 재개 가능 라벨도 아니면 실패로 확정하며 장애 조치 런북을 붙인다. 런북 어디를 봐도 그 세 낱말이 없다. 이 연쇄는 형제 기록이 오플로그 없는 서버에서 실행으로 확인했다.
수정은 셋 중 하나다. 플래그를 되살려 조립 조건으로 쓰거나, 소비자 빈이 설 때 능력을 기동에서 확인하거나, 최소한 주석을 현재 사실로 고치는 것이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 전체 자동 구성을 올린 스프링 컨텍스트에서 빈 집합과 바인딩 값 비교, 코드베이스 정적 검색
소스 수정 : x
## 재현 조건
1. 시작 검증기의 인접한 두 분기와 그 좌항을 넘기는 두 줄을 읽는다.
2. 그 검사를 만드는 빈의 조건과 입력이 빠졌을 때의 처리를 읽는다.
3. 컴팩트 생성자의 플래그 강제와 그 주석을 읽는다.
4. 소비자 빈에 붙은 조건과 자동 구성 파일에서 그 플래그가 나오는 줄 수를 확인한다.
5. MongoPlatformAutoConfiguration 전체와 블로킹·리액티브 템플릿 빈을 등록한 컨텍스트를 ca-skeleton.persistence-mongo.enabled=true 로 띄우고 두 빈과 설정 빈의 유무, 그리고 바인딩된 플래그 값을 읽는다.
6. 조건 다섯을 함께 넣고 같은 것을 읽는다.
7. 두 경우를 change-streams=true 를 넣은 상태에서 반복한다.
8. opt-in 을 켜지 않은 경우와 리액티브 템플릿이 없는 경우를 각각 읽는다.
9. 복구 정책의 분류와 그것이 붙이는 런북 전체를 읽는다.
## 본문
<!-- body:start -->
시작 검증기에는 능력을 요구하는 분기가 둘 있고, 두 좌항은 같은 설정 타입의 형제 불리언이다.
## 두 분기가 읽는 값이 오는 자리
:::evidence key="a06-f018-changestreams-false" alt="시작 검증기의 인접한 두 능력 분기와 그 좌항을 넘기는 자동 구성의 두 줄, 그 검사를 만드는 빈의 조건과 입력이 빠졌을 때 닫는 처리, 컴팩트 생성자의 플래그 강제와 그 주석, 자동 구성 파일에서 그 플래그가 나오는 줄 수, 소비자 빈에 붙은 조건 전체, 복구 정책의 분류 메서드, 그리고 그것이 붙이는 런북의 증상 절 전체와 그 런북에서 단독 서버·오플로그·해당 오류 코드가 나오는 줄 수를 출력한 터미널 기록." caption="두 분기의 좌항은 형제 불리언이고 인접한 두 줄이 넘김 · 검사 빈은 토폴로지 프로브 조건이고 입력이 빠지면 닫음 · 변경 스트림은 생성자에서 고정 거짓이고 자동 구성 파일에 그 이름이 나오는 줄은 1 · 소비자 조건은 @ConditionalOnMissingBean 과 타입 다섯 · 실패는 FAILED 와 장애 조치 런북 · 그 런북에 단독 서버·오플로그·40573 은 0줄 — 91줄 · exit 0" zoom="true"
:::
```java
if (transactionsEnabled && !capabilities.isStable(MongoCapability.TRANSACTION)) {
...
if (changeStreamsEnabled && !capabilities.isStable(MongoCapability.CHANGE_STREAM)) {
```
자동 구성의 인접한 두 줄이 그 좌항을 넘긴다.
```java
properties.transactions(),
properties.changeStreams(),
```
앞의 값은 배포가 넣은 대로 도착한다. 그 값에서 무슨 일이 벌어지는지는 형제 기록이 다룬다. 여기서는 뒤의 값이 이미 거짓이라는 것과, 그런데도 조립은 진행된다는 것만 본다.
## 조립 조건
주석이 근거로 든 것은 빈이 0 이라는 사실이다.
```java
// Experimental, and therefore not a switch (MNG-INT-003). The driver-side source — watch,
// resumeAfter/startAfter, cursor lifetime, reconnection — is not shipped; what exists is policy
// and value objects that do not add up to a running consumer. Accepting the flag and ignoring …
...
changeStreams = false;
```
소비자 빈에 붙은 조건은 `@ConditionalOnMissingBean` 과 타입 다섯의 `@ConditionalOnBean` 이다.
```java
@org.springframework.boot.autoconfigure.condition.ConditionalOnBean({
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription.class,
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore.class,
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec.class,
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector.class,
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
.class
})
```
다섯 다 배포가 공급해야 하는 타입이다. 이 플래그는 목록에 없고, 자동 구성 파일 전체에서 그 이름이 나오는 줄은 검증기 인자 하나뿐이다.
## 컨텍스트를 띄운 결과
:::evidence key="a06-f018-changestreams-false-wiring" alt="전체 자동 구성을 등록한 스프링 컨텍스트를 모듈 opt-in 없이, opt-in 과 리액티브 템플릿만으로, opt-in 과 배포가 공급해야 하는 다섯을 함께, 그리고 opt-in 과 리액티브 템플릿 없이 각각 띄워 드라이버 쪽 구현 빈과 소비자 빈과 설정 빈의 유무, 그리고 바인딩된 플래그 값을 change-streams 를 넣지 않은 경우와 넣은 경우에 대해 읽은 터미널 기록." caption="opt-in 없으면 셋 다 없음 · opt-in 과 템플릿이면 드라이버 쪽만 섬 · 다섯을 넣으면 소비자도 섬 · 설정 빈은 change-streams=true 를 받고도 changeStreams()=false · 두 경우의 빈 집합이 같음 — 13줄 · exit 0" zoom="true"
:::
`MongoPlatformAutoConfiguration` 전체를 등록하고 블로킹·리액티브 템플릿을 넣어 띄웠다.
```text
[opt-in, 리액티브 템플릿만]
기본 드라이버쪽=있음 소비자=없음 설정빈=있음 changeStreams()=false
change-streams=true 드라이버쪽=있음 소비자=없음 설정빈=있음 changeStreams()=false
[opt-in, 배포가 공급해야 하는 다섯을 함께]
기본 드라이버쪽=있음 소비자=있음 설정빈=있음 changeStreams()=false
change-streams=true 드라이버쪽=있음 소비자=있음 설정빈=있음 changeStreams()=false
```
설정 빈은 컨텍스트에 있고 속성을 받는다. 받고도 거짓을 보고하므로 조립 조건에 닿기 전에 이미 값이 정해져 있다. 소비자가 서는 조건은 다섯을 공급했는지 하나다.
모듈 opt-in 이 없으면 세 빈이 모두 만들어지지 않고, opt-in 이 있어도 리액티브 템플릿이 없으면 두 빈이 만들어지지 않는다.
```text
[모듈 opt-in 없이]
기본 드라이버쪽=없음 소비자=없음 설정빈=없음
[opt-in, 리액티브 템플릿 없이]
기본 드라이버쪽=없음 소비자=없음 설정빈=있음 changeStreams()=false
```
## 능력 검사가 열리는 조건
검사가 꺼진 것과 검사가 애초에 만들어지지 않는 것은 다르다. 검사 빈부터 조건이 있다.
```java
@Bean
@ConditionalOnBean(MongoTopologyProbe.class)
public InitializingBean mongoPlatformStartupCheck(
...
if (security == null || admin == null || versions == null) {
// Fail closed rather than validate a subset. A partial startup check reports success for
// the parts nobody supplied, which is the shape the missing wiring already had.
```
토폴로지 프로브가 있어야 만들어지고, 보안 프로파일과 관리 자격 참조와 스키마 버전 범위가 다 있어야 돈다. 그리고 검증기는 능력 검사보다 먼저 선언 토폴로지와 실제를 대조한다. 그 둘을 통과한 배포에서만 변경 스트림 분기가 자기 차례를 얻고, 그 차례에서 좌항이 거짓이다.
## 그 다음 실패가 가는 곳
```java
if (failure.hasLabel("ResumableChangeStreamError")) {
return MongoChangeStreamRecoveryDecision.resume();
}
return MongoChangeStreamRecoveryDecision.halt(MongoChangeStreamState.FAILED, FAILURE_RUNBOOK);
...
private static boolean isHistoryLost(int serverCode) {
return serverCode == 286 || serverCode == 280;
```
토폴로지가 복제 셋이 아니라는 오류는 286 도 280 도 아니고 재개 가능 라벨도 없으므로 셋째 갈래다. 붙는 런북의 증상 절은 네 항목이고 전부 프라이머리 선출과 서버 선택 지연이다.
```text
- `MongoServerSelectionException` / `MongoConnectionException` spike, then recovery within seconds.
- `MongoSdamObservationListener` reports a topology change (primary removed, new primary elected).
- `MongoPoolObservationListener` shows checkout wait times rising while server-side command duration
stays flat — the wait is topology, not query cost.
- Latency spike on writes with no corresponding rise in read latency.
```
증상 절뿐 아니라 그 런북 전체에서 단독 서버도 오플로그도 해당 오류 코드도 나오지 않는다. 이 연쇄를 실제 서버에서 이은 것은 형제 기록이다.
## 확인하지 못한 것
다섯을 공급한 포크의 배포를 오플로그 없는 토폴로지에 올려 기동 통과와 커서 열기 실패를 이어서 재현하지는 않았다. 그 연쇄는 형제 기록이 단독 서버에서 실행으로 확인했다. 여기서는 조립 조건과 검사가 열리는 조건까지 확인했다.
<!-- body:end -->
@@ -0,0 +1,90 @@
---
kind: CASE
slug: autoconfiguration-in-name-only
title: 이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:autoconfiguration-in-name-only
evidenceCapturedOn: 2026-09-01
assets:
- key: autoconfiguration-in-name-only
file: ../../../final/evidence/rendered/autoconfiguration-in-name-only.svg
evidence:
- ../../../final/evidence/raw/autoconfiguration-in-name-only.txt
source:
- 원본 분석 절은 analysis/05 §14.4 이다.
---
# 이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다
이름이 AutoConfiguration 으로 끝나는 세 클래스가 실제로는 평범한 팩토리였다. 컴포지션 루트는 그 패키지를 스캔에서 뺐고, 자동설정으로 등록되지도 않았다. 능력 리포트는 세 능력을 Stable 로 보고했고 실행 컨텍스트에는 그중 아무것도 없었다.
## 관계
- **@Bean이 있다는 것은 조립 증거가 아니다**
이름과 위치가 조립을 보장하지 않는다는 사례다.
- **조건부 빈의 평가 시점 — 파싱 시점과 등록 시점**
같은 클래스에서 이어진 두 번째 결함이 그 개념을 설명한다.
- **시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다**
같은 형태의 확인 절차다.
## 문제
세 클래스가 이름을 AutoConfiguration 으로 끝냈다. 그러나 셋 다 다음을 갖고 있지 않았다.
@AutoConfiguration 애너테이션
@Bean 메서드
AutoConfiguration.imports 항목
동시에 컴포지션 루트는 이 패키지를 컴포넌트 스캔에서 의도적으로 제외한다. 자동설정이 이 패키지에 들어가는 유일한 경로이기 때문이다.
세 조건이 겹치면 결과는 하나다. 아무도 이 클래스들을 등록하지 않는다.
## 결론
능력 리포트는 트랜잭션 재시도와 완료 증거와 관측성을 Stable 로 올려 두었고, 실행 컨텍스트에는 그중 아무것도 없었다.
이 격차의 위험은 리포트를 읽는 사람에게 있다. 재시도에 의존하는 코드를 배포할 수 있고, 그 재시도는 한 번도 일어나지 않는다. 리포트가 그것을 Stable 이라고 말했기 때문이다.
수정은 등록을 추가하는 것이었다. 팩토리는 그대로 남았다. 팩토리가 조립 결정을 담고 있고, 자기 컴포지션 루트를 직접 배선하는 애플리케이션은 여전히 그것을 직접 호출할 수 있기 때문이다. 달라진 것은 기본 애플리케이션이 이제 빈을 받는다는 점이다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀
소스 수정 : x
## 재현 조건
수정된 형태를 확인하는 절차다.
1. JpaPlatformRuntimeAutoConfiguration 의 클래스 javadoc 두 번째 문단을 읽는다. 세 클래스가 무엇을 갖고 있지 않았는지 열거되어 있다.
2. CaSkeletonApplication 의 AUTO_CONFIGURED_PACKAGES 에서 이 패키지가 제외되는지 확인한다.
3. 현재 클래스에 Configuration 애너테이션과 조건들이 붙어 있고 실제 @Bean 을 갖는지 확인한다.
## 본문
<!-- body:start -->
세 클래스가 `...AutoConfiguration`으로 이름 붙었고 plain factory였다 — `@AutoConfiguration`도, `@Bean`도, `.imports` 엔트리도 없었고 합성 루트는 그 패키지를 스캔에서 제외한다.
## 세 클래스가 갖지 않은 것
:::evidence key="autoconfiguration-in-name-only" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 16줄" zoom="true"
:::
## 리포트와 컨텍스트가 어긋났다
capability 리포트는 transaction retry·completion evidence·observability를 Stable로 나열했고 **돌고 있는 컨텍스트에는 그중 아무것도 없었다.** 개발자가 재시도되지 않는 재시도에 의존하는 코드를 배포할 수 있었다.
## 확인하지 못한 것
당시 능력 리포트의 출력을 직접 보지 않았다. 이 기록은 저장소가 javadoc 에 남긴 사후 기록에 근거한다.
없음 — 수정 후 형태를 코드로 확인했다
<!-- body:end -->
@@ -0,0 +1,90 @@
---
kind: CASE
slug: conditionalonbean-evaluated-at-parse-time
title: '@ConditionalOnBean(DataSource.class)가 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다'
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:conditionalonbean-evaluated-at-parse-time
evidenceCapturedOn: 2026-09-01
assets:
- key: conditionalonbean-evaluated-at-parse-time
file: ../../../final/evidence/rendered/conditionalonbean-evaluated-at-parse-time.svg
evidence:
- ../../../final/evidence/raw/conditionalonbean-evaluated-at-parse-time.txt
source:
- 원본 분석 절은 analysis/05 §14.4 이다.
---
# @ConditionalOnBean(DataSource.class)가 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다
@Import 로 들어오는 설정 클래스에 붙은 @ConditionalOnBean 이 데이터소스 빈 정의가 생기기 전에 평가되어 항상 거짓이었다. 여덟 빈이 조용히 사라졌고, 아무것도 그것을 보고하지 않았다.
## 관계
- **조건부 빈의 평가 시점 — 파싱 시점과 등록 시점**
이 사례가 설명하는 메커니즘이다.
- **ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다**
이 함정이 현재 리비전에도 남아 있는지에 대한 미해결 질문이다.
- **이름만 AutoConfiguration이던 세 클래스가 capability 리포트에 Stable로 올라 있었다**
같은 클래스에서 앞서 일어난 결함이다.
## 문제
이 클래스는 예전에 @ConditionalOnBean(DataSource.class) 를 갖고 있었다.
문제는 이 클래스가 자동설정으로 등록되는 것이 아니라 PersistenceJpaRootAutoConfiguration 이 @Import 로 끌어온다는 점이다. 그래서 조건이 클래스 파싱 시점에 평가된다. 데이터소스 빈 정의가 아직 존재하지 않는 시점이다.
따라서 조건은 실제 배포 전부에서 거짓이었다.
## 결론
여덟 빈이 조용히 사라졌다.
아무것도 그것을 보고하지 않았다. 그 여덟에 의존하는 것이 없었기 때문이다. 결함이 드러난 것은 데이터소스 검증기가 마침내 호출자에 연결되고 JPA Compose 레인이 적격 빈 없음이라고 답했을 때다.
수정은 조건의 순서를 바꾸는 것이 아니라 조건을 제거하는 것이었다. 근거는 이렇다. 이 클래스는 JPA 루트를 통해서만 도달하고 그 루트가 이미 마스터 스위치를 갖고 있으므로, 파싱 시점에는 데이터소스가 있느냐는 질문에 이미 예라고 답한 상태다. 데이터소스가 필요한 빈은 그것을 파라미터로 받고, 스위치가 켜진 채 데이터소스가 없으면 시끄러운 실패가 된다. 계층이 사라지는 것보다 그쪽이 원하던 결과다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀
소스 수정 : x
## 재현 조건
수정된 형태를 확인하는 절차다.
1. JpaPlatformRuntimeAutoConfiguration 의 클래스 javadoc 다섯 번째와 여섯 번째 문단을 읽는다.
2. 현재 클래스 애너테이션에 ConditionalOnBean 이 없고 ConditionalOnClass 와 ConditionalOnProperty 만 있는지 확인한다.
3. PersistenceJpaRootAutoConfiguration 이 이 클래스를 Import 하는지 확인한다.
## 본문
<!-- body:start -->
이 클래스는 루트가 **import**하지 auto-configure하지 않으므로, 그 조건이 클래스 파싱 중 — datasource 빈 정의가 존재하기 전에 — 평가됐고 따라서 **모든 실제 배포에서 false**였다.
## 조건이 평가된 시점
:::evidence key="conditionalonbean-evaluated-at-parse-time" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 16줄" zoom="true"
:::
## 여덟 빈이 조용히 사라졌다
아무것도 그중 어느 것에도 의존하지 않아 아무것도 보고하지 않았다.
## 드러난 시점
datasource validator가 caller에 배선되고 Compose 레인이 "No qualifying bean"이라고 답했을 때다. 같은 함정을 피하려고 루트의 검사가 validator를 주입받지 않고 직접 생성한다.
## 확인하지 못한 것
현재 리비전의 다른 조건부 빈들이 각각 어느 시점에 평가되는지 런타임에서 확인하지 않았다. debug 부팅의 조건 평가 리포트가 그것을 답한다.
현재 리비전에서 재발하지 않는지 ConditionEvaluationReport로 확인하지 않았다
<!-- body:end -->
@@ -0,0 +1,104 @@
---
kind: CASE
slug: narrowing-the-scan-orphaned-eight-components
title: 넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:narrowing-the-scan-orphaned-eight-components
evidenceCapturedOn: 2026-09-01
assets:
- key: narrowing-the-scan-orphaned-eight-components
file: ../../../final/evidence/rendered/narrowing-the-scan-orphaned-eight-components.svg
evidence:
- ../../../final/evidence/raw/narrowing-the-scan-orphaned-eight-components.txt
source:
- 원본 분석 절은 analysis/05 §14.2 이다.
---
# 넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다
컴포지션 루트가 퍼시스턴스 패키지를 스캔에서 뺐다. 그 제외는 옳았지만 나머지 절반이 빠져 있었다. 스캔 컴포넌트로 작성된 여덟 클래스에 아무도 도달하지 않았고, 그중 하나는 트랜잭션 포트의 유일한 구현이었다.
## 관계
- **스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다**
같은 형태가 웹 리프에서 반복된 사례다.
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
스캔 제외가 그 규칙을 따른 조치라는 점이 이 사례의 전제다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
스테레오타입이 붙어 있다는 것도 조립 증거가 아니다.
## 문제
컴포지션 루트의 컴포넌트 스캔은 퍼시스턴스 어댑터 패키지 전체를 정규식으로 제외한다. javadoc 은 그 제외가 옳다고 명시한다. 선택적 능력을 선택적으로 만드는 것이 그 제외이며, JPA 가 꺼진 배포는 퍼시스턴스 빈을 조립하지 않는다.
빠진 것은 나머지 절반이다. 이 리프의 여덟 클래스가 스캔 컴포넌트로 작성되어 있었다.
SpringTransactionPort
PersistenceExceptionTranslator
StandardSqlStateErrorMapping
DomainContextAuditContextPort
멱등성 저장소와 그 리퍼
outbox 저장소와 그 리퍼
넓은 스캔이 이들에게 닿지 않게 되자 다른 어떤 것도 닿지 않았다. @Component@Repository 가 붙어 있었지만 실행 중인 어떤 애플리케이션에서도 빈이 아니었다.
특히 TransactionPort 는 구현이 아예 없는 상태가 됐다. 트랜잭션을 여는 모든 유스케이스가 그것을 열 포트를 갖지 못했다.
## 결론
단위 테스트로는 보이지 않았다. 이 클래스들은 각자의 테스트에서 직접 생성되기 때문이다.
드러난 것은 트랜잭션이 필요한 능력이 실제로 조립됐을 때다. 알림 오케스트레이터가 local-notification-ingest 레인에서 미충족 의존성으로 실패했다.
수정은 스캔을 복원하되 원래 덮었어야 할 패키지로 좁히고, PersistenceJpaRootAutoConfiguration 을 통해서만 도달하게 만드는 것이었다. 그 루트가 JPA 마스터 스위치를 갖는다. 꺼짐은 여전히 구조적이다.
두 패키지는 의도적으로 빠져 있다. fileserver 는 자기 능력 스위치로 게이트되고 자기 설정 클래스가 스캔한다. notification 은 전용 파사드가 빈 단위로 명시적으로 조립한다.
이 패키지들 아래 컴포넌트는 각자의 ConditionalOnProperty 가드를 유지한다. 스캔 대상이 된다는 것은 후보가 된다는 뜻이지 무조건 빈이 된다는 뜻이 아니다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀
소스 수정 : x
## 재현 조건
수정된 형태를 확인하는 절차다.
1. JpaAdapterComponentsConfig 의 클래스 javadoc 을 읽는다. 여덟 클래스가 이름으로 열거되어 있다.
2. CaSkeletonApplication 의 제외 정규식에 퍼시스턴스 패키지가 있는지 확인한다.
3. 이 설정 클래스가 PersistenceJpaRootAutoConfiguration 을 통해서만 도달하는지 확인한다.
4. 의도적으로 빠진 두 패키지의 대체 조립 경로를 확인한다.
## 본문
<!-- body:start -->
합성 루트의 스캔이 persistence 트리를 정규식으로 제외했고 **그 제외는 옳다** — 그것이 optional capability를 optional하게 만든다. 빠진 것은 나머지 절반이다.
## SpringTransactionPort 참조 위치
:::evidence key="narrowing-the-scan-orphaned-eight-components" alt="코드베이스에서 SpringTransactionPort 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="SpringTransactionPort 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## 여덟 클래스에 아무것도 도달하지 않았다
`SpringTransactionPort`·`PersistenceExceptionTranslator`·`StandardSqlStateErrorMapping`·`DomainContextAuditContextPort`·idempotency store와 reaper·outbox store와 reaper가 scanned component로 쓰여 있는데, 넓은 스캔이 멈추자 아무것도 도달하지 않았다.
## 특히 TransactionPort 는 구현이 전혀 없었다
트랜잭션을 여는 모든 유스케이스가 열 포트를 갖지 못했고, 단위 테스트는 각 클래스를 직접 생성하므로 볼 수 있는 것이 없었다.
## 확인하지 못한 것
당시 실패했던 local-notification-ingest 레인을 이 리비전에서 재실행하지 않았다.
없음 — 수정된 @ComponentScan 대상 6개를 코드로 확인했다
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CASE
slug: observation-downgraded-by-the-composition
title: 관측을 필수 생성자 인자로 만든 수정을 조립이 6인자 생성자로 되돌렸다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:observation-downgraded-by-the-composition
evidenceCapturedOn: 2026-09-01
body: case-observation-downgraded-by-the-composition.body.md
assets:
- key: observation-downgraded-by-the-composition
file: ../../../final/evidence/rendered/observation-downgraded-by-the-composition.svg
evidence:
- ../../../final/evidence/raw/observation-downgraded-by-the-composition.txt
- ../../../final/evidence/raw/tl-messaging-observation-noop.txt
source:
- 원본 분석 절은 final/document.md#5-2 · analysis/19 §5.1 이다.
---
# 관측을 필수 생성자 인자로 만든 수정을 조립이 6인자 생성자로 되돌렸다
발행기는 관측 구현을 필수 인자로 요구하는 생성자를 갖고 있다. 자동설정이 관측 인자가 없는 짧은 생성자를 부르고, 그 생성자는 no-op 관측으로 위임한다. 출하 배포에서 메시징 관측은 아무것도 기록하지 않는다.
## 관계
- **@Bean이 있다는 것은 조립 증거가 아니다**
타입이 요구하는 것과 조립이 넘기는 것이 다를 수 있다는 사례다.
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
발행기만 읽으면 관측이 필수로 보인다.
- **outbox가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다**
같은 조립 지점에서 나온 다른 공백이다.
## 문제
DefaultMessagePublisher 는 생성자가 둘이다.
7인자 : 관측 인자가 없고, NO_OBSERVATION 으로 위임한다
8인자 : 관측을 받고 Objects.requireNonNull 로 검사한다
8인자 쪽 javadoc 은 그것이 모든 결과를 기록하는 발행기라고 적는다. 7인자 쪽은 주입 가능한 시계를 위한 것이라고 적는다.
NO_OBSERVATION 은 필드 javadoc 이 그 성격을 밝힌다. 메트릭을 배선하지 않은 배포를 위한 no-op 관측이다. 여섯 개 기록 메서드가 전부 빈 본문이다.
## 결론
자동설정이 6인자 호출을 한다.
MessagingCoreAutoConfiguration 의 messagingPublisher 빈은 여섯 개 협력자만 받아 6인자 생성자를 호출한다. 그 생성자는 다시 7인자로, 7인자는 8인자로 NO_OBSERVATION 을 넣어 위임한다.
결과적으로 출하 배포의 발행기는 발행도, 전달도, 정산도, 백로그도, 진단도 기록하지 않는다. 관측 인터페이스는 존재하고 구현체도 존재하지만 그 사이를 잇는 조립이 없다.
이 실패의 성격은 조용하다. 빈은 생성되고 발행은 정상 동작하며 로그에도 신호가 없다. no-op 구현이 명시적으로 존재하기 때문에 널 참조도 예외도 나지 않는다. 관측이 꺼진 것과 관측이 배선되지 않은 것이 런타임에서 구별되지 않는다.
@ConditionalOnMissingBean 이 붙어 있으므로 애플리케이션이 자기 MessagePublisher 빈을 등록하면 이 자동설정은 물러난다. 그러나 이 저장소의 출하 컴포지션은 그렇게 하지 않는다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
확인 방식 : 정적 도달성 확인. 애플리케이션을 부팅하지 않았다
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/tl-messaging-observation-noop.txt 에 있다.
1. DefaultMessagePublisher 의 생성자 두 개와 NO_OBSERVATION 필드를 읽는다.
2. 6인자 호출이 7인자로, 7인자가 8인자로 위임하며 관측 자리에 NO_OBSERVATION 이 들어가는 경로를 확인한다.
3. MessagingCoreAutoConfiguration 의 messagingPublisher 빈이 몇 개의 인자로 생성자를 부르는지 확인한다.
## 본문
<!-- body:start -->
관측을 선택적 데코레이터가 아니라 필수 생성자 인자로 만든 수정이 runtime-core에 있고 그 javadoc이 "an unobserved publish path is how 'the dashboards were empty during the incident' happens"로 이유를 적는다.
## 관측을 필수 인자로 만든 수정
:::evidence key="observation-downgraded-by-the-composition" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 조립이 그 수정을 되돌린다
자동설정은 6인자 생성자를 골라 `NO_OBSERVATION`(다섯 메서드 전부 빈 본문)을 주입하고, 방출자 넷은 main 참조 0이며 등록되는 것은 협력자 둘뿐이다.
## 같은 경로가 예외 메시지를 의도적으로 버린다
둘이 합쳐지면 진단 흔적이 남지 않는다.
## 확인하지 못한 것
애플리케이션을 부팅해 액추에이터에서 messaging 메트릭 시리즈의 부재를 관측하지 않았다. 부팅 한 번이면 확증된다.
<!-- body:end -->
@@ -0,0 +1,127 @@
---
kind: CASE
slug: outbox-chain-behind-an-unsatisfiable-condition
title: outbox 가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:outbox-chain-behind-an-unsatisfiable-condition
evidenceCapturedOn: 2026-09-01
body: case-outbox-chain-behind-an-unsatisfiable-condition.body.md
assets:
- key: outbox-chain-behind-an-unsatisfiable-condition
file: ../../../final/evidence/rendered/outbox-chain-behind-an-unsatisfiable-condition.svg
evidence:
- ../../../final/evidence/raw/outbox-chain-behind-an-unsatisfiable-condition.txt
- ../../../final/evidence/raw/tl-outbox-unsatisfiable-condition.txt
source:
- 원본 분석 절은 final/document.md#4-3 · analysis/19 §7.1 이다.
---
# outbox 가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다
messaging 플랫폼의 outbox 사슬은 조건이 참이 될 수 없어 조립되지 않는다. 원인은 조건 결함이 아니라 이 저장소가 outbox 를 두 번 구현했고 다른 쪽을 출하하기 때문이다.
## 관계
- **@Bean이 있다는 것은 조립 증거가 아니다**
두 스택 중 하나만 컨텍스트에 들어간다는 것이 이 규칙의 사례다.
- **@ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다**
조건이 참이 될 수 없다는 관측이 이 규칙으로 이어진다.
- **조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다**
조립하는 쪽을 읽지 않으면 중복을 조건 결함으로 오진한다.
- **high-water mark가 본 위치를 뜻해서 재전달된 변경이 영구히 사라졌다**
같은 신뢰성 계열에서 조용한 실패가 나타난 다른 사례다.
## 문제
messaging 스타터의 MessagingReliabilityAutoConfiguration 은 outbox 와 inbox 운영 빈을 조건부로 등록한다.
81행 : OutboxRelay 는 OutboxRepository 와 OutboxEnvelopeFactory 빈이 있을 때만
106행 : OutboxRelayWorker 는 OutboxRelay 가 있을 때만
123행 : MessagingOutboxRelayLifecycle 은 OutboxRelayWorker 가 있을 때만
138행 : OutboxCleanupJob 은 OutboxRepository 가 있을 때만
167행 : InboxCleanupJob 은 InboxRepository 가 있을 때만
사슬의 뿌리는 OutboxRepository 와 OutboxEnvelopeFactory 다. 프로덕션 코드 어디에도 그 두 타입의 빈을 만드는 곳이 없다. 따라서 사슬 전체가 조립되지 않고, outbox 와 inbox 리프의 main 파일 19개 2,818 LOC 가 조용히 비어 있다.
여기까지가 앞선 분석의 판정이었고 그것은 사실이다. 다시 잰 이유는 스타터의 클래스 javadoc 이 이 조건들을 결함이 아니라 계약으로 서술하기 때문이다. 플랫폼은 그 저장소를 제공할 수 없다고 명시한다. 애플리케이션 자신의 트랜잭션에서 애플리케이션 자신의 데이터소스에 쓰기 때문이다.
그렇다면 남는 질문은 하나다. 이 저장소의 출하 애플리케이션은 그 계약을 이행하는가.
## 결론
이행하지 않는다. 대신 자기 outbox 를 갖고 있다.
스택 A 는 배선되어 출하된다.
포트 : application-core 의 outbox 패키지. OutboxStorePort 와 OutboxAppendPort 와 OutboxMessagePublishPort 와 PublishPendingOutboxEventsUseCase 를 포함해 15개 파일
구현 : persistence-jpa 의 OutboxStoreAdapter. @Repository 가 붙어 있어 컴포넌트 스캔으로 컨텍스트에 들어간다
구동 : app-bootstrap 의 OutboxConfig 가 PublishPendingOutboxEventsUseCase 를 직접 생성하고, OutboxRelayScheduler 가 @Scheduled 로 5초 간격 폴링한다
스택 B 는 어둡다.
포트 : messaging-reliability-api 의 OutboxRepository
구현 : JdbcOutboxRepository 2,276 LOC. 스프링 스테레오타입이 없다
조립 : MessagingReliabilityAutoConfiguration 81행의 조건 뒤
측정으로 확정한 것은 이렇다. main 코드에서 JdbcOutboxRepository 나 JdbcInboxRepository 나 OutboxEnvelopeFactory 를 생성하는 곳이 0 이고, app-bootstrap 에서 관련 빈을 만드는 곳도 0 이다. OutboxEnvelopeFactory 를 @Bean 으로 만드는 곳은 스타터의 테스트 하나뿐이다.
이 차이가 중요한 이유는 수정 방향이 반대이기 때문이다. 조건이 만족되지 않는다고 읽으면 app-bootstrap 에 빈을 등록하는 수정이 된다. outbox 가 둘이라고 읽으면 어느 쪽이 정본인지 먼저 정해야 하는 문제가 된다.
후자가 맞다. 두 스택은 저장 모델도 다르고 발행 경로도 다르다. 둘을 동시에 켜면 같은 업무 이벤트가 두 테이블에 적히거나 두 번 발행될 수 있다.
스타터의 javadoc 은 relay 를 자기가 구동하는 이유를 이렇게 적는다. 아무도 relay 를 돌리지 않는 것은 성공을 보고한 업무 트랜잭션 뒤에서 테이블이 차오르는 상황이라는 것이다. 그 경고는 스택 B 의 테이블에는 해당하지 않는다. 그 테이블에 쓰는 코드가 배선되어 있지 않으므로 채워질 일이 없다. 스택 B 는 위험한 것이 아니라 존재하지 않는다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
확인 방식 : 정적 도달성 전수 확인. 애플리케이션을 부팅하지 않았다
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/335-two-outboxes-one-wired.txt 에 있다.
1. MessagingReliabilityAutoConfiguration 의 조건 사슬과 클래스 javadoc 을 읽는다.
2. main 소스 전체에서 JdbcOutboxRepository 와 JdbcInboxRepository 와 OutboxEnvelopeFactory 를 생성하는 코드를 찾는다. 일치 0 이다.
3. app-bootstrap 의 main 에서 outbox 또는 inbox 저장소 빈을 만드는 곳을 찾는다. 일치 0 이다.
4. application-core 의 outbox 패키지와 persistence-jpa 의 OutboxStoreAdapter 스테레오타입, app-bootstrap 의 OutboxConfig 와 OutboxRelayScheduler 를 읽어 다른 스택이 배선되어 있음을 확인한다.
앞선 사이클의 증거 파일 tl-outbox-unsatisfiable-condition.txt 는 일부 구획에 셸 변수가 전개되지 않은 채 저장돼 있다. 그 구획의 수치는 이번에 다시 쟀다.
## 본문
<!-- body:start -->
이 저장소에는 서로를 모르는 outbox 구현이 둘 있다.
## 스택 A — 배선되어 출하된다
`application-core/.../outbox/` 의 포트와 `persistence-jpa``@Repository OutboxStoreAdapter`, 그리고 `app-bootstrap``OutboxConfig``@Scheduled` 로 구동하는 `PublishPendingOutboxEventsUseCase` 다.
## OutboxConfig 참조 위치
:::evidence key="outbox-chain-behind-an-unsatisfiable-condition" alt="코드베이스에서 OutboxConfig 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboxConfig 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 스택 B — 조건이 프로덕션에서 참이 되지 않는다
`messaging-reliability-api``OutboxRepository``JdbcOutboxRepository`(2,276 LOC)와 `OutboxRelay` 계열이며, `MessagingReliabilityAutoConfiguration:81``@ConditionalOnBean({OutboxRepository.class, OutboxEnvelopeFactory.class})` 뒤에 있다. `JdbcOutboxRepository` 에는 스프링 스테레오타입이 없고 그것을 만드는 `@Bean` 도 main 에 없다.
## 조건 결함이 아니라 정본이 정해지지 않은 중복이다
스타터의 javadoc 은 이 조건들을 결함이 아니라 계약으로 서술하고 저장소·팩토리 제공을 애플리케이션 책임으로 둔다. 두 스택은 저장 모델과 발행 경로가 다르므로 동시에 켜면 같은 이벤트가 두 번 적히거나 두 번 발행될 수 있다.
## 확인하지 못한 것
애플리케이션을 부팅해 조건 평가 리포트로 확인하지 않았다. 부팅 한 번이면 두 스택 중 어느 것이 컨텍스트에 들어가는지 직접 관측된다.
두 스택을 동시에 켰을 때 실제로 이중 기록이 일어나는지 재현하지 않았다. 저장 모델이 다르다는 것은 코드로 확인했으나 그 결과를 관측한 것은 아니다.
ConditionEvaluationReport로 미충족 사유를 확인하지 않았다
<!-- body:end -->
@@ -0,0 +1,110 @@
---
kind: CASE
slug: scan-exclusion-without-an-owner
title: 스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:scan-exclusion-without-an-owner
evidenceCapturedOn: 2026-09-01
body: case-scan-exclusion-without-an-owner.body.md
assets:
- key: scan-exclusion-without-an-owner
file: ../../../final/evidence/rendered/scan-exclusion-without-an-owner.svg
evidence:
- ../../../final/evidence/raw/scan-exclusion-without-an-owner.txt
- ../../../final/evidence/raw/tl-web-six-unowned-components.txt
source:
- 원본 분석 절은 final/document.md#3-1 · analysis/14 §8.1, §51 이다.
---
# 스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다
컴포지션 루트가 웹 플랫폼의 다섯 패키지를 컴포넌트 스캔에서 뺐다. 그 패키지를 소유해야 할 두 자동설정은 등록되어 실행되지만, 그 안의 컴포넌트 여섯 개는 어느 쪽도 만들지 않는다.
## 관계
- **@Bean이 있다는 것은 조립 증거다가 아니다**
자동설정이 등록되고 실행된다는 것과 그것이 무엇을 만드는가는 별개다.
- **Spring 조립의 세 경로와 각각이 결정하는 것**
스캔 제외와 자동설정 소유가 서로를 보완해야 하는 구조다.
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
스캔 제외라는 구조적 선택 자체는 이 규칙을 따른 것이다.
## 문제
컴포지션 루트는 @SpringBootApplication 대신 @ComponentScan 을 직접 쓰고, 정규식 하나로 자동설정이 소유해야 할 패키지들을 스캔에서 뺀다.
그 목록에 웹 플랫폼의 다섯 패키지가 들어 있다.
adapter.inbound.web.mvc.error
adapter.inbound.web.mvc.budget
adapter.inbound.web.mvc.operation
adapter.inbound.web.webflux.error
adapter.inbound.web.webflux.operation
주석은 이 다섯이 목록에 있는 이유를 명시한다. 그 안의 어드바이스와 컨트롤러는 해당 플랫폼 자동설정이 활성일 때만 존재하는 빈을 필요로 하는데, 컴포넌트 스캔은 그 조건과 무관하게 그것들을 찾는다. 그래서 전부 꺼진 배포나 부분 설정 배포가 미충족 의존성으로 기동에 실패했다. 자동설정이 소유하게 하는 것이 컨트롤의 존재를 그 의존성의 존재에 묶는 방법이다.
의도는 명확하다. 문제는 그 소유가 실제로 일어나는가다.
## 결론
두 자동설정은 등록되어 있고 출하 컨텍스트에 도달한다. 그러나 그 안의 컴포넌트 여섯 개를 만들지 않는다.
스캔에서 뺀 다섯 패키지 안의 컴포넌트와 그 main 참조 수는 이렇다.
WebMvcProblemExceptionHandler : @RestControllerAdvice @Order, main 참조 0
WebFluxProblemExceptionHandler : @RestControllerAdvice @Order, main 참조 0
WebMvcBudgetExceptionHandler : @RestControllerAdvice @Order, main 참조 0
WebMvcBudgetFilter : 애너테이션 없음, main 참조 0
OperationHttpController : @RestController, main 참조 0
ReactiveOperationHttpController : @RestController, main 참조 0
두 자동설정이 만드는 것은 각각 13개와 10개 빈이고, @Import 는 0 이다. 그 23개는 전부 협력자다. 문제 카탈로그, 예산 카탈로그, 검증 예외 매퍼, URI 정책, 요청 ID 필터 같은 것들이며 위 여섯 중 하나도 포함하지 않는다.
즉 스캔 제외는 소유권을 자동설정으로 옮기려는 조치였는데, 옮겨받을 쪽이 그것을 받지 않았다. 여섯 컴포넌트는 스캔에서도 빠지고 자동설정에도 없다. 어디에도 없다.
기동은 성공한다. 미충족 의존성으로 실패하던 원래 증상은 사라졌다. 다만 그 컨트롤들도 함께 사라졌다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
확인 방식 : 정적 도달성 확인. 애플리케이션을 부팅하지 않았다
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/tl-web-six-unowned-components.txt 에 있고, 터미널 자산은 evidence/terminal 아래에 있다.
1. CaSkeletonApplication 의 AUTO_CONFIGURED_PACKAGES 정규식을 읽고 제외 대상 패키지를 확인한다.
2. 그 다섯 패키지 안의 스프링 스테레오타입 컴포넌트를 열거한다.
3. 각각에 대해 main 소스에서의 참조 수를 센다.
4. WebMvcPlatformAutoConfiguration 과 WebFluxPlatformAutoConfiguration 의 @Bean 목록과 @Import 수를 확인한다.
## 본문
<!-- body:start -->
합성 루트가 다섯 web 패키지를 스캔에서 제외하고 근거를 "Ownership by auto-configuration is what ties a control's presence to its dependency's"로 적었다.
## ProblemCatalog 참조 위치
:::evidence key="scan-exclusion-without-an-owner" alt="코드베이스에서 ProblemCatalog 를 검색한 출력 15줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ProblemCatalog 코드베이스 검색 — 15줄 · exit 0" zoom="true"
:::
## 두 자동설정은 실제로 존재하고 도달한다
`.imports`에 있고 출하 컨텍스트에 도달하는데, 등록하는 `@Bean` 13개·10개가 전부 협력자이고 제외된 패키지의 컴포넌트 여섯은 어느 쪽도 소유하지 않는다(전부 main 참조 0).
## 빈은 있고 그것을 쓰는 조언은 빈이 아니다
`ProblemCatalog``WebProblemFactory`는 빈이고 그것을 쓰는 `@RestControllerAdvice`는 빈이 아니다.
## 확인하지 못한 것
애플리케이션을 부팅해 실제 빈 목록으로 확인하지 않았다. 부팅 한 번이면 여섯 컴포넌트의 부재가 직접 관측된다.
<!-- body:end -->
@@ -0,0 +1,90 @@
---
kind: CASE
slug: thirteen-startup-rules-never-run
title: 시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:thirteen-startup-rules-never-run
evidenceCapturedOn: 2026-09-01
assets:
- key: thirteen-startup-rules-never-run
file: ../../../final/evidence/rendered/thirteen-startup-rules-never-run.svg
evidence:
- ../../../final/evidence/raw/thirteen-startup-rules-never-run.txt
source:
- 원본 분석 절은 final/document.md#5-3 · analysis/20 §3.1 이다.
---
# 시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다
gRPC 플랫폼의 시작 검증기는 188줄에 13개 위반 규칙을 담고 있다. 이 검증기를 부르는 것은 자기 테스트뿐이고, 유일한 자동설정 지점은 부르지 않는다.
## 관계
- **시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다**
이 사례에서 끌어낸 확인 절차다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
검증기가 존재한다는 것과 그것이 도는 것은 별개다.
## 문제
GrpcPlatformStartupValidator 는 188줄이고 violations 목록에 13개 항목을 추가한다. TLS 요구, 실행기 풀 크기, 채널 프로파일, 자격증명 누출 등을 검사한다.
이 검증기를 호출하는 곳을 저장소 전체에서 찾으면 자기 테스트 GrpcPlatformStartupValidatorTest 하나뿐이다.
같은 패키지의 GrpcPlatformAutoConfiguration 은 106줄이고 @Bean 이 9개인데, 그중 어느 것도 이 검증기를 부르지 않는다.
## 결론
검증기는 정확하고 잘 테스트되어 있으며 돌지 않는다.
이 판정은 gRPC 블록 전체가 어떤 런타임 컴포지션에도 속하지 않는다는 더 큰 사실 안에 있다. 그 상태는 저장소가 문서로 인정하고 있으며 결함이 아니다. 다만 이 검증기의 경우, 블록이 배포되기 시작하는 날에도 자동으로 돌기 시작하지는 않는다는 점이 남는다. 조립 지점이 그것을 부르지 않기 때문이다.
13개 규칙은 테스트로 고정되어 있으므로 회귀는 잡힌다. 잡히지 않는 것은 그 규칙이 실행 시점에 적용되는가다.
확인 절차로 일반화하면 이렇다. 시작 검증기가 실제로 도는지는 그 능력에 자동설정 루트가 있고 그 루트가 검증기를 부르는지와 일치한다. 검증기 파일의 존재나 그 테스트의 통과는 답이 아니다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
확인 방식 : 정적 도달성 확인
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/267-grpc-family-reachability.txt 에 있다.
1. GrpcPlatformStartupValidator 의 줄 수와 violations 추가 지점 수를 센다.
2. 저장소 전체에서 이 타입의 참조를 찾고 main 과 test 를 구분한다.
3. GrpcPlatformAutoConfiguration 의 @Bean 목록에서 검증기 호출이 있는지 확인한다.
## 본문
<!-- body:start -->
validator가 5개 그룹 13개 규칙을 갖고(transport·security 4 / executor 2 / methods 4 / channels 2 / advanced isolation 1) javadoc이 그 13개를 고른 기준을 "None of them fails a smoke test"로 적는다.
## 다섯 그룹 13개 규칙
:::evidence key="thirteen-startup-rules-never-run" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 유일한 조립 지점이 부르지 않는다
자동설정은 `@Bean` 9개를 만들면서 이 validator를 부르지 않고, static 메서드라 빈이 될 수도 없다.
## 두 개의 강제가 이 하나를 통해서만 성립한다
CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 startup을 거부한다"와 §2.2의 runtime 강제 둘 다이므로, 둘 다 실행되지 않는다.
## 확인하지 못한 것
gRPC 블록이 어떤 배포에도 포함되지 않으므로 런타임 관측은 불가능하다. 이 판정은 정적 도달성에 근거한다.
build-only 가족이라 부팅 확인이 불가능하다 — 채택 시점에만 관측 가능
<!-- body:end -->
@@ -0,0 +1,147 @@
---
kind: CONCEPT
slug: three-assembly-paths
title: Spring 조립의 세 경로와 각각이 결정하는 것
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:three-assembly-paths
evidenceCapturedOn: 2026-09-01
assets:
- key: three-assembly-paths
file: ../../../final/evidence/rendered/three-assembly-paths.svg
- key: three-assembly-paths-diagram
file: ../../../final/assets/diagrams/three-assembly-paths.svg
evidence:
- ../../../final/evidence/raw/three-assembly-paths.txt
- ../../../final/evidence/raw/tl-web-six-unowned-components.txt
source:
- 원본 분석 절은 final/document.md#1-4, #7-1 · analysis/18 · analysis/14 §7.1 이다.
---
# Spring 조립의 세 경로와 각각이 결정하는 것
이 저장소의 빈은 컴포넌트 스캔, 자동설정 등록, 그리고 컨텍스트 이전 확장의 세 경로로 들어온다. 어떤 컴포넌트가 실제로 존재하는지는 그 셋 중 어느 것이 그것을 소유하는가로 정해진다.
## 관계
- **스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다**
경로가 바뀌는 지점에서 소유권이 끊긴 사례다.
- **넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다**
같은 형태가 퍼시스턴스 리프에서 나타난 사례다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
이 개념을 확인 절차로 옮긴 규칙이다.
## 본문
<!-- body:start -->
이 저장소에서 빈이 컨텍스트에 들어오는 경로가 셋이고 각각 다른 질문에 답한다.
## 조립의 세 경로
:::evidence key="three-assembly-paths-diagram" alt="빈이 들어오는 경로에서 컴포넌트 스캔과 imports 와 spring.factories 세 갈래가 나온다" caption="조립의 세 경로" zoom="false"
:::
**컴포넌트 스캔**(`@ComponentScan` + `AUTO_CONFIGURED_PACKAGES` 제외 정규식) · **`.imports`**(8개 파일 / 13개 클래스가 전부) · **`spring.factories`**(EnvironmentPostProcessor 6 · SpringBootExceptionReporter · AutoConfigurationImportFilter · ApplicationListener).
## 세 경로가 각각 답하는 질문
:::evidence key="three-assembly-paths" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 조립 표면이 좁다
조립 결함을 판정할 때 세 경로를 다 읽어야 한다. main Java 4,614개 중 스테레오타입 보유가 206개(4.5%)뿐이다.
:::note
없음 — 세 경로의 내용을 전수 판독했다
:::
## 경로 1 — 컴포넌트 스캔
컴포지션 루트는 `@SpringBootApplication` 대신 `@SpringBootConfiguration` + `@EnableAutoConfiguration` + `@ComponentScan`을 직접 쓴다. javadoc이 이유를 적는다 — 스캔에 `excludeFilters`가 필요한데 그 애너테이션은 속성을 노출하지 않으므로, 스캔할 패키지를 대신 명시한다.
```java
@ComponentScan(
basePackages = {
"dev.caskeleton.bootstrap",
"dev.caskeleton.adapter",
"dev.caskeleton.application",
"dev.caskeleton.domain",
"dev.caskeleton.shared"
},
excludeFilters = {
@ComponentScan.Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
@ComponentScan.Filter(
type = FilterType.CUSTOM,
classes = AutoConfigurationExcludeFilter.class),
@ComponentScan.Filter(
type = FilterType.REGEX,
pattern = CaSkeletonApplication.AUTO_CONFIGURED_PACKAGES)
})
```
정규식은 13개 패키지 접두사를 제외한다. 클래스 목록이 아니라 접두사인 이유도 적혀 있다 — 선택적 능력에 새 설정이 추가됐을 때, 아무도 제외를 기억하지 못했다는 이유로 활성화되면 안 되기 때문이다.
## 경로 2 — 자동설정 등록
제외된 패키지는 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`로 들어온다. 이 저장소에는 그런 파일이 8개 있다.
```text
adapter/inbound/graphql
adapter/inbound/web
adapter/outbound/cache-redis
adapter/outbound/messaging
adapter/outbound/persistence-mongo
app-bootstrap
grpc/grpc-spring-boot-starter
messaging/messaging-spring-boot-starter
```
여기에 관리 컨텍스트용 `ManagementContextConfiguration.imports`가 하나 더 있다. Fileserver admin 라우트가 공개 커넥터가 아니라 관리 평면에 실리는 것이 이 파일의 역할이다.
## 경로 3 — 컨텍스트가 생기기 전
`META-INF/spring.factories`는 컨텍스트가 존재하기 전에 동작하는 확장을 등록한다.
```text
org.springframework.boot.EnvironmentPostProcessor=\
dev.caskeleton.bootstrap.activation.MasterSwitchEnvironmentPostProcessor,\
dev.caskeleton.bootstrap.activation.RuntimeEnvironmentProfileValidator,\
dev.caskeleton.bootstrap.activation.CapabilityDependencyEnvironmentValidator,\
...
org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\
dev.caskeleton.bootstrap.autoconfigure.persistencejpa.JpaOffAutoConfigurationImportFilter
```
`AutoConfigurationImportFilter`가 특히 중요하다. 이것은 후보 집합 자체를 좁히므로, 프로젝트의 어떤 조건보다 먼저 동작한다. 프레임워크 자신의 자동설정을 후보에서 빼는 것이 그 일이다.
## 세 경로가 만드는 함정
| 상황 | 증상 |
|---|---|
| 스캔에서 뺐는데 자동설정이 소유하지 않음 | 컴포넌트가 아무 데도 없다. 기동은 성공한다 |
| 자동설정 파일에 없는데 이름만 AutoConfiguration | 등록되지 않는다. 능력 리포트는 있다고 말한다 |
| `@Import`로 들어오는 클래스에 빈 조건 | 파싱 시점 평가라 빈 정의가 아직 없다 |
:::warning
세 경로의 공통점은 실패가 조용하다는 것이다. Spring은 "등록되지 않은 컴포넌트"를 오류로 보지 않는다. 그것이 정상 동작이기 때문이다.
:::
## 확인 순서
어떤 컴포넌트가 실제로 존재하는지 물을 때는 순서가 있다.
1. 컴포지션 루트의 스캔 범위와 제외 정규식을 읽는다
2. 제외됐다면 `.imports` 파일에서 그 패키지의 소유자를 찾는다
3. 소유자가 있다면 그것이 `@Bean`이나 `@Import`로 대상을 실제로 만드는지 확인한다
4. 조건이 붙어 있다면 그 조건이 언제 평가되는지 확인한다
<!-- body:end -->
@@ -0,0 +1,114 @@
---
kind: CONCEPT
slug: when-conditions-are-evaluated
title: 조건부 빈의 평가 시점 — 파싱 시점과 등록 시점
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:when-conditions-are-evaluated
evidenceCapturedOn: 2026-09-01
assets:
- key: when-conditions-are-evaluated
file: ../../../final/evidence/rendered/when-conditions-are-evaluated.svg
evidence:
- ../../../final/evidence/raw/when-conditions-are-evaluated.txt
source:
- 원본 분석 절은 analysis/05 §14.4 이다.
---
# 조건부 빈의 평가 시점 — 파싱 시점과 등록 시점
같은 `@ConditionalOnBean`이라도 그 클래스가 자동설정으로 등록되는지 `@Import`로 들어오는지에 따라 평가 시점이 다르다. 그 차이가 조건을 항상 거짓으로 만들 수 있다.
## 관계
- **ConditionalOnBean(DataSource)이 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다**
이 개념이 실제로 문제가 된 사례다.
- **ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다**
이 개념에서 나온 확인 규칙이다.
- **ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다**
현재 리비전에 대한 미해결 질문이다.
## 본문
<!-- body:start -->
`@ConditionalOnBean`은 그 클래스가 **언제 평가되는가**에 따라 답이 달라진다. `@AutoConfiguration`으로 등록되면 다른 자동설정 이후에 평가되지만, plain `@Configuration`이 `@Import`로 들어오면 **클래스가 파싱되는 동안 — 대상 빈 정의가 존재하기 전에** 평가된다.
## 등록 방식이 평가 시점을 정한다
:::evidence key="when-conditions-are-evaluated" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 16줄" zoom="true"
:::
## 이 저장소가 그 함정을 밟았다
여덟 빈이 조용히 사라졌다.
## 남은 회피 방법 둘
검증기를 주입받지 않고 직접 생성하기, 그리고 조건을 루트로 올리기.
:::note
현재 리비전의 각 조건부 빈이 어느 시점에 평가되는지는 ConditionEvaluationReport로 확인하지 않았다
:::
## 두 시점
`@ConditionalOnBean`은 "이 타입의 빈 정의가 이미 등록되어 있는가"를 묻는다. 그 질문의 답은 언제 묻느냐에 달라진다.
| 클래스가 들어오는 경로 | 조건이 평가되는 시점 |
|---|---|
| 자동설정 (`.imports`) | 자동설정 순서에 따라 등록 단계에서 |
| `@Import` | 그것을 import 하는 클래스가 파싱될 때 |
| 컴포넌트 스캔 | 스캔 단계에서 |
`@Import`로 들어오는 클래스가 문제다. 부모 설정이 파싱되는 시점에 자식 클래스의 클래스 수준 조건이 함께 평가되는데, 그 시점에는 자동설정이 만들 빈 정의가 아직 존재하지 않는다.
## 이 저장소가 겪은 형태
```java
/**
* <p>This class used to carry {@code @ConditionalOnBean(DataSource.class)}. It is imported by
* {@code PersistenceJpaRootAutoConfiguration} rather than auto-configured, so that condition was
* evaluated while the class was parsed — before the datasource bean definition existed — and was
* therefore false in every real deployment. All eight beans below silently disappeared, and nothing
* reported it because nothing depended on any of them. It surfaced only when the datasource
* validator was finally wired to a caller and the JPA Compose lane answered "No qualifying bean".
*/
```
두 문장이 이 개념의 핵심이다. 조건이 모든 실제 배포에서 거짓이었다는 것, 그리고 아무것도 그것을 보고하지 않았다는 것.
보고되지 않은 이유가 특히 중요하다. 사라진 여덟 빈에 의존하는 것이 없었기 때문이다. 의존이 있었다면 미충족 의존성으로 시끄럽게 실패했을 것이다.
## 해결 방향은 순서가 아니라 제거였다
```java
/**
* <p>The condition is removed rather than reordered: this class is reached only through the JPA
* root, which already carries the master switch, so "is there a datasource" has been answered yes
* by the time it is parsed. A bean here that needs one takes it as a parameter, and a missing
* datasource with the switch on is then a loud failure — which is the outcome that was wanted,
* rather than the layer vanishing.
*/
```
:::tip
조건을 옮기거나 순서를 바꾸는 대신, 그 조건이 이미 답해진 지점으로 도달 경로를 제한하고 조건 자체를 없앴다. 그리고 필요한 의존은 파라미터로 받게 해서, 없을 때 조용히 사라지는 대신 시끄럽게 실패하게 만들었다.
:::
## 확인 방법
정적으로는 두 가지를 본다.
1. 그 클래스가 `.imports`에 있는가, 아니면 다른 클래스가 `@Import` 하는가
2. 조건이 요구하는 빈을 누가 언제 등록하는가
런타임으로는 `debug=true` 부팅의 조건 평가 리포트가 답한다. `Negative matches` 항목의 사유 문자열이 "빈 정의 없음"인지 "타입 자체가 없음"인지를 구별해 준다.
<!-- body:end -->
@@ -0,0 +1,63 @@
---
kind: PROJECT_DECISION
slug: one-root-owns-the-master-switch
title: 마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:one-root-owns-the-master-switch
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java
- src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/MongoRootAutoConfiguration.java
- src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java
- src/app-bootstrap/src/main/resources/META-INF/spring.factories
- analysis/19-messaging-platform.md
- analysis/05-adapter-outbound-persistence-jpa.md
---
# 마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다
## 결정문
능력의 활성 여부는 자동설정 루트 하나가 판정하고, 그 루트가 임포트하는 자식 설정은 같은 조건을 갖지 않는다.
## 판단 이유
같은 속성을 읽는 주체가 여럿이면 각자가 다른 것이 켜졌다고 믿는 상태가 생긴다. mongo 리프에서 실제로 그랬다. 자동설정 임포트 필터와 컴포넌트 스캔되는 퍼시스턴스 설정과 플랫폼 자동설정이 각각 같은 속성을 읽었고, 셋 다 다른 둘이 꺼졌다고 믿는 무언가를 조립할 수 있었다.
자식이 조건을 반복하는 방식은 다음 달에 추가되는 빈이 그 조건을 기억해야 한다는 뜻이다. 기억하지 못한 빈 하나가 꺼진 능력을 부분적으로 켠다.
루트가 조건을 소유하면 그 문제가 구조적으로 사라진다. 자식에 빈이 추가되어도 도달 경로가 루트를 지나므로 자동으로 게이트된다.
그리고 자식을 컴포넌트 스캔에서 빼는 것이 이 구조의 나머지 절반이다. 스캔이 자식 설정을 독립적으로 발견하면 루트를 우회하기 때문이다.
임포트 필터는 권한을 잃고 도구로 남는다. 프레임워크 자신의 자동설정을 후보 집합에서 빼는 일은 어떤 프로젝트 조건보다 먼저 일어나야 하므로 그 자리가 필요하지만, 능력이 켜졌는지 판정하는 것은 그 필터의 일이 아니다.
## 영향
감수하는 것
스캔 제외와 자동설정 소유를 한 쌍으로 관리해야 한다. 한쪽만 하면 컴포넌트가 어디에도 없게 되고, 이 저장소에서 그 실패가 두 번 일어났다.
루트를 통하지 않는 도달 경로가 생기면 이 구조가 깨진다. 그 경로를 막는 것은 규약이 아니라 스캔 제외 정규식이다.
패키지 접두사로 제외하므로, 제외된 패키지에 새로 만든 클래스는 자동설정이 소유하지 않으면 존재하지 않는다.
얻는 것
꺼짐이 구조적 사실이 된다. 조건을 반복해서 지키는 것이 아니라 도달 경로가 하나뿐이다.
자식에 빈을 추가하는 사람이 마스터 스위치를 몰라도 된다.
## 근거
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
이 결정을 규칙으로 옮긴 것이다.
- **프레임워크가 기여하는 자동설정까지 세지 않으면 스위치가 아니다**
이 결정이 임포트 필터에 남긴 역할이다.
- **스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다**
이 구조의 나머지 절반이 빠졌을 때의 결과다.
@@ -0,0 +1,63 @@
---
kind: PROJECT_DECISION
slug: pool-need-is-a-capability-question
title: 풀이 필요한지는 "JPA가 켜졌나"가 아니라 "커넥션이 필요한 capability가 있나"로 묻는다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:pool-need-is-a-capability-question
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/DataSourceRequirement.java
- analysis/05-adapter-outbound-persistence-jpa.md
- analysis/18-app-bootstrap.md
---
# 풀이 필요한지는 "JPA가 켜졌나"가 아니라 "커넥션이 필요한 capability가 있나"로 묻는다
## 결정문
관계형 커넥션 풀을 열지 말지는 활성 능력 중 커넥션을 요구하는 것이 하나라도 있는지로 판정한다.
## 판단 이유
풀은 JPA 의 사유물이 아니다. outbox 와 JDBC 멱등성 저장소와 다중 인스턴스 락과 알림 저장소와 Fileserver 트랜잭션 경로가 전부 커넥션을 필요로 한다.
JPA 가 꺼졌는지만 물으면 두 방향으로 틀린다. 아무도 쓰지 않는 풀을 열거나, 정당하게 쓰고 있던 능력을 조용히 망가뜨린다.
그래서 요구하는 능력을 이름으로 열거하고 그 논리합으로 판정한다. 여섯 조건이다.
퍼시스턴스 JPA 마스터 스위치
outbox 활성
멱등성 제공자가 jdbc
다중 인스턴스 락 활성
알림 플랫폼의 관계형 저장소 요구
Fileserver 플랫폼이 관계형 트랜잭션 제공자와 함께 활성
각 조건은 참일 때 사람이 읽을 수 있는 사유 문자열을 남긴다. 그래서 판정이 예 또는 아니오가 아니라 근거 목록이 되고, 그것이 운영자가 읽을 수 있는 의존성 오류가 된다.
## 영향
감수하는 것
능력이 추가될 때마다 이 목록을 갱신해야 한다. 빠뜨리면 그 능력은 풀 없이 조립되고 첫 사용에서 실패한다.
목록이 설정 속성 이름에 직접 의존한다. 속성 이름이 바뀌면 이 판정도 함께 바뀌어야 한다.
얻는 것
꺼진 JPA 배포에서 outbox 만 켠 구성이 정상 동작한다.
풀이 열린 이유를 사람이 읽을 수 있다. 판정이 사유 목록을 남기기 때문이다.
## 근거
- **프레임워크가 기여하는 자동설정까지 세지 않으면 스위치가 아니다**
이 결정이 속한 규칙 계열이다.
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
같은 조립 원칙의 다른 면이다.
- **마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다**
이 판정을 소비하는 구조다.
@@ -0,0 +1,68 @@
---
kind: QUESTION
slug: conditional-evaluation-order-unverified
title: '@ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다'
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:conditional-evaluation-order-unverified
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# @ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다
조건이 거짓인 이유는 뿌리 타입 부재일 수도 있고 평가 시점일 수도 있다. 정적으로는 전자로 보이지만 둘은 같은 증상을 낸다.
## 사실
이 저장소에 평가 시점 함정의 실제 사례가 기록되어 있다. Import 로 들어오는 설정의 클래스 수준 조건이 파싱 시점에 평가되어 여덟 빈이 사라졌다.
그 회피를 위해 루트가 검증기를 직접 생성한다.
outbox 사슬의 경우 JdbcOutboxRepository 에 스프링 스테레오타입이 없고, 그것을 생성하는 main 코드가 0 이며, OutboxEnvelopeFactory 를 Bean 으로 만드는 곳은 테스트 하나뿐이다.
## 가정
정적 측정이 보여 주는 뿌리 부재가 런타임 사유와 일치할 것이라고 전제하고 있다. 두 원인이 같은 증상을 내므로 이 전제는 확인 전까지 가정이다.
## 미지수
현재 리비전의 조건부 빈들이 각각 어느 시점에 평가되는가.
outbox 사슬이 뿌리 부재인가 평가 시점인가.
## 제약
런타임 사유 문자열은 debug 부팅으로만 얻을 수 있다.
애플리케이션 소스를 수정하지 않는다.
## 선택지
debug 부팅으로 부정 매치 사유를 읽는다
가장 직접적이다. OnBeanCondition 이 남기는 사유가 뿌리 타입 부재를 지목하는지가 갈림점이다.
정적 측정만으로 뿌리 부재로 확정한다
생산자가 어디에도 없다는 측정은 강하지만, 평가 시점 문제가 함께 있을 가능성을 배제하지 못한다.
## 다음 검증
debug 를 켜고 부팅해 시작 로그의 부정 매치 사유 문자열을 읽는다.
사유가 뿌리 타입 부재를 지목하면 해당 Case 의 원인 분류가 확정된다.
평가 시점 문제로 드러나면 분류와 조치가 바뀐다.
## 관계
- **outbox가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다**
이 질문이 원인을 확정하려는 사례다.
- **ConditionalOnBean(DataSource)이 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다**
같은 함정이 실제로 일어난 기록이다.
- **조건부 빈의 평가 시점 — 파싱 시점과 등록 시점**
이 질문이 다루는 메커니즘이다.
- **부팅 한 번으로 확증 가능한 두 건이 아직 정적 추론으로만 남아 있다**
같은 부팅으로 함께 확인된다.
@@ -0,0 +1,71 @@
---
kind: QUESTION
slug: one-boot-would-settle-two-findings
title: 부팅 한 번으로 확증 가능한 두 건이 아직 정적 추론으로만 남아 있다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: open-question:one-boot-would-settle-two-findings
questionStatus: OPEN
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 부팅 한 번으로 확증 가능한 두 건이 아직 정적 추론으로만 남아 있다
관측 no-op 과 outbox 사슬 미조립은 둘 다 정적 도달성으로 판정했다. 브로커 없이 부팅 한 번이면 둘 다 직접 관측된다.
## 사실
messagingPublisher 빈이 6인자 생성자를 호출하고 그 생성자가 NO_OBSERVATION 을 넘긴다.
관측 방출자 넷의 main 참조가 0 이다.
outbox 와 inbox 저장소 구현의 main 참조가 0 이다.
JdbcOutboxRepository 에 스프링 스테레오타입이 없고 그것을 생성하는 main 코드도 없다.
## 가정
액추에이터 메트릭 목록이 조립되지 않은 관측을 시리즈 부재로 드러낼 것이라고 전제하고 있다. 다른 경로로 같은 이름의 시리즈가 등록될 가능성은 배제하지 않았다.
## 미지수
actuator 메트릭에 messaging 으로 시작하는 시리즈가 실제로 없는가.
조건 평가 리포트가 outbox 사슬 미충족을 어떤 사유로 보고하는가.
## 제약
브로커 없이 부팅해야 한다. 이 확인에 실제 카프카는 필요하지 않다.
애플리케이션 소스를 수정하지 않는다.
## 선택지
한 번 부팅해 둘을 함께 캡처한다
messaging 을 켜고 브로커를 카프카로 지정하고 debug 를 켜서 부팅한다. 메트릭 목록과 시작 로그의 부정 매치를 한 번에 얻는다.
정적 판정을 유지하고 확인하지 못한 것으로 남긴다
비용은 없지만 두 Case 의 확인하지 못한 것 항목이 계속 남는다.
## 다음 검증
app.messaging.enabled 를 참으로, app.messaging.broker 를 kafka 로, debug 를 참으로 두고 한 번 부팅한다. 그다음 두 가지를 캡처한다.
액추에이터 메트릭의 시리즈 목록
시작 로그의 부정 매치 중 MessagingReliabilityAutoConfiguration 항목
브로커가 없어도 둘 다 가능하다.
두 관측이 정적 판정과 일치하면 두 Case 의 확인하지 못한 것을 지우고 이 질문을 닫는다.
어긋나면 모듈 분석을 먼저 갱신한다.
## 관계
- **관측을 필수 생성자 인자로 만든 수정을 조립이 6인자 생성자로 되돌렸다**
이 질문이 확증하려는 첫 번째 판정이다.
- **outbox가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다**
이 질문이 확증하려는 두 번째 판정이다.
@@ -0,0 +1,64 @@
---
kind: REFERENCE
slug: a-bean-is-not-composition-evidence
title: '@Bean이 있다는 것은 조립 증거가 아니다'
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:a-bean-is-not-composition-evidence
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# @Bean이 있다는 것은 조립 증거가 아니다
## 목적
클래스에 붙은 애너테이션이나 팩토리의 존재를 그것이 실행 컨텍스트에 있다는 증거로 읽는 것을 막는다.
## 규칙
1. 애너테이션은 후보를 만들 뿐이다
Component 나 Repository 나 Bean 은 이 클래스가 빈이 될 수 있다는 뜻이지 빈이라는 뜻이 아니다. 스캔 범위 밖이거나 조건이 거짓이거나 소유자가 없으면 후보로 끝난다.
2. 이름은 아무것도 보장하지 않는다
이름이 AutoConfiguration 으로 끝나는 클래스가 자동설정 파일에 없으면 등록되지 않는다.
3. 타입이 하나뿐이어도 그것이 빈이라는 뜻은 아니다
구현이 하나뿐인 포트는 그 하나가 조립된다는 인상을 준다. 그 하나에 스테레오타입이 없고 생성하는 코드도 없으면 포트는 비어 있다.
4. 확인은 도달 경로로 한다
세 경로 중 어느 것이 이 클래스를 소유하는지 묻는다. 스캔이면 범위와 제외를, 자동설정이면 imports 파일과 조건을, 명시 조립이면 그 생성 지점을 확인한다.
5. main 참조 0 은 강한 신호다
프로덕션 소스에서 그 타입을 참조하는 파일이 자기 자신뿐이면, 테스트만 그것을 쓴다는 뜻이다.
## 적용 조건
능력이 활성인지 판정할 때
능력 리포트나 지원 매트릭스의 항목을 검증할 때
결함을 보고하기 전에 그 코드가 실제로 도는지 확인할 때
## 예외
명시적으로 애플리케이션이 제공하도록 설계된 포트는 플랫폼 쪽에 생산자가 없는 것이 정상이다. 그때 물을 것은 출하 애플리케이션이 그것을 제공하는가다.
## 예시
이름이 AutoConfiguration 인 세 클래스가 애너테이션도 Bean 도 imports 항목도 갖지 않은 채 능력 리포트에 Stable 로 올라 있었다.
스캔에서 제외된 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 만들지 않았다. 두 자동설정은 등록되어 실행되고 있었다.
JdbcOutboxRepository 는 2,276 줄이고 스프링 스테레오타입이 없으며 그것을 생성하는 main 코드가 없다.
## 관계
- **스캔에서 뺀 다섯 패키지의 컴포넌트 여섯을 두 자동설정 어느 쪽도 소유하지 않았다**
이 규칙이 필요한 대표 사례다.
- **outbox가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다**
타입이 하나뿐인데도 조립되지 않는 사례다.
- **Spring 조립의 세 경로와 각각이 결정하는 것**
이 규칙의 확인 절차가 기대는 구조다.
@@ -0,0 +1,60 @@
---
kind: REFERENCE
slug: conditionalonbean-must-be-satisfiable
title: '@ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다'
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:conditionalonbean-must-be-satisfiable
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# @ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다
## 목적
조건부 빈을 선언해 두고 그 조건을 만족시킬 수 있는 경로가 있는지 확인하지 않아, 능력 전체가 조용히 없는 상태를 막는다.
## 규칙
1. 조건의 뿌리를 끝까지 따라간다
사슬이면 뿌리 하나가 없을 때 전부 없다. 뿌리 타입의 빈을 만드는 곳이 프로덕션에 있는지 확인한다.
2. 테스트의 Bean 은 답이 아니다
조건을 만족시키는 유일한 곳이 테스트 설정이면 프로덕션에서는 만족되지 않는다.
3. 플랫폼이 제공하지 않겠다고 선언한 경우 질문을 바꾼다
애플리케이션이 제공해야 하는 계약이라면, 물을 것은 조건이 아니라 출하 애플리케이션이 그 계약을 이행하는가다.
4. 조건 불만족은 오류로 보고되지 않는다
Spring 은 조건부 빈이 조건을 만족하지 못하는 것을 정상 동작으로 본다. 로그에도 액추에이터에도 신호가 없다.
5. 꺼진 것과 조립될 수 없는 것을 구별할 방법을 남긴다
둘이 런타임에서 같아 보이면 운영자는 차이를 알 수 없다.
## 적용 조건
조건부 자동설정을 작성하거나 검토할 때
능력이 활성인지 판정할 때
조건 사슬이 두 단계 이상일 때
## 예외
의도적으로 애플리케이션이 채우도록 남긴 확장점은 조건이 프로덕션에서 거짓인 것이 정상이다. 그 경우 그 사실이 문서에 있어야 하고, 이 저장소처럼 출하 애플리케이션이 함께 있다면 그것이 채우는지 확인해야 한다.
## 예시
outbox 사슬 다섯 단계가 뿌리 두 타입의 빈에 걸려 있고, 그 두 타입을 만드는 프로덕션 코드가 없다.
OutboxEnvelopeFactory 를 Bean 으로 만드는 곳은 스타터의 테스트 하나뿐이다.
## 관계
- **outbox가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다**
이 규칙을 적용해 원인이 갈린 사례다.
- **조건부 빈의 평가 시점 — 파싱 시점과 등록 시점**
조건이 거짓인 두 번째 이유를 다룬다.
@@ -0,0 +1,53 @@
---
kind: REFERENCE
slug: count-the-frameworks-own-autoconfigurations
title: 프레임워크가 기여하는 자동설정까지 세지 않으면 스위치가 아니다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:count-the-frameworks-own-autoconfigurations
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 프레임워크가 기여하는 자동설정까지 세지 않으면 스위치가 아니다
## 목적
프로젝트 코드만 게이트하고 프레임워크가 기여하는 자동설정을 남겨 두어, 꺼진 능력이 여전히 자원을 잡는 것을 막는다.
## 규칙
1. 프로젝트 조건은 프레임워크 자동설정을 막지 못한다
후보 집합에 들어온 자동설정은 자기 조건으로 판단한다. 프로젝트의 마스터 스위치는 그 판단에 참여하지 않는다.
2. 후보 집합을 좁히는 필터가 따로 필요하다
AutoConfigurationImportFilter 는 어떤 프로젝트 조건보다 먼저 동작하므로 이 일을 할 수 있는 유일한 자리다.
3. 그 필터는 권한이 아니라 도구다
후보를 빼는 일과 능력이 켜졌는지 판정하는 일은 다르다. 판정 권한은 루트 하나가 갖는다.
4. 자원 필요 여부는 능력 질문으로 묻는다
커넥션 풀 같은 공유 자원은 한 능력의 사유물이 아니다. 그것을 필요로 하는 능력이 하나라도 활성인지를 묻는다.
## 적용 조건
프레임워크가 같은 기술에 대해 자기 자동설정을 갖는 모든 능력. 데이터소스, 메시징, 캐시가 대표적이다.
## 예외
프레임워크 자동설정이 이미 프로젝트 조건과 같은 속성을 보도록 설계되어 있으면 필터가 필요 없다.
## 예시
JPA 가 꺼진 배포에서 프레임워크의 데이터소스 자동설정을 후보에서 빼는 필터가 spring.factories 에 등록되어 있다.
풀이 필요한지는 JPA 가 켜졌는지가 아니라 커넥션을 필요로 하는 능력이 하나라도 활성인지로 묻는다. 이 저장소는 여섯 조건의 논리합으로 판정한다.
## 관계
- **풀이 필요한지는 JPA가 켜졌나가 아니라 커넥션이 필요한 capability가 있나로 묻는다**
이 규칙을 채택한 결정이다.
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
같은 목표의 짝 규칙이다.
@@ -0,0 +1,60 @@
---
kind: REFERENCE
slug: off-must-be-structural
title: '"꺼짐"은 조건의 반복이 아니라 구조여야 한다'
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:off-must-be-structural
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# "꺼짐"은 조건의 반복이 아니라 구조여야 한다
## 목적
능력을 끄는 일을 빈마다 조건을 반복하는 방식으로 처리해서, 다음 달에 추가된 빈이 조건을 잊는 것을 막는다.
## 규칙
1. 루트 하나가 조건을 소유한다
그 루트가 자식 설정을 Import 하고 자식은 조건을 갖지 않는다. 자식에 빈이 추가되어도 자동으로 게이트된다.
2. 자식을 컴포넌트 스캔 밖에 둔다
스캔이 자식 설정을 독립적으로 발견하면 마스터 스위치와 무관하게 조립된다. 스캔 제외가 루트를 유일한 입구로 만든다.
3. 스캔에서 뺐으면 소유자를 반드시 지정한다
제외와 소유는 한 쌍이다. 한쪽만 하면 컴포넌트가 어디에도 없게 된다.
4. 스위치를 읽는 곳이 여럿이면 그중 하나만 권한을 갖는다
같은 속성을 읽는 주체가 여럿이면 각자가 다른 것이 켜졌다고 믿는 상태가 생긴다.
5. 프레임워크 자신의 자동설정도 후보에서 빼야 한다
프로젝트 조건은 그것을 막지 못한다. 후보 집합을 좁히는 필터가 따로 필요하다.
## 적용 조건
선택적 능력을 갖는 모든 모듈
능력이 여러 설정 클래스로 나뉘는 경우
## 예외
능력 안에서 다시 갈리는 하위 선택지는 자기 조건을 가질 수 있다. 그 조건은 마스터 스위치의 반복이 아니라 다른 질문이어야 한다.
## 예시
컴포지션 루트가 13개 패키지 접두사를 스캔에서 빼고 자동설정이 소유하게 한다. 클래스 목록이 아니라 접두사인 이유는 새 설정이 추가됐을 때 아무도 제외를 기억하지 못했다는 이유로 활성화되면 안 되기 때문이다.
mongo 리프에서는 세 주체가 같은 속성을 읽으며 각자 마스터처럼 행동했다. 지금은 루트 하나가 권한을 갖고, 자동설정 임포트 필터는 프레임워크 자신의 자동설정을 후보에서 빼는 일만 한다.
## 관계
- **마스터 스위치는 루트 하나가 소유하고 자식 설정은 조건을 갖지 않는다**
이 규칙을 채택한 결정이다.
- **넓은 스캔을 좁히자 여덟 컴포넌트에 아무것도 도달하지 않았다**
제외만 하고 소유를 지정하지 않았을 때의 결과다.
- **프레임워크가 기여하는 자동설정까지 세지 않으면 스위치가 아니다**
다섯 번째 규칙을 별도로 다룬다.
@@ -0,0 +1,55 @@
---
kind: REFERENCE
slug: read-the-assembling-side-first
title: 조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:read-the-assembling-side-first
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 조립 결함을 판정하려면 조립하는 쪽을 먼저 읽어야 한다
## 목적
조립되는 쪽만 읽고 결함을 판정해서, 원인을 잘못 지목하고 수정 방향을 반대로 잡는 것을 막는다.
## 규칙
1. 조립하는 쪽이 원인을 갖는다
빈이 없거나 값이 no-op 이거나 조건이 거짓인 이유는 대개 조립 지점에 있다. 조립되는 클래스만 읽으면 그 클래스가 옳게 보인다.
2. 타입이 요구하는 것과 조립이 넘기는 것을 비교한다
생성자가 무언가를 필수로 만들었더라도, 그것을 우회하는 오버로드가 있고 조립이 그쪽을 부르면 요구는 지켜지지 않는다.
3. 같은 일을 하는 다른 구현이 이미 배선되어 있는지 본다
조건이 만족되지 않는 이유가 그것을 대체하는 구현이 이미 있기 때문일 수 있다. 그때 문제는 조건이 아니라 정본이 정해지지 않은 중복이다.
4. 수정 방향은 원인 분류에서 갈린다
조건 결함으로 읽으면 조건을 만족시키는 수정이 되고, 중복으로 읽으면 어느 쪽이 정본인지 먼저 정하는 문제가 된다.
## 적용 조건
빈이 없다, 값이 기본값이다, 능력이 동작하지 않는다 계열의 모든 판정
플랫폼과 애플리케이션이 한 저장소에 함께 있는 경우 특히
## 예외
조립 지점이 저장소 밖에 있는 라이브러리라면 조립하는 쪽을 읽을 수 없다. 그때는 조립 계약을 문서로 확인하고, 판정에 그 한계를 적는다.
## 예시
messaging 스타터의 outbox 조건을 조건 결함으로 읽으면 app-bootstrap 에 빈을 등록하는 수정이 된다. 조립하는 쪽을 읽으면 application-core 쪽 outbox 가 이미 배선되어 돌고 있음이 보이고, 문제는 정본이 정해지지 않은 중복이 된다.
발행기만 읽으면 관측이 필수 인자로 보인다. 자동설정을 읽으면 인자 수가 여섯이다.
## 관계
- **outbox가 둘이고, 출하되는 것은 messaging 플랫폼 쪽이 아니다**
이 규칙을 적용해 원인 분류가 바뀐 사례다.
- **관측을 필수 생성자 인자로 만든 수정을 조립이 6인자 생성자로 되돌렸다**
타입과 조립이 어긋난 사례다.
@@ -0,0 +1,53 @@
---
kind: REFERENCE
slug: the-startup-validator-follows-the-autoconfiguration-root
title: 시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다
topic: assembly-ownership
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:the-startup-validator-follows-the-autoconfiguration-root
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다
## 목적
검증기 파일이 존재하고 그 테스트가 통과한다는 사실을 검증이 실행된다는 증거로 읽는 것을 막는다.
## 규칙
1. 검증기의 호출자를 센다
프로덕션 호출자가 0 이고 테스트 호출자만 있으면 그 검증은 실행 시점에 적용되지 않는다.
2. 자동설정 루트가 부르는지 확인한다
능력의 조립 지점이 검증기를 호출하지 않으면, 그 능력이 배포되기 시작해도 검증은 자동으로 시작되지 않는다.
3. 규칙 수와 실행 여부를 분리해서 본다
규칙이 많고 잘 테스트되어 있다는 것은 품질의 증거이지 실행의 증거가 아니다.
4. 시작 실패로 드러나야 할 것이 조용하면 검증기를 의심한다
설정 오류가 기동에서 잡히지 않고 런타임 증상으로만 나타나면, 그 검사가 배선되어 있는지 먼저 본다.
## 적용 조건
시작 검증기나 설정 검증기를 갖는 모든 능력
능력이 Stable 로 보고되는데 그 검증이 실제로 도는지 확인할 때
## 예외
의도적으로 라이브러리로만 제공되고 애플리케이션이 직접 호출하도록 설계된 검증기는 여기 해당하지 않는다. 그 경우 호출 방법이 문서에 있어야 한다.
## 예시
gRPC 플랫폼의 시작 검증기는 188줄에 13개 위반 규칙을 담고 있고, 호출자는 자기 테스트뿐이다. 같은 패키지의 자동설정은 106줄에 Bean 이 9개인데 검증기를 부르지 않는다.
## 관계
- **시작 검증기 13개 규칙이 유일한 조립 지점에서 호출되지 않는다**
이 규칙을 끌어낸 사례다.
- **Bean 애너테이션이 있다는 것은 조립 증거가 아니다**
같은 계열의 확인 규칙이다.
@@ -0,0 +1,125 @@
---
kind: CASE
slug: a-report-that-cannot-carry-a-datasource
title: 진단 리포트가 살아 있는 리소스를 담지 않도록 값 타입을 좁혔다
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-report-that-cannot-carry-a-datasource
evidenceCapturedOn: 2026-09-02
assets:
- key: a-report-that-cannot-carry-a-datasource
file: ../../../final/evidence/rendered/a-report-that-cannot-carry-a-datasource.svg
evidence:
- ../../../final/evidence/raw/a-report-that-cannot-carry-a-datasource.txt
source:
- 분석 문서는 persistence-jpa 편 §3.2 가 이 레코드의 컴포넌트와 생성자 계약을, §3.3 이 능력 목록에서 엔드포인트까지의 실제 소비자 사슬을 적는다. §3.4 가 "경계가 있는" 이 강제되지 않는다는 항목이고, 제약 경계를 검증하는 테스트가 없다는 것은 §9.3 이다. 축약된 불리언의 이름이 실제보다 넓다는 판정은 §69 다.
- §3.3 의 사슬은 엔드포인트에서 끝나고 노출 목록까지 따라가지 않는다. 그 마지막 칸은 위 터미널 출력에서 확인할 수 있다.
---
# 진단 리포트가 살아 있는 리소스를 담지 않도록 값 타입을 좁혔다
능력 선언 레코드가 제약을 평범한 문자열로만 담는다. 제공자 객체를 담을 자리를 만들지 않은 것이고, 그 이유는 이 레코드가 액추에이터로 공개될 수 있는 자리에 있기 때문이다.
## 관계
- **관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다**
이 사례가 그 규칙을 타입으로 실현한 형태다.
- **시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다**
같은 목표를 시그니처로만 지키려다 실패한 반대 사례다.
- **카디널리티 경계를 타입으로 표현하기**
카디널리티 경계를 타입으로 표현한 사례다.
## 문제
능력 선언 레코드는 어떤 능력이 어떤 지원 등급에서 어떤 제약 아래 지원되는지를 담는다.
이 레코드는 리포트로 직렬화되어 액추에이터 엔드포인트의 반환값이 된다. 제약을 무엇으로 담을지가 이 레코드의 선택지였다.
## 결론
제약을 평범한 문자열로만 담기로 했고, 그 이유가 javadoc 에 있다. 값 타입이 제공자 객체를 쥐는 순간 살아 있는 리소스가 딸려 들어오고, 접속 문자열과 자격증명이 리포트를 타고 나갈 수 있다는 것이다.
같은 방향의 결정이 리포트 두 층에서 반복된다. 특권 리포트는 연결된 역할과 검색 경로와 불리언 둘만 담는다. 밖으로 나가는 리포트는 그 특권 리포트조차 담지 않고 불리언 하나로 줄인다. 엔드포인트에는 쓰기 연산도 파라미터도 없다.
리포트를 공개할 때 무엇을 지울지 정하는 대신, 지울 것이 애초에 들어올 수 없는 타입을 만들었다.
세 가지가 이 그림에서 어긋난다. 이 엔드포인트는 지금 노출되지 않는다. 축약된 불리언의 이름이 그 식이 계산하는 것보다 넓다. 그리고 javadoc 이 쓴 "경계가 있는" 은 생성자가 강제하지 않는다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 값 타입 정의와 그 javadoc, 리포트 조립과 엔드포인트 선언과 노출 설정 확인
소스 수정 : x
## 재현 조건
1. 능력 선언 레코드의 컴포넌트와 클래스 javadoc 을 읽고, 컴팩트 생성자가 무엇을 강제하는지 확인한다.
2. 특권 리포트의 컴포넌트와 javadoc 을 읽는다.
3. 밖으로 나가는 리포트가 특권 리포트를 어떤 식으로 줄이는지, 그 식이 무엇을 보는지 확인한다.
4. 역할 이름과 검색 경로를 검사하는 정책의 프로덕션 호출자를 센다.
5. 엔드포인트의 애너테이션과, 저장소에서 그 엔드포인트 아이디가 나오는 곳과 출하 설정의 노출 목록을 확인한다.
## 본문
<!-- body:start -->
`CapabilitySupport` 는 능력과 지원 등급과 문자열 목록 셋을 담는 레코드다. 그 세 번째 자리가 이 사례의 대상이다.
## 담을 자리를 만들지 않은 것이 설계다
:::evidence key="a-report-that-cannot-carry-a-datasource" alt="코드베이스에서 능력 선언 레코드의 javadoc 과 컴팩트 생성자, 특권 리포트의 javadoc 과 컴포넌트, 밖으로 나가는 리포트의 javadoc 과 특권 리포트를 불리언으로 줄이는 식과 그 식이 실제로 계산하는 것, 역할 이름과 검색 경로를 검사하는 정책의 프로덕션 호출자 수, 두 리포트 컴포넌트 선언부의 자격증명 식별자 수, 엔드포인트의 읽기 전용 애너테이션과 쓰기 애너테이션 매치 수, 그리고 저장소에서 이 엔드포인트 아이디가 나오는 곳과 출하 설정의 노출 목록을 뽑은 출력 78줄. 엔드포인트 아이디가 선언부 한 곳에만 있고 노출 목록에 없다는 것이 그 출력에 보인다." caption="세 타입이 담지 않는 것 · 불리언이 실제로 계산하는 식 · 정책 호출자 0 · 엔드포인트 아이디는 선언부 1곳 · 노출 목록에 없음 — 78줄" zoom="true"
:::
javadoc 이 "일부러"라고 적고 이유를 잇는다. 이 레코드는 리포트로 직렬화 가능해야 하고 액추에이터로 공개해도 안전해야 하므로 제공자 객체를 저장하지 않는다는 것이다. `DataSource` 도, `EntityManagerFactory` 도, Hibernate 의 `SessionFactory` 도 이름으로 지목해 배제한다.
그중 하나를 들고 있으면 살아 있는 리소스를 값 타입 안으로 끌고 들어오게 되고, 리포트가 JDBC URL 이나 자격증명을 흘릴 수 있다.
담을 수 있었는데 안 담은 것이 아니라, 담을 자리를 만들지 않았다.
## 같은 태도를 리포트 두 층이 다른 문장으로 적는다
특권 리포트는 일부러 좁게 만들었고, JDBC URL 도 패스워드도 호스트도 없으며, 공개 전에 마스킹해야 할 것은 여기 들어오지 않는다고 적는다.
밖으로 나가는 리포트는 배제 목록을 더 길게 적는다. javadoc 은 이 타입에서 볼 것이 담을 수 있는 값이 아니라 담을 수 없는 값이라고 적고, 관리 포트에 닿는 사람이면 누구나 읽을 수 있으니 URL 도 사용자명도 패스워드도 SQL 도 엔티티 목록도 전부 공짜 정찰 답변이 된다고 잇는다.
그리고 그 리포트는 특권 리포트조차 담지 않는다. 조립 메서드가 그것을 불리언 하나로 줄인다.
두 리포트의 컴포넌트 선언부에서 제공자와 자격증명 식별자를 세면 둘 다 0 이다. 마스킹 목록이 아니라 타입이 이 일을 하므로, 목록을 갱신하는 사람이 없어도 성립한다.
## 엔드포인트도 좁고, 그리고 지금 닫혀 있다
읽기 연산 하나뿐이고 파라미터가 없다. 쓰기와 삭제와 선택자 애너테이션은 0 이다. 그 javadoc 이 이유를 적는다 — 마이그레이션이나 복구나 캐시 축출을 부를 수 있는 엔드포인트는 관리 포트에 닿는 사람이면 누구나 HTTP 로 쓸 수 있는 관리 기능이 된다는 것이다.
다만 이 엔드포인트는 지금 나가지 않는다. 저장소에서 이 엔드포인트 아이디가 나오는 곳은 선언부 한 군데뿐이고, 출하 설정의 노출 목록에는 헬스와 프로메테우스와 정보와 로거와 어댑터 활성화 다섯만 있다.
등록 지점의 javadoc 에는 애너테이션만으로는 아무것도 등록되지 않으며 노출은 애플리케이션의 기존 정책이 정하고 여기서 그것을 넓히지 않는다고 적혀 있다.
그러므로 이 타입들이 막는 것은 지금 나가는 값이 아니다. 누군가 노출을 켜는 순간 나가게 될 값이다. 타입이 담을 수 없게 만든 것이, 설정으로 노출을 끄는 것보다 먼저다.
## 축약의 방향은 옳고 이름은 넓다
축약한 자리 javadoc 은 그 불리언을 이렇게 소개한다. 특권 리포트의 역할 이름과 검색 경로가 일부러 하나로 줄었고, 운영자가 알아야 하는 것은 역할이 검증을 통과했다는 사실이지 그 역할이 무엇인지가 아니라는 것이다.
실제로 계산하는 식은 특권 리포트가 있고 그것이 생성 권한을 갖지 않는다는 것뿐이다. 역할 이름도 검색 경로도 보지 않는다.
그 둘을 실제로 검사하는 정책이 따로 있다. 그 정책을 부르는 프로덕션 코드가 0 이다.
그래서 잘못된 역할 이름이나 안전하지 않은 검색 경로를 가진 배포에서도 이 값은 참이 될 수 있다. 분석 문서가 이것을 P1 으로 올려 두었다.
## "경계가 있는"은 문장이지 제약이 아니다
javadoc 은 제약을 평범하고 경계가 있는 문자열이라고 부른다.
컴팩트 생성자가 강제하는 것은 목록의 불변 복사와 빈 문자열 거절 둘이다. 길이 상한도 형식 상한도 없다.
문자열 안에 무엇이 들어가는지도 보지 않는다. 능력을 선언하는 팩토리는 공개된 가변 인자를 받고 내용을 검사하지 않으므로, 제약 문자열에 자격증명이 붙은 접속 문자열을 넣으면 그대로 리포트에 실린다. javadoc 이 제공자 객체를 막아 방지하겠다고 한 유출이 문자열 경로로 되돌아온다.
값을 채우는 쪽이 이 저장소 안이라면 문제가 되지 않는다. 출하 조립이 넣는 것은 짧은 고정 리터럴뿐이다. 이 타입이 공개 API 라는 것이 남는 조건이다.
## 확인하지 못한 것
노출 목록에 없는 엔드포인트라 응답 본문까지는 보지 못했다. 확인한 범위는 값 타입이 무엇을 담을 수 없느냐까지다. 노출을 켠 배포에서의 직렬화 결과는 여기 들어 있지 않다.
<!-- body:end -->
@@ -0,0 +1,133 @@
---
kind: CASE
slug: a05-f004-retrydecision-reason
title: 안전하다고 적힌 값이 같은 모듈의 저카디널리티 정의를 통과하지 못한다
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f004-retrydecision-reason
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f004-retrydecision-reason
file: ../../../final/evidence/rendered/a05-f004-retrydecision-reason.svg
- key: a05-f004-retrydecision-reason-probe
file: ../../../final/evidence/rendered/a05-f004-retrydecision-reason-probe.svg
evidence:
- ../../../final/evidence/raw/a05-f004-retrydecision-reason.txt
- ../../../final/evidence/raw/a05-f004-retrydecision-reason-probe.txt
source:
- 분석 문서는 persistence-jpa 편 §7.4 다. 생성자가 널과 공백만 확인하고 길이·형식 상한이 없다는 판정이 그 절에 있다. §10 의 backlog 가 이것을 P3 로 두고, 십만 자 사유가 통과한 것과 현재 재시도 메트릭이 이 값을 태그로 쓰지 않는다는 것을 각각 관측으로 적는다.
---
# 안전하다고 적힌 값이 같은 모듈의 저카디널리티 정의를 통과하지 못한다
재시도 결정의 사유 필드가 낮은 카디널리티 메트릭 태그로 안전하다고 설명된다. 같은 모듈이 저카디널리티를 정규식 하나로 정의해 두고 있는데, 프로덕션이 실제로 내는 사유 일곱 개가 그 정의에 하나도 맞지 않는다.
## 관계
- **카디널리티 경계를 타입으로 표현하기**
이 값이 지켜야 할 경계 체계다.
- **CapabilitySupport.constraints 의 경계가 타입에 없다**
같은 모듈에서 타입이 값의 경계를 담지 않아 생긴 다른 사례다.
- **이름은 값이 아니라 registry key다**
사유가 등록된 어휘여야 하는지의 문제다.
## 문제
재시도 결정의 사유 필드는 왜 재시도가 허용되거나 거부되었는지를 담는다.
타입의 javadoc 이 그 값을 정책이 고른 경계 있는 진단 문자열이라 부르고, 제공자 메시지가 아니므로 로그에도 낮은 카디널리티 메트릭 태그에도 안전하다고 적는다.
## 결론
컴팩트 생성자가 보는 항목은 넷이다. 널 검사 셋과 지연의 부호, 전체 트랜잭션 재시도가 아닐 때 지연이 0 인지, 사유가 공백인지다. 상한, 어휘, 정규식 어느 것도 걸려 있지 않다.
십만 자를 넣어 봤다. 생성자를 통과하고, 그대로 담긴다.
공개 팩토리는 넷이고 그중 셋이 사유를 인자로 받는다. 나머지 하나는 고정 문자열을 쓴다.
경계를 실제로 지키고 있는 것은 생성자가 아니다. 현재 사유는 리터럴 여섯 개와 실패 범주 enum 을 붙인 문자열 하나이고, 개수는 그 두 가지로 이미 유계다.
문제는 개수가 아니라 형식이다. 같은 모듈의 관측 패키지가 저카디널리티를 정규식 하나로 정의해 뒀다. 등록된 이름은 이미 그 모양을 만족하므로 맞지 않는 것은 정제하지 않고 거절한다는 것이다. 정제해 버리면 호출자가 무한한 값을 계속 넘기면서도 모른다는 것이 그 javadoc 의 설명이다.
일곱 개를 그 가드에 넣으면 전부 거절된다. 29자에서 54자 사이의 띄어쓴 산문이기 때문이다. 문서가 저카디널리티 태그로 안전하다고 적은 값은, 같은 모듈이 저카디널리티라고 정의한 형식을 하나도 만족하지 않는다.
값이 닿지 않는 것도 아니다. 코디네이터는 실패한 시도마다 결정을 리스너에 넘기고, 출하되는 리스너는 그 결정에서 처분을 꺼내 태그로 단다. 같은 리스너가 영속 단위 이름은 그 가드에 통과시킨다. 아직 읽히지 않는 것은 사유 하나다.
시계열이 늘지 않는 것은 값이 못 닿아서가 아니다. 닿은 자리에서 필드 하나를 안 꺼내기 때문이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 생성자 검사 열람, 사유 전수와 같은 모듈 가드에 대한 통과 여부 실행, 소비 파일의 필드 접근 계수
소스 수정 : x
## 재현 조건
1. 타입의 javadoc 과 컴팩트 생성자를 읽고, 사유에 대한 검사가 무엇인지 확인한다.
2. 사유를 인자로 받는 공개 팩토리를 센다.
3. 십만 자 문자열로 결정을 만들어 통과하는지 본다.
4. 프로덕션 정책이 내는 사유를 전수로 뽑는다.
5. 같은 모듈의 저카디널리티 가드를 찾아, 그 사유들을 실제로 넣어 본다.
6. 그 타입을 소비하는 main 파일을 추리고, 그 안에서 사유 필드를 읽는 줄을 센다.
## 본문
<!-- body:start -->
재시도 결정 타입의 javadoc 에는 사유 필드가 정책이 고른 경계 있는 진단 문자열이고, 제공자 메시지가 아니므로 로그에도 낮은 카디널리티 메트릭 태그에도 안전하다고 적혀 있다.
## 그 경계를 강제하는 코드가 없다
:::evidence key="a05-f004-retrydecision-reason" alt="재시도 결정 타입의 javadoc 과 사유 필드 선언, 컴팩트 생성자가 검사하는 것 전부, 공개 팩토리 넷, 길이와 어휘를 검사하는 코드 수, 그 타입을 소비하는 main 파일 목록과 그 안에서 사유를 읽는 줄 수, 그리고 코디네이터가 결정을 리스너에 넘기는 줄과 그 리스너가 다는 태그를 출력한 터미널 기록." caption="javadoc 과 생성자가 보는 것 넷 · 팩토리 넷 중 셋이 사유를 받음 · 길이·어휘 검사 0 · 소비 파일 셋에서 사유를 읽는 줄 0 · 결정은 리스너까지 감 — 40줄 · exit 0" zoom="true"
:::
컴팩트 생성자가 검사하는 것은 넷이다. 세 필드의 널 아님, 지연이 음수 아님, 전체 트랜잭션 재시도가 아니면 지연이 0 일 것, 그리고 사유가 공백 아님이다.
사유에 대한 검사는 마지막 하나뿐이다. 길이 상한도, 허용 어휘 목록도, 형식 정규식도 없다. 이 파일에서 그런 검사를 세면 0 이다.
공개 팩토리는 넷이고, 그중 셋이 호출자가 준 문자열을 그대로 담는다. 사유 인자가 없는 재시도 하나만 고정 문자열을 쓴다.
## 값은 이미 메트릭을 내는 곳까지 간다
코디네이터는 실패한 시도마다 결정을 리스너에 넘긴다. 출하되는 리스너는 그 결정에서 처분을 꺼내 태그로 달고, 영속 단위 이름은 저카디널리티 가드에 통과시킨다.
사유만 아직 읽히지 않는다. 이 타입을 소비하는 main 파일 셋 안에서 사유를 읽는 줄은 0 이다.
시계열이 늘지 않는 이유는 값이 닿지 않아서가 아니라, 닿은 자리에서 한 필드를 아직 꺼내지 않아서다.
## 같은 모듈이 저카디널리티를 이미 정의해 뒀다
:::evidence key="a05-f004-retrydecision-reason-probe" alt="프로덕션 정책이 내는 사유 리터럴 전수와, 같은 모듈의 저카디널리티 가드가 쓰는 정규식과 거절 방식, 그리고 그 사유들과 십만 자 문자열을 실제로 그 가드와 생성자에 넣어 본 결과를 출력한 터미널 기록." caption="사유 리터럴 일곱 · 같은 모듈 가드의 정규식과 거절 방식 · 일곱 전부 거절 · 십만 자는 생성자 통과 — 41줄 · exit 0" zoom="true"
:::
관측 패키지의 `LowCardinality` 가 등록된 이름의 모양을 정규식 하나로 못박는다.
```text
[a-zA-Z][a-zA-Z0-9._-]{0,95}
```
맞지 않으면 정제하지 않고 거절한다. 정제하면 호출자가 계속 무한한 값을 넘기면서도 알아채지 못한다고 그 javadoc 이 적는다.
그 가드를 부르는 곳 중 하나가 재시도 관측기다. 재시도 결정을 받는 바로 그 리스너가 영속 단위 이름을 통과시킨다. 사유는 그 문을 지나지 않는다.
## 지금 사유 일곱 개를 그 가드에 넣으면 전부 거절된다
프로덕션이 내는 사유는 일곱이다. 결정 타입 안의 고정 문자열 하나, 기본 정책의 리터럴 다섯, 그리고 실패 범주 enum 을 붙인 문자열 하나다.
컴파일된 가드에 그대로 넣었다. 29자에서 54자 사이이고, 전부 거절된다. 띄어쓴 산문이기 때문이다.
경계를 실제로 지키고 있는 것은 생성자가 아니라 그 리터럴들과 enum 이다. 개수는 그 둘로 이미 유계다. 위험한 것은 개수가 아니라 형식이고, 문서가 안전하다고 적은 형식이 같은 모듈의 정의와 어긋나 있다.
같은 생성자에 십만 자를 넣어 봤다. 통과하고, 그대로 담긴다.
## 고칠 방향
길이나 어휘 경계를 타입에 넣거나, 낮은 카디널리티 주장을 실제 사용 범위에 맞게 좁히는 것이다. 분석 문서가 후보로 적은 것도 그 둘이다.
## 확인하지 못한 것
이 값이 실제 메트릭 백엔드에 태그로 도달했을 때의 시계열 증가는 관측하지 않았다. 지금 그 필드를 읽는 코드가 없어 관측할 대상이 없다.
<!-- body:end -->
@@ -0,0 +1,101 @@
---
kind: CASE
slug: cursor-verification-order
title: 커서 서명 검증이 길이·상수시간·순서를 전부 지켜야 했던 이유
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:cursor-verification-order
evidenceCapturedOn: 2026-09-01
assets:
- key: cursor-verification-order
file: ../../../final/evidence/rendered/cursor-verification-order.svg
evidence:
- ../../../final/evidence/raw/cursor-verification-order.txt
source:
- 원본 분석 절은 analysis/05 §2.4 이다.
---
# 커서 서명 검증이 길이·상수시간·순서를 전부 지켜야 했던 이유
커서 디코드는 길이 상한을 먼저 보고, 형태를 확인하고, 버전을 확인하고, 디코딩될 크기를 인코딩된 길이로 추정해 거절한 다음에야 페이로드를 푼다. MAC 비교는 상수시간이다. 각 단계가 서로 다른 공격을 막는다.
## 관계
- **서명된 커서의 구조와 검증 순서**
이 사례가 다루는 메커니즘이다.
- **커서에 서명하는 이유는 기밀성이 아니라 무결성이다**
이 검증이 무엇을 지키는지 정한 결정이다.
## 문제
서명된 커서를 검증하는 코드는 여러 가지를 확인해야 한다. 형태가 맞는지, 버전이 맞는지, MAC 이 맞는지, 크기가 상한 안인지다.
그 확인들을 어떤 순서로 하느냐가 코드의 성질을 바꾼다. 순서가 잘못되면 검증 코드 자체가 공격 표면이 된다.
## 결론
디코드 경로는 값을 해석하기 전에 형태부터 검사한다.
빈 문자열 거절
인코딩된 전체 길이 상한 확인
구분자 두 개의 위치 확인
버전 문자열 일치 확인
인코딩된 페이로드 길이로 디코딩될 크기를 추정해 상한 확인
그다음에 디코딩
다섯 번째 단계에 붙은 주석이 순서의 핵심을 담는다. Base64 는 4/3 으로 팽창하므로 인코딩된 페이로드 구간의 길이가 디코딩된 크기의 상한을 준다. 즉 디코딩하기 전에 크기를 거절할 수 있다.
이 단계가 없으면 공격자가 보낸 큰 토큰이 먼저 메모리에 풀리고 나서 거절된다.
MAC 비교는 MessageDigest 의 상수시간 비교를 쓴다. javadoc 이 이유를 적는다. 여기서 단축 평가 비교를 쓰면 올바른 MAC 이 한 바이트씩 새어 나간다.
MAC 이 버전과 페이로드를 함께 덮는 것도 같은 계열의 결정이다. 페이로드만 서명하면 공격자가 접두사를 고쳐 옛 커서 형식으로 토큰을 강등할 수 있다.
인코딩 시점에도 두 상한을 검사한다. 발급하는 쪽이 자기가 받아들일 수 없는 토큰을 만들지 않게 한다.
키 길이 하한도 생성자에서 검사한다. 32 바이트 미만이면 코덱을 만들 수 없다.
## 검증 환경
OpenJDK : 21.0.12
알고리즘 : HmacSHA256
인코딩 : Base64 URL 인코더, 패딩 없음
확인 방식 : 구현과 javadoc 확인
소스 수정 : x
## 재현 조건
1. SignedJsonCursorCodec 의 클래스 javadoc 을 읽는다. 인코딩 형태와 서명 목적과 상수시간 비교의 이유가 적혀 있다.
2. 디코드 경로의 검사 순서를 확인한다.
3. 인코딩된 길이로 디코딩될 크기를 추정하는 주석과 그 계산을 확인한다.
4. 인코드 경로가 같은 두 상한을 검사하는지 확인한다.
5. 생성자의 최소 키 길이 검사를 확인한다.
## 본문
<!-- body:start -->
다섯 방어가 각각 다른 공격을 막고 순서가 계약이다.
## 다섯 방어와 그 순서
:::evidence key="cursor-verification-order" alt="분석 문서 analysis/05-adapter-outbound-persistence-jpa.md 에서 이 기록의 근거 절을 그대로 잘라낸 12줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/05-adapter-outbound-persistence-jpa.md 발췌 — 12줄" zoom="true"
:::
## 각 순서가 막는 것
`MAX_ENCODED_LENGTH(4096)` 검사가 첫 줄에 없으면 decode가 caller가 보낸 크기만큼 할당한다. MAC 길이 확인이 없으면 `MessageDigest.isEqual`의 상수 시간 보장이 깨진다. 서명 검증 전에 파싱하면 서명 없는 토큰이 애플리케이션 JSON 파서에 도달한다.
## MAC이 버전과 payload를 함께 덮는다
prefix 재작성으로 옛 포맷으로 다운그레이드하는 것을 막는다.
## 확인하지 못한 것
타이밍 공격이나 크기 공격을 실제로 시도해 보지 않았다. 이 기록은 각 단계가 무엇을 막도록 배치되었는지에 대한 것이며, 그 방어의 실효성을 측정한 것은 아니다.
없음
<!-- body:end -->
@@ -0,0 +1,106 @@
---
kind: CASE
slug: pii-through-an-exception-message
title: 시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:pii-through-an-exception-message
evidenceCapturedOn: 2026-09-01
body: case-pii-through-an-exception-message.body.md
assets:
- key: pii-through-an-exception-message
file: ../../../final/evidence/rendered/pii-through-an-exception-message.svg
evidence:
- ../../../final/evidence/raw/pii-through-an-exception-message.txt
source:
- 원본 분석 절은 final/document.md#8-1 · analysis/04 §4 이다.
---
# 시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다
로거 메서드가 본문이나 수신자를 인자로 받지 않으므로 PII 가 로그에 닿지 않는다는 주장이 문서에 있다. 그 메서드는 예외 메시지를 그대로 넣고, 예외 메시지에는 무엇이든 들어갈 수 있다.
## 관계
- **관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다**
이 사례가 그 규칙의 로그 판이다.
- **sanitize가 아니라 reject가 기본이다**
마스킹으로 막으려 할 때의 한계를 다룬 규칙이다.
- **카디널리티 경계를 타입으로 표현하기**
같은 문제를 메트릭 쪽에서 다룬 개념이다.
## 문제
지원 소스와 README 는 로거 메서드가 본문이나 수신자나 페이로드를 인자로 받지 않기 때문에 PII 가 로그에 닿지 않는다고 주장한다.
시그니처만 보면 그 주장이 맞다.
logFailure 는 의존성 이름과 의존성 종류와 연산 이름과 Throwable 을 받는다.
문제는 네 번째 인자다.
## 결론
실행해서 확인했다. 예외 메시지가 그대로 로그에 나온다.
probe 는 컴파일된 현재 로거에 명시적인 테스트 PII 표시가 든 예외를 넘겼다.
입력 : RuntimeException 의 메시지가 provider rejected recipient secret@gmail.com body=secret-body-content
출력 : WARN 한 줄, error 필드에 RuntimeException: provider rejected recipient secret@gmail.com body=secret-body-content
로거 구현이 예외의 단순 클래스명과 getMessage 를 형식 문자열에 그대로 넣는다.
기존 테스트가 통과한 이유는 픽스처의 예외 메시지가 connection refused 처럼 단순했기 때문이다. 그 테스트는 페이로드 객체가 직접 인자로 전달되지 않는다는 것만 확인하고, 예외 메시지를 통한 유출은 검사하지 않는다.
컴포지션 루트의 로그 마스킹 패턴도 이것을 막지 않는다. 임의의 이메일이나 자유 형식 본문 PII 를 제거하는 규칙이 없고, README 자신이 정규식 마스킹을 보증이 아니라 심층 방어라고 적는다.
이 결함의 성격은 경계의 위치다. 타입 경계는 인자에 그어져 있고 데이터는 예외를 타고 그 경계를 넘는다.
## 검증 환경
OpenJDK : 21.0.12
slf4j-api : 2.0.18
logback-classic : 1.5.38
logback-core : 1.5.38
실행 방식 : 컴파일된 프로덕션 클래스에 단일 파일 probe 를 붙여 실행
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/021-support-logger-pii-probe.txt 이고 probe 소스는 021a-support-logger-pii-probe.java 다.
1. adapter/outbound/support 의 컴파일된 클래스와 slf4j 및 logback 을 클래스패스에 둔다.
2. FailOpenDependencyLogger 를 만들고 logFailure 에 PII 표시가 든 메시지를 가진 예외를 넘긴다.
3. 렌더된 로그 한 줄을 확인한다.
관측 경계는 probe 파일에 명시되어 있다. 이 probe 가 보여 주는 것은 통제된 입력에 대한 로거의 렌더 결과이고, 특정 외부 제공자가 프로덕션에서 그런 메시지를 내보내는지는 아니다.
## 본문
<!-- body:start -->
source와 README가 "logger method가 body/recipient/payload를 받지 않기 때문에 PII가 log에 닿지 않는다"고 주장하고 테스트도 그것을 검사한다.
## 시그니처가 받지 않는 것
:::evidence key="pii-through-an-exception-message" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## fixture 의 예외에 marker 가 없었다
`"connection refused"` 라서 어떤 argument에도 marker가 없다 — 테스트는 payload object가 직접 전달되지 않는다는 것만 확인한다. PII를 담은 예외를 넣는 probe를 실행하니 formatted WARN에 그대로 남았다.
## 소비자 SPI 넷이 arbitrary 예외를 던질 수 있다
전역 masking은 password/token 계열만 덮으며 README 스스로 "보증이 아니라 defence-in-depth"라고 적는다.
## 확인하지 못한 것
실제 제공자들이 어떤 예외 메시지를 만드는지 조사하지 않았다. 이 기록의 결함은 로거가 임의의 메시지를 통과시킨다는 것이지, 특정 제공자가 PII 를 담는다는 것이 아니다.
실제 provider SDK가 recipient/body를 예외 메시지에 넣는지는 확인하지 않았다. probe는 그것이 가능할 때 logger가 막지 못한다는 것만 보인다
<!-- body:end -->
@@ -0,0 +1,149 @@
---
kind: CONCEPT
slug: cardinality-bounds-as-types
title: 카디널리티 경계를 타입으로 표현하기
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:cardinality-bounds-as-types
evidenceCapturedOn: 2026-09-01
assets:
- key: cardinality-bounds-as-types
file: ../../../final/evidence/rendered/cardinality-bounds-as-types.svg
- key: cardinality-bounds-as-types-diagram
file: ../../../final/assets/diagrams/cardinality-bounds-as-types.svg
evidence:
- ../../../final/evidence/raw/cardinality-bounds-as-types.txt
source:
- 원본 분석 절은 final/document.md#4-1, #5-2 · analysis/05 §2.1, §12.2 · analysis/02 이다.
---
# 카디널리티 경계를 타입으로 표현하기
메트릭 태그의 카디널리티 상한을 문서나 리뷰가 아니라 타입과 생성자로 표현한다. 이 저장소는 그것을 세 층으로 나눈다 — 전역 금지 목록, 태그별 상한, 그리고 리프별 폐쇄 태그 집합이다.
## 관계
- **tenant id는 메트릭 태그가 되지 않는다**
이 세 층이 한 값에 대해 서로 다른 답을 내는 지점이다.
- **관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다**
이 경계가 지키려는 것 중 하나다.
- **이름은 값이 아니라 registry key다**
같은 계열의 다른 축이다.
## 본문
<!-- body:start -->
metric tag·trace·retry policy의 키가 되는 문자열을 값 타입으로 만들고 정규식을 생성자에 두는 패턴의 설명이다. `PersistenceOperationName`·`QueryName`·`ConstraintCode`·`FetchPlanName`·`WorkQueueName`·`JsonPathName`·`TenantId`가 전부 같은 모양이다.
## 값 타입 생성자가 막는 것
:::evidence key="cardinality-bounds-as-types-diagram" alt="등록된 이름과 정규식 통과 값이 값 타입 생성자 안에 놓이고 엔티티 id 와 SQL 조각이 바깥에 빗금으로 놓인다" caption="값 타입 생성자가 막는 것" zoom="false"
:::
목적은 엔티티 id·tenant id·SQL 조각·요청 스코프 값이 그 자리에 올 수 없게 하는 것이다.
## 같은 모양을 가진 일곱 값 타입
:::evidence key="cardinality-bounds-as-types" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## registry가 아니라 생성자에 둔 이유
대시보드가 안 뜰 때까지 살아남지 않게 하기 위해서다. 그리고 sanitize가 아니라 reject인 이유는, sanitize하면 caller가 계속 넘기고 눈치채지 못하기 때문이다.
:::note
없음
:::
## 층 1 — 전역 금지 목록
```java
public static final Set<String> FORBIDDEN =
Set.of("user_id", "request_id", "raw_url", "raw_query", "raw_header_value", "ip_address");
```
여섯 개다. 무한한 식별자 셋과 가공되지 않은 HTTP 출처 셋이다. javadoc 이 이 목록의 성격을 적는다 — 어떤 등록된 메트릭에도 절대 나타나면 안 되는 태그 키의 전수 집합이다.
`request_id` 에 대한 비대칭이 명시적으로 기록되어 있다.
> the deliberate `request_id` asymmetry — forbidden as a metric label here yet allowed as
> W3C baggage. Do not "fix" that asymmetry by removing `request_id` from the set.
메트릭 레이블로는 금지이고 추적 baggage 로는 허용이다. 두 저장소의 성격이 다르기 때문이고, 그 차이를 일관성으로 착각해 고치지 말라고 못 박아 두었다.
## 층 2 — 태그별 상한
```java
/** HTTP status-class tag bound (1xx5xx + ok/other). */
public static final int STATUS_CODE = 7;
/** URI template tag bound — routes must be template-normalised (e.g. /users/{id}). */
public static final int URI_TEMPLATE = 200;
/** Distinct named external dependency tag bound. */
public static final int DEPENDENCY_NAME = 50;
/** Error code tag bound — kept in sync with the row count of error-codes.yaml. */
public static final int ERROR_CODE = 100;
/** Tenant id tag bound — bounded mapping-table id / cohort bucket only, no raw UUID. */
public static final int TENANT_ID = 1000;
```
허용되는 태그에도 숫자가 붙는다. javadoc 이 이 숫자들의 지위를 밝힌다 — 외부 표준이 아니라 운영상의 가정이다.
`TENANT_ID` 주석이 특히 중요하다. 1000 이라는 상한은 원시 UUID 가 아니라 매핑 테이블 id 나 코호트 버킷만 허용한다는 조건과 함께 온다.
## 층 3 — 리프별 폐쇄 집합
```java
/**
* The complete, bounded tag set every JPA metric carries (design §37).
*
* <p>Five tags, all registered identifiers. What is absent is the point: no entity id, no tenant
* id, no SQL parameter, no exception message. Each of those is unbounded, so each would create a
* new time series per row or per failure — and several of them are the data the platform keeps out
* of logs, which a metrics backend would then store just as durably and export just as widely.
*/
public record JpaMetricTags(
String persistenceUnit,
String operationName,
String queryName,
String outcome,
String failureCategory) {
```
리프는 상한을 더 좁힐 수 있다. JPA 는 tenant id 를 아예 넣지 않는다. 층 2 가 조건부로 허용한 것을 층 3 이 거절한다.
"What is absent is the point" 가 이 개념의 요약이다. 태그 집합을 열거형처럼 닫아 두면 없는 것이 설계가 된다.
## 검증 위치
```java
public JpaMetricTags {
persistenceUnit = orNone(persistenceUnit);
...
LowCardinality.requireRegistered(
persistenceUnit, operationName, queryName, outcome, failureCategory);
```
:::tip
검증이 레지스트리가 아니라 생성자에 있다. javadoc 이 이유를 적는다 — 무한한 값이 대시보드가 로딩되지 않을 때까지 살아남는 대신, 그것이 도입된 자리에서 실패한다.
:::
## 세 층이 함께 답하는 것
| 질문 | 답하는 층 |
|---|---|
| 이 키는 절대 안 되는가 | 층 1 금지 목록 |
| 이 키는 몇 개까지 허용되는가 | 층 2 상한과 그 조건 |
| 이 리프의 메트릭은 어떤 키를 갖는가 | 층 3 폐쇄 집합 |
<!-- body:end -->
@@ -0,0 +1,136 @@
---
kind: CONCEPT
slug: signed-cursor-structure
title: 서명된 커서의 구조와 검증 순서
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:signed-cursor-structure
evidenceCapturedOn: 2026-09-01
assets:
- key: signed-cursor-structure
file: ../../../final/evidence/rendered/signed-cursor-structure.svg
- key: signed-cursor-structure-diagram
file: ../../../final/assets/diagrams/signed-cursor-structure.svg
evidence:
- ../../../final/evidence/raw/signed-cursor-structure.txt
source:
- 원본 분석 절은 final/document.md#10-4 · analysis/05 §2.4 · analysis/19 §4.x 이다.
---
# 서명된 커서의 구조와 검증 순서
페이징 커서는 버전과 페이로드와 MAC 세 부분으로 인코딩되고, MAC 이 버전과 페이로드를 함께 덮는다. 검증은 길이 상한부터 상수시간 비교까지 정해진 순서를 지킨다.
## 관계
- **커서에 서명하는 이유는 기밀성이 아니라 무결성이다**
이 구조가 무엇을 위한 것인지 정한 결정이다.
- **커서 서명 검증이 길이·상수시간·순서를 전부 지켜야 했던 이유**
이 검증 순서의 각 단계가 무엇을 막는지 다룬 사례다.
## 본문
<!-- body:start -->
이 저장소에 서명 커서 구현이 둘 있고 같은 다섯 단계를 지킨다.
## 커서 검증의 순서
:::evidence key="signed-cursor-structure-diagram" alt="길이 검사에서 상수 시간 MAC 비교로 할당 전이 건너가고 상수 시간 MAC 비교에서 payload 파싱으로 검증 통과가 건너간다" caption="커서 검증의 순서" zoom="false"
:::
1. 길이 검사가 substring/decode/MAC **이전 첫 줄**에 온다 — 페이징 엔드포인트는 public이고 그 아래 모든 코드가 caller가 보낸 크기에 비례해 할당한다.
2. base64 확장률로 decode 후 크기를 할당 전에 bound한다.
3. MAC 길이를 먼저 확인한다 — `MessageDigest.isEqual`은 같은 길이 입력에 대해서만 상수 시간이다.
4. 상수 시간 비교.
5. **서명 검증 후에야** payload를 파싱한다.
## 두 구현이 지키는 다섯 단계
:::evidence key="signed-cursor-structure" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## gRPC 쪽이 더하는 두 가지
알 수 없는 key id를 현재 키로 폴백하지 않고 거부한다 — 폴백은 rotation을 "탈취된 키가 여전히 검증되는 창"으로 만든다. 그리고 malformed·unknown key·verify 실패를 **구별 불가능하게** 반환한다 — 구별은 probing oracle이다.
:::note
없음 — 두 구현을 코드로 확인했다
:::
## 인코딩 형태
```java
/**
* A versioned, tamper-evident cursor codec built only on the Java standard library (design §27.3).
*
* <p>The encoded form is {@code <version>.<base64url(payload)>.<base64url(mac)>}. The MAC covers
* the version and the payload together, so an attacker cannot downgrade a token to an older cursor
* format by rewriting the prefix.
*/
```
MAC 이 버전까지 덮는 것이 이 구조의 첫 결정이다. 페이로드만 서명하면 접두사를 고쳐 옛 커서 형식으로 내려보낼 수 있다.
## 서명하는 이유
```java
/**
* <p>Signing a cursor is not about confidentiality — the payload is readable — it is about
* integrity. An unsigned cursor is client-controlled ordering state: rewriting it lets a caller
* seek to arbitrary keys, which turns a paging token into an access-control bypass wherever the
* predicate depends on where the scan started.
*/
```
페이로드는 읽을 수 있다. 숨기는 것이 목적이 아니다. 서명이 없으면 커서는 클라이언트가 통제하는 정렬 상태이고, 그것을 고쳐 임의의 키로 이동할 수 있다.
## 상수시간 비교
```java
/**
* <p>Verification is constant-time via {@link MessageDigest#isEqual}. A short-circuiting comparison
* here leaks the correct MAC one byte at a time.
*/
```
## 검증 순서
디코드 경로는 값을 해석하기 전에 형태부터 검사한다.
```java
if (encoded == null || encoded.isBlank()) { ... }
if (encoded.length() > MAX_ENCODED_LENGTH) {
throw new IllegalArgumentException("cursor exceeds the maximum token length");
}
if (payloadSeparator <= 0 || macSeparator <= payloadSeparator) { ... }
if (!VERSION.equals(version)) { ... }
// Base64 expands by 4/3, so the encoded payload segment's length bounds the decoded size
if (decodedLengthOf(encodedPayloadLength) > MAX_PAYLOAD_BYTES) { ... }
```
주석 한 줄이 순서의 이유를 담는다. Base64 는 4/3 으로 팽창하므로 인코딩된 길이로 디코딩될 크기의 상한을 알 수 있다. 즉 디코딩하기 전에 크기를 거절할 수 있다.
이 순서가 없으면 공격자가 보낸 큰 토큰이 먼저 메모리에 풀린다.
## 상수와 그 의미
| 상수 | 값 | 무엇을 막는가 |
|---|---|---|
| `VERSION` | `v1` | 이 코덱이 발급하고 받아들이는 유일한 버전 |
| `ALGORITHM` | `HmacSHA256` | |
| `MINIMUM_KEY_LENGTH` | 32 | 짧은 키로 서명하는 구성 |
| `MAX_PAYLOAD_BYTES` | — | 디코딩 후 크기 |
| `MAX_ENCODED_LENGTH` | — | 디코딩 전 크기 |
인코딩 시점에도 두 상한을 검사한다. 발급하는 쪽이 받아들일 수 없는 토큰을 만들지 않게 한다.
## 같은 형태가 다른 곳에도 있다
gRPC 정책 리프의 재개 토큰 코덱이 같은 문제를 같은 방식으로 푼다. HMAC 으로 서명해 서버측 커서 테이블을 없애고, 토큰 필드마다 존재 이유를 javadoc 에 남긴다.
<!-- body:end -->
@@ -0,0 +1,60 @@
---
kind: PROJECT_DECISION
slug: cursors-are-signed-for-integrity
title: 커서에 서명하는 이유는 기밀성이 아니라 무결성이다
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:cursors-are-signed-for-integrity
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/api/query/SignedJsonCursorCodec.java
- src/grpc/grpc-policy/src/main/java/dev/caskeleton/grpc/policy/streaming/GrpcResumeTokenCodec.java
- analysis/05-adapter-outbound-persistence-jpa.md
---
# 커서에 서명하는 이유는 기밀성이 아니라 무결성이다
## 결정문
페이징 커서는 서명하되 암호화하지 않는다. 목적은 내용을 숨기는 것이 아니라 변조를 감지하는 것이다.
## 판단 이유
서명 없는 커서는 클라이언트가 통제하는 정렬 상태다. 그것을 고치면 호출자가 임의의 키로 이동할 수 있고, 술어가 스캔 시작 위치에 의존하는 곳에서는 페이징 토큰이 접근 통제 우회 수단이 된다.
암호화는 이 문제를 풀지 않는다. 암호화된 토큰도 변조 감지 없이는 잘린 값이나 바꿔치기된 값을 받아들일 수 있다. 반대로 서명은 페이로드가 읽히더라도 변조를 잡는다.
페이로드가 읽히는 것을 감수하는 대신 두 가지를 얻는다. 디버깅할 때 토큰을 읽을 수 있고, 키 관리가 서명 키 하나로 끝난다.
서명 범위는 버전까지 포함한다. 페이로드만 서명하면 접두사를 고쳐 옛 커서 형식으로 강등할 수 있기 때문이다.
검증은 상수시간 비교를 쓴다. 단축 평가 비교는 올바른 MAC 을 한 바이트씩 흘린다.
## 영향
감수하는 것
커서 페이로드가 클라이언트에게 읽힌다. 따라서 페이로드에 민감한 값을 담을 수 없다.
서명 키를 관리해야 하고 최소 길이 요구가 생긴다. 32 바이트 미만이면 코덱이 만들어지지 않는다.
키를 교체하면 발급된 커서가 무효가 된다.
얻는 것
정렬 상태가 서버의 것이 된다. 클라이언트가 스캔 시작 위치를 임의로 정할 수 없다.
서버측 커서 테이블이 필요 없다. 같은 원칙이 gRPC 재개 토큰에도 적용되어 그쪽도 커서 테이블을 없앴다.
## 근거
- **서명된 커서의 구조와 검증 순서**
이 결정이 만든 구조다.
- **커서 서명 검증이 길이·상수시간·순서를 전부 지켜야 했던 이유**
이 결정을 구현할 때 필요했던 세부다.
- **path·identifier는 등록하고 value는 바인딩한다**
정렬 구조를 클라이언트가 정하지 못하게 하는 짝 규칙이다.
@@ -0,0 +1,63 @@
---
kind: PROJECT_DECISION
slug: tenant-id-is-never-a-metric-tag
title: tenant id는 메트릭 태그가 되지 않는다
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: decision:tenant-id-is-never-a-metric-tag
decisionStatus: ADOPTED
decidedOn: 2026-08-30
source:
- src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/CardinalityBounds.java
- src/shared-contract/src/main/java/dev/caskeleton/shared/metrics/ForbiddenMetricTags.java
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/observation/JpaMetricTags.java
- src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/experimental/tenant/TenantId.java
- analysis/02-shared-contract.md
- analysis/05-adapter-outbound-persistence-jpa.md
---
# tenant id는 메트릭 태그가 되지 않는다
## 결정문
원시 tenant 식별자는 메트릭 태그가 되지 않는다. 태그가 될 수 있는 것은 경계가 있는 매핑 테이블 id 나 코호트 버킷이다.
## 판단 이유
tenant 카디널리티는 정의상 무한하다. 원시 식별자를 태그로 쓰면 tenant 하나가 늘 때마다 시계열 집합 전체가 복제된다.
그래서 두 층이 서로 다른 강도로 이 값을 다룬다. 이 구별을 뭉뚱그리면 둘 중 하나를 잘못 읽게 된다.
공유 계약 층은 tenant_id 를 태그로 허용하되 상한을 1000 으로 두고, 그 상수의 주석이 조건을 붙인다. 경계가 있는 매핑 테이블 id 나 코호트 버킷만 허용하며 원시 UUID 는 안 된다는 것이다. 즉 tenant 를 관측 차원으로 쓰는 것 자체를 금지하지는 않는다.
JPA 리프는 더 좁게 간다. 메트릭 태그 집합이 다섯 개로 닫혀 있고 tenant id 가 그 안에 없다. 이 리프의 tenant 식별자 타입은 자기 javadoc 에 그 값이 메트릭 태그가 되지 않는다고 적는다.
두 층이 모순이 아니다. 공유 계약은 플랫폼 전체의 상한이고, 리프는 자기 관측 표면에 대해 더 엄격한 선택을 한 것이다.
전역 금지 목록은 또 다른 층이다. user_id 와 request_id 와 ip_address 와 가공되지 않은 HTTP 출처 셋은 어떤 등록된 메트릭에도 나타날 수 없다. tenant_id 는 그 목록에 없다.
## 영향
감수하는 것
tenant 별 메트릭을 원시 식별자로 바로 볼 수 없다. 필요하면 경계가 있는 매핑을 먼저 만들어야 한다.
층이 셋이라 어느 층의 규칙인지 확인해야 한다. 공유 계약의 허용을 리프의 허용으로 읽으면 틀린다.
얻는 것
tenant 증가가 시계열 폭발로 이어지지 않는다.
관측 백엔드가 tenant 식별자를 원본 저장소보다 오래 그리고 넓게 보관하는 일이 없다.
## 근거
- **카디널리티 경계를 타입으로 표현하기**
이 결정이 속한 세 층 구조다.
- **관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다**
이 결정의 근거가 되는 규칙이다.
- **이름은 값이 아니라 registry key다**
태그 값이 등록된 것만 허용되는 이유다.
@@ -0,0 +1,55 @@
---
kind: REFERENCE
slug: names-are-registry-keys-not-values
title: 이름은 값이 아니라 registry key다
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:names-are-registry-keys-not-values
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 이름은 값이 아니라 registry key다
## 목적
연산 이름이나 쿼리 이름 같은 식별자를 자유 문자열로 두어, 오타가 새 시계열을 만들거나 정책이 조용히 적용되지 않는 것을 막는다.
## 규칙
1. 이름은 등록된 것만 허용한다
식별자를 값 객체로 감싸고 등록 여부를 생성자에서 검사한다. 등록되지 않은 이름은 값이 만들어지지 않는다.
2. 검사는 사용처가 아니라 도입 지점에 둔다
레지스트리나 대시보드에서 걸러내면 잘못된 값이 그때까지 살아남는다. 값이 만들어지는 자리에서 실패해야 한다.
3. 이름 집합은 닫혀 있어야 한다
무엇이 등록되어 있는지 열거할 수 있어야 한다. 열거할 수 없으면 그것은 레지스트리가 아니라 관행이다.
4. 이름으로 정책을 고르는 곳은 이름의 정본을 공유한다
메트릭 태그와 재시도 프로파일과 감사 기록이 각자 다른 이름 목록을 쓰면 같은 연산이 세 이름을 갖는다.
## 적용 조건
메트릭 태그가 되는 모든 식별자
정책이나 프로파일을 선택하는 키
감사와 추적에서 연산을 지목하는 이름
## 예외
사람이 읽는 설명이나 자유 서술 필드는 등록 대상이 아니다. 그런 값은 애초에 태그나 키가 되어서는 안 된다.
## 예시
JPA 메트릭 태그 다섯 개는 전부 등록된 식별자이고, 생성자가 등록 여부를 검사한다. 검사가 레지스트리가 아니라 생성자에 있는 이유는 무한한 값이 대시보드가 로딩되지 않을 때까지 살아남는 대신 도입된 자리에서 실패하게 하기 위해서다.
## 관계
- **카디널리티 경계를 타입으로 표현하기**
이 규칙이 속한 경계 체계다.
- **path·identifier는 등록하고 value는 바인딩한다**
같은 구별의 다른 축이다.
@@ -0,0 +1,55 @@
---
kind: REFERENCE
slug: register-paths-bind-values
title: path·identifier는 등록하고 value는 바인딩한다
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:register-paths-bind-values
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# path·identifier는 등록하고 value는 바인딩한다
## 목적
구조를 가리키는 것과 데이터를 담는 것을 같은 방식으로 다루어, 구조 자리에 임의 문자열이 들어가거나 값 자리에 코드가 들어가는 것을 막는다.
## 규칙
1. 구조를 가리키는 것은 등록 대상이다
경로 정렬 키 컬럼 이름 연산 이름은 미리 등록된 집합에서만 고른다.
2. 값은 바인딩한다
비교 대상이나 필터 값은 문자열로 이어 붙이지 않고 파라미터로 넘긴다.
3. 둘을 한 API 에서 섞지 않는다
같은 메서드가 구조와 값을 모두 문자열로 받으면 호출자가 어느 쪽인지 헷갈린다.
4. 등록되지 않은 구조 이름은 거절한다
기본값으로 대체하거나 정화하지 않는다.
## 적용 조건
동적 정렬과 필터를 받는 조회 API
경로나 필드 이름을 외부 입력으로 받는 모든 경계
## 예외
내부적으로 생성되고 외부 입력이 닿지 않는 구조 이름은 등록 절차 없이 상수로 둘 수 있다.
## 예시
정렬 키와 커서 필드는 등록된 이름이어야 하고, 비교 값은 바인딩된다. 커서는 서명되어 클라이언트가 정렬 상태를 고쳐 임의 키로 이동하지 못하게 한다.
## 관계
- **이름은 값이 아니라 registry key다**
이 규칙의 이름 쪽 절반이다.
- **sanitize가 아니라 reject가 기본이다**
등록되지 않은 구조 이름을 어떻게 다룰지 정한 규칙이다.
- **서명된 커서의 구조와 검증 순서**
정렬 상태를 클라이언트가 고치지 못하게 하는 메커니즘이다.
@@ -0,0 +1,55 @@
---
kind: REFERENCE
slug: reject-rather-than-sanitize
title: sanitize가 아니라 reject가 기본이다
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:reject-rather-than-sanitize
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# sanitize가 아니라 reject가 기본이다
## 목적
위험한 입력을 고쳐서 받아들이는 습관이, 고치지 못한 경우를 통과시키는 것을 막는다.
## 규칙
1. 거절이 기본이다
허용 목록에 없으면 거절한다. 값을 다듬어 통과시키지 않는다.
2. 정화는 완전성을 증명할 수 없다
무엇을 지웠는지는 말할 수 있지만 무엇을 놓쳤는지는 말할 수 없다.
3. 정화를 쓴다면 그 지위를 밝힌다
심층 방어인지 보증인지 적는다. 보증이 아니면 그것에 기대는 다른 판단이 없어야 한다.
4. 거절 이유에 코드를 붙인다
거절이 운영자가 읽을 수 있는 사건이 되어야 한다. 조용한 거절은 조용한 통과와 구별되지 않는다.
## 적용 조건
외부 입력을 구조 위치에 쓰는 모든 경계
로그와 메트릭에 들어가는 값
## 예외
표시용 문자열을 길이로 자르는 것처럼 의미를 바꾸지 않는 정규화는 이 규칙의 대상이 아니다.
## 예시
로그 마스킹 패턴의 README 자신이 정규식 마스킹을 보증이 아니라 심층 방어라고 적는다. 그리고 임의의 이메일이나 자유 형식 본문을 제거하는 규칙은 없다.
이 저장소의 HTTP 클라이언트는 절대 URI 를 정화하지 않고 거절한다.
## 관계
- **시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다**
정화에 기댔을 때의 한계를 보여 주는 사례다.
- **path·identifier는 등록하고 value는 바인딩한다**
거절 대상을 정하는 규칙이다.
@@ -0,0 +1,60 @@
---
kind: REFERENCE
slug: telemetry-can-be-more-dangerous-than-its-subject
title: 관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다
topic: bounding-by-type
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:telemetry-can-be-more-dangerous-than-its-subject
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 관측을 위해 수집한 데이터가 관측 대상보다 위험할 수 있다
## 목적
로그와 메트릭과 리포트가 원본보다 더 오래 보관되고 더 넓게 공유된다는 사실을 잊고, 관측 경로로 민감한 데이터를 흘리는 것을 막는다.
## 규칙
1. 관측 저장소는 원본보다 오래 산다
데이터베이스에서 지운 값이 메트릭 백엔드와 로그 색인에는 남는다. 보존 정책이 다르기 때문이다.
2. 관측 데이터는 더 넓게 공유된다
대시보드와 알림과 액추에이터 엔드포인트는 원본 저장소보다 접근 권한이 넓다.
3. 값 타입이 위험한 것을 담을 수 없게 만든다
공개 시점에 지울 것을 정하는 대신, 애초에 들어올 수 없는 타입을 쓴다.
4. 경계를 인자 목록에만 긋지 않는다
예외 메시지나 toString 결과처럼 인자 옆으로 지나가는 경로가 있다.
5. 태그 집합을 닫아 두고 무엇이 없는지 적는다
없는 것이 설계라면 그 사실이 코드에 있어야 한다.
## 적용 조건
로그와 메트릭과 추적과 진단 리포트를 만드는 모든 코드
액추에이터로 공개되는 모든 값 타입
## 예외
의도적으로 민감 데이터를 다루는 감사 로그는 별도의 저장소와 접근 통제를 갖는다. 그 경우 이 규칙이 아니라 그 통제가 적용된다.
## 예시
능력 선언 레코드는 제약을 평범한 문자열로만 담는다. 제공자 객체를 담으면 리포트가 JDBC URL 이나 자격증명을 유출할 수 있기 때문이다.
JPA 메트릭 태그 javadoc 은 없는 것이 요점이라고 적는다. 엔티티 id 도 tenant id 도 SQL 파라미터도 예외 메시지도 없다. 그중 몇 가지는 플랫폼이 로그에서 빼 두는 데이터이고, 메트릭 백엔드는 그것을 똑같이 오래 저장하고 똑같이 넓게 내보낸다.
## 관계
- **진단 리포트가 살아 있는 리소스를 담지 않도록 값 타입을 좁혔다**
이 규칙을 타입으로 실현한 사례다.
- **시그니처가 payload를 받지 않는데 예외 메시지로 PII가 로그에 남았다**
네 번째 규칙이 필요한 이유를 보여 주는 사례다.
- **tenant id는 메트릭 태그가 되지 않는다**
이 규칙이 적용된 결정이다.
@@ -0,0 +1,224 @@
---
kind: CASE
slug: a10-f001-readme
title: 표가 증거로 지목한 시험이 그 표를 반증한다
topic: caching-and-redis
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a10-f001-readme
evidenceCapturedOn: 2026-09-02
body: case-a10-f001-readme.body.md
assets:
- key: a10-f001-readme
file: ../../../final/evidence/rendered/a10-f001-readme.svg
- key: a10-f001-readme-counts
file: ../../../final/evidence/rendered/a10-f001-readme-counts.svg
evidence:
- ../../../final/evidence/raw/a10-f001-readme.txt
- ../../../final/evidence/raw/a10-f001-readme-counts.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L121 이다. 등급은 P2 다. 표 네 행 중 셋이 사실과 다르다는 판정, 패키지별 파일과 줄 수, `@Bean` 메서드 일곱, 빌드 파일 주석의 임포트 계수, 그리고 무겁게 보는 세 이유가 그 절에 있다.
- 그 절은 이 수치를 LOC 라고 적지만 실제로 센 것은 물리 줄 수여서, 여기서는 줄 수라고만 적는다.
- 표가 둘째 축의 증거로 지목한 시험이 표를 반증한다는 것, 같은 README 의 산문이 세 줄 뒤에서 표와 어긋난다는 것, 다섯 포트 중 세션만 표가 맞다는 것, 일곱째 `@Bean` 이 조건부라는 것, 그리고 주석의 주어절은 맞고 괄호만 틀렸다는 것은 이 기록에서 확인했다.
---
# 표가 증거로 지목한 시험이 그 표를 반증한다
리프 README 의 준비도 표와 그 아래 두 문단이 이 리프의 상태를 없음으로 적는다. 표가 둘째 열의 증거로 이름을 대 놓은 시험이 그 빈들이 조립된다고 단언하고, 같은 README 의 산문이 세 줄 뒤에서 같은 기능을 제공한다고 적는다.
## 관계
- **문서 계약 테스트의 단언 경계 밖에 발견된 드리프트 세 건이 전부 있었다**
그 테스트가 단언하는 범위 밖에서 문서와 코드가 어긋났다.
- **README의 세 가지 사실 오류**
같은 README 에서 확인한 다른 사실 오류다.
- **산문이 선언한 게이트는 빌드에 있는 게이트가 아니다**
문서와 빌드를 대조하는 규칙이다.
## 문제
이 리프의 README 는 준비도 보고를 자기 주제로 삼는다. 세 질문을 합치지 말라고 못 박고, 축마다 증거를 지정한 다음 표를 놓는다.
## 결론
둘째 축의 증거로 이름을 댄 시험이 RedisSdkAutoConfigurationTest 다. 그 시험이 RedisRuntimeClient 와 RedisRuntimeOwner 를 hasSingleBean 으로 단언한다. 표가 그 둘을 조립되지 않는다고 적은 자리다.
같은 README 도 자기와 어긋난다. 표에서 세 줄 뒤 산문이 이 모듈은 Lettuce 연결 수명과 명령 타임아웃과 재연결 재생 차단을 제공한다고 적는다. 표 둘째 행이 없음이라고 적은 바로 그것이다.
수를 세면 이렇다.
sdk/lettuce/connection 에 아홉 파일 1,473 줄이 있다. RedisTopologyClientFactory 600, RedisRuntimeOwner 316, SentinelFailoverObserver 153, RedisConnectionRegistry 142 줄이다.
의미 포트 행은 다섯 이름을 한 칸에 묶는다. 그중 넷은 아홉 파일 2,598 줄로 있고 세션 하나만 표가 맞다. 세션 쪽은 패키지 자체가 없고, 남은 여덟 건은 코드가 아니라 문장과 선택자 값이다.
상태 기여자 행도 틀렸다. RedisHealthContributor 88 줄과 RedisCorrectnessRoles 62 줄이 있다.
자동 설정에는 @Bean 메서드가 일곱 있다. 여섯은 조건 없이 조립되고, 일곱째 redisRequired 에는 @Conditional 이 붙어 세션·멱등·레이트리밋·리스 중 하나가 Redis 를 고를 때만 생긴다. 조립되는 자리를 못 찾은 것은 게이트웨이와 의미 어댑터다.
빌드 파일 주석은 절반만 틀렸다. 주석의 주어는 SDK 이고, sdk 패키지의 어떤 파일도 그 간선들을 임포트하지 않는다. 괄호 안의 일반화가 틀렸다. 메인 소스 전체로 넓히면 애플리케이션 코어를 일곱 파일이, 공유 계약을 세 파일이 임포트한다. 등록된 간선 셋 중 adapter:outbound:support 만 실제로 0 이다.
같은 주석은 그 어댑터들이 제거됐다고도 적는다. 위에서 센 파일들이 그것이다. 그리고 간선을 남겨 두는 이유로 든 문장 — 여기서 무언가가 그것에 대해 컴파일되기 때문이 아니라는 것 — 도 뒤집힌다.
스무 줄 뒤에 있는 별개 주석 쪽은 맞다. spring-data-redis 와 io.micrometer 임포트는 실제로 0 이다.
판정은 P2 다. 코드 결함이 아니라 문서 결함인데 이 저장소 기준으로는 무겁다.
첫째, 이 문서는 정직한 준비도 보고를 자기 주제로 삼고, 축마다 증거를 지정하기까지 한다. 그 증거가 문서를 반증한다.
둘째, 방향이 이례적이다. 보통의 표류는 없는 것을 있다고 하는데 여기는 있는 것을 없다고 한다. 포크한 쪽은 있는 것을 다시 만들거나, 있는 줄도 모른 채 지나친다.
셋째 근거는 자동 설정 안의 문장이다. 이 클래스가 생기기 전까지 설정 검증 메서드에 프로덕션 호출자가 없었다고 적는다.
## 검증 환경
확인 방식 : 패키지별 파일과 줄 수 계수, @Bean 메서드와 조건 계수, 임포트 계수
소스 수정 : x
## 재현 조건
1. README 가 세 축마다 지정한 증거를 읽는다.
2. 둘째 축의 증거로 지목된 시험이 무엇을 단언하는지 읽는다.
3. 준비도 표 네 행과 그 아래 두 문단을 읽고, 세 줄 뒤 산문까지 이어 읽는다.
4. 표가 없다고 적은 자리마다 패키지의 파일과 줄 수를 센다.
5. 의미 포트 행이 든 다섯 이름과 실제 패키지 이름을 대조하고, 행에 없는 패키지는 합계에서 뺀다.
6. 세션을 리프 메인 전체에서 문자열로 검색한다.
7. 자동 설정의 @Bean 메서드를 조건 애너테이션까지 함께 읽는다.
8. 빌드 파일이 등록한 간선 셋을 각각 임포트하는 파일을 세고, 주석의 주어인 sdk 패키지만으로도 센다.
9. 스무 줄 뒤의 별개 주석과 그 주장을 확인한다.
## 본문
<!-- body:start -->
README 는 준비도가 서로 다른 세 질문이며 하나로 합치면 안 된다는 문장으로 시작하고, 축마다 무엇을 증거로 삼는지까지 적는다.
## 표가 지목한 증거
:::evidence key="a10-f001-readme" alt="README 가 준비도 세 축마다 지정한 증거, 준비도 표 네 행, 표 아래 두 문단. 표에서 세 줄 뒤 같은 README 의 산문이 이 모듈이 제공한다고 적는 목록. 그리고 둘째 축의 증거로 지목된 시험이 어떤 빈들을 단언하는지 출력한 터미널 기록." caption="표는 연결 수명과 의미 포트와 상태 기여자를 없음으로 적음 · 세 줄 뒤 산문은 같은 모듈이 Lettuce 연결 수명을 제공한다고 적음 · 표가 증거로 지목한 시험은 RedisRuntimeClient 와 RedisRuntimeOwner 를 hasSingleBean 으로 단언 — 46줄 · exit 0" zoom="true"
:::
```text
| **Spring composition 구현** | `APP_REDIS_ENABLED=true`에서 실제 bean이 조립된다 | `RedisSdkAutoConfigurationTest` |
```
그 시험이 무엇을 단언하는지 보면 이렇다.
```java
assertThat(context).hasSingleBean(RedisSdkSettings.class);
...
assertThat(context).hasSingleBean(RedisRuntimeClient.class);
...
assertThat(context).hasSingleBean(RedisRuntimeOwner.class);
```
표는 같은 둘을 조립되지 않는다고 적는다.
```text
| Topology client / connection lifecycle | 없음 | 없음 | 없음 |
| cache / session / idempotency / rate limit / lease semantic port | 없음 | 없음 | 없음 |
| role-aware health·readiness contributor | 없음 | 없음 | 없음 |
```
## 같은 README 가 세 줄 뒤에서
```text
모듈은 Lettuce connection lifecycle,
finite command timeout, reconnect replay 차단, finite request queue/admission, positive/negative
TTL, absolute soft/hard expiry, deterministic bounded TTL jitter, digest-protected v2 binary
envelope, HMAC physical key,
invalidation, closed-catalog
`EVALSHA -> NOSCRIPT -> SCRIPT LOAD -> digest verify -> EVALSHA` recovery를 제공한다.
```
표 둘째 행이 없음이라고 적은 것을 산문이 제공한다고 적는다. 어긋난 것은 문서 전체가 아니라 표와 그 아래 두 문단이다.
## 수를 세면
:::evidence key="a10-f001-readme-counts" alt="표가 없다고 적은 자리의 파일 수와 줄 수. 의미 포트 행이 든 다섯 이름 중 구현이 있는 넷의 합계와, 그 행에 이름이 없어 합계에서 뺀 두 패키지. 세션 문자열의 리프 전체 계수. 상태 기여자의 줄 수. 자동 설정의 `@Bean` 메서드 전부와 거기 붙은 조건 애너테이션. 빌드 파일이 등록한 간선 셋과 앞 주석, 그 간선들을 임포트하는 파일 수와 주석의 주어인 sdk 패키지만의 계수, 그리고 스무 줄 뒤의 별개 주석을 출력한 터미널 기록." caption="연결 패키지 9 파일 1,473 줄 · 표가 든 다섯 포트 중 넷이 9 파일 2,598 줄이고 세션만 없음 · @Bean 일곱 중 하나에 @Conditional · 등록 간선 셋 중 둘은 열 파일이 임포트하고 support 만 0 · 주석의 주어인 sdk 패키지만 보면 0 — 58줄 · exit 0" zoom="true"
:::
```text
# sdk/lettuce/connection : 9 파일 1473 줄
600 sdk/lettuce/connection/RedisTopologyClientFactory.java
316 sdk/lettuce/connection/RedisRuntimeOwner.java
153 sdk/lettuce/connection/SentinelFailoverObserver.java
142 sdk/lettuce/connection/RedisConnectionRegistry.java
# 표가 든 다섯 포트 중 구현이 있는 넷
cache 2 파일 641 줄
idempotency 2 파일 835 줄
ratelimit 3 파일 516 줄
lease 2 파일 606 줄
합계 9 파일 2598 줄
# 다섯째 session : 패키지 없음, main 전체에서 session 문자열 8
# 그 행에 이름이 없어 뺀 패키지 : realtime 591 줄, keyspace 106 줄
# 상태 기여자 : 88 + 62
```
의미 포트 행은 다섯 이름을 한 칸에 묶는데 그중 하나만 맞다. 세션은 패키지가 없고, 리프 메인 전체에서 그 문자열 여덟 건이 전부 javadoc 산문과 역할 선택자 값이다. `realtime``keyspace` 는 그 행에 없는 이름이라 합계에서 뺐다.
## `@Bean` 은 일곱, 무조건은 여섯
```text
273: @Bean(destroyMethod = "close")
274- public RedisRuntimeOwner redisRuntimeOwner(RedisRuntimeClient client, RedisSdkSettings settings) {
297: @Bean(RedisCorrectnessRoles.OPTIONAL_HEALTH_CONTRIBUTOR)
298- public HealthIndicator redisOptional(RedisRuntimeOwner owner, RedisSdkSettings settings) {
324: @Bean(RedisCorrectnessRoles.REQUIRED_HEALTH_CONTRIBUTOR)
325- @Conditional(RedisCorrectnessRoleBound.class)
326- public HealthIndicator redisRequired(RedisRuntimeOwner owner, RedisSdkSettings settings) {
```
일곱째만 조건부다. 세션·멱등·레이트리밋·리스 중 하나가 Redis 를 고를 때 생긴다. 나머지 여섯은 스위치 하나로 조립된다. 어디서도 조립되지 않는 것은 게이트웨이와 의미 어댑터 둘이다.
## 빌드 파일의 의존성 주석
```groovy
// Registered edges the semantic port adapters need. The SDK itself imports nothing from them
// today (0 imports across main source) — the semantic cache/session/idempotency/rate-limit
// adapters that did were removed and are restored by Phase E of
// …
// because that restoration is the module's stated responsibility, not because anything here
// compiles against them.
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
```
주어절은 맞다.
```text
dev.caskeleton.application 7 파일
dev.caskeleton.shared 3 파일
dev.caskeleton.adapter.outbound.support 0 파일
# 주석의 주어인 sdk 패키지만 보면 : 0
```
`sdk` 패키지는 세 간선 어디에서도 임포트하지 않는다. 틀린 것은 괄호 안의 일반화다. 메인 소스로 넓히면 열 파일이 임포트하고, 셋 중 `support` 만 주석대로 0 이다.
같은 주석이 그 어댑터들은 제거됐다고 적는데, 위에서 센 파일들이 그것이다. 간선을 남겨 두는 이유로 든 문장도 뒤집힌다 — 여기서 무언가가 그것에 대해 컴파일되기 때문이 아니라고 적혀 있는데, 컴파일된다.
스무 줄 뒤의 별개 주석은 맞다.
```groovy
// Deliberately absent:
// org.springframework.data:spring-data-redis — … Zero imports.
// io.micrometer:micrometer-core — … Zero imports.
```
## 어긋난 방향
표가 없다고 적은 자리마다 코드가 있다. 보통의 표류는 반대 방향이다. 이 문서를 읽고 분기하는 쪽은 이미 있는 코드를 다시 구현하거나, 조립되지 않은 채 존재하는 코드의 존재 자체를 모른다.
순서를 시사하는 문장이 자동 설정 안에 있다.
```java
* RedisSdkSettings#validate()} the fail-fast its own documentation claims until this class
* existed the method had no production caller at all.
```
## 확인하지 못한 것
README 가 마지막으로 갱신된 시점과 자동 설정이 추가된 시점을 이력에서 대조하지 않았다. 자동 설정 javadoc 의 문장이 순서를 시사할 뿐이다.
<!-- body:end -->
@@ -0,0 +1,219 @@
---
kind: CASE
slug: a10-f004-pub-sub
title: 상한을 주입받는 자리는 있고 주입하는 곳은 없다
topic: caching-and-redis
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a10-f004-pub-sub
evidenceCapturedOn: 2026-09-02
body: case-a10-f004-pub-sub.body.md
assets:
- key: a10-f004-pub-sub
file: ../../../final/evidence/rendered/a10-f004-pub-sub.svg
- key: a10-f004-pub-sub-bound
file: ../../../final/evidence/rendered/a10-f004-pub-sub-bound.svg
evidence:
- ../../../final/evidence/raw/a10-f004-pub-sub.txt
- ../../../final/evidence/raw/a10-f004-pub-sub-bound.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L263 이다. 등급은 P3 이다. 세 타입 중 하나만 크기 검사를 부른다는 표, 채널 이름이 Redis 에서 키와 같은 문자열 공간을 쓰고 같은 상한을 받는다는 지적, 구성 요소가 각자 토큰 길이를 제한하므로 현실적인 초과는 어렵다는 단서, 그리고 두 채널 타입의 렌더 본문이 같다는 관찰이 그 절에 있다. 원본이 P3 의 근거로 든 것은 검사가 패턴에는 있고 채널에는 없다는 비대칭 자체다.
- 이 기록이 더한 것은 셋이다. 원본이 「현실적인 초과는 어렵다」고만 적은 것에 수를 붙였다 — 최대 388 바이트, 기본 이름공간에서 221 바이트다. 같은 규칙을 받은 조각들로 만든 키가 슬롯 태그가 붙으면 519 바이트가 되어 같은 검사에 거부된다. 그리고 그 검사가 보는 상한이 주입 인자인데 저장소에 주입하는 곳이 없다.
- 원본 backlog 가 reachability 로 적어 둔 「긴 namespace/entity/identifier 조합」은 388 바이트가 답이다. 셋을 최대로 채워도 상수 상한을 넘지 않는다. 넘는 경로는 슬롯 태그 쪽이고, 그것도 채널이 아니라 키에서 일어난다.
---
# 상한을 주입받는 자리는 있고 주입하는 곳은 없다
조립된 문자열이 최대 바이트를 넘지 않는지 보는 검사가 같은 성격의 세 타입 중 하나에만 있다. 검사를 부르는 다른 한 곳은 키 렌더러이고, 키 렌더러는 상수가 아니라 생성자로 받은 상한을 본다. 그 인자를 채우는 코드가 저장소에 없다.
## 관계
- **채워 넣은 상한은 자기가 잴 요청에서 값을 가져온다**
같은 리프에서 상한 값을 어디서 가져오는지가 문제가 된 다른 사례다.
- **R1과 R2의 설정 취급이 비대칭이고, 검증된 쪽은 하나뿐이다**
두 사례 모두 같은 성격의 두 자리 중 한쪽에만 검사가 있다.
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
경계가 어디서 지켜지는지 묻는 규칙이다.
## 문제
키 규칙에 조립된 문자열의 렌더 크기를 검사하는 메서드가 있다. Pub/Sub 쪽에는 같은 성격의 타입이 셋 있고, 그중 하나만 그 검사를 부른다.
## 결론
부르는 쪽은 패턴 타입이다. 컴팩트 생성자에서 이름공간 접두와 접미를 이어 놓고 크기를 잰다. 재는 때가 렌더보다 한 발 앞이다.
두 채널 타입은 부르지 않는다. 널 검사만 하고 렌더에서 세 조각을 잇는다.
상한이 상수 512 인 한 그 빠진 검사가 발화할 입력이 없다. 조각 셋이 모두 같은 규칙을 먼저 통과한 값이다. 이름공간의 세 토큰과 개체 토큰은 최대 64자, 식별자는 최대 128자다. 두 정규식은 ASCII 만 받으므로 길이가 곧 바이트다. 세 토큰을 모두 최대로 채운 렌더가 388 바이트다. 설정 기본 이름공간에서는 221 바이트다.
패턴은 다르다. 접미에 붙는 규칙은 공백이 아닐 것과 구분자를 넘지 않을 것 둘뿐이고 길이 제한이 없다. 이름공간을 최대치로 잡으면 접미 317자까지 512 바이트로 통과하고 318자에서 513 바이트가 되어 생성자가 거부한다.
원본의 판단은 조각마다 길이 제한이 있어 현실적인 초과가 어렵다는 것이었다. 그 반례가 같은 패키지의 키 경로에 있다. 키 렌더러는 이름공간과 개체와 식별자 사이에 슬롯 태그를 하나 더 넣는데, 그 태그도 식별자 규칙을 받아 최대 128자다. 넷을 최대로 채우면 519 바이트가 되고 같은 검사가 거부한다. 조각이 규칙을 받는다는 것과 합이 상한 안에 있다는 것은 다른 말이다. 다만 이것도 이름공간이 194 바이트일 때의 값이고, 기본 이름공간에서는 같은 태그를 붙여도 352 바이트다.
두 번째 차이는 상한을 어디서 가져오느냐다. 키 렌더러가 보는 상한은 생성자 인자이고 1 부터 512 까지 받는다. 패턴은 인자를 받지 않고 상수를 본다. 채널은 아무것도 보지 않는다. 상한 256 으로 만든 렌더러는 388 바이트짜리 키를 거부하는데, 같은 크기의 채널 이름은 그대로 나간다.
그런데 그 인자를 채우는 배선이 없다. 저장소의 src/main 전체에서 렌더러를 만드는 곳이 0 이고, 만드는 곳 열둘은 전부 시험 소스다. 설정 쪽도 마찬가지다. max-key-bytes 쪽도 같다. 등록과 범위 검증은 지나는데 읽는 자리가 없다.
그래서 지금 배포에서는 상수 512 가 셋을 다 덮는다. 셋이 갈리는 것은 그 인자가 설정과 이어지는 날이다.
판정은 P3 이다. 세 타입이 같은 문자열 공간을 쓰는데 상한을 하나는 주입받고 하나는 상수로 박고 하나는 아예 보지 않는다.
두 채널 타입의 렌더 본문은 서로 완전히 같다. 갈린 이유는 전송 경로이지 렌더링이 아니다 — 군집에서 슬롯을 소유한 샤드로만 전달된다. 타입을 나눈 것은 맞고, 두 벌이 된 것은 렌더 규칙 쪽이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 세 타입의 생성자와 렌더 대조, 구성 요소 규칙 확인, 배선 탐색, 실행 탐침
소스 수정 : x
## 재현 조건
1. 세 타입의 컴팩트 생성자와 렌더 메서드를 각각 읽는다.
2. 렌더 크기 검사가 나오는 곳을 저장소 전체에서 센다. 선언과 호출을 구분한다.
3. 채널 구성 요소가 받는 토큰과 식별자 규칙, 그리고 상한 상수를 읽는다.
4. 키 렌더러가 조각을 어떤 순서로 잇는지, 상한을 어디서 받는지 읽는다.
5. 그 렌더러를 만드는 곳과 설정값을 읽는 곳을 src/main 과 src/test 로 나눠 센다.
6. 구성 요소를 최대로 채운 채널과 키를, 그리고 기본 이름공간의 채널과 키를 각각 만들어 길이를 잰다.
7. 슬롯 태그를 붙인 키를 만들어 같은 검사가 거부하는지 본다.
8. 상한 256 으로 만든 렌더러에 같은 키를 넣고, 같은 크기의 채널과 대조한다.
## 본문
<!-- body:start -->
키 규칙에 조립된 문자열의 렌더 크기를 재는 메서드가 있고, Pub/Sub 쪽 세 타입 중 하나만 그것을 부른다.
## 부르는 하나와 부르지 않는 둘
:::evidence key="a10-f004-pub-sub" alt="Pub/Sub 세 타입의 컴팩트 생성자와 렌더 메서드, 그중 두 채널 타입의 렌더 본문이 같다는 것. 렌더 크기 검사가 나오는 세 줄 — 선언 하나와 호출 둘. 채널 조각이 받는 토큰·식별자 정규식과 상한 상수, 그 규칙을 부르는 세 타입. 키를 렌더하는 메서드가 조각 사이에 슬롯 태그를 넣는 줄과 그 상한을 생성자로 받는 줄. 그 생성자를 부르는 곳을 src/main 과 src/test 로 나눠 센 수, 설정값을 읽는 src/main 코드, 그리고 기본 이름공간 값을 출력한 터미널 기록." caption="크기 검사를 부르는 곳은 키 렌더러와 패턴 생성자 둘 · 두 채널 타입은 널 검사만 하고 렌더 본문이 동일 · 조각은 토큰 64자와 식별자 128자 규칙을 받고 슬롯 태그도 같은 규칙 · 렌더러 생성은 src/main 0건 src/test 12건이고 max-key-bytes 를 읽는 src/main 코드도 없음 — 89줄 · exit 0" zoom="true"
:::
```java
public PubSubPattern {
...
if (suffixPattern.isBlank() || suffixPattern.indexOf(':') >= 0) {
throw new IllegalArgumentException(
"a pattern suffix must be non-blank and must not cross a namespace separator");
}
RedisKeyRules.requireRenderedSize(
namespace.prefix() + ':' + suffixPattern, RedisKeyRules.MAX_KEY_BYTES);
}
```
렌더 시점이 아니라 생성 시점에 잰다. 두 채널 타입의 생성자에는 널 검사만 있다.
```text
sdk/api/key/RedisKeyRules.java:89: public static String requireRenderedSize(String rendered, int maxKeyBytes) {
sdk/api/key/RedisKeyRenderer.java:45: return RedisKeyRules.requireRenderedSize(rendered.toString(), maxKeyBytes);
sdk/api/operations/PubSubPattern.java:30: RedisKeyRules.requireRenderedSize(
```
첫 줄은 선언이고 부르는 곳은 아래 둘이다. 시험 소스까지 포함해 이게 전부다.
## 조각이 받는 규칙
```text
19: public static final int MAX_KEY_BYTES = 512;
21: private static final Pattern TOKEN = Pattern.compile("^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$");
23: private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$");
91: if (maxKeyBytes < 1 || maxKeyBytes > MAX_KEY_BYTES) {
92: throw new IllegalArgumentException("maximum key bytes must be in 1.." + MAX_KEY_BYTES);
RedisNamespace.java:16: RedisKeyRules.requireToken("environment", environment);
RedisNamespace.java:17: RedisKeyRules.requireToken("service", service);
RedisNamespace.java:18: RedisKeyRules.requireToken("domain", domain);
RedisKeyName.java:12: RedisKeyRules.requireToken("entity", entity);
RedisKeyName.java:13: RedisKeyRules.requireIdentifier(identifier);
RedisSlotTag.java:15: RedisKeyRules.requireIdentifier(value);
```
채널이 잇는 세 조각은 전부 이 규칙을 통과한 값이다. 두 정규식이 ASCII 밖을 받지 않아 바이트 수가 자 수와 같다. 패턴이 잇는 접미는 규칙을 받지 않는다 — 공백이 아닐 것과 구분자를 넘지 않을 것뿐이다.
## 최대로 채워 보면
:::evidence key="a10-f004-pub-sub-bound" alt="이름공간 세 토큰과 개체 토큰과 식별자를 각 규칙의 최대치로 채워 만든 두 채널 타입의 렌더 길이, 같은 조각으로 만든 키와 거기에 슬롯 태그를 더했을 때의 결과, 설정 기본 이름공간으로 만든 같은 둘의 길이, 접미 길이를 64·317·318 로 바꿔 가며 만든 패턴의 결과, 그리고 상한 256 으로 만든 렌더러가 같은 키와 같은 크기의 채널을 각각 어떻게 처리하는지 출력한 터미널 기록." caption="최대로 채운 채널 렌더는 388 바이트, 기본 이름공간에서는 221 바이트 · 같은 조각에 슬롯 태그를 더한 키는 519 바이트로 거부되지만 기본 이름공간에서는 352 바이트 · 상한 256 렌더러는 388 바이트 키를 거부하고 같은 크기 채널은 검사 자체가 없음 — 28줄 · exit 0" zoom="true"
:::
```text
[구성 요소의 상한] 토큰 64, 식별자 128, 슬롯 태그 128
namespace.prefix() 길이 : 194
[채널] 구성 요소를 최대로 채운 렌더
PubSubChannel 렌더 388 바이트 여유 124
ShardedPubSubChannel 렌더 388 바이트 여유 124
```
이 388 바이트는 이름공간 세 토큰을 모두 64자로 채웠을 때의 값이다. 설정 기본값인 `local:sample-service:shared` 는 27 바이트라 같은 조각으로 만든 채널이 221 바이트에 그친다.
패턴은 같은 최대 이름공간에서 접미 317자까지 512 바이트로 통과하고 318자에서 넘긴다.
## 조각이 규칙을 받는다는 말의 한계
원본은 조각이 각자 길이를 제한하므로 현실적인 초과가 어렵다고 봤다. 같은 규칙을 받은 조각들이 상한을 넘는 경우가 같은 패키지에 있다.
```java
rendered.append(key.namespace().prefix()).append(':');
key.slotTag().ifPresent(tag -> rendered.append('{').append(tag.value()).append("}:"));
rendered.append(key.name().entity()).append(':').append(key.name().identifier());
return RedisKeyRules.requireRenderedSize(rendered.toString(), maxKeyBytes);
```
키 렌더러는 조각 사이에 슬롯 태그를 하나 더 넣는다. 그 태그도 식별자 규칙을 받아 최대 128자다.
```text
[키] 같은 규칙을 받은 조각들, 슬롯 태그 하나가 더 붙는다
태그 없음 -> 렌더 388 바이트
태그 있음 -> rendered key is 519 bytes and exceeds the configured 512
[기본 이름공간] 설정 기본값 local:sample-service:shared
prefix 길이 : 27
채널 렌더 : 221 바이트
태그 붙인 키 : 352 바이트
```
519 바이트도 이름공간이 194 바이트일 때의 값이다. 기본 이름공간에서는 같은 태그를 붙여도 352 바이트로 통과한다.
## 상한을 어디서 가져오는가
```text
[상한을 어디서 가져오는가]
RedisKeyRenderer : 생성자 인자 (1..512)
PubSubPattern : RedisKeyRules.MAX_KEY_BYTES 상수
상한 256 렌더러에 키 -> rendered key is 388 bytes and exceeds the configured 256
같은 배포의 채널 388 바이트 -> 검사 없음
```
키 렌더러만 상한을 주입받는다. 그런데 주입하는 곳이 없다.
```text
src/main 에서 new RedisKeyRenderer( : 0 건
src/test 에서 new RedisKeyRenderer( : 12 건
getMaxKeyBytes / getLimits 를 부르는 src/main 코드
sdk/config/RedisSdkSettings.java:272: public int getMaxKeyBytes() {
sdk/config/RedisSdkSettings.java:908: public Limits getLimits() {
```
두 줄 다 선언 자신이다. `max-key-bytes` 는 환경 키 레지스트리에 등록되어 있고 부팅 검증이 1..512 범위까지 보지만, 읽는 코드가 없다.
그래서 이 대비는 지금 배포에서 일어나는 일이 아니다. 그 인자가 설정과 이어지는 날 일어날 일이다.
채널 이름이 렌더러를 지나가는 일도 없다. Pub/Sub 요청은 `channel.render()` 를 직접 부르고 키 목록으로 빈 리스트를 넘긴다.
## 남는 것
두 채널 타입은 렌더 본문이 한 글자도 다르지 않다.
```java
public String render() {
return namespace.prefix() + ':' + name.entity() + ':' + name.identifier();
}
```
갈린 이유는 전송 경로다. 군집에서 슬롯을 소유한 샤드로만 전달된다는 성질이지 렌더링이 아니다. 분리 자체는 옳고, 렌더 규칙만 두 벌이라 한 곳에서 고칠 수 없다.
## 확인하지 못한 것
상한을 넘는 채널 이름을 브로커에 보내면 어떻게 되는지 확인하지 않았다. 상수 상한을 넘는 이름은 이 타입들로 만들 수 없고, 상한을 낮춘 렌더러로 만든 키는 애초에 나가지 못한다.
<!-- body:end -->
@@ -0,0 +1,215 @@
---
kind: CASE
slug: a10-f005-hyperloglog-merge
title: 채워 넣은 상한은 자기가 잴 요청에서 값을 가져온다
topic: caching-and-redis
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a10-f005-hyperloglog-merge
evidenceCapturedOn: 2026-09-02
body: case-a10-f005-hyperloglog-merge.body.md
assets:
- key: a10-f005-hyperloglog-merge
file: ../../../final/evidence/rendered/a10-f005-hyperloglog-merge.svg
- key: a10-f005-hyperloglog-merge-scope
file: ../../../final/evidence/rendered/a10-f005-hyperloglog-merge-scope.svg
- key: a10-f005-hyperloglog-merge-budget
file: ../../../final/evidence/rendered/a10-f005-hyperloglog-merge-budget.svg
evidence:
- ../../../final/evidence/raw/a10-f005-hyperloglog-merge.txt
- ../../../final/evidence/raw/a10-f005-hyperloglog-merge-scope.txt
- ../../../final/evidence/raw/a10-f005-hyperloglog-merge-budget.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L277 이다. 등급은 P3 이다. 다중 키 fan-in 표 다섯 줄 중 확률적 집계 둘만 서명에 예산 인자가 없다는 표, 두 명령의 비용이 입력 레지스터 수에 비례한다는 지적, 레지스터가 12KB 고정이라 폭발 범위가 좁다는 판단, 그리고 규칙의 예외가 이유 없이 존재한다는 문장이 그 절에 있다. 그 표의 첫 줄이 집합 대수 셋을 묶은 것이라 연산 수로는 일곱이다.
- 이 기록에서 확인한 것은 셋이다. 두 명령도 예산 없이는 관문을 통과하지 못하고, 그 예산은 SDK가 채우며, 채운다는 설계는 상한 타입의 첫 문단에 적혀 있다.
- 남는 문제는 원본이 든 것과 다르다. 예산을 만드는 세 메서드가 요청 바이트 상한을 잴 대상에서 그대로 가져오므로, 이 두 명령뿐 아니라 SDK가 예산을 채우는 R2 명령 전부에서 관문의 요청 바이트 검사가 발화하지 못한다.
---
# 채워 넣은 상한은 자기가 잴 요청에서 값을 가져온다
여러 키를 읽어 하나에 쓰는 연산 일곱 중 다섯은 호출자에게 비용 상한을 받고 둘은 받지 않는다. 그 둘도 예산 없이는 관문을 통과하지 못한다. SDK가 대신 만들어 넣는데, 그 상한의 요청 바이트 항목이 자기가 잴 요청의 크기에서 값을 가져온다.
## 관계
- **상한을 주입받는 자리는 있고 주입하는 곳은 없다**
같은 리프에서 상한을 받을 자리는 있는데 넣어 주는 코드가 없다.
- **미배선 인터셉터는 누락이 아니라 중복이다**
두 사례 모두 빠진 것으로 읽은 자리에 실제로는 다른 형태의 구현이 있었다.
- **타입이 문서화한 불변식은 타입이 강제한다**
예산 타입은 기본값으로 채워지지 않는다고 적어 두고 강제하지 않는다.
## 문제
여러 키를 읽어 하나에 쓰고 비용이 입력 크기에 비례하는 연산은 이 계층에 일곱이다. 원본 표는 집합 대수 셋을 한 줄로 묶어 다섯 줄로 적었다. 그중 다섯에는 호출자가 비용 상한을 건네도록 서명에 인자를 두고, 확률적 집계 계열 둘에는 두지 않는다.
인자가 없다는 것과 상한이 없다는 것이 같은 말인지 확인했다.
## 결론
같은 말이 아니다.
정책 카탈로그에서 두 명령은 R2 다. 관문은 R2 요청의 예산이 비어 있으면 거부한다. 예산 없이는 실행 자체가 안 된다.
빌더가 예산을 직접 조립해 요청에 얹는다. 두 곳 모두 요소 수와 요청 바이트를 인자로 넘긴다.
채워 넣는다는 것 자체는 적혀 있다. 상한을 모아 둔 타입의 첫 문단이 서명에 예산이 없을 때 적용하는 천장이라고 말하고, 호출자가 건넨 쪽이 언제나 이긴다고 덧붙인다.
그 문단은 R2 전체를 설명하지 않는다. 비교 대상 셋과 해시 전체 읽기는 허가와 예산을 둘 다 호출자에게 받는다. 반대로 WATCH 와 BLPOP 은 둘 다 SDK가 자기에게 발급한다. 확률적 집계는 SMOVE 나 RENAME 이나 MGET 과 같은 자리에 있다. 허가만 호출자에게 받는 쪽이다.
이것이 두 곳만의 방식도 아니다. src/main 에 이름이 나오는 R2 명령 쉰여섯 중 서른넷이 SDK 쪽이고 열넷이 호출자 서명 쪽이다. 나머지 여덟은 예산을 다른 파일에서 조립하거나 직접 만들어 이 스캔으로는 가리지 못했다.
실행해 보면 요청 바이트 항목은 어떤 크기에서도 통과한다. 상한을 요청 크기에서 그대로 가져오기 때문이다. 렌더된 키는 타입 상한인 512 바이트를 넘지 못하고 기본 설정의 요소 천장이 1000 이라 이 경로의 요청은 512000 바이트 아래인데, 그 상한선에서도 예산의 상한은 같은 512000 이다.
관문은 요소 수를 보지 않는다. 요소 수를 막는 것은 예산을 만드는 쪽이고, 그것도 예산이 만들어지기 전에 던진다. 관문에 도착한 예산이 실제로 기여하는 것은 타임아웃 하나다.
같은 패키지에 반대 문장이 있다. 예산 타입의 첫 문단은 모든 R2 API가 예산을 요구하며 기본값으로 채워지지 않는다고 적는다. 두 문장 중 코드가 지키는 쪽은 상한 타입이다.
판정은 P3 이되 이유가 다르다. 원본은 인자가 없는 것을 이유로 들었는데, 실제로 남는 문제는 채워 넣은 상한이 관문의 요청 바이트 검사를 발화시킬 수 없다는 것이고, 그것은 이 두 명령만의 일이 아니다. 예산을 만드는 세 메서드가 전부 같은 식을 쓴다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 서명과 정책 카탈로그 대조, 요청 빌더 추적, 실행 탐침
소스 수정 : x
## 재현 조건
1. 여러 키를 읽어 하나에 쓰는 연산 일곱의 서명을 나란히 읽는다.
2. 정책 카탈로그에서 그 명령들의 위험 등급을 확인한다.
3. 관문이 R2 요청의 빈 예산을 어떻게 처리하는지 읽는다.
4. 확률적 집계 요청 빌더가 예산을 어디서 얻는지 따라간다.
5. 예산을 만드는 세 메서드가 요청 바이트 항목에 무엇을 넣는지 읽는다.
6. 그 메서드를 여러 요청 크기로 불러 상한과 판정을 출력한다.
7. R2 명령마다 예산이 호출자 서명에서 오는지 SDK가 만드는지 가려 센다.
## 본문
<!-- body:start -->
확률적 집계 계열의 다중 키 연산 둘은 호출자에게 비용 상한을 받지 않는다. 같은 성격의 다른 다섯은 받는다.
## 인자가 없는 것과 상한이 없는 것
:::evidence key="a10-f005-hyperloglog-merge" alt="여러 키를 읽어 하나에 쓰는 연산 일곱의 서명 — 다섯에는 비용 상한 인자가 있고 둘에는 없다. 정책 카탈로그가 그 명령들에 매긴 위험 등급, 관문이 R2 요청의 빈 예산을 거부하는 구문, 확률적 집계 요청 빌더가 예산을 만들어 넣는 두 줄과 그 메서드가 상한을 정하는 세 줄, 같은 식을 쓰는 다른 두 메서드의 줄, 그리고 이 설계를 적어 둔 문단과 같은 패키지에서 반대로 적어 둔 문단을 출력한 터미널 기록." caption="일곱 중 다섯의 서명에만 OperationBudget 인자가 있고 확률적 집계 둘은 없다 · 카탈로그 등급은 전부 R2 · 관문은 R2 요청의 빈 예산을 거부하므로 요청 빌더가 예산을 만들어 넣는다 · 요청 바이트 상한을 요청 크기에서 가져오는 식이 세 메서드에 모두 있다 — 112줄 · exit 0" zoom="true"
:::
```java
long count(Collection<? extends HyperLogLogKey<?>> keys, MultiKeyPermit permit);
void merge(
HyperLogLogKey<?> destination,
Collection<? extends HyperLogLogKey<?>> sources,
MultiKeyPermit permit);
```
앞의 다섯에는 `OperationBudget budget` 이 마지막 인자로 붙어 있다. 이 둘에는 없다. 그런데 카탈로그에서 두 명령은 `R2` 이고, 관문은 이렇게 한다.
```java
if (request.budget().isEmpty()) {
throw new RedisCommandRejectedException(
"R2 command requires permit and budget", metadata(policy, OptionalInt.empty()));
}
```
예산이 비면 실행되지 않는다. 빌더가 직접 조립해 얹는다.
```text
69: Optional.of(context.collectionBudget(rendered.qualified().size(), rendered.requestBytes())),
93: Optional.of(context.collectionBudget(qualified.size(), size)),
```
## 채워 넣는다고 적힌 곳
```text
/**
* The ceilings the typed operations apply when the public signature does not carry a budget.
*
* <p>Design section 10 gives some R2 methods a caller-supplied {@code OperationBudget} and others a
* caller-supplied permit, but {@code CommandPolicyGuard} requires both for every R2 command. These
* limits are what the SDK fills in for the half the signature omits, so an R2 command is never
* admitted with an unbounded cost. The caller-supplied half always wins; this only supplies what
* the caller had no way to pass.
*
```
확률적 집계는 허가만 호출자에게 받고 예산은 SDK가 채운다.
이 문단을 R2 전체의 규칙으로 읽으면 틀린다. 집합 대수와 비트 연산과 지리 검색 저장과 해시 전체 읽기는 허가와 예산을 둘 다 호출자에게 받는다. 반대로 `WATCH``BLPOP` 은 둘 다 SDK가 자기에게 발급한다 — `BLPOP` 의 서명에는 허가 인자가 아예 없다. `BLMOVE` 는 둘 다인 것처럼 보이지만 아니다. 호출자의 다중 키 허가를 문맥이 먼저 검증하고, 관문에는 정책 이름이 맞는 SDK 허가를 대신 건넨다.
같은 방식이 R2 전반에 있다.
:::evidence key="a10-f005-hyperloglog-merge-scope" alt="src/main 에 이름이 문자열로 나오는 R2 명령마다, 요청을 만드는 메서드의 서명이 비용 상한을 인자로 받는지 아니면 그 메서드 또는 같은 파일의 헬퍼에서 SDK가 만들어 넣는지 가려 센 터미널 기록. 두 단계까지 따라가고도 출처를 못 가린 명령은 따로 적는다." caption="R2 명령 56개 중 34개는 SDK가 예산을 만들고 14개는 호출자 서명이 받는다 · 나머지 8개는 예산이 다른 파일에 있어 미판정 — 25줄 · exit 0" zoom="true"
:::
## 채워 넣은 예산이 막는 것
:::evidence key="a10-f005-hyperloglog-merge-budget" alt="예산을 만드는 메서드를 세 가지 요청 크기로 불러 얻은 요청 바이트 상한과 그 요청에 대한 판정, 이 경로의 요청이 가질 수 있는 최대치를 키 상한과 요소 천장에서 계산한 값, 같은 크기를 거부하는 호출자 예산 하나, 요소 수를 천장과 천장 초과로 부른 결과, 그리고 두 명령이 선언하는 예상 회신 크기에 대한 판정을 출력한 터미널 기록. 실행에 쓴 자바 판을 첫 줄에 함께 적는다." caption="요청 바이트 상한이 요청 크기와 같아 이 경로의 상한선 512000 바이트에서도 통과 · 호출자가 건넨 상한 4096 은 8192 요청을 거부 · 실제로 거부되는 것은 요소 천장 초과뿐이고 그 거부는 KEY 계열로 기록된다 — 16줄 · exit 0" zoom="true"
:::
예산을 만드는 메서드는 요청 바이트 상한을 이렇게 정한다.
```java
long replyCeiling = (long) elements * limits.maxReplyBytesPerElement();
return new OperationBudget(
elements, Math.max(1L, requestBytes), replyCeiling, limits.collectionTimeout());
```
상한이 요청 크기다. 관문이 그 둘을 비교한다.
```text
요청 512 -> maxRequestBytes 512 allowsRequestBytes(요청) true
요청 4096 -> maxRequestBytes 4096 allowsRequestBytes(요청) true
요청 512000 -> maxRequestBytes 512000 allowsRequestBytes(요청) true
이 경로의 요청 최대 : 렌더된 키 512 바이트 x 요소 천장 1000 = 512000 바이트
```
기본 설정에서 이 경로가 만들 수 있는 가장 큰 요청에서도 통과한다. 호출자가 건넨 상한은 다르다.
```text
maxRequestBytes 4096, 요청 8192 -> allowsRequestBytes false
```
회신 검사도 지나가는데, 이쪽은 맞는 값이다. `PFCOUNT` 는 정수 하나, `PFMERGE``OK` 하나라 두 요청이 선언하는 `0L` 이 실제 크기다.
거부되는 것은 요소 수 하나다.
```text
요소 1001개 -> RedisCommandRejectedException: operation over 1001 elements exceeds the configured ceiling of 1000 [command=KEY, mode=STANDALONE, ambiguous=false]
```
그 거부는 관문이 아니라 예산을 만드는 쪽에서, 예산이 만들어지기 전에 나온다. 그리고 `command=KEY` 다. 계열 이름이 `"KEY"` 로 고정돼 있어서, 병합 하나가 천장을 넘긴 일이 운영자에게는 키 연산으로 기록된다. 이 자리를 계열 이름으로 먼저 거르는 검사가 확률적 집계에는 없기 때문이다.
관문까지 간 예산에서 실제로 쓰이는 것은 타임아웃뿐이다.
## 두 명령만의 일이 아니다
```text
313: elements, Math.max(1L, requestBytes), replyCeiling, limits.collectionTimeout());
336: Math.max(1L, requestBytes),
349: 1, Math.max(1L, requestBytes), limits.maxReplyBytesPerElement(), limits.scriptTimeout());
```
예산을 만드는 메서드가 셋이고 셋 다 같은 식을 쓴다. 그러니 이 성질은 SDK가 예산을 채우는 R2 명령 서른넷 전부에 있다.
## 반대로 적어 둔 곳
```text
/**
* Explicit bound a caller accepts for one advanced operation.
*
* <p>Every R2 API requires a budget. The budget is never optional and never defaulted, because the
* whole point is that the caller states the cost it is prepared to pay before Redis is asked.
*/
```
구현이 따르는 것은 상한 타입 쪽이다.
## 확인하지 못한 것
실제 서버에 큰 병합을 보내 지연이 얼마나 커지는지 재지 않았다.
512000 은 기본 설정에서의 상한선이다. 요소 천장은 설정에서 올릴 수 있고, 이 리비전에서 요청 문맥을 만드는 곳은 테스트뿐이라 배포에서 실제로 쓰이는 천장은 아직 없다.
예산의 출처를 못 가린 여덟 명령은 확인하지 않았다. 그 여덟은 예산을 다른 파일의 공용 실행기에서 받거나 등록된 함수의 자체 한도로 직접 만든다.
<!-- body:end -->
@@ -0,0 +1,216 @@
---
kind: CASE
slug: a10-f006-requireidentifier
title: 지워도 test가 초록인 검사가 셋이다
topic: caching-and-redis
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a10-f006-requireidentifier
evidenceCapturedOn: 2026-09-02
body: case-a10-f006-requireidentifier.body.md
assets:
- key: a10-f006-requireidentifier
file: ../../../final/evidence/rendered/a10-f006-requireidentifier.svg
- key: a10-f006-requireidentifier-branch
file: ../../../final/evidence/rendered/a10-f006-requireidentifier-branch.svg
evidence:
- ../../../final/evidence/raw/a10-f006-requireidentifier.txt
- ../../../final/evidence/raw/a10-f006-requireidentifier-branch.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L375 이다. 등급은 P3 이다. 문자 클래스가 메일과 전화 형태의 필수 문자를 이미 배제하므로 두 분기가 도달 불가라는 관찰, 대응 test 가 예외 타입만 보므로 그 사실을 가리지 않는다는 지적, 보안 효과는 그대로이고 잃는 것은 진단 품질과 검증 겹수의 착시라는 판단, 그리고 두 분기를 지우거나 검사 순서를 뒤집으라는 제안이 그 절에 있다.
- 이 기록이 더한 것은 셋이다. 두 입력에 실제로 돌아오는 메시지가 문자 클래스 메시지라는 실행 결과. 웹 토큰 분기는 도달하지만 그것을 겨냥한 test 입력이 접두 검사에도 걸려 지워도 초록이고, 혼자 잡는 것은 실제 토큰이 아니라 합성 값이라는 것. 그리고 네 메시지를 단언하는 곳이 저장소에 하나도 없다는 것이다. 원본이 도달 불가로 센 것은 둘이고, 지워도 test 가 초록인 것은 셋이다.
---
# 지워도 test가 초록인 검사가 셋이다
식별자 검증이 다섯 겹으로 보인다. 그중 둘은 앞선 문자 클래스 검사가 이미 걸러내 도달하지 않고, 하나는 도달하지만 그것을 겨냥한 test 입력이 뒤 검사에도 걸린다. 셋 다 지워도 test 는 초록이다.
## 관계
- **커버리지 gate 둘이 나란히 있고, 하나는 발화할 수 없다**
두 사례 모두 조건이 성립할 수 없어 그 분기가 실행되지 않는다.
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
예외 타입만 보는 단언은 어느 검사가 던졌는지 구분하지 않는다.
- **상한을 주입받는 자리는 있고 주입하는 곳은 없다**
같은 리프의 다른 검증 사례다.
## 문제
식별자 검증에 검사가 다섯 있다. 문자 클래스 하나와 구체적 형태 넷이다. 메일 주소, 웹 토큰, 국제 전화번호, 인증 재료 접두다.
각 검사가 실제로 발화하는지, 그리고 발화한다면 어느 test 가 그것을 붙들고 있는지 확인했다.
## 결론
문자 클래스는 첫 문자로 영숫자를 요구하고 이후 문자로 영숫자와 점과 물결과 밑줄과 붙임표를 허용한다. 길이는 128 이하다.
거기에 골뱅이도 더하기도 없다.
그래서 메일 분기는 도달하지 않는다. 골뱅이를 포함한 값은 첫 검사에서 탈락한다. 전화 분기도 도달하지 않는다. 국제 전화번호 패턴이 반드시 더하기로 시작하는데 더하기는 첫 문자로도 이후 문자로도 허용되지 않는다.
실행으로 확인했다. 두 형태를 넣으면 돌아오는 메시지가 문자 클래스 메시지다. 무작위 입력 20만 개에서도 이 두 검사에서 갈린 값이 하나도 없다.
웹 토큰 분기는 도달한다. 웹 토큰 패턴이 쓰는 문자를 문자 클래스가 모두 허용하므로, 26자에서 128자 사이이고 영숫자로 시작하는 토큰 형태는 첫 검사를 통과해 전용 검사에 닿는다.
다만 그것이 잡는 것은 진짜 토큰이 아니다. 진짜 토큰은 늘 같은 세 글자로 시작하고 길이도 128자를 넘긴다. 그런 값은 접두 검사나 문자 클래스가 먼저 잡는다. 혼자 걸리는 값은 토큰 흉내를 낸 합성 문자열뿐이다.
그리고 그것을 겨냥한 test 입력 하나가 다음 검사에도 걸린다. 그 값이 eyJ 로 시작해서 접두 검사가 같은 타입의 예외를 낸다. 웹 토큰 분기를 지워도 그 test 는 초록이다.
남는 둘은 붙들려 있다. 문자 클래스 검사는 128자 초과 입력과 구분자 주입을 거부하는 test 가 붙들고, 접두 검사는 웹 토큰 형태가 아닌 두 입력이 붙든다. 둘 중 하나를 지우면 대응 test 가 빨개진다.
저 네 메시지는 저장소에서 던지는 자리에만 있다. 단언하는 곳이 없다.
판정은 P3 이다.
거부는 그대로다. 세 형태는 여전히 전부 거부된다.
잃는 것은 둘이다. 운영자가 받는 메시지가 구체 형태에서 문자 클래스로 내려앉는다. 그리고 세 분기가 test 에 붙들리지 않은 채 검증이 다섯 겹인 것처럼 보이게 만든다.
원본은 두 분기를 지우고 문자 클래스 메시지에 그 의도를 포함시키거나, 검사 순서를 뒤집어 구체적 형태를 먼저 판정하는 수정을 제안했다. 뒤집는 쪽을 고르면 세 분기가 전부 발화한다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 문자 클래스와 후속 분기 대조, 실행 탐침, test 단언 대상 확인
소스 수정 : x
## 재현 조건
1. 식별자 검증의 다섯 검사를 순서대로 읽는다.
2. 각 검사가 쓰는 패턴 넷을 읽는다.
3. 각 형태 검사가 요구하는 필수 문자가 문자 클래스 안에 있는지 본다.
4. 이 메서드에 값을 넣는 test 를 모아 무엇을 단언하는지 확인한다.
5. 그 입력들을 그대로 넣고 실제로 돌아오는 메시지를 본다.
6. 각 입력이 다섯 중 어느 검사에 걸리는지 전부 표시한다.
7. 웹 토큰 분기에만 걸리는 값과, 실제 크기의 토큰을 각각 넣어 본다.
8. 무작위 입력으로 각 검사에서 갈린 수를 센다.
9. 저장소 전체에서 네 메시지가 나오는 곳을 센다.
## 본문
<!-- body:start -->
검사는 아래 순서로 돈다.
## 다섯 검사
:::evidence key="a10-f006-requireidentifier" alt="식별자 검증 메서드의 다섯 검사 전체와 그 검사들이 쓰는 정규식 넷, 이 메서드에 값을 넣는 test 다섯 개가 무엇을 단언하는지, 그리고 저장소 전체에서 네 예외 메시지가 나오는 곳을 파일 형식 제한 없이 센 터미널 기록." caption="검사 다섯은 문자 클래스·메일·웹 토큰·전화·접두 순 · test 다섯 개의 단언은 모두 예외 타입뿐 · 네 메시지는 던지는 자리 넷에만 있고 단언하는 곳이 없다 — 78줄 · exit 0" zoom="true"
:::
```java
if (value == null || !IDENTIFIER.matcher(value).matches()) {
throw new IllegalArgumentException(
"identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a"
+ " key separator");
}
String lowerCase = value.toLowerCase(Locale.ROOT);
if (value.indexOf('@') >= 0) {
throw new IllegalArgumentException("identifier must not contain a mail address");
}
if (JSON_WEB_TOKEN.matcher(value).matches()) {
throw new IllegalArgumentException("identifier must not contain a JSON web token");
}
if (INTERNATIONAL_PHONE.matcher(value).matches()) {
throw new IllegalArgumentException("identifier must not contain a phone number");
}
if (lowerCase.startsWith("bearer") || lowerCase.startsWith("eyj")) {
throw new IllegalArgumentException("identifier must not contain authentication material");
}
```
첫 검사가 쓰는 문자 클래스에는 골뱅이도 더하기도 없다.
```text
private static final Pattern IDENTIFIER = Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$");
private static final Pattern JSON_WEB_TOKEN =
Pattern.compile("^[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$");
private static final Pattern INTERNATIONAL_PHONE = Pattern.compile("^\\+\\d[\\d.~-]{7,}$");
```
## 넣어 보면 어느 메시지가 오는가
:::evidence key="a10-f006-requireidentifier-branch" alt="test 가 쓰는 여섯 입력을 실제 검증 메서드에 넣어 돌아온 메시지와, 각 입력이 다섯 검사 중 어디에 걸리는지 전부 표시한 표. 웹 토큰 분기에만 걸리는 합성 값, 밑줄로 시작하는 값, 실제 크기의 토큰. 그리고 무작위 입력 20만 개로 옮겨 적은 정규식과 실제 메시지를 대조하고 각 검사에서 갈린 수를 함께 센 터미널 기록." caption="메일·전화 입력이 받는 메시지는 문자 클래스 메시지 · 웹 토큰 test 입력은 접두 검사에도 걸리고 실제 크기 토큰은 문자 클래스에서 먼저 걸린다 · 무작위 20만 개에서 메일·전화 검사에서 갈린 입력은 0 — 27줄 · exit 0" zoom="true"
:::
```text
입력 길이 걸리는 검사 전부 | requireIdentifier 가 낸 메시지
test: 메일 18 문자클래스 메일 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
test: 전화 13 문자클래스 전화 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
test: eyj 20 접두 | identifier must not contain authentication material
test: bearer 19 접두 | identifier must not contain authentication material
test: JWT 57 JWT 접두 | identifier must not contain a JSON web token
test: 129자 129 문자클래스 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
```
메일과 전화 입력은 전용 검사 앞에서 이미 걸린다. 문자 클래스가 골뱅이와 더하기를 빼 놓았으니 그 둘에 도달할 값 자체가 없다.
웹 토큰 입력은 전용 검사에 닿는다. 다만 같은 값이 다음 검사에도 걸린다 — `eyJ` 로 시작하기 때문이다.
## 웹 토큰 분기가 혼자 잡는 것
```text
합성 JWT 26자 26 JWT | identifier must not contain a JSON web token
_로 시작 26 문자클래스 JWT | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
실제 크기 JWT 238 문자클래스 JWT 접두 | identifier must be 1..128 characters of [A-Za-z0-9._~-] and must not contain a key separator
```
혼자 걸리려면 26자에서 128자 사이이고 영숫자로 시작하며 `eyJ` 로도 `bearer` 로도 시작하지 않아야 한다. 실제 토큰은 헤더가 `{"` 로 시작해 base64url 로 늘 `eyJ` 가 되고, 길이도 128자를 넘기 일쑤다. 그러니 이 분기가 혼자 잡는 것은 토큰을 닮은 합성 값이다.
## 어느 검사를 지우면 test 가 빨개지는가
웹 토큰 분기는 일을 하지만 붙들고 있는 test 가 없다. 저 test 입력은 분기를 지워도 접두 검사가 같은 타입의 예외를 낸다.
문자 클래스 검사와 접두 검사는 다르다. 129자 입력과 `1:2` 는 문자 클래스 검사가 없으면 아무 검사에도 안 걸리고, `eyJhbGciOiJIUzI1NiJ9``bearer-abcdefabcdef` 는 접두 검사 말고 걸리는 데가 없다.
## 왜 test 가 이것을 못 잡는가
```java
assertThatThrownBy(() -> new RedisKeyName("user", "person@example.com"))
.isInstanceOf(IllegalArgumentException.class);
```
단언 대상이 예외 타입뿐이다. 어느 검사가 던졌는지 보지 않는다.
```text
sdk/api/key/RedisKeyRules.java:67: throw new IllegalArgumentException("identifier must not contain a mail address");
sdk/api/key/RedisKeyRules.java:70: throw new IllegalArgumentException("identifier must not contain a JSON web token");
sdk/api/key/RedisKeyRules.java:73: throw new IllegalArgumentException("identifier must not contain a phone number");
sdk/api/key/RedisKeyRules.java:76: throw new IllegalArgumentException("identifier must not contain authentication material");
```
저장소 전체를 파일 형식 제한 없이 훑어도 네 메시지가 나오는 곳은 던지는 자리 넷뿐이다.
## 옮겨 적은 정규식을 믿어도 되는가
표의 「걸리는 검사」 열은 소스에서 옮겨 적은 정규식으로 계산한다. 그 사본이 실제와 같은지 무작위 입력으로 대조했다.
```text
200000 / 200000 일치
문자클래스 에서 갈린 입력 144166
메일 에서 갈린 입력 0
JWT 에서 갈린 입력 23462
전화 에서 갈린 입력 0
접두 에서 갈린 입력 30843
통과 1529
```
문자 클래스와 웹 토큰과 접두는 각각 수만 번씩 갈렸다. 메일과 전화는 0 인데, 그게 이 기록의 결론이다. 도달할 값이 없으니 대조할 방법도 없다.
## 잃는 것
거부는 그대로다. 세 형태 모두 거부된다.
운영자가 보는 메시지가 달라진다. 메일 주소를 담지 말라는 문장 대신 문자 클래스 문장이 온다.
그리고 세 분기가 검증을 다섯 겹처럼 보이게 만든다.
## 확인하지 못한 것
세 분기를 실제로 지운 빌드로 전체 test 를 돌리지 않았다. 소스를 고치지 않는 것이 이 작업의 조건이다. 대신 각 입력이 걸리는 검사를 전부 표시해, 지운 뒤에도 같은 타입의 예외를 낼 검사가 남는지 확인했다.
분기와 함께 쓰이지 않게 되는 패턴 필드까지 지운 빌드가 경고 없이 컴파일되는지도 확인하지 못했다.
<!-- body:end -->
@@ -0,0 +1,108 @@
---
kind: CASE
slug: analysis-finding-a10-f003
title: SDK가 선언한 두 진입점에 구현이 없다
topic: caching-and-redis
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a10-f003
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a10-f003.body.md
assets:
- key: analysis-finding-a10-f003
file: ../../../final/evidence/rendered/analysis-finding-a10-f003.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a10-f003.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L251 이다.
---
# SDK가 선언한 두 진입점에 구현이 없다
동기와 반응형 진입점 인터페이스가 각각 열두 접근자를 선언한다. 개별 표면은 사십삼 종이 모두 구현되어 있는데 두 진입점을 구현하는 클래스는 하나도 없다. 대칭 테스트는 인터페이스끼리만 비교하므로 이것을 가리지 못한다.
## 관계
- **README readiness 표가 있는 것을 없다고 적는다**
같은 리프의 반대 방향 사례이고 원인은 같다.
- **미배선 경계가 문서에만 있고 compile 경로에서 닫히지 않는다**
같은 형태의 절반 조립이다.
- **인터페이스끼리 비교하는 test는 구현의 부재를 못 본다**
이 사례가 그 규칙의 형태다.
## 문제
동기 진입점 인터페이스가 자신을 형 있는 API 로의 동기 진입점이라 소개하고, 반응형 인터페이스가 그 짝이다.
두 인터페이스는 각각 열두 접근자를 선언한다. 값과 해시와 리스트와 집합과 정렬 집합과 비트맵과 비트 필드와 확률적 집계와 지리와 스트림과 키와 배치다.
## 결론
둘 다 구현체가 없다.
리프 전체의 구현 선언을 전수 조사했다. 개별 표면은 전부 구현되어 있다. 동기 스물여섯 종과 반응형 열일곱 종이다.
그런데 두 진입점을 구현한다고 선언한 클래스는 0 건이다.
main 안에서 두 타입을 이름으로 부르는 곳도 없다. 유일한 참조가 반응형 인터페이스 자바독의 링크 하나와 대칭 테스트의 반사 두 줄이다.
결과적으로 이 SDK 를 쓰는 코드는 진입점을 얻을 수 없다.
열두 표면을 각각 어디선가 따로 받아야 하고, 진입점이 약속하는 하나의 객체에서 형 있는 표면 전체는 존재하지 않는다.
접근자를 추가하고 반응형 짝을 맞추는 규율은 실행되고 있다. 그 규율이 만드는 대상을 실제로 만드는 코드가 없다.
판정은 P2 다.
데이터 위험은 없다. 없는 타입은 잘못된 답을 주지 않는다.
위험은 API 계약의 신뢰다. 이 리프의 공개 표면 중 가장 먼저 읽히는 두 타입이 구현되지 않은 상태이고, 대칭 테스트가 그 사실을 가리지 못한다. 인터페이스끼리만 비교하기 때문이다.
같은 리프의 준비도 표 사례와 방향이 반대이면서 원인은 같다. 조립이 절반이다.
수정은 이미 존재하는 구현들을 묶는 두 클래스를 추가하고, 대칭 테스트에 두 진입점이 구현을 가진다는 검사를 더하는 것이다.
## 검증 환경
확인 방식 : 구현 선언 전수 조사와 이름 참조 검색
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/160 계열에 있다.
1. 두 진입점 인터페이스의 접근자 목록을 확인한다.
2. 리프 전체에서 구현 선언을 전수 조사한다.
3. 두 진입점을 구현하는 클래스가 있는지 센다.
4. main 안에서 두 타입 이름을 검색한다.
5. 대칭 테스트가 무엇과 무엇을 비교하는지 확인한다.
## 본문
<!-- body:start -->
SDK가 선언한 두 진입점에 구현이 없다.
## SDK 가 선언한 두 진입점
:::evidence key="analysis-finding-a10-f003" alt="분석 문서 analysis/10-adapter-outbound-cache-redis.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/10-adapter-outbound-cache-redis.md 발췌 — 15줄" zoom="true"
:::
## 데이터 위험은 없다
없는 타입은 잘못된 답을 주지 않는다. P2.
## 위험은 API 계약의 신뢰다
이 leaf의 공개 표면 중 가장 먼저 읽히는 두 타입이 구현되지 않은 상태이고, 대칭 test가 그 사실을 가리지 못한다 — 인터페이스끼리만 비교하기 때문이다. sub-scope 01의 §5와 방향이 반대이면서 원인은 같다: 조립이 절반이다.
## 수정
이미 존재하는 26개 구현을 묶는 `LettuceRedisOperations` / `LettuceReactiveRedisOperations` 두 클래스를 추가하고, `ApiParityTest`에 "두 facade는 구현을 가진다"는 검사를 더하는 것이다.
## 확인하지 못한 것
두 진입점이 과거에 구현체를 가졌는지 이력에서 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,123 @@
---
kind: CASE
slug: analysis-finding-a10-f007
title: 패턴 구독의 R2 승인만 호출자가 아니라 배포에 대해 이루어진다
topic: caching-and-redis
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a10-f007
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a10-f007.body.md
assets:
- key: analysis-finding-a10-f007
file: ../../../final/evidence/rendered/analysis-finding-a10-f007.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a10-f007.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L485 이다.
---
# 패턴 구독의 R2 승인만 호출자가 아니라 배포에 대해 이루어진다
같은 위험 등급의 연산 대부분은 호출자가 허가를 들고 오도록 서명이 요구한다. 패턴 구독만 서명에 허가 인자가 없고, SDK 가 자기 자신에게 발급한 허가를 쓴 뒤 버린다. 실제 효과는 배포가 그 정책을 켰는지 확인하는 것이다.
## 관계
- **같은 위험 등급에 두 승인 모델이 있으면 차이를 문서가 적어야 한다**
이 사례가 그 규칙의 형태다.
- **permit 정책 이름이 세 곳에 문자열로 존재하고 교차 검사가 없다**
같은 리프의 허가 체계 사례다.
- **식별자 검증의 다섯 검사 중 둘은 도달할 수 없다**
같은 리프의 다른 검증 사례다.
## 문제
이 리프는 위험한 연산을 등급으로 나누고, 상위 등급 연산에 명시적 승인을 요구한다.
그 승인이 어디서 오는지 연산별로 대조했다.
## 결론
패턴 구독만 다르다.
집합 연산 셋은 서명이 고급 연산 허가를 요구한다. 호출자가 들고 온다.
키 훑기와 해시 항목 조회도 마찬가지다.
비트 필드 실행은 예산이 필수이고 허가는 가드가 목록의 필수 정책으로 요구한다.
패턴 구독은 서명에 허가 인자가 없다.
대상을 계산하는 쪽이 부르는 것은 SDK 가 자기 자신에게 발급하는 경로다.
발급 구현은 정책 이름이 배포의 활성 정책 목록에 없으면 던진다. 그러므로 실제 효과는 이 배포가 패턴 구독을 켰는지 확인하는 것이다.
그리고 반환된 허가는 버려진다.
즉 다른 상위 등급 연산은 호출 지점이 승인을 증명하는데, 패턴 구독은 배포가 켜 두었는지만 본다.
자바독이 허가가 필요한 이유는 적는다. 그 확산 범위를 서버가 정한다는 것이다.
그런데 그 허가가 호출자가 아니라 SDK 가 스스로 발급한 것이라는 약해진 보증은 적지 않는다.
고급 연산 허가의 계약이 상위 등급 연산이 명시적으로 승인되었음을 증명한다는 것과 견주면 차이가 있다.
판정은 P3 다.
배포 수준 게이트는 실재하고 이름공간 봉쇄도 있으므로 열린 구멍은 아니다.
기록하는 이유는 같은 위험 등급에 두 가지 다른 승인 모델이 적용되고 그 차이가 문서화되어 있지 않기 때문이다.
수정은 둘 중 하나다. 패턴 구독 서명에 고급 연산 허가를 추가하거나, 자바독에 배포 수준 승인임을 명시하는 것이다.
## 검증 환경
확인 방식 : 연산별 서명과 허가 발급 경로 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/163 계열에 있다.
1. 상위 등급 연산 목록을 만든다.
2. 각 연산의 서명에 허가 인자가 있는지 확인한다.
3. 패턴 구독이 부르는 발급 경로를 확인한다.
4. 그 발급 구현이 무엇을 검사하는지 읽는다.
5. 반환된 허가가 어디에 쓰이는지 확인한다.
## 본문
<!-- body:start -->
R2 연산마다 승인 모델이 다르다.
| R2 연산 | 호출자가 permit을 들고 오는가 |
|---|---|
| `sets.difference/intersection/union` | **예** — 서명이 `AdvancedOperationPermit`을 요구 |
| `keys.scan` · `hashes.entries` | **예** |
| `bitFields.execute` | budget 필수, permit은 guard가 catalog의 `required-policy`로 요구 |
| `pubSub.patternSubscribe` | **아니오** — 서명에 permit 인자가 없다 |
## AdvancedOperationPermit 참조 위치
:::evidence key="analysis-finding-a10-f007" alt="코드베이스에서 AdvancedOperationPermit 를 검색한 출력 31줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AdvancedOperationPermit 코드베이스 검색 — 31줄 · exit 0" zoom="true"
:::
## SDK가 자기 자신에게 발급하고 그 permit을 버린다
`patternTargets`가 부르는 `context.sdkPermit(PATTERN_SUBSCRIBE)`는 SDK가 자기 자신에게 발급하는 경로다. `ConfiguredRedisPolicyAuthority.issueAdvanced`는 정책 이름이 배포의 `enabledPolicies`에 없으면 던지므로 실제 효과는 "이 배포가 `pattern-subscribe`를 켰는가"를 확인하는 것이고, 반환된 permit은 **버려진다**.
## javadoc이 적지 않는 것
permit이 필요한 이유("its fan-out is decided by the server")는 적지만, 그 permit이 호출자가 아니라 SDK가 스스로 발급한 것이라는 **약해진 보증**은 적지 않는다. `AdvancedOperationPermit`의 계약이 "proving that an R2 operation was explicitly approved"인 것과 견주면 차이가 있다.
## 열린 구멍은 아니다
배포 수준 게이트는 실재하고 네임스페이스 봉쇄도 있다. 기록하는 이유는 같은 위험 등급에 두 가지 다른 승인 모델이 적용되고 그 차이가 문서화돼 있지 않기 때문이다. 수정은 `patternSubscribe` 서명에 `AdvancedOperationPermit`을 추가하거나, javadoc에 "배포 수준 승인"임을 명시하는 것이다. P3.
## 확인하지 못한 것
정책을 끈 배포에서 패턴 구독이 실제로 거부되는지 실행하지 않았다. 발급 구현상 그 결과가 나온다.
<!-- body:end -->
@@ -0,0 +1,110 @@
---
kind: CASE
slug: analysis-finding-a10-f008
title: permit 정책 이름이 세 곳에 문자열로 존재하고 교차 검사가 없다
topic: caching-and-redis
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a10-f008
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a10-f008.body.md
assets:
- key: analysis-finding-a10-f008
file: ../../../final/evidence/rendered/analysis-finding-a10-f008.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a10-f008.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L502 이다.
---
# permit 정책 이름이 세 곳에 문자열로 존재하고 교차 검사가 없다
허가 정책 이름의 출처가 셋이다. 두 집합의 차분은 정확히 둘이고 양쪽 다 설명이 있다. 문제는 차분이 아니라 차분을 감지하는 장치가 없다는 것이다. 어느 쪽 오타도 빌드를 깨지 않는다.
## 관계
- **패턴 구독의 승인만 호출자가 아니라 배포에 대해 이루어진다**
같은 리프의 허가 체계 사례다.
- **문자열로 이어진 두 세계는 오타에서 조용히 갈라진다**
이 사례가 그 규칙의 형태다.
- **catalog drift gate는 서버 메타데이터와 대조하지 Java 상수와 대조하지 않는다**
감지 장치가 없는 이유다.
## 문제
허가 정책 이름의 출처가 셋이다.
연산 문맥의 공개 상수 열여덟 개, 명령 정책 설정 파일의 필수 정책 값 열여덟 개, 검색 확장의 비공개 상수 하나다.
두 집합이 일치하는지 확인했다.
## 결론
두 집합의 차분은 정확히 둘이고 양쪽 다 설명이 있다.
지속 키 정책은 자바에만 있다. 해당 명령들이 하위 등급이라 목록의 필수 정책이 아니라 연산 문맥의 전용 요구 메서드가 강제한다.
검색 인덱스 정책은 설정에만 있다. 색인 생성 명령의 필수 정책이고, 자바 쪽 짝은 연산 문맥이 아니라 검색 확장 패키지의 비공개 상수다.
즉 차분 자체는 설명된다.
문제는 다른 데 있다. 차분을 감지하는 장치가 없다.
설정에 오타가 들어가면 그 명령은 아무도 발급받을 수 없는 정책을 요구하게 된다.
자바 상수 쪽에 오타가 들어가면 발급 구현이 정책이 활성화되지 않았다고 던진다.
어느 쪽도 빌드를 깨지 않는다.
목록 표류 게이트는 설정을 서버 메타데이터와 대조한다. 자바 상수 집합과 대조하지 않는다.
판정은 P3 다.
확정은 다음 하위 범위로 이월한다. 정책 적재기 테스트가 정책 이름 집합을 검사하는지 그 범위에서 확인한다.
## 검증 환경
확인 방식 : 세 출처의 문자열 집합 대조
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/162 계열에 있다.
1. 연산 문맥의 정책 상수를 모은다.
2. 명령 정책 설정 파일의 필수 정책 값을 모은다.
3. 두 집합의 차분을 계산한다.
4. 각 차분 항목의 이유를 확인한다.
5. 목록 표류 게이트가 무엇과 무엇을 대조하는지 확인한다.
## 본문
<!-- body:start -->
정책 이름의 출처가 셋이다.
| 출처 | 개수 |
|---|---|
| `RedisOperationContext``public static final String` 상수 | 18 |
| `redis-command-policy.yml``required-policy:` 값 | 18 |
| `LettuceRedisSearchOperations:31`의 private 상수 `SEARCH_INDEX` | 1 |
## RedisCommandPolicyLoaderTest 참조 위치
:::evidence key="analysis-finding-a10-f008" alt="코드베이스에서 RedisCommandPolicyLoaderTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RedisCommandPolicyLoaderTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 두 집합의 차분에는 설명이 있다
`162-...` §8.3 — `persistent-key`는 Java에만 있다(해당 명령들이 R1이라 catalog의 `required-policy`가 아니라 `RedisOperationContext.requirePersistentKeyPermit`이 강제한다, §34). `search-index`는 YAML에만 있다(`FT.CREATE``required-policy`이고, Java 쪽 짝은 `sdk/extensions/search`의 private 상수다).
## 문제는 차분이 아니라 감지 장치의 부재다
YAML에 `required-policy: bounded-collectoin-read`처럼 오타가 들어가면 그 명령은 아무도 발급받을 수 없는 정책을 요구하게 되고, Java 상수 쪽에 오타가 들어가면 `issueAdvanced`가 "policy is not enabled"로 던진다. 어느 쪽도 빌드를 깨지 않는다. catalog drift gate는 YAML을 **서버 메타데이터**와 대조하지, Java 상수 집합과 대조하지 않는다. P3 — 확정은 sub-scope 05로 이월한다.
## 확인하지 못한 것
정책 적재기 테스트가 이름 집합을 검사하는지 확인하지 않았다. 다음 하위 범위로 이월한다.
<!-- body:end -->
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c01
title: 상호배타성이 프로퍼티가 아니라 타입에서 온다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c01
file: ../../../final/evidence/rendered/adapter-inbound-web-c01.svg
- key: adapter-inbound-web-c01-diagram
file: ../../../final/assets/diagrams/adapter-inbound-web-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c01.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L116 이다.
module: adapter-inbound-web
---
# 상호배타성이 프로퍼티가 아니라 타입에서 온다
MVC와 WebFlux 자동설정 둘 다 `matchIfMissing = true`로 기본 켜짐이다. 두 아티팩트가 클래스패스에 함께 있어도 하나만 활성화되는 이유는 프로퍼티가 아니라 `@ConditionalOnWebApplication`의 타입 조건이다.
## 본문
<!-- body:start -->
두 자동설정의 게이트는 이렇게 붙어 있다.
```text
MVC: @ConditionalOnWebApplication(SERVLET) + @ConditionalOnProperty(backend.web.mvc.enabled, matchIfMissing = true)
WebFlux: @ConditionalOnWebApplication(REACTIVE) + @ConditionalOnProperty(backend.web.webflux.enabled, matchIfMissing = true)
```
둘 다 `matchIfMissing = true` — **기본 켜짐**이다. notification·messaging·cache-redis가 전부 `matchIfMissing = false`(옵트인)인 것과 반대인데, 이유가 다르다: 저쪽은 선택적 능력이고 이쪽은 웹 애플리케이션의 본체다.
## 활성화를 가르는 것
:::evidence key="adapter-inbound-web-c01-diagram" alt="웹 애플리케이션 타입에서 MVC 자동설정과 WebFlux 자동설정으로 각각 화살표가 나가고 화살표에 SERVLET 과 REACTIVE 가 붙은 구조" caption="타입이 고르는 자동설정" zoom="false"
:::
상호배타성은 프로퍼티가 아니라 `@ConditionalOnWebApplication`의 타입 수준에서 온다 — "a reactive application cannot accidentally activate the servlet filters even if both artifacts are on the classpath."
## 등록되는 빈 수
두 자동설정이 등록하는 빈은 MVC 12개, WebFlux 11개다. `AutoConfiguration.imports`에는 이 둘만 있다.
## 분석 원문의 게이트 비교
:::evidence key="adapter-inbound-web-c01" alt="분석 문서 analysis/14-adapter-inbound-web.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/14-adapter-inbound-web.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c17
title: ndjson 스위치 하나가 두 능력을 함께 켠다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c17
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c17
file: ../../../final/evidence/rendered/adapter-inbound-web-c17.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c17.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L1255 이다.
module: adapter-inbound-web
---
# ndjson 스위치 하나가 두 능력을 함께 켠다
`NDJSON``JSON_SEQUENCE`는 enum에서 서로 다른 상수이고 각자 프로퍼티 이름을 갖는데, 실제 조건은 `ndjson` 하나뿐이라 둘이 함께 켜진다.
## 본문
<!-- body:start -->
조건이 붙은 자리는 하나다.
```java
// advanced/mvc/MvcStreamingExecutorConfiguration.java:14-15, 36-39
/** Wires servlet-side record streaming: NDJSON and RFC 7464 JSON text sequences. */
@ConditionalOnProperty(prefix = "backend.web.advanced.ndjson", name = "enabled", havingValue = "true")
```
`NDJSON``JSON_SEQUENCE`는 enum에서 서로 다른 상수이고 각자 프로퍼티 이름을 갖는데, 실제로는 `ndjson` 스위치 하나가 둘을 함께 켠다.
## javadoc이 금지한 형태
`WebAdvancedFeature`의 javadoc이 그 형태를 금지한다 — "A single switch would make those one decision."
## WebAdvancedFeature 참조 위치
:::evidence key="adapter-inbound-web-c17" alt="코드베이스에서 WebAdvancedFeature 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WebAdvancedFeature 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,57 @@
---
kind: CONCEPT
slug: adapter-outbound-objectstorage-c01
title: 컴파일이 전부 끝난 뒤에야 생성이 시작된다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-objectstorage-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-objectstorage-c01
file: ../../../final/evidence/rendered/adapter-outbound-objectstorage-c01.svg
- key: adapter-outbound-objectstorage-c01-diagram
file: ../../../final/assets/diagrams/adapter-outbound-objectstorage-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-objectstorage-c01.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L63 이다.
module: adapter-outbound-objectstorage
---
# 컴파일이 전부 끝난 뒤에야 생성이 시작된다
`ObjectStorageProviderContribution``describe``create`의 계약을 나누고, `ObjectStorageCapabilityAssembler.assemble`이 그 순서를 코드 구조로 지킨다. 컴파일러 자체가 fail-closed다.
## 본문
<!-- body:start -->
`ObjectStorageProviderContribution`이 두 메서드의 계약을 나눈다.
> `describe` **must not resolve credentials, create files, clients, threads, or schedulers**. `create` owns cleanup of every partial allocation before it throws; after a successful return the assembler owns the returned lifecycle exactly once.
## 컴파일과 생성의 순서
:::evidence key="adapter-outbound-objectstorage-c01-diagram" alt="설정 컴파일에서 provider 생성으로, 다시 조립된 능력으로 이어지는 왼쪽에서 오른쪽 흐름. 화살표에 검증된 바인딩과 수명주기 소유권이 붙어 있다" caption="컴파일과 생성의 순서" zoom="false"
:::
`ObjectStorageCapabilityAssembler.assemble`이 그 순서를 지킨다 — `compiler.compile(settings)`**전부** 끝난 뒤(`:25`)에야 선택된 destination을 돌며 `contribution.create(provider)`를 부른다(`:48`). 그리고 도중에 실패하면 이미 만든 것을 **역순으로** 닫는다(`:5356`). `AssembledCapability.close()`도 역순이고 `AtomicBoolean`으로 정확히 한 번만 실행된다. README의 "Settings compile fully before any selected provider creates a directory, client, thread, scheduler, or credential lookup"이 코드 구조로 성립한다.
## ObjectStorageProviderContribution 참조 위치
:::evidence key="adapter-outbound-objectstorage-c01" alt="코드베이스에서 ObjectStorageProviderContribution 를 검색한 출력 39줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ObjectStorageProviderContribution 코드베이스 검색 — 39줄 · exit 0" zoom="true"
:::
## 컴파일러가 거부하는 것
컴파일러 자체가 fail-closed다. 비활성이면 빈 바인딩을 돌려주고, 활성인데 provider·destination·default destination 중 하나라도 비면 거부한다. provider마다 `describe`가 돌려준 서술자와 설정을 **대조**한다 — providerType 일치, version 일치, `maximumObjectBytes`가 서술자 상한 이하, `chunkBytes`가 서술자 상한 이하. chunk는 추가로 `1 ≤ chunk ≤ min(maxObject, 16 MiB)`이고 `Integer.MAX_VALUE`를 넘지 못한다.
destination은 route token 중복을 거부하고, 요구한 capability를 provider가 `SUPPORTED`로 신고하지 않으면 거부하며, `SCAN_CLEAN`을 요구하는데 scanner seam이 없으면 이름을 대며 거부한다.
## 식별자 검증의 범위
`canonicalId`는 64자 이내, `[a-z0-9][a-z0-9_-]*`, 소문자, 그리고 **0x20–0x7e 밖 문자를 전부 거부**한다.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: adapter-outbound-objectstorage-c08
title: 레지스트리가 덮는 것은 문서 주장이고 설정 경로가 아니다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-objectstorage-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-objectstorage-c08
file: ../../../final/evidence/rendered/adapter-outbound-objectstorage-c08.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-objectstorage-c08.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L560 이다.
module: adapter-outbound-objectstorage
---
# 레지스트리가 덮는 것은 문서 주장이고 설정 경로가 아니다
§41에서 "R0 경계가 문서에만 있다"고 적었다. §47을 반영해 정확히 다시 말하면, R0 경계는 문서 주장에 대해서는 기계 검사되지만 런타임 설정 경로는 그 검사를 거치지 않는다.
## 본문
<!-- body:start -->
§41에서 "R0 경계가 문서에만 있다"고 적었다. §47을 반영해 정확히 다시 말한다.
## 기계 검사의 대상이 무엇인가
R0 경계는 **문서 주장에 대해서는** 기계 검사된다(§47). 그러나 그 검사의 대상은 `docs/registries/object-storage-readiness.yaml`이고, `KNOWN_PROVIDERS``filesystem-local-dev` 하나다.
## S3ProviderBinding 참조 위치
:::evidence key="adapter-outbound-objectstorage-c08" alt="코드베이스에서 S3ProviderBinding 를 검색한 출력 19줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="S3ProviderBinding 코드베이스 검색 — 19줄 · exit 0" zoom="true"
:::
## 레지스트리를 거치지 않는 경로
운영자가 `app.object-storage` 설정에 AWS provider용 qualification profile을 쓰면서 `DIRECT_UPLOAD` capability를 주장하는 경로는 이 레지스트리를 **거치지 않는다**. `S3ProviderBinding.compileProfiles`가 그 주장을 MinIO에 대해서만 거부하므로, AWS + DIRECT_* 조합은 여전히 compile을 통과하고 presigner를 할당한다(§41).
따라서 §41의 판정은 유지되고 오히려 선명해진다 — 이 저장소에는 "이 카드는 R0"를 강제하는 장치가 이미 있는데, 런타임 설정 경로가 그 장치의 사정권 밖에 있다.
<!-- body:end -->
@@ -0,0 +1,49 @@
---
kind: CONCEPT
slug: adapter-outbound-persistence-jpa-c24
title: 질의 계층은 프레임워크가 아니라 세 단계의 정책층이다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-persistence-jpa-c24
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-persistence-jpa-c24
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c24.svg
- key: adapter-outbound-persistence-jpa-c24-diagram
file: ../../../final/assets/diagrams/adapter-outbound-persistence-jpa-c24.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c24.txt
source:
- 원본 분석 절은 analysis/05-adapter-outbound-persistence-jpa.md#L1610 이다.
module: adapter-outbound-persistence-jpa
---
# 질의 계층은 프레임워크가 아니라 세 단계의 정책층이다
`springdata``querydsl`은 application-core의 repository contract를 대체하는 generic CRUD layer가 아니다. `JpaRepositoryFragmentSupport`에는 범용 `save/findAll/delete`가 없다.
## 본문
<!-- body:start -->
현재 code shape는 대략 다음처럼 읽는 것이 맞다. `springdata``querydsl`이 application-core의 repository contract를 대체하는 generic CRUD layer가 아니다.
## 질의 계층의 세 단계
:::evidence key="adapter-outbound-persistence-jpa-c24-diagram" alt="응용 계층 질의 계약과 springdata 와 hibernate 가 위에서 아래로 쌓이고 위임 방향 화살표가 아래로 그려진 구조" caption="질의 계층의 세 단계" zoom="false"
:::
`springdata/**`는 allowlisted sort, keyset assembly/predicate, fetch-plan catalog, bounded stream lifetime, Specification safety를 맡는다. `hibernate/**`는 provider/version facts, 실제 Statistics/JDBC batch evidence, statement naming, batch/bulk/stateless provider optimization을 맡는다.
## JpaRepositoryFragmentSupport 참조 위치
:::evidence key="adapter-outbound-persistence-jpa-c24" alt="코드베이스에서 JpaRepositoryFragmentSupport 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaRepositoryFragmentSupport 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
## 범용 CRUD가 없는 이유
`JpaRepositoryFragmentSupport`에는 범용 `save/findAll/delete`가 없고, domain-owned adapter가 필요한 query mechanism만 조합하게 설계돼 있다. 이 방향은 support matrix의 "platform-owned generic CRUD repository는 unsupported"와 일치한다.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: app-bootstrap-c01
title: 세 환경 검증기의 관심사가 서로 다르다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:app-bootstrap-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: app-bootstrap-c01
file: ../../../final/evidence/rendered/app-bootstrap-c01.svg
evidence:
- ../../../final/evidence/raw/app-bootstrap-c01.txt
source:
- 원본 분석 절은 analysis/18-app-bootstrap.md#L179 이다.
module: app-bootstrap
---
# 세 환경 검증기의 관심사가 서로 다르다
`EnvironmentPostProcessor`가 셋 있지만 각각 스위치 값 문법, 프로파일, 능력 간 의존이라는 다른 관심사를 본다. 중복 아님.
## 본문
<!-- body:start -->
`EnvironmentPostProcessor`가 셋이다.
| 검증기 | 보는 것 |
|---|---|
| `MasterSwitchEnvironmentPostProcessor` | 스위치 값 문법 |
| `RuntimeEnvironmentProfileValidator` (93) | 프로파일 |
| `CapabilityDependencyEnvironmentValidator` (62 → `CapabilityDependencyValidator` 156) | 능력 간 의존 |
## MasterSwitchEnvironmentPostProcessor 참조 위치
:::evidence key="app-bootstrap-c01" alt="코드베이스에서 MasterSwitchEnvironmentPostProcessor 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MasterSwitchEnvironmentPostProcessor 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
## 중복으로 보지 않은 이유
셋 다 `EnvironmentPostProcessor`라는 확장 지점을 공유할 뿐 관심사가 다르다. 중복 아님.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: app-bootstrap-c02
title: 런타임 멤버십이 결정하는 어댑터 활성화 스위치 범위
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:app-bootstrap-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: app-bootstrap-c02
file: ../../../final/evidence/rendered/app-bootstrap-c02.svg
evidence:
- ../../../final/evidence/raw/app-bootstrap-c02.txt
source:
- 원본 분석 절은 analysis/18-app-bootstrap.md#L185 이다.
module: app-bootstrap
---
# 런타임 멤버십이 결정하는 어댑터 활성화 스위치 범위
`src/config/architecture/modules.json``runtime_memberships`가 인바운드 어댑터의 런타임 포함 여부를 결정한다. gRPC와 WebSocket은 build-only leaf라 런타임 멤버십이 없고, `MasterSwitch`·`env-keys.yaml`·`AdapterActivationReport`에 활성화 스위치가 없는 상태가 이 모델과 일치한다.
## 본문
<!-- body:start -->
## build-only 전송의 런타임 멤버십
`adapter-inbound-grpc``adapter-inbound-websocket``runtime_memberships`는 비어 있다. `ConditionalTransportCompositionContractTest`도 두 leaf가 어떤 런타임에도 올라가지 않아야 한다는 계약을 강제한다.
## MasterSwitch 참조 위치
:::evidence key="app-bootstrap-c02" alt="코드베이스에서 MasterSwitch 를 검색한 출력 17줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MasterSwitch 코드베이스 검색 — 17줄 · exit 0" zoom="true"
:::
## 멤버십과 활성화 스위치의 관계
런타임에 포함되지 않는 build-only 어댑터에는 운영자가 켜고 끌 활성화 스위치가 필요하지 않다. 따라서 gRPC와 WebSocket이 `MasterSwitch`·`env-keys.yaml`·`AdapterActivationReport`에 없는 것은 누락이 아니라 런타임 멤버십 모델과 일치한다.
## 별도로 남는 web 경계
`adapter-inbound-web``["app-bootstrap", "sample-portfolio"]` 두 런타임에 포함된다. `backend.web.mvc.enabled`, `backend.web.webflux.enabled`, `backend.web.budgets.enabled`, `app.web-platform.durable-operations.enabled``MasterSwitch``env-keys.yaml` 341개 키에 포함되지 않는다. 이 경계는 같은 분석의 §4.1b에서 별도 finding으로 다룬다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: app-bootstrap-c03
title: .imports 여섯 줄 중 다섯이 능력 루트다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:app-bootstrap-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: app-bootstrap-c03
file: ../../../final/evidence/rendered/app-bootstrap-c03.svg
evidence:
- ../../../final/evidence/raw/app-bootstrap-c03.txt
source:
- 원본 분석 절은 analysis/18-app-bootstrap.md#L264 이다.
module: app-bootstrap
---
# .imports 여섯 줄 중 다섯이 능력 루트다
`.imports`의 여섯 항목 중 다섯이 능력 루트이고 하나(`AdapterActivationAutoConfiguration`)가 자기 액추에이터다. `MasterSwitch`의 다섯과 일치하지만 `PERSISTENCE_MONGO`만 루트가 없다.
## 본문
<!-- body:start -->
`.imports`의 여섯 항목 중 다섯이 능력 루트이고 하나(`AdapterActivationAutoConfiguration`)가 자기 액추에이터다. `MasterSwitch`의 다섯과 일치한다.
## AdapterActivationAutoConfiguration 참조 위치
:::evidence key="app-bootstrap-c03" alt="코드베이스에서 AdapterActivationAutoConfiguration 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AdapterActivationAutoConfiguration 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
## PERSISTENCE_MONGO만 루트가 없다
`PERSISTENCE_MONGO``.imports`에 루트가 없고 컴포넌트 스캔 제외 정규식(`adapter\.outbound\.mongo\..*`)으로만 관리된다. §7.1.
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: grpc-advanced-diagnostics-c02
title: 진단 열람은 망 게이트와 역할 게이트를 함께 요구한다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-diagnostics-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-diagnostics-c02
file: ../../../final/evidence/rendered/grpc-advanced-diagnostics-c02.svg
- key: grpc-advanced-diagnostics-c02-diagram
file: ../../../final/assets/diagrams/grpc-advanced-diagnostics-c02.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-diagnostics-c02.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-diagnostics.md#L51 이다.
module: grpc-advanced-diagnostics
---
# 진단 열람은 망 게이트와 역할 게이트를 함께 요구한다
`GrpcChannelDiagnosticsPolicy`는 네트워크와 역할 두 게이트를 모두 요구하고, 하나라도 비면 생성자가 거부한다. CSDS는 그 위에 xDS 사용 여부라는 조건이 더 붙는다.
## 본문
<!-- body:start -->
`GrpcChannelDiagnosticsPolicy` 는 네트워크와 역할 두 게이트를 모두 요구하고, 하나라도 비면 생성자가 거부한다.
> "diagnostics need both a network and a role gate; Channelz holds every socket's peer and security detail, so either gate alone is the whole surface"
## 열람을 여는 조건
:::evidence key="grpc-advanced-diagnostics-c02-diagram" alt="정책 경계 안에 네트워크 게이트와 역할 게이트가 나란히 들어 있고 CSDS 는 경계 밖에 점선 상자로 놓인 구조" caption="열람을 여는 조건" zoom="false"
:::
## GrpcChannelDiagnosticsPolicy 참조 위치
:::evidence key="grpc-advanced-diagnostics-c02" alt="코드베이스에서 GrpcChannelDiagnosticsPolicy 를 검색한 출력 12줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcChannelDiagnosticsPolicy 코드베이스 검색 — 12줄 · exit 0" zoom="true"
:::
## CSDS가 등록되는 조건
등록 판정이 능력 깃발에 걸려 있다. CSDS 는 Channelz 가 켜져 있고 xDS 도 켜져 있을 때만 등록된다.
> "A CSDS service on a deployment that does not use xDS answers every query with nothing, which is harmless, and advertises a control-plane surface that does not exist, which is not."
<!-- body:end -->
@@ -0,0 +1,57 @@
---
kind: CONCEPT
slug: messaging-kafka-share-experimental-c07
title: 이전 결함 대신 막으려는 것 셋을 적는다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-kafka-share-experimental-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-kafka-share-experimental-c07
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-kafka-share-experimental-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-kafka-share-experimental.md#L399 이다.
module: messaging-kafka-share-experimental
---
# 이전 결함 대신 막으려는 것 셋을 적는다
이 leaf의 javadoc에는 이전 결함 서술이 없다. 다른 messaging leaf 대부분이 "X used to …" 형태의 기록을 갖는 것과 대비되며, 대신 막으려는 것을 셋 적는다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
같은 분석 리프에서 끌어낸 규칙이다.
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
이 leaf의 javadoc에 **이전 결함 서술이 없다.** 다른 messaging leaf 대부분이 "X used to …" 형태의 기록을 갖는 것과 대비된다. 대신 **막으려는 것**을 셋 적는다.
| 위치 | 막으려는 것 |
|---|---|
| `KafkaShareProfileValidator` | 순서 목적지를 share group에 설정 → 브로커가 주지 않는 보장을 광고 |
| 같은 곳 | experimental이 기본 켜져 Stable 배포로 drift |
| `KafkaShareGroupRegistrar` | pause를 조용히 무시 → pause에 의존하는 retry 정책이 동작하는 것처럼 보이며 아무것도 하지 않음 |
## MessagingCapabilityUnavailableException 참조 위치
:::evidence key="messaging-kafka-share-experimental-c07" alt="코드베이스에서 MessagingCapabilityUnavailableException 를 검색한 출력 37줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingCapabilityUnavailableException 코드베이스 검색 — 37줄 · exit 0" zoom="true"
:::
## 거절이 무시보다 낫다는 원칙이 빠진 자리
세 번째가 이 leaf에서 가장 성숙한 판단이다 — **거절이 무시보다 낫다**는 원칙이고, `messaging-core-api`의 `MessagingCapabilityUnavailableException` javadoc과 같은 계열이다. 역설적으로 **그 원칙이 `register(...)`에는 적용되지 않았다** — spec을 받아 무시하고 성공을 반환한다(§17).
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: messaging-testkit-c05
title: 등급을 판정하는 경로가 증거를 만드는 경로 없이도 돈다
topic: capability-and-disclosure-models
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-testkit-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-testkit-c05
file: ../../../final/evidence/rendered/messaging-testkit-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-testkit-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-testkit.md#L452 이다.
module: messaging-testkit
---
# 등급을 판정하는 경로가 증거를 만드는 경로 없이도 돈다
실행 경로가 셋이다. 등급 판정(경로 C)은 컨테이너 없이 매 빌드 돌고, 그 판정이 읽는 증거를 만드는 경로 B는 컨테이너를 요구해 `test`에서 제외돼 있다.
## 본문
<!-- body:start -->
실행 경로가 셋이고 컨테이너 요구가 서로 다르다.
**경로 A — 어댑터 계약 실행 (컨테이너 불필요, 항상 실행)** `MessagingAdapterHarness extends AutoCloseable` 이고 `close()` 가 checked exception 을 던지지 않도록 재선언되어 있다(`MessagingAdapterHarness.java:71-72`). 7개 테스트 전부 `try (…)` 로 감싸므로 하니스 누수 경로가 없다.
**경로 B — 인증 증거 생산 (컨테이너 필요, `test` 에서 제외)**
**경로 C — 등급 판정 (컨테이너 불필요, 매 빌드)**
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-testkit-c05" alt="코드베이스에서 파일 목록을 만든 출력 13줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 13줄 · exit 0" zoom="true"
:::
## 경로 C가 경로 B 없이 도는 것이 설계의 핵심이다
경로 C 가 경로 B 없이도 돌고, 경로 B 가 없으면 매니페스트가 비어 등급 주장이 무너진다.
<!-- body:end -->
@@ -0,0 +1,103 @@
---
kind: CASE
slug: a-delayed-delivery-flag-without-the-topology-that-delivers-it
title: 지연 배달을 참으로 선언하는데 그 지연을 제공할 토폴로지가 조립되지 않는다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-delayed-delivery-flag-without-the-topology-that-delivers-it
evidenceCapturedOn: 2026-09-02
assets:
- key: a-delayed-delivery-flag-without-the-topology-that-delivers-it
file: ../../../final/evidence/rendered/a-delayed-delivery-flag-without-the-topology-that-delivers-it.svg
evidence:
- ../../../final/evidence/raw/a-delayed-delivery-flag-without-the-topology-that-delivers-it.txt
source:
- 원본 분석은 Rabbit 어댑터 문서 §17.4 다. 성분 위치와 소비 사슬, 큐 선언 코드의 부재는 위 자산에서 확인할 수 있다.
---
# 지연 배달을 참으로 선언하는데 그 지연을 제공할 토폴로지가 조립되지 않는다
Rabbit 어댑터가 지연 배달을 참으로 선언한다. 그 지연을 만드는 큐를 선언하는 코드는 저장소에 없다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
- **능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다**
- **선택할 수 없는 브로커가 지원 매트릭스에 기능 목록과 함께 실려 있다**
## 문제
능력 선언은 재시도 엔진이 읽는 값이다. 지연 배달이 참이면 브로커에게 지연을 맡기는 재시도 모드를 고를 수 있다.
RabbitMQ 의 코어 브로커에는 메시지별 지연이 없다. 지연 교환 플러그인을 설치하거나, 메시지 수명과 데드레터 라우팅으로 대기 큐를 만들어야 한다. 둘 다 토폴로지 선언을 요구한다.
## 결론
선언과 그것을 뒷받침할 큐 사이가 비어 있다.
이 어댑터는 그 큐를 어떻게 만드는지 이미 기술해 두었다. 그런데 그 기술을 참조하는 파일이 자기 자신과 시험 하나뿐이고, 큐를 실제로 선언하는 코드는 저장소 전체에 없다.
값을 읽는 엔진은 조립되어 있다. 지금 그 값이 엔진까지 닿지 않는 이유와, 닿더라도 남는 문제는 본문이 다룬다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 능력 성분의 위치 확인, 값을 읽는 엔진과 그 조립 지점 확인, 재시도 결정 소비자의 인자 확인, 큐 선언 코드 검색
소스 수정 : x
## 재현 조건
1. 능력 record 의 성분 순서에서 지연 배달이 몇 번째인지 확인하고, Rabbit 전송이 그 자리에 넘기는 값을 읽는다.
2. 그 값을 읽는 조건문과 그 엔진이 빈으로 등록되는 지점을 확인한다.
3. 재시도 결정을 소비하는 코드가 지연 값을 어떻게 다루는지 확인한다.
4. 지연 큐를 기술하는 타입을 참조하는 파일과, 큐를 선언하는 코드를 각각 검색한다.
## 본문
<!-- body:start -->
능력 record 는 열두 개의 불리언을 위치로 받고, 여덟째가 지연 배달이다. Rabbit 전송이 그 자리에 참을 넘긴다.
## 소비 사슬을 끝까지 따라가면 세 군데가 끊겨 있다
:::evidence key="a-delayed-delivery-flag-without-the-topology-that-delivers-it" alt="코드베이스에서 능력 성분의 여덟째 자리와 Rabbit 이 넘기는 값, 그 값을 읽는 엔진과 엔진의 조립 지점, 재시도 결정의 유일한 소비자, 지연 큐 타입을 참조하는 파일, 큐 선언 코드 매치 수를 뽑은 출력 23줄. 엔진이 빈으로 등록되고 소비자가 지연 값을 넘기지 않으며 큐를 선언하는 코드가 0 이라는 것이 그 출력에 그대로 보인다." caption="여덟째 성분 · 엔진과 조립 지점 · 결정 소비자 · 지연 큐 참조 · 큐 선언 매치 0 — 23줄 · exit 0" zoom="true"
:::
`DefaultRetryDecisionEngine` 이 64행에서 그 값을 읽는다. 그리고 그 엔진은 자동 설정이 빈으로 등록한다 — 오늘 조립되어 돌고 있다.
끊긴 곳은 그 앞이다. Rabbit 은 전송을 출하하지 않아서 Rabbit 의 능력 record 가 엔진까지 도달하지 못한다. 스타터의 브로커 선택이 rabbit 을 이름으로 거절하고, 이유를 문장으로 적는다 — 검증기와 보안 설정은 출하하지만 전송이 없어 발행이 탈 것이 없다는 것이다.
## 지연을 만드는 방법은 이미 기술되어 있다
`RabbitRetryQueueTopology` 가 대기 큐를 기술한다. javadoc 이 왜 필요한지부터 적는다 — 코어 브로커에 메시지별 지연이 없으므로, 재시도 큐는 메시지 수명이 걸린 큐이고 그 데드레터 교환이 작업 큐를 다시 가리킨다. 메시지는 수명이 다할 때까지 앉아 있다가 다시 라우팅된다.
플러그인 뒤에 숨기지 않고 명시적으로 모델링한 이유도 적는다 — 그래야 동작이 검토 가능하다는 것이다. 함정까지 같이 적는다. 수명 만료는 큐 머리에서 평가되므로, 한 재시도 큐에 서로 다른 지연이 섞이면 각자 독립적으로 만료되지 않는다.
그 타입을 참조하는 파일은 자기 자신과 시험 하나다. 그리고 큐를 선언하는 코드를 이름으로 찾으면 매치가 0 이다.
## 배선해도 지연은 아직 흐르지 않는다
전송을 구현하는 것만으로 끝나지 않는다.
재시도 결정은 목적지와 지연을 함께 담는다. 그런데 그 결정을 소비하는 production 코드가 하나뿐이고 — Kafka 쪽 실행기다 — 그 실행기는 목적지만 넘기고 **지연 값을 넘기지 않는다.**
Rabbit 에는 대응하는 실행기가 없다. 그러니 배선하는 쪽이 해야 할 일은 전송 구현과 재시도 실행기와 큐 선언 셋이고, 그중 어느 하나만 해도 이 플래그는 여전히 참이다.
## 상수는 아무것도 강제하지 않는다
이 플래그는 프로파일에서 파생된 값이 아니라 소스에 박힌 상수다. 큐가 선언되었는지, 플러그인이 설치되었는지 보지 않는다.
그래서 위의 세 가지 중 무엇이 언제 채워지든 이 값은 바뀌지 않고, 바꿔야 한다고 알려 주는 것도 없다.
## 오늘 무엇이 이 결함을 막고 있나
Rabbit 능력이 엔진에 닿지 않는다는 것 하나다. 어댑터 자신이 아니라 그 위의 배선 부재가 막고 있다.
## 확인하지 못한 것
실제 배달 시점은 브로커를 띄워 확인해 보지 못했다. 큐 선언의 부재는 이름 기반 검색으로 판정했다.
<!-- body:end -->
@@ -0,0 +1,133 @@
---
kind: CASE
slug: a-transaction-capability-true-and-its-validator-never-run
title: 브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 기동 시 돌지 않는다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a-transaction-capability-true-and-its-validator-never-run
evidenceCapturedOn: 2026-09-02
assets:
- key: a-transaction-capability-true-and-its-validator-never-run
file: ../../../final/evidence/rendered/a-transaction-capability-true-and-its-validator-never-run.svg
evidence:
- ../../../final/evidence/raw/a-transaction-capability-true-and-its-validator-never-run.txt
source:
- 분석 문서는 메시징 플랫폼 편 §3.5 다. 그 절이 프로파일 검증기 여덟 개의 도달성을 세고, 조립에서 실행되는 셋을 적는다. 실행되지 않는 다섯 중 넷은 빌드 전용 모듈에 있어 조립 지점이 없는 것이 등급과 일치한다. 출하되는 모듈에서 조립되지 않은 것은 이 트랜잭션 검증기 하나뿐이고, 그래서 이 항목이 P2 다.
- 능력 상수의 아홉째가 프로파일과 무관한 상수라는 것은 Kafka 어댑터 편이고, 아홉째와 열째의 독자 수 대비는 같은 플랫폼 편 §3.4 의 표에 있다.
---
# 브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 기동 시 돌지 않는다
Kafka 어댑터의 능력 상수가 브로커 트랜잭션을 프로파일과 무관하게 참으로 답한다. 그 조건을 검사하는 검증기는 스타터가 빈으로 만들지만 기동 검증에 감싸지 않아 실행되지 않는다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
이 사례가 속한 구조다.
- **검증기는 발행이 아니라 주입이 강제다**
이 사례의 두 번째 절반에 해당하는 규칙이다.
- **능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다**
첫 번째 절반에 해당하는 규칙이다.
## 문제
Kafka 트랜잭션은 조건 다섯이 모두 맞아야 활성화된다.
그 다섯을 전부 보는 클래스가 이 저장소에 있다.
## 결론
능력 상수가 프로파일을 보지 않는다. 열두 성분 중 아홉째 자리가 고정으로 참이고 그 선언에 프로파일 참조가 없다.
그 플래그를 읽는 프로덕션 코드는 0 이다. 바로 옆 열째 플래그는 발행 경로가 읽는데, 그 플래그는 일부러 거짓으로 내려져 있다. 그 자리 javadoc 이 이유를 적는다 — 참으로 선언하면 호출자가 브로커가 중복을 제거한다고 믿고 자기 멱등성을 만들지 않는다는 것이다.
아홉째의 과대 선언이 오늘 낳는 결과는 조회 경로의 피해와 다르다.
두 번째 절반이 검증 경로다. 스타터가 트랜잭션 검증기를 빈으로 발행하지만 기동 검증에 감싸지 않는다. 자동설정 바깥에서 이것을 아는 코드는 하나도 없다. 다섯 규칙이 어디에서도 실행되지 않는다.
감쌀 수 없는 이유는 인자 수가 아니다. 래퍼는 소비자 함수를 받으므로 나머지를 캡처하는 람다면 들어간다. 두 번째 인자가 그것을 막는다. 트랜잭션 식별자 접두는 이 저장소의 main 에서 이 검증기의 파라미터 이름으로만 존재한다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 능력 상수의 성분 위치와 독자 계수, 검증기의 규칙과 언급 계수, 래퍼 시그니처와 인자 출처 확인
소스 수정 : x
## 재현 조건
1. 능력 record 에서 브로커 트랜잭션이 몇 번째인지 확인하고, Kafka 가 그 자리에 넘기는 값과 그 선언의 프로파일 참조를 확인한다.
2. 그 플래그를 읽는 프로덕션 코드를 세고, 옆 열째 플래그와 대조한다.
3. 트랜잭션 검증기가 요구하는 조건을 전부 나열한다.
4. 기동 검증으로 감싸이는 검증기와 직접 불리는 검증기를 확인한다.
5. 트랜잭션 검증기를 언급하는 프로덕션 코드와 테스트를 센다.
6. 래퍼의 시그니처와 두 번째 인자의 출처를 확인한다.
## 본문
<!-- body:start -->
Kafka 트랜잭션은 생산자 설정 넷과 목적지 선언 하나가 동시에 맞아야 성립한다. 그중 어느 하나라도 어긋나면 커밋 경계가 갈라진다.
이 저장소에는 그 다섯을 전부 검사하는 클래스가 있다. 스타터가 그것을 빈으로 만든다. 그리고 아무도 그것을 부르지 않는다.
## 아홉째 자리가 프로파일을 보지 않는다
:::evidence key="a-transaction-capability-true-and-its-validator-never-run" alt="코드베이스에서 능력 record 의 아홉째 성분과 Kafka 가 그 자리에 넘기는 값과 그 상수의 프로파일 참조 수, 그 플래그를 읽는 코드 수와 바로 옆 열째 플래그를 읽는 코드와 그 열째가 거짓인 이유를 적은 javadoc, 트랜잭션 검증기가 요구하는 다섯 조건, 기동 검증으로 감싸이는 검증기와 직접 불리는 검증기와 감싸이지 않은 채 빈으로만 발행되는 트랜잭션 검증기와 그것을 언급하는 코드 수, 그리고 감쌀 수 없는 진짜 이유인 래퍼 시그니처와 두 번째 인자의 출처를 뽑은 출력 54줄. 아홉째 플래그를 읽는 코드가 0 이고 열째는 읽히면서 일부러 거짓이라는 대비가 그 출력에 보인다." caption="아홉째 성분과 그 값 · 읽는 코드 0 · 옆 열째는 읽히고 거짓 · 검증기의 다섯 조건 · 감싸인 것과 아닌 것 · 두 번째 인자의 출처 없음 — 54줄" zoom="true"
:::
능력 record 는 열두 개의 불리언을 위치로 받고, 아홉째가 브로커 트랜잭션이다. Kafka 전송이 그 자리에 참을 넘기고, 그 선언에 프로파일 참조는 0 이다.
트랜잭션 식별자 없이 구성된 배포도 같은 답을 받는다.
## 옆자리가 이 플래그의 무게를 보여 준다
이 아홉째 플래그를 읽는 프로덕션 코드가 0 이다.
바로 옆 열째는 다르다. 발행 경로가 그 값을 읽어 판단한다. 그리고 Kafka 는 그 자리에 거짓을 넘긴다.
그 자리 javadoc 이 왜 거짓인지 적는다. 참으로 선언하면 중복 제거 요청이 받아들여진 뒤 조용히 아무 일도 하지 않고, 호출자는 브로커가 중복을 제거한다고 믿어 원래 만들었을 멱등성을 건너뛴다. 거짓으로 두면 그 요청이 기동 실패가 되는데, 그것이 이 플래그가 존재하는 이유라는 것이다.
같은 종류의 과대 선언이 하나는 실제 피해를 만들고 하나는 만들지 않는다. 차이는 읽는 코드가 있느냐다.
그러므로 아홉째의 과대 선언이 오늘 만드는 것은 조회 경로의 피해가 아니다. 남는 것은 검증 경로다.
## 검증기가 요구하는 다섯
트랜잭션 식별자 접두가 비어 있지 않을 것, 생산자가 멱등일 것, 응답 확인이 전부일 것, 오프셋 커밋이 수동일 것. 그리고 다섯째로 목적지가 인박스 트랜잭션을 선언하지 않을 것이다.
다섯째가 중요하다고 javadoc 이 직접 말한다. 목적지가 인박스 트랜잭션을 선언한다는 것은 부작용이 데이터베이스에 있다는 뜻이고, Kafka 트랜잭션은 거기까지 걸칠 수 없다. 둘을 함께 설정할 수 있게 두면 팀이 "트랜잭션"이라는 단어를 두 번 읽고 경로 전체가 원자적이라고 결론짓게 된다는 것이다.
## 그 검증기는 어디에서도 실행되지 않는다
스타터에는 기동 시 프로파일마다 검증기를 돌리는 래퍼가 있다. 그 래퍼로 감싸인 검증기가 셋이다. 목적지 프로파일 검증기는 래퍼 대신 직접 호출로 돈다.
트랜잭션 검증기는 그냥 빈이다. 자동설정 밖에서 그것을 언급하는 프로덕션 코드가 0 이고, 그것을 만드는 테스트도 0 이다.
다섯 규칙은 main 에서도 test 에서도 한 번도 실행되지 않는다.
## 같은 결함이 이 스타터에서 한 번 고쳐졌다
래퍼 클래스의 javadoc 이 왜 만들어졌는지 적는다.
Kafka 와 Rabbit 과 보안 검증기가 전부 빈이었고 어디에도 주입되지 않았다. 컨텍스트는 브로커마다 검증기를 발행했고 아무것도 검증하지 않았다.
그다음 문장이 결과를 적는다. 브로커가 줄 수 없는 보증을 약속하는 프로파일이 — 비트랜잭션 생산자 위의 정확히 한 번 주장, 복제본 하나짜리의 정족수 확인, 프로덕션 리스너의 평문 자격증명이 — 깨끗하게 부팅한 뒤 그것에 의존하는 첫 메시지에서 실패한다. 그것을 알게 되는 자리로는 틀린 곳이다.
그 수정이 그 세 검증기에 적용됐다. 트랜잭션 검증기가 남았다.
## 감쌀 수 없는 이유는 인자 수가 아니다
래퍼는 프로파일 공급자와 소비자 함수를 받는다. 인자 수 자체는 장애가 아니다 — 나머지 둘을 캡처하는 람다면 타입이 맞는다.
걸리는 것은 두 번째 인자다. 트랜잭션 식별자 접두는 이 저장소의 main 에서 이 검증기의 파라미터 이름과 그 javadoc 과 그것을 검사하는 조건문, 셋으로만 존재한다. 브로커 프로파일에도 설정 키에도 그 값이 없다.
감쌀 자리보다 공급할 값이 먼저 없다.
## 확인하지 못한 것
검증기가 실제로 건너뛰는지 컨텍스트를 세워 보지는 않았다. 판정 근거가 감싸기 목록과 언급 계수의 대조라, 리플렉션으로 부르는 경로까지는 배제하지 못했다.
<!-- body:end -->
@@ -0,0 +1,149 @@
---
kind: CASE
slug: a05-f006-stable
title: 안정 등급으로 광고한 증거를 만드는 매니저가 어디서도 만들어지지 않는다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f006-stable
evidenceCapturedOn: 2026-09-02
assets:
- key: a05-f006-stable
file: ../../../final/evidence/rendered/a05-f006-stable.svg
- key: a05-f006-stable-chain
file: ../../../final/evidence/rendered/a05-f006-stable-chain.svg
evidence:
- ../../../final/evidence/raw/a05-f006-stable.txt
- ../../../final/evidence/raw/a05-f006-stable-chain.txt
source:
- 분석 문서는 persistence-jpa 편 §23 이고 세부는 §23.1 이다. 능력이 안정으로 보고되는데 매니저 생성이 0 이라는 판정과, 루트의 몫으로 남은 분류기 팩토리에도 소비자가 없다는 관찰이 거기 있다. 같은 문서 §23.5 가 범위를 한정한다.
---
# 안정 등급으로 광고한 증거를 만드는 매니저가 어디서도 만들어지지 않는다
완료 증거 능력이 안정 등급으로 보고된다. 그 증거를 만드는 트랜잭션 매니저를 생성하는 코드는 자기 파일의 정적 팩토리뿐이고, 그것을 부르는 곳이 프로덕션에도 테스트에도 없다.
## 관계
- **커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지**
이 능력이 만드는 증거 모델이다.
- **@Bean이 있다는 것은 조립 증거가 아니다**
이 사례가 그 규칙의 형태다.
- **지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다**
등급과 실제의 거리를 다룬 결정이다.
## 문제
완료 증거란 커밋 진행 지점을 남길 수 있는가의 문제다. 그 기록이 없으면 커밋 실패를 롤백된 것과 결과 미상으로 나눌 수 없다.
이 능력의 등급은 능력 리포트에 안정으로 올라 있다.
## 결론
계수는 이렇다. 매니저를 만드는 코드 0, 클래스 이름을 언급하는 다른 프로덕션 파일 1 이고 그것도 javadoc 안이다. 설정 리소스에 FQCN 0, 상속 0, 트랜잭션 매니저 빈을 등록하는 main 코드 0 이다.
그래서 완료 불명 예외는 출하 조립에서 던져질 경로가 없다. 그것을 만드는 프로덕션 코드는 분류기 한 곳이고, 그 분류기를 부르는 프로덕션 코드는 매니저의 커밋 catch 한 곳이며, 그 매니저를 설치하는 코드가 없다.
컴포지션 루트의 javadoc 에는 루트에서 만들면 ORM 타입이 루트의 컴파일 클래스패스에 올라오기 때문에 매니저를 영속성 리프 안에서 만든다고 적혀 있다. 그 설명은 클래스패스에서 사실로 확인된다. 그런데 영속성 리프 쪽에도 그것을 만드는 코드가 없다.
그 javadoc 은 루트가 맡을 것으로 둘을 든다. 설치 여부의 결정, 그리고 거기에 쓸 커밋 실패 분류기다. 팩토리는 있는데 그것을 부르는 코드가 없다. 그 팩토리를 담은 클래스는 스프링 설정이 아니라 평범한 클래스이고, 그것을 쓰는 프로덕션 코드가 부르는 메서드는 실행기와 재시도 코디네이터 둘뿐이다.
프레임이 안 만들어지는 것은 아니다. 실제로 조립되는 실행기는 트랜잭션마다 프레임을 밀어 넣는다. 그 프레임을 커밋 단계로 옮기는 것이 설치되지 않는 매니저뿐이라 프레임은 시작 전 상태로 남는다.
매니저의 javadoc 에는 코드와 맞지 않는 문단도 있다. 증거가 모든 경로에서 지워진다고 적는데, 커밋과 롤백의 finally 는 둘 다 비어 있고 여기서 지우지 않는다고 주석이 달려 있다. 꺼내는 쪽은 실행기의 스코프이고 매니저가 하는 일은 단계 표시다. 두 주인이 꺼내던 시절의 서술이 남은 것이다.
단계를 읽는 접근자도 아무도 부르지 않는다. 분류기가 프레임에서 꺼내는 것은 작업 이름과 시작 시각과 시도 횟수와 조정 키이고, 단계는 보지 않는다. 단계에 민감해지는 것은 검사 때문이 아니라 어디서 부르느냐 때문이다.
그 순서를 실제로 돌리는 테스트도 없다. 이름만 같은 테스트가 정작 그 타입을 건드리지 않고, 순서는 다른 시험이 본다고 자기 javadoc 에 적어 둔다.
범위는 한정된다. 정규 트랜잭션 경로 쪽은 다른 감시자가 커밋 예외를 불확정 결과로 바꿔 놓고 재실행은 하지 않는다. 없는 것은 자동 재시도 안전이 아니라 안정 등급으로 내건 조정 증거 쪽이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 생성 지점과 빈 등록의 정적 계수, javadoc 과 코드 대조, 예외 생산·호출 사슬 추적
소스 수정 : x
## 재현 조건
1. 능력 리포트에서 이 능력의 등급을 확인한다.
2. 매니저의 javadoc 과 같은 파일의 doCommit·doRollback 을 나란히 읽는다.
3. 그 매니저를 만드는 코드를 센다. 자기 파일과 javadoc 을 뺀다.
4. 설정 리소스의 FQCN 과 상속과 트랜잭션 매니저 빈 등록을 각각 센다.
5. 루트가 몫이라고 적은 분류기 팩토리의 호출자를 센다.
6. 완료 불명 예외의 생산 지점과 그것을 부르는 지점을 따라간다.
7. 단계를 읽는 접근자의 호출자를 센다.
## 본문
<!-- body:start -->
완료 증거는 트랜잭션이 어디까지 갔는지를 기록한다. 커밋 실패를 롤백된 것과 결과를 모르는 것으로 나누려면 그 기록이 있어야 한다.
능력 리포트는 이 능력을 안정 등급으로 보고한다.
## javadoc 이 주장하는 것과 코드가 하는 것
:::evidence key="a05-f006-stable" alt="능력 리포트가 완료 증거 능력에 매긴 등급, 그 증거를 만드는 트랜잭션 매니저의 javadoc 과 같은 파일의 커밋·롤백 구현, 그 매니저를 만드는 코드와 자기 파일을 뺀 생성 지점 수, 설정 리소스의 FQCN 과 상속 수, 그리고 트랜잭션 매니저 빈을 등록하는 main 과 test 코드 수를 출력한 터미널 기록." caption="능력 등급은 stable · javadoc 의 정리 규칙과 비어 있는 finally · 자기 파일 밖 생성 0 · 설정 리소스 0 · 상속 0 · 매니저 빈 main 0, test 1 — 56줄 · exit 0" zoom="true"
:::
매니저의 javadoc 에는 단계가 제공자 커밋 직전에 표시되고 그 뒤에는 표시되지 않는다고 한 문장으로 적혀 있다. 커밋 안에서 죽으면 마지막으로 기록된 것은 `we asked, we do not know` 이고, javadoc 은 그것이 롤백으로 오인되어서는 안 되는 상태라고 적는다.
같은 javadoc 에는 증거가 커밋 성공과 실패와 롤백과 정리, 모든 경로에서 지워진다는 정리 규칙도 적혀 있다. 코드는 그렇지 않다.
```java
} finally {
// Deliberately not cleared here. The executor's scope owns the frame's lifetime; a second
// owner popping was how an inner REQUIRES_NEW transaction deleted its outer frame.
}
```
커밋과 롤백의 `finally` 가 둘 다 비어 있고 같은 주석이 붙어 있다. 프레임을 꺼내는 것은 실행기의 스코프뿐이고 매니저는 단계만 표시한다. 그 javadoc 문단은 주인이 둘이던 시절의 서술이 남은 것이다.
## 그 매니저를 만드는 코드가 없다
자기 파일 안에 정적 팩토리가 있고, 그것을 부르거나 생성자를 쓰는 코드는 자기 파일 밖에 0 이다. 클래스 이름을 언급하는 다른 프로덕션 파일은 하나뿐이고 그것도 자동설정의 javadoc 안이다.
설정 리소스에 FQCN 이 나오는 곳도 0 이고, 상속하는 코드도 0 이다. 트랜잭션 매니저 빈을 등록하는 main 코드도 0 이다. 테스트에 하나 있는데, 그것은 자동설정 시험이 조건을 만족시키려고 세운 평범한 매니저다.
이 저장소가 등록하는 매니저가 없다는 뜻이고, 매니저가 없다는 뜻은 아니다. 부트의 JPA 자동설정이 평범한 것을 넣고, 그것은 단계를 표시하지 않는다.
## 루트가 남긴 몫도 비어 있다
:::evidence key="a05-f006-stable-chain" alt="컴포지션 루트가 매니저를 만들지 않는 이유와 루트의 몫으로 지목한 두 가지를 적은 javadoc, 그 분류기 팩토리의 호출자 수, 그 클래스가 스프링 설정이 아니라는 서술과 그것을 쓰는 프로덕션 코드가 부르는 메서드, 완료 불명 예외의 생산 지점과 그것을 부르는 지점, 분류기가 프레임에서 꺼내는 값과 단계 접근자의 호출자 수, 조립되는 실행기가 프레임을 미는 줄, 그리고 같은 이름의 테스트가 그 타입을 참조하는 횟수를 출력한 터미널 기록." caption="루트가 만들지 않는 이유와 남긴 몫 둘 · 분류기 팩토리 호출자 0 · 예외 생산 1곳과 호출 1곳 · 분류기는 단계를 보지 않음 · 단계 접근자 호출자 0 · 실행기는 프레임을 만듦 — 45줄 · exit 0" zoom="true"
:::
javadoc 이 루트가 여기서 만들지 않는 이유를 적는다. 매니저는 영속성 리프 안에서 만들어지고, 루트가 만들면 `jakarta.persistence``org.hibernate` 가 루트의 컴파일 클래스패스에 올라온다는 것이다.
그 이유는 클래스패스 구성으로 성립한다. 다만 영속성 리프 안에도 그것을 만드는 코드는 없다. javadoc 은 일어난 일이 아니라 일어났어야 할 일을 서술한다.
같은 javadoc 이 루트의 몫으로 둘을 지목한다. 설치할지 말지의 결정과 그것이 쓸 커밋 실패 분류기다.
분류기를 만드는 팩토리는 존재하고, 부르는 코드는 0 이다. 그 팩토리를 담은 클래스는 `@Configuration``@Bean` 도 없는 평범한 클래스이고, 스스로 그렇게 적는다. 그것을 쓰는 유일한 프로덕션 코드가 부르는 메서드는 실행기와 재시도 코디네이터 둘이다.
## 예외로 가는 길이 한 줄씩 끊긴다
완료 불명 예외를 만드는 프로덕션 코드는 분류기 97행 한 곳이다. 그 분류기의 번역 메서드를 부르는 프로덕션 코드는 매니저 57행의 커밋 catch 한 곳이다. 그 매니저를 설치하는 코드가 0 이다.
분류기가 프레임에서 꺼내는 것은 작업 이름, 시작 시각, 시도 횟수, 조정 키다. 단계는 보지 않는다. 단계 민감성은 검사가 아니라 호출 위치에서 나온다. 단계를 읽는 접근자를 부르는 코드는 저장소 전체에 0 이다.
## 프레임은 만들어지고, 단계만 오르지 않는다
조립되는 실행기가 트랜잭션마다 프레임을 민다. 그 실행기는 플랫폼 트랜잭션 매니저 빈이 있을 때 붙는 빈이고, 부트가 넣은 매니저가 그 조건을 만족시킨다.
그 프레임을 활성과 커밋 중과 커밋됨으로 옮기는 것은 설치되지 않는 매니저뿐이다. 프레임은 시작 전 상태로 남는다.
순서를 실행하는 테스트도 없다. 같은 이름의 테스트는 그 타입을 한 번도 참조하지 않고 컨텍스트와 분류기를 따로 검증하며, 자기 javadoc 이 실제 순서는 커밋 모호성 계약 시험이 본다고 적는다.
## 범위
이 사건이 모든 유스케이스가 불확정 커밋을 중복 실행한다는 뜻은 아니다. 애플리케이션의 정규 트랜잭션 경로는 별도의 스프링 동기화 감시자로 커밋 예외를 불확정 결과로 되돌리고 재실행하지 않는다.
빠진 것은 자동 재시도 안전이 아니라, 안정 등급으로 광고한 조정 증거다. 지속되는 기록도 런북 지표도 없고, 애플리케이션이 돌려주는 불확정 결과에도 조정 참조가 비어 있다.
## 확인하지 못한 것
애플리케이션을 부팅해 어떤 트랜잭션 매니저가 실제로 쓰이는지 관측하지 않았다. 조립 코드에 그것을 만드는 자리가 없다는 것까지만 확인했다.
<!-- body:end -->
@@ -0,0 +1,185 @@
---
kind: CASE
slug: a05-f022-stable
title: 검증기는 도는데 정책을 넘기는 한 번의 호출이 없다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a05-f022-stable
evidenceCapturedOn: 2026-09-02
body: case-a05-f022-stable.body.md
assets:
- key: a05-f022-stable
file: ../../../final/evidence/rendered/a05-f022-stable.svg
evidence:
- ../../../final/evidence/raw/a05-f022-stable.txt
source:
- 원본 분석 절은 `analysis/05-adapter-outbound-persistence-jpa.md` §69 다. 등급은 P1 이다. 검증기 자체는 빈으로 구성되지만 정책을 적용하는 호출자가 없다는 판정과, 액추에이터 불리언이 생성 권한 일부만 확인한다는 관찰이 그 절에 있다.
- 검증기 javadoc 의 두 주장과 실제 호출 시점·예외 처리의 대조, 그리고 두 시험이 각각 절반만 덮는다는 것은 이 기록에서 확인했다.
---
# 검증기는 도는데 정책을 넘기는 한 번의 호출이 없다
런타임 롤 검증기가 빈으로 등록되고 프로덕션에서 실제로 권한을 읽는다. 다만 기동 시점이 아니라 액추에이터 리포트를 만들 때이고, 읽은 결과를 정책에 넘기지 않는다. 검증기 자신의 javadoc 은 기동 시점에 돌고 닫힌 방식으로 실패한다고 적는다.
## 관계
- **시작 검증기가 도는지는 그 능력에 자동설정 루트가 있는지와 일치한다**
그 대응이 깨지는 경우다. 루트가 있고 검증기가 빈으로 등록되고 호출까지 되는데, 정책을 넘기는 호출만 빠져 있다.
- **지원 등급은 추론이 아니라 선언이고 증거 없이는 올라가지 않는다**
등급이 뜻하는 것과 문서가 약속한 것이 다른 경우다.
- **RLS가 성립하기 위한 세 전제**
런타임 롤 속성이 격리 판정에 관여하는 다른 국면이다.
## 문제
보안 문서가 기동 실패 조건을 적는다. 런타임 롤이 허용 목록에 없거나 스키마나 데이터베이스에 생성 권한을 가지면 기동이 실패한다는 것이다.
검증기의 클래스 javadoc 은 더 직접적이다. 검증이 기동 시점에 돌고 닫힌 방식으로 실패한다고 적는다.
## 결론
배선 자체는 되어 있다. 자동설정이 검증기를 빈으로 만들고, 액추에이터 리포트를 만들 때마다 roleVerifier.verify(dataSource) 를 부른다.
기동 시점이 아니다. 기동 검사 빈에 걸린 것은 위험 설정 가드 하나이고, 그 가드의 인자에 롤 정책이 없다.
닫히지도 않는다. 리포트를 만드는 쪽이 검증기의 예외를 잡아 널을 돌려준다. 실패는 기동을 막는 대신 미검증 표시가 된다.
정책이 보는 항목은 넷이다. 허용 목록, 스키마 생성 권한, 데이터베이스 생성 권한, 검색 경로다. 그 정책에 검증 결과를 넘기는 두 인자짜리 메서드가 검증기에 있고, 그것을 부르는 곳은 코드베이스 전체에 하나다. PostgreSQL 보안 계약 시험이다.
정책 객체를 만드는 main 코드는 0 이다. 언급하는 파일을 세면 자기 자신, 검증기, 시험 둘이다.
액추에이터의 검증 완료 표시는 별개의 문제다. 권한 보고서가 널이 아닌지와 생성 권한을 갖지 않는지 둘로 계산한다. 그 넷 중 가운데 둘만 들어간다.
그 위 javadoc 은 현재 사용자와 검색 경로를 하나의 불리언으로 일부러 줄였다고 적고, 운영자는 그 롤이 검증을 통과했는지를 알면 된다고 덧붙인다. 이 축소가 하는 일은 둘이다. 리포트에서 어느 롤인지가 빠지고, 그 롤이 허용 목록과 검색 경로 정책을 통과했는지도 같이 빠진다.
그 불리언을 고정하는 시험 셋은 입력의 롤이 전부 허용된 이름이고 검색 경로도 전부 안전하다. 허용 목록 밖 롤을 넣은 입력이 없다. 정책 쪽 단위 시험은 바로 그 두 조합을 넣지만 액추에이터 불리언은 보지 않는다.
능력 등급 자체는 다른 이야기다. 안정 등급이란 계약 시험 스위트가 매트릭스 전체를 검증했다는 뜻이고, 그 시험 자체는 존재한다. 어긋난 것은 등급이 아니라 문서와 javadoc 이 약속한 기동 실패, 그리고 액추에이터 불리언의 의미다.
## 검증 환경
OpenJDK : 해당 없음. 정적 검색이다.
확인 방식 : 검증기 호출 경로 추적, 정책의 검사 항목 열람, 액추에이터 계산식과 그 시험 입력 대조
소스 수정 : x
## 재현 조건
1. 검증기의 클래스 javadoc 과 보안 문서의 기동 실패 조건을 읽는다.
2. 검증기의 verify 를 부르는 프로덕션 코드를 찾고, 그 호출이 언제 일어나는지 본다.
3. 그 호출을 감싼 코드가 예외를 어떻게 다루는지 읽는다.
4. 정책이 검사하는 항목을 열거하고, 정책을 넘기는 두 인자짜리 메서드의 호출처를 레포 전체에서 센다.
5. 정책 객체를 만드는 main 코드와 그 타입을 언급하는 파일을 센다.
6. 액추에이터의 검증 완료 계산식과 그것을 고정하는 시험의 입력을 나란히 본다.
7. 안정 등급의 정의를 읽는다.
## 본문
<!-- body:start -->
검증기의 클래스 javadoc 이 이렇게 적는다.
```text
* <p>The verification runs at startup and fails closed. Discovering after an incident that the
* application's own credential could drop tables is discovering it too late.
```
보안 문서도 같은 방향으로 적는다. 롤이 허용 목록에 없거나 생성 권한을 가지면 기동이 실패한다는 것이다.
## 검증기는 돈다. 기동 시점이 아닐 뿐이다
:::evidence key="a05-f022-stable" alt="검증기 클래스의 javadoc 주장, 정책이 검사하는 네 항목, 검증기를 프로덕션에서 부르는 코드와 그 예외 처리, 정책을 넘기는 두 인자짜리 메서드와 그 호출처와 정책 객체를 만드는 코드 수, 기동 검사 빈이 실행하는 것, 액추에이터의 검증 완료 계산식과 그 위 javadoc, 그 표시를 고정하는 시험의 입력과 정책 쪽 단위 시험의 입력, 그리고 안정 등급의 정의를 출력한 터미널 기록." caption="javadoc 은 기동 시점·닫힌 실패를 주장 · 정책은 네 항목 검사 · verify 는 리포트 요청 때 불리고 예외는 널로 삼켜짐 · 두 인자짜리 호출처는 계약 시험 하나, 정책 생성 0 · 표시는 네 항목 중 둘만 · 시험 입력에 허용 목록 밖 롤 없음 — 63줄 · exit 0" zoom="true"
:::
```java
private DatabasePrivilegeReport readPrivileges(DataSource dataSource) {
try {
return roleVerifier.verify(dataSource);
} catch (IllegalStateException unverified) {
return null;
}
}
```
액추에이터 리포트를 만들 때 불린다. 기동 검사 빈이 실행하는 것은 위험 설정 가드 하나이고, 그 가드는 롤 정책을 인자로 받지 않는다.
닫히지도 않는다. 검증기의 예외는 널이 되고, 널은 미검증 표시가 된다.
## 정책을 넘기는 호출이 없다
정책은 네 항목을 검사한다.
```java
if (!allowedRoles.contains(currentUser)) { ... }
if (report.canCreateInSchema()) { ... }
if (report.canCreateInDatabase()) { ... }
searchPathPolicy.requireSafe(report.searchPath());
```
검증 결과를 그 정책에 넘기는 메서드는 검증기에 있다.
```java
public void requireSafe(DataSource dataSource, DatabaseRolePolicy policy) {
Objects.requireNonNull(policy, "policy");
policy.requireSafe(verify(dataSource));
}
```
그것을 부르는 곳은 코드베이스 전체에 하나이고, PostgreSQL 보안 계약 시험이다. 정책 객체를 만드는 main 코드는 0 이므로 프로덕션에서는 그 메서드를 부를 수도 없다. 정책 타입을 언급하는 파일은 자기 자신과 검증기, 그리고 시험 둘뿐이다.
## 액추에이터 불리언의 계산식
```java
privileges != null && !privileges.holdsCreatePrivilege(),
```
정책이 검사하는 네 항목 중 가운데 둘만 들어간다. 허용 목록도 검색 경로도 계산에 없다.
그 위 javadoc 이 이렇게 적는다.
```text
* <p>The privilege report's {@code currentUser} and {@code searchPath} are deliberately reduced
* to a single boolean here: an operator needs to know the runtime role passed verification, not
* which role it is.
```
축소는 두 가지를 동시에 한다. 어느 롤인지를 리포트에서 지우고, 그 롤이 허용 목록과 검색 경로 정책을 통과했는지도 함께 지운다. 문서가 기동 실패 조건으로 지목한 값이 그 둘이다.
같은 불리언이 플랫폼 안전 판정에도 그대로 들어간다.
```java
return openInViewDisabled && runtimeRoleVerified;
```
## 두 시험이 각각 절반만 덮는다
이 불리언을 고정하는 시험은 셋인데, 입력의 롤이 전부 `app_runtime` 이고 검색 경로도 전부 안전하다.
```text
new DatabasePrivilegeReport("app_runtime", "app, pg_catalog", false, false)
new DatabasePrivilegeReport("app_runtime", "app", true, false)
```
허용 목록 밖 롤을 넣은 입력이 없으니 시험은 통과한다.
정책 쪽 단위 시험은 바로 그 조합을 넣는다.
```text
new DatabasePrivilegeReport("postgres", "app", false, false)
new DatabasePrivilegeReport("app_runtime", "app, public", false, false)
```
다만 그 시험은 정책 객체만 보고 액추에이터 불리언은 보지 않는다. 둘 사이의 틈을 아무도 보지 않는다.
## 등급은 어긋나지 않았다
안정 등급의 정의는 계약 시험 스위트가 전체 PostgreSQL 매트릭스에서 검증했다는 것이고, 그 시험은 실제로 있다. 두 인자짜리 호출이 있는 유일한 자리가 바로 그 시험이다.
어긋난 것은 등급이 아니다. 문서와 javadoc 이 약속한 기동 실패가 어디서도 일어나지 않고, 액추에이터가 내는 판정이 정책의 네 항목 중 둘만 반영한다.
## 확인하지 못한 것
허용 목록 밖 롤로 기동해 실패하지 않는 것을 재현하지 않았다. 정적 도달성과 계산식까지만 확인했다.
<!-- body:end -->
@@ -0,0 +1,87 @@
---
kind: CASE
slug: the-support-matrix-says-nothing-is-deployed-and-eighteen-are
title: 운영자용 지원 매트릭스가 런타임 편입을 반대로 적고, 틀린 쪽이 옳은 쪽을 권위로 지목한다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:the-support-matrix-says-nothing-is-deployed-and-eighteen-are
evidenceCapturedOn: 2026-09-01
assets:
- key: the-support-matrix-says-nothing-is-deployed-and-eighteen-are
file: ../../../final/evidence/rendered/the-support-matrix-says-nothing-is-deployed-and-eighteen-are.svg
evidence:
- ../../../final/evidence/raw/the-support-matrix-says-nothing-is-deployed-and-eighteen-are.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md §17 이다.
---
# 운영자용 지원 매트릭스가 런타임 편입을 반대로 적고, 틀린 쪽이 옳은 쪽을 권위로 지목한다
지원 매트릭스가 messaging 리프는 모두 어느 배포에도 편입되지 않았다고 적는다. 레지스트리는 25개 중 18개가 출하 애플리케이션에 편입되어 있다고 말한다. 틀린 문단이 권위로 지목하는 문서는 이미 그 사실을 정정했다.
## 관계
- **문서의 수치는 세지 말고 파생하거나 게이트로 붙든다**
이 사례가 만든 규칙의 상위형이다.
- **과대 진술 문서를 과소보다 먼저 고친다**
이 사례는 과소 진술이고 방향이 반대다.
- **다섯 문서가 "exactly 19 leaf"라고 적고 레지스트리는 62다**
같은 형태가 다른 숫자에서 나타난 사례다.
## 문제
운영자가 messaging 플랫폼을 도입할 때 먼저 읽는 문서가 지원 매트릭스다. 그 문서에 어떤 리프가 실제 배포에 들어가는지를 적은 문단이 있다.
## 결론
그 문단이 반대를 적는다.
문서는 registry 의 messaging 리프가 모두 런타임 편입이 비어 있고 어느 composition root 에도 들어가지 않는다고 적는다. 현재 레지스트리는 25개 중 18개가 출하 애플리케이션 소속이고, 그 문서가 속한 리프 자신이 그 안에 있다.
형태가 특이한 것은 틀린 문단이 자기 권위로 지목하는 문서가 이미 정정을 마쳤다는 점이다. 그 문서는 같은 사실을 고쳤고 결론까지 적어 두었다.
> 정확한 목록은 registry 가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift 한다
그 결론이 지원 매트릭스에는 적용되지 않았다. 같은 리비전에서 두 문서가 모순되고, 틀린 쪽이 옳은 쪽을 가리키고 있다.
운영자에게 남는 결과는 구체적이다. 배포 아티팩트가 실제로 이 리프들을 싣고 설정 한 줄로 켜진다는 사실을 문서에서 알 수 없다. 켜져 있는 것을 꺼져 있다고 읽는 방향이므로 과대 진술보다 덜 위험하지만, 그 대신 도입 검토 자체가 잘못된 전제 위에서 이뤄진다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 레지스트리의 런타임 편입 필드 집계와 두 문서의 해당 문단 대조
소스 수정 : x
## 재현 조건
1. 레지스트리에서 messaging 리프의 런타임 편입 필드를 전부 세어 비어 있지 않은 것의 수를 구한다.
2. 지원 매트릭스에서 편입을 서술하는 문단을 찾는다.
3. 그 문단이 권위로 지목하는 문서의 해당 절을 읽는다.
## 본문
<!-- body:start -->
지원 매트릭스가 "registry 의 messaging leaf 는 모두 `runtime_memberships` 가 비어 있고 어느 composition root 에도 편입되지 않았다" 고 적는다. 현재 레지스트리는 25개 중 18개가 `["app-bootstrap"]` 이고 `messaging-core-api` 자신이 그 안에 있다.
## 매트릭스의 문장과 레지스트리의 값
:::evidence key="the-support-matrix-says-nothing-is-deployed-and-eighteen-are" alt="분석 문서 analysis/messaging/messaging-core-api.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/messaging/messaging-core-api.md 발췌 — 18줄" zoom="true"
:::
## 틀린 문단이 권위로 지목하는 문서는 이미 정정을 마쳤다
`src/messaging/CLAUDE.md` 는 같은 사실을 고쳤고 "정확한 목록은 registry 가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift 한다" 는 결론까지 적었다. 그 결론이 지원 매트릭스에는 적용되지 않았다.
## 운영자가 문서에서 알 수 없는 것
배포 아티팩트가 실제로 이 리프들을 싣고 `app.messaging.enabled` 하나로 켜진다는 사실이다.
## 확인하지 못한 것
없다. 레지스트리와 두 문서를 전수 대조했다.
<!-- body:end -->
@@ -0,0 +1,95 @@
---
kind: CASE
slug: the-transport-and-the-validator-answer-differently
title: 같은 어댑터의 능력을 전송과 검증기가 다르게 답하고, 런타임이 쓰는 쪽이 record 의 의미와 어긋난다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:the-transport-and-the-validator-answer-differently
evidenceCapturedOn: 2026-09-01
assets:
- key: the-transport-and-the-validator-answer-differently
file: ../../../final/evidence/rendered/the-transport-and-the-validator-answer-differently.svg
evidence:
- ../../../final/evidence/raw/the-transport-and-the-validator-answer-differently.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-pulsar-experimental.md §17.1 이다.
---
# 같은 어댑터의 능력을 전송과 검증기가 다르게 답하고, 런타임이 쓰는 쪽이 record 의 의미와 어긋난다
Pulsar 어댑터에서 키 공유 구독의 능력을 전송과 검증기가 다르게 답한다. 성분 문서를 기준으로 보면 검증기 쪽이 맞고 전송 쪽이 자기 안에서 모순인데, 런타임이 읽는 것은 전송 쪽이다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
이 사례가 속한 구조다.
- **능력 플래그의 무게는 그것을 읽는 코드가 정한다**
어느 쪽이 틀렸는지가 아니라 어느 쪽이 읽히는지가 심각도를 정한다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
같은 판정 절차의 일반형이다.
## 문제
이 어댑터는 두 구독 종류를 노출한다. 공유 구독은 경쟁 소비자에 순서 없음이고, 키 공유 구독은 경쟁 소비자에 키별 순서다.
능력을 답하는 자리가 둘이다. 전송이 구독 종류에 따라 두 상수 중 하나를 고르고, 검증기가 같은 판단을 자기 메서드로 한다.
## 결론
키 공유에 대해 두 답이 갈린다.
전송은 순서 있는 스트림을 거짓, 키별 순서를 참으로 답한다. 검증기는 둘 다 참으로 답한다.
성분 문서가 판정 기준이다. 순서 있는 스트림은 순서 단위 안에서 순서가 보존되는지를 뜻하고, 키 공유의 순서 단위는 키다. 그 단위 안에서 순서는 보존된다. 그러므로 검증기 쪽이 문서화된 의미와 맞다.
전송 쪽은 자기 안에서도 모순이다. 키별 순서를 참이라고 하면서 순서 있는 스트림을 거짓이라고 하면, 순서가 보존되는 단위가 있는데 그 단위 안에서 순서가 보존되지 않는다는 말이 된다.
그리고 어긋난 쪽이 런타임이 읽는 쪽이다. 목적지별 능력을 돌려주는 것은 SPI 메서드이고 그것을 구현하는 것은 전송이다. 순서 있는 스트림은 이 저장소에서 production 코드가 실제로 읽는 몇 안 되는 능력 중 하나로, 재시도 결정 엔진이 그 값을 보고 순서 보존 재시도를 고를지 정한다. 결과적으로 키별 순서를 약속한 목적지가 순서 보존 재시도를 받지 못한다.
두 리터럴을 묶는 것은 아무것도 없다. 열두 개의 불리언이 두 파일에 각각 손으로 적혀 있다. 테스트는 키별 순서만 단언하고 순서 있는 스트림은 보지 않는다.
자매 어댑터인 NATS 는 두 곳이 같은 값을 답한다. 다만 그 일치도 공유가 아니라 손으로 복사한 리터럴이므로, 오늘 같다는 것이 내일도 같으리라는 보장은 코드에 없다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
확인 방식 : 두 열두 성분 리터럴의 성분별 대조와 성분 문서 확인
소스 수정 : x
## 재현 조건
1. 전송의 키 공유용 능력 상수 열두 성분을 순서대로 적는다.
2. 검증기의 능력 메서드가 키 공유에 대해 만드는 열두 성분을 적는다.
3. 두 목록을 성분별로 대조한다.
4. 능력 record 의 성분 문서에서 두 이름의 정의를 읽는다.
5. 순서 있는 스트림을 읽는 production 코드를 찾는다.
## 본문
<!-- body:start -->
Key_Shared 구독에 대해 전송은 `orderedStream=false, keyedOrdering=true` 를, 검증기는 `orderedStream=true, keyedOrdering=true` 를 답한다.
## 성분 문서가 판정 기준이다
`orderedStream` 은 "순서 단위 안에서 순서가 보존되는가" 이고 Key_Shared 의 순서 단위는 키다. 그러므로 검증기 쪽이 문서화된 의미와 맞고, 전송 쪽은 자기 안에서 모순이다.
## 어긋난 쪽이 런타임이 읽는 쪽이다
:::evidence key="the-transport-and-the-validator-answer-differently" alt="코드베이스에서 DefaultRetryDecisionEngine 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DefaultRetryDecisionEngine 코드베이스 검색 — 3줄 · exit 0" zoom="true"
:::
`capabilities(DestinationName)` 이 SPI 메서드이고 `orderedStream` 은 production 코드가 실제로 읽는 세 능력 중 하나다 — `DefaultRetryDecisionEngine` 이 그 값으로 순서 보존 재시도를 고른다.
## 두 리터럴을 묶는 것이 없다
테스트는 `keyedOrdering` 만 단언해 `orderedStream` 을 보지 않는다. 자매 어댑터 NATS 는 두 곳이 같은 값을 답하지만 그 일치도 공유가 아니라 손으로 복사한 리터럴이다.
## 확인하지 못한 것
두 답이 실제 재시도 선택을 어떻게 가르는지 실행으로 재현하지 않았다. 이 가족은 배선 경로가 없다.
<!-- body:end -->
@@ -0,0 +1,97 @@
---
kind: CONCEPT
slug: three-sources-of-a-capability-answer
title: 능력 선언의 세 출처와 그것이 파생되지 않을 때
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:three-sources-of-a-capability-answer
evidenceCapturedOn: 2026-09-01
assets:
- key: three-sources-of-a-capability-answer
file: ../../../final/evidence/rendered/three-sources-of-a-capability-answer.svg
- key: three-sources-of-a-capability-answer-diagram
file: ../../../final/assets/diagrams/three-sources-of-a-capability-answer.svg
evidence:
- ../../../final/evidence/raw/three-sources-of-a-capability-answer.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-core-api.md §4.12 · analysis/99-cross-scope.md §3.2 이다.
---
# 능력 선언의 세 출처와 그것이 파생되지 않을 때
이 플랫폼에서 어댑터가 무엇을 증명할 수 있는지에 답하는 곳이 셋이다. 전송의 능력 상수, 검증기의 같은 이름 메서드, 그리고 운영자가 읽는 지원 매트릭스. 셋이 같은 값을 답해야 한다는 것이 계약인데 그것을 붙드는 장치가 없다.
## 관계
- **능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다**
이 개념에서 나온 규칙이다.
- **능력 플래그의 무게는 그것을 읽는 코드가 정한다**
같은 개념의 심각도 판정 쪽이다.
- **능력 상수가 프로파일 조건보다 오래 살아서 중복 제거 없는 목적지가 가드를 통과한다**
이 개념이 실제로 발현한 사례다.
## 본문
<!-- body:start -->
이 플랫폼에서 "이 어댑터가 무엇을 증명할 수 있는가" 에 답하는 곳이 셋이다.
## 능력을 답하는 세 자리
:::evidence key="three-sources-of-a-capability-answer-diagram" alt="능력 질문에서 전송의 상수와 검증기의 메서드와 지원 매트릭스 문서 세 갈래가 나온다" caption="능력을 답하는 세 자리" zoom="false"
:::
전송의 `MessagingCapabilities` 상수(SPI `capabilities(DestinationName)` 가 런타임에 돌려주는 값), 검증기의 같은 이름 메서드(기동 시점 판정용), 그리고 운영자가 읽는 지원 매트릭스 문서다.
## MessagingCapabilities 참조 위치
:::evidence key="three-sources-of-a-capability-answer" alt="코드베이스에서 MessagingCapabilities 를 검색한 출력 13줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingCapabilities 코드베이스 검색 — 13줄 · exit 0" zoom="true"
:::
## 열두 성분과 그 소유자
전부 `boolean` 이고 의미는 record javadoc 이 소유한다 — `brokerAcknowledgement` · `replicationOrPersistenceEvidence` · `perMessageSettlement` · `batchSettlement` · `orderedStream` · `keyedOrdering` · `replay` · `delayedDelivery` · `brokerTransaction` · `deduplicatedPublish` · `nativeDeadLetter` · `topologyManagement`.
## 세 출처를 붙드는 장치가 없다
세 출처가 같은 값을 답해야 한다는 것이 계약인데, 그것을 붙드는 장치가 없다. 그리고 열둘의 무게가 같지 않다 — 부재가 예외를 만드는 것은 `deduplicatedPublish` 하나이고(`DefaultMessagePublisher`), 나머지는 읽히지 않거나 분기에만 쓰인다. record javadoc 이 그 위험을 미리 서술한다 — "a silently weakened guarantee is indistinguishable from a working one until the incident."
:::note
세 출처를 전수 대조하는 스크립트를 돌리지 않았다. 어댑터별 SSOT 의 §능력 절을 읽어 대조했다
:::
## 세 출처
전송이 SPI 메서드로 돌려주는 값이 런타임의 답이다. 호출자가 목적지를 넘기면 그 목적지에 대한 능력 집합을 받는다.
검증기가 같은 이름의 메서드를 갖는다. 이쪽은 기동 시점 판정용이고, 목적지 프로파일이 요구하는 보장을 어댑터가 줄 수 있는지 확인할 때 쓴다.
지원 매트릭스 문서가 셋째다. 운영자가 브로커를 고를 때 읽는 표이고, 어댑터별로 열두 성분의 지원 여부를 적는다.
## 열두 성분
브로커 승인, 복제·지속 증거, 개별 메시지 정착, 배치 정착, 순서 있는 스트림, 키별 순서, 재생, 지연 배달, 브로커 트랜잭션, 중복 제거 발행, 네이티브 데드레터, 토폴로지 관리.
전부 불리언이고 의미는 record 의 javadoc 이 소유한다. 성분 이름만으로는 판정할 수 없는 것들이 있다. 순서 있는 스트림은 "순서 단위 안에서 순서가 보존되는가" 이고 그 단위가 무엇인지는 구독 형태가 정한다.
## 무게가 같지 않다
열둘 중 부재가 예외를 만드는 것은 중복 제거 발행 하나다. 발행자가 중복 제거를 요구하는 목적지에 대해 그 플래그를 확인하고 없으면 던진다.
나머지는 읽히지 않거나 분기에만 쓰인다. 순서 있는 스트림은 재시도 결정 엔진이 읽어 순서 보존 재시도를 고를지 정한다.
그래서 같은 정도의 과대 선언이라도 결과가 다르다. 심각도를 매기려면 그 플래그를 읽는 코드를 먼저 세어야 한다.
## 이 구조가 미리 경고한 것
능력 record 의 클래스 javadoc 이 이 상황을 서술한다.
> a silently weakened guarantee is indistinguishable from a working one until the incident.
조용히 약해진 보장은 사고가 나기 전까지 동작하는 보장과 구별되지 않는다. 세 출처가 갈리는 것이 정확히 그 형태다. 어느 것도 오류를 내지 않고, 셋 중 하나만 읽은 사람은 자기가 읽은 것이 사실이라고 믿는다.
<!-- body:end -->
@@ -0,0 +1,59 @@
---
kind: REFERENCE
slug: a-capability-constant-must-derive-from-the-profile
title: 능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:a-capability-constant-must-derive-from-the-profile
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 능력 선언은 프로파일에서 파생되어야 하고 상수는 그것을 할 수 없다
## 목적
어댑터가 무엇을 할 수 있는지와 이 구성에서 무엇이 성립하는지를 구분한다. 둘이 갈리는 조건이 프로파일에 있으면 상수는 그 답을 담을 수 없다.
## 규칙
1. 이 플래그가 참이 되는 조건을 문장으로 쓴다
조건이 없으면 상수가 맞다.
2. 그 문장에 프로파일 필드가 등장하는지 본다
등장하면 상수는 틀린 표현이다.
3. 파생시킬 수 없으면 검증기가 그 조건을 기동 시점에 요구한다
창이 없는 목적지를 거부하는 것도 답이다. 다만 그 검증기가 실제로 도는지를 함께 확인해야 한다.
4. 어느 쪽도 못 하겠다면 문서에 조건을 적는다
가장 약한 답이고, 문서가 코드보다 먼저 낡는다는 것을 감수하는 선택이다.
## 적용 조건
능력 record 의 모든 성분과 그에 대응하는 gRPC 쪽 선언. 브로커가 제공하는 기능을 어댑터가 대신 선언하는 자리 전부.
## 예외
어댑터가 브로커와 무관하게 항상 제공하는 성질은 상수가 맞다. 구분 기준은 이 값을 거짓으로 만드는 구성이 존재하는지이고, 존재하지 않으면 상수다.
## 예시
NATS 의 중복 제거 발행이 참인데, 중복 제거 식별자는 프로파일에 창이 있을 때만 만들어지고 창은 선택 사항이다.
Kafka 의 브로커 트랜잭션이 참인데, 트랜잭션은 생산자에 트랜잭션 식별자가 있어야 성립한다.
Rabbit 의 지연 배달이 참인데, 그 지연을 만드는 토폴로지가 조립되지 않는다.
셋 다 형태가 같다. 조건을 아는 코드가 같은 리프에 있고, 상수가 그것을 참조하지 않는다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
이 규칙이 나온 구조다.
- **능력 상수가 프로파일 조건보다 오래 살아서 중복 제거 없는 목적지가 가드를 통과한다**
이 규칙을 어긴 사례 중 가장 무거운 것이다.
- **브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 기동 시 돌지 않는다**
같은 규칙을 어기면서 검증기까지 함께 빠진 사례다.
@@ -0,0 +1,57 @@
---
kind: REFERENCE
slug: the-weight-of-a-flag-is-set-by-the-code-that-reads-it
title: 능력 플래그의 무게는 그것을 읽는 코드가 정한다
topic: capability-declaration-vs-proof
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:the-weight-of-a-flag-is-set-by-the-code-that-reads-it
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 능력 플래그의 무게는 그것을 읽는 코드가 정한다
## 목적
같은 record 의 성분이라고 무게가 같지 않다. 과대 선언의 심각도를 매기기 전에 그 플래그의 소비자를 먼저 센다.
## 규칙
1. 성분 접근자 이름으로 저장소를 훑는다
호출자를 전부 모은다.
2. 호출자를 셋으로 나눈다
아무도 읽지 않음, 분기에만 쓰임, 부재가 예외를 만듦.
3. 심각도는 그 분류에서 나온다
읽히지 않는 플래그의 과대 선언은 문서 결함이고, 예외를 만드는 플래그의 과대 선언은 가드 우회다.
4. 배선되지 않은 블록에서는 미래의 소비자를 센다
지금 무게가 0 이어도 배선되면 무엇이 그것을 읽게 되는지가 답이고, 그 답은 같은 가족의 배선된 리프에 있다.
## 적용 조건
능력·기능 플래그를 담은 모든 record 와 그것을 읽는 정책 코드.
## 예외
플래그가 외부에 공개되는 계약의 일부이면 소비자 수와 무관하게 정확해야 한다. 지원 매트릭스에 실리는 값이 그렇다.
## 예시
이 플랫폼의 능력 열두 성분 중 부재가 예외를 만드는 것은 중복 제거 발행 하나다. 발행자가 그 플래그를 확인하고 없으면 던진다.
순서 있는 스트림은 재시도 결정 엔진이 읽어 순서 보존 재시도를 고를지 정한다. 분기에만 쓰이는 쪽이다.
나머지 열은 production 코드가 읽지 않는다. 같은 정도로 틀렸더라도 결과가 다르다.
## 관계
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
이 규칙이 나온 구조다.
- **같은 어댑터의 능력을 전송과 검증기가 다르게 답하고, 런타임이 쓰는 쪽이 문서화된 의미와 어긋난다**
어느 쪽이 틀렸는지보다 어느 쪽이 읽히는지가 중요했던 사례다.
- **`runtime_memberships`를 먼저 읽고 심각도를 정한다**
같은 계열의 판정 순서 규칙이다.
@@ -0,0 +1,102 @@
---
kind: CASE
slug: commit-ambiguity-is-not-only-sqlstate-08
title: pg_terminate_backend가 57P01로 도착하고 커밋 레코드는 이미 WAL에 있었다
topic: commit-ambiguity-as-a-result
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:commit-ambiguity-is-not-only-sqlstate-08
evidenceCapturedOn: 2026-09-01
body: case-commit-ambiguity-is-not-only-sqlstate-08.body.md
assets:
- key: commit-ambiguity-is-not-only-sqlstate-08
file: ../../../final/evidence/rendered/commit-ambiguity-is-not-only-sqlstate-08.svg
evidence:
- ../../../final/evidence/raw/commit-ambiguity-is-not-only-sqlstate-08.txt
source:
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §3.5 이다.
---
# pg_terminate_backend가 57P01로 도착하고 커밋 레코드는 이미 WAL에 있었다
커밋 모호성을 SQLSTATE class 08로만 정의했던 규칙이, 서버가 자기 종료를 알리는 57P01 앞에서 성립하지 않았다. 그 시점에 커밋 레코드는 이미 WAL에 있을 수 있다.
## 관계
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
이 사례가 그 세 번째 결과를 필요로 하는 이유다.
- **커밋 모호성 판정은 넓혀도 좁혀도 해롭다**
이 사례가 규칙을 넓힌 쪽이고, 그 규칙의 반대편 비용을 함께 다룬다.
- **completion-unknown은 자동으로도 수동으로도 재시도하지 않는다**
이 분류가 만들어 내는 예외를 어떻게 다룰지 정한 결정이다.
- **커밋 모호성 계약 레인이 이 리비전에서 통과하는지 실행으로 확인되지 않았다**
이 사례의 회귀 방지 레인에 대한 미해결 질문이다.
## 문제
설계는 커밋 모호성을 SQLSTATE class 08, 즉 연결 예외로 정의했다. 그 정의는 클라이언트가 자기 연결에서 무슨 일이 일어났는지를 기준으로 삼는다.
문제는 서버가 스스로 종료를 알리는 경우다. in-flight 커밋 중인 백엔드를 pg_terminate_backend 로 끊으면 클라이언트는 57P01 을 받는다. 이것은 class 08 이 아니다. 클라이언트의 연결 시도에 대한 이야기가 아니라 서버가 자기 종료를 알린 것이기 때문이다.
그러나 커밋 입장에서 결과는 같고 오히려 더 나쁘다. 57P01 이 도착한 시점에 커밋 레코드가 이미 WAL 에 있을 수 있다. class 08 만 모호성으로 보는 규칙은 이 실패를 평범한 실패로 분류하고, 평범한 실패는 재시도된다. 커밋됐을 수도 있는 쓰기를 다시 실행하는 경로가 여기서 열린다.
## 결론
규칙이 57P01 과 57P02 와 57P03 까지 넓어졌다.
현재 CommitFailureClassifier 는 네 가지를 모호성으로 본다.
40003 : statement completion unknown
08 으로 시작하는 상태 : connection exception
57P01 57P02 57P03 : 서버가 자기 종료를 알린 상태
transport 수준 단절
세 상태를 함께 넣은 이유는 코드 주석에 남아 있다. 57P01 은 종료된 백엔드나 fast shutdown 이나 failover 가 보고하는 것이고, 그것이 도착할 때 커밋 레코드가 이미 WAL 에 있을 수 있다. 57P02 는 crash shutdown, 57P03 은 지금 접속할 수 없음이며, 서버가 in-flight 커밋을 어떻게 했든 클라이언트가 그것을 알지 못했다는 점에서 같다.
넓어진 규칙이 다시 좁아지지 못하도록 CommitAmbiguityContractTest 가 SQLSTATE 를 직접 assert 한다.
이 규칙에는 반대 방향의 비용도 함께 기록되어 있다. 커밋 단계의 모든 연결 오류를 모호성으로 표시하면 평범한 풀 고갈과 서버 재시작이 조정 큐로 밀려들고, 운영자는 그 큐를 읽지 않고 비우는 습관을 배운다. 그리고 정작 중요한 항목 하나가 나머지와 함께 지워진다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
데이터베이스 : PostgreSQL
관측 출처 : 저장소가 기록한 컨테이너 레인. 이 분석에서 재실행하지 않았다
## 재현 조건
원래 관측은 컨테이너 레인에서 나왔다. 절차는 다음과 같다.
1. 커밋이 진행 중인 트랜잭션을 만든다.
2. 그 백엔드를 pg_terminate_backend 로 끊는다.
3. 클라이언트가 받는 SQLSTATE 와 그 시점의 WAL 상태를 확인한다.
이번 사이클에서 확인한 것은 규칙의 현재 코드 형태와 그 근거 문장이다.
## 본문
<!-- body:start -->
설계가 "커밋 모호성은 SQLSTATE class 08뿐"이라고 적었다.
## 컨테이너 레인이 보인 것
in-flight 커밋 중인 백엔드를 `pg_terminate_backend`로 끊었을 때 `57P01`(admin_shutdown)이 도착하며, 그 시점에 커밋 레코드가 이미 WAL에 있을 수 있다.
## CommitAmbiguityContractTest 참조 위치
:::evidence key="commit-ambiguity-is-not-only-sqlstate-08" alt="코드베이스에서 CommitAmbiguityContractTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="CommitAmbiguityContractTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 규칙이 넓어지고 다시 좁아지지 못하게 고정됐다
`57P01/57P02/57P03`까지 넓어졌고 `CommitAmbiguityContractTest`가 SQLSTATE를 직접 assert한다.
## 확인하지 못한 것
jpaPlatformFailureTest 를 이 리비전에서 실행하지 않았다. 57P01 관측이 PostgreSQL 16 과 17 과 18 전부에서 재현되는지도 확인하지 않았다. 관련 미해결 질문에 그 조건을 적어 두었다.
<!-- body:end -->
@@ -0,0 +1,111 @@
---
kind: CASE
slug: high-water-mark-swallowed-a-redelivered-change
title: high-water mark가 "본 위치"를 뜻해서 재전달된 변경이 영구히 사라졌다
topic: commit-ambiguity-as-a-result
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:high-water-mark-swallowed-a-redelivered-change
evidenceCapturedOn: 2026-09-01
body: case-high-water-mark-swallowed-a-redelivered-change.body.md
assets:
- key: high-water-mark-swallowed-a-redelivered-change
file: ../../../final/evidence/rendered/high-water-mark-swallowed-a-redelivered-change.svg
evidence:
- ../../../final/evidence/raw/high-water-mark-swallowed-a-redelivered-change.txt
source:
- 원본 분석 절은 final/document.md#4-2 · analysis/06 §67 이다.
---
# high-water mark가 "본 위치"를 뜻해서 재전달된 변경이 영구히 사라졌다
change stream 파이프라인의 high-water mark가 이벤트 수신 즉시 전진한다. 그래서 투영이 끝나기 전에 failover가 나면 재전달분이 mark에 걸려 버려지고, 다음 이벤트의 checkpoint가 그것을 지나친다. 구독 상태는 RUNNING이다.
## 관계
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
본 적 있지만 완료되지 않은 위치라는 제3의 상태가 없어서 생긴 손실이다.
- **조건이 만족될 수 없는 outbox 사슬**
같은 형태의 손실이 다른 리프에서 나타난 사례다.
## 문제
MongoChangeStreamPipeline 은 이벤트마다 위치를 비교해 앞으로 가는 것만 처리한다.
processOne : advancesPosition 이 false 면 Flux.empty
advancesPosition : highWaterMark 를 getAndAccumulate 로 갱신하고 전진 여부를 반환
문제는 이 갱신이 투영 실행 전에 일어난다는 점이다. mark 는 완료된 위치가 아니라 본 위치를 뜻한다. 그리고 resume 이 같은 pipeline 인스턴스를 재사용하므로 그 mark 가 남는다.
## 결론
worker 하나, 평범한 failover 만으로 변경이 영구히 사라진다. probe 세 개가 그것을 관측했다.
probe C 가 결정적이다. 스트림 1 이 이벤트 E 를 전달하고 투영이 200 밀리초 걸리는 동안 50 밀리초 시점에 재개 가능한 드라이버 오류로 스트림이 끊긴다. 서버가 재개하면 checkpoint 가 E 를 지나지 않았으므로 E 를 다시 보내고 이어서 F 를 보낸다.
관측 결과는 이렇다.
terminal : COMPLETED, opens 2
projector : started 2, completed 1
results : APPLIED 하나
checkpoints saved : token-6
highWaterMark : 6.1
state : RUNNING, runbook 비어 있음
E 의 투영은 시작됐다가 failover 로 취소됐다. 재개 시 파이프라인은 재전달된 E 를 버렸다. E 의 첫 전달 때 이미 mark 가 5.1 로 전진했기 때문이다. 그 뒤 F 가 투영되고 checkpoint 가 저장되면서 저장 위치가 E 를 지나쳤다. change stream 은 checkpoint 가 지난 것을 다시 재생하지 않으므로 E 는 도달 불가능해졌다.
그런데 구독은 RUNNING 이고 runbook 은 비어 있고 호출자의 Flux 는 정상 완료한다. 손실을 알리는 신호가 없다.
probe A 는 같은 손실이 BUSY 청구와 재개 가능 실패의 조합으로도 나타남을 보인다. token-5 는 투영되지 않았는데 checkpoint 는 그것을 지나쳤다.
근본 원인은 상태 어휘다. 본 적 있지만 완료되지 않은 위치라는 제3의 상태가 없어서, 파이프라인은 본 것과 완료한 것을 같은 값으로 다룬다.
## 검증 환경
OpenJDK : 21.0.12
probe 구성 : in-memory, reactor 와 플랫폼 자체 타입만 사용, 서버 없음
조립 방식 : MongoPlatformAutoConfiguration 과 동일하게 consumer 를 조립
probe 클래스 : 임시로 추가하고 실행 후 제거
production 소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/135a-mongo-changestream-execution-probes.txt 에 있고, 매니페스트는 135-mongo-changestream-manifest-and-probes.txt 에 있다.
probe C 의 조건은 다음과 같다.
1. worker 하나, 중복 제거는 항상 청구를 허용한다.
2. 스트림 1 이 클러스터 시각 5.1 의 이벤트 E 를 전달한다.
3. 투영은 200 밀리초 걸린다.
4. 50 밀리초 시점에 재개 가능한 드라이버 오류로 스트림이 끊긴다. errorLabels 는 ResumableChangeStreamError, code 는 133 이다.
5. 스트림 2 가 E 를 재전달하고 이어서 클러스터 시각 6.1 의 F 를 전달한다.
## 본문
<!-- body:start -->
mark가 이벤트 수신 즉시 전진하므로 "완료된 위치"가 아니라 "본 위치"를 뜻하고, resume이 같은 pipeline 인스턴스를 재사용해 mark가 남는다.
## mark 가 전진하는 시점
:::evidence key="high-water-mark-swallowed-a-redelivered-change" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## probe 세 개가 관측한 것
worker 하나·평범한 failover에서 checkpoint 없이 지나간 이벤트를 다음 이벤트의 checkpoint가 추월하고, 그 사이 재전달분을 pipeline이 삼킨다.
## 아무 신호도 나지 않는다
구독은 `RUNNING`, runbook 비어 있음, caller의 `Flux`는 정상 완료.
## 세 테스트가 각각 절반씩만 본다
"본 적 있지만 완료되지 않은 위치"라는 제3의 상태가 없다.
## 확인하지 못한 것
probe 는 in-memory 구성이다. 실제 replica set failover 에서 같은 순서가 재현되는지는 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,100 @@
---
kind: CASE
slug: two-owners-popped-the-evidence-frame
title: 커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다
topic: commit-ambiguity-as-a-result
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:two-owners-popped-the-evidence-frame
evidenceCapturedOn: 2026-09-01
body: case-two-owners-popped-the-evidence-frame.body.md
assets:
- key: two-owners-popped-the-evidence-frame
file: ../../../final/evidence/rendered/two-owners-popped-the-evidence-frame.svg
evidence:
- ../../../final/evidence/raw/two-owners-popped-the-evidence-frame.txt
source:
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §3.4 이다.
---
# 커밋 증거 프레임을 두 주인이 pop해서 바깥 트랜잭션의 실패가 익명이 됐다
증거 프레임을 pop 하는 주인이 둘이었다. REQUIRES_NEW 안쪽 트랜잭션에서 executor 가 바깥 프레임을 자기 것으로 오인해 pop 했고, 이후 바깥의 커밋 실패는 operation 도 조정 키도 없이 보고됐다.
## 관계
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
증거가 사라지면 세 번째 결과를 만들 재료도 사라진다.
- **재시도 단위는 statement가 아니라 유스케이스 전체다**
executor 의 finally 가 프레임을 정리하려 한 이유가 그 재시도 단위에 있다.
- **커밋 증거 단계 — NOT_STARTED에서 UNKNOWN까지**
이 프레임이 담는 단계 모델이다.
## 문제
증거 컨텍스트는 스레드에 묶인 프레임 스택이다. 스택인 이유는 REQUIRES_NEW 가 같은 스레드에서 바깥 트랜잭션을 suspend 하고 안쪽을 시작하기 때문이다. 슬롯이 하나면 안쪽의 커밋이 바깥의 phase 를 덮어쓰고, 나중에 바깥에서 난 커밋 실패가 이미 끝난 작업의 증거로 분류된다.
문제는 그 프레임을 pop 하는 주인이 둘이었다는 점이다.
트랜잭션 매니저 : commit 과 rollback 에서 자기 프레임을 정리
executor 의 finally : operation 과 attempt 가 일치하면 top 프레임을 정리
바깥과 안쪽이 같은 operation 이고 같은 attempt 인 경우, executor 의 조건은 안쪽 프레임만큼이나 바깥 프레임도 정확하게 서술한다. 그리고 기본 경로에서는 둘 다 attempt 1 이므로 이 조건이 항상 성립한다.
순서는 이렇게 된다. 안쪽 매니저가 안쪽 프레임을 pop 하고, executor 가 바깥 프레임을 자기 것으로 오인해 pop 한다. 이후 바깥 트랜잭션의 커밋 실패는 operation 없이, attempt 1 로, 조정 키 없이 보고된다.
## 결론
소유권을 내용이 아니라 깊이로 식별하도록 바꿨다.
scope 가 스택에서의 자기 위치를 들고, 닫을 때 그 위치가 top 일 때만 pop 한다. 매니저는 phase 만 표시하고 아무것도 pop 하지 않는다.
깊이를 기준으로 삼은 이유는 내용이 변하기 때문이다. phase 전이마다 프레임 값이 교체되지만 깊이는 변하지 않고, 엄격하게 중첩된 수명주기에서는 이 scope 가 push 한 깊이의 프레임이 곧 이 scope 의 프레임이다.
순서를 어겨 닫으면 아무것도 pop 하지 않는다. 이것은 의도된 동작이다. 중첩되지 않은 수명주기는 버그이고, 무관한 프레임을 지워서 그것을 덮는 것이 원래 결함이 밖에서 보이던 모습이기 때문이다.
프레임이 남는 것은 프레임이 없는 것보다 나쁘다. 풀링된 요청 스레드가 낡은 COMMITTING 을 무관한 작업으로 들고 가고, 플랫폼은 존재한 적 없는 트랜잭션에 대해 completion-unknown 을 보고하게 된다.
ThreadLocal 을 withInitial 로 만들지 않은 것도 같은 계열의 판단이다. 초기화하는 스레드 로컬은 읽을 때마다 값을 설치하므로, 마지막 프레임을 지운 뒤의 읽기가 clear 가 방금 제거한 것을 다시 등록한다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀와 현재 코드 형태
## 재현 조건
수정된 형태를 확인하는 절차는 다음과 같다.
1. TransactionEvidenceContext 의 클래스 javadoc 에서 두 주인 문제의 서술을 읽는다.
2. TransactionEvidenceScope 가 depth 를 들고 ownsTopOf 로 판정하는지 확인한다.
3. close 가 멱등인지 확인한다. 두 번째 close 는 그 사이에 들어온 것을 pop 하지 않고 아무것도 하지 않는다.
4. TransactionEvidenceScopeTest 의 hasRawThreadLocalValue 단언을 확인한다. 프레임이 남지 않았음을 스레드 로컬 수준에서 검사한다.
## 본문
<!-- body:start -->
증거 프레임을 pop하는 주인이 둘이었다 — 트랜잭션 매니저가 commit/rollback에서, executor의 `finally`가 operation·attempt 일치 시.
## 두 주인이 pop 하는 조건
:::evidence key="two-owners-popped-the-evidence-frame" alt="분석 문서 final/document.md 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md 발췌 — 18줄" zoom="true"
:::
## 기본 경로에서는 항상 겹친다
바깥과 같은 operation·같은 attempt를 가진 `REQUIRES_NEW` 안쪽 트랜잭션에서 — 기본 경로에서는 둘 다 attempt 1이라 **항상** 그렇다 — 안쪽 매니저가 안쪽을 pop하고 executor가 바깥을 자기 것으로 오인해 pop했다. 이후 바깥의 커밋 실패는 operation 없이, attempt 1로, reconciliation key 없이 보고됐다.
## 해결
깊이로 소유권을 식별하고 자기 프레임이 top일 때만 pop한다.
## 확인하지 못한 것
없다. 수정된 형태와 그 테스트를 코드로 확인했다.
<!-- body:end -->
@@ -0,0 +1,99 @@
---
kind: CASE
slug: untranslated-contention-bypassed-the-retry-catch
title: 번역되지 않은 경합 예외가 재시도 코디네이터의 catch를 통째로 비껴갔다
topic: commit-ambiguity-as-a-result
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:untranslated-contention-bypassed-the-retry-catch
evidenceCapturedOn: 2026-09-01
body: case-untranslated-contention-bypassed-the-retry-catch.body.md
assets:
- key: untranslated-contention-bypassed-the-retry-catch
file: ../../../final/evidence/rendered/untranslated-contention-bypassed-the-retry-catch.svg
evidence:
- ../../../final/evidence/raw/untranslated-contention-bypassed-the-retry-catch.txt
source:
- 원본 분석 절은 final/document.md#4-1 · analysis/05 §3.6 이다.
---
# 번역되지 않은 경합 예외가 재시도 코디네이터의 catch를 통째로 비껴갔다
재시도 코디네이터가 자기 플랫폼 예외 하나만 catch 하는데 executor 는 아무것도 번역하지 않았다. 경합이 실제로 만들어 내는 예외들이 번역되지 않은 채 나가서 catch 를 비껴갔고, 프로덕션 경합은 재시도되지 않았다.
## 관계
- **실패 번역 사슬의 순서는 계약이다**
이 사례가 그 순서를 계약으로 만든 이유다.
- **모르는 것은 성공도 실패도 아닌 세 번째 결과여야 한다**
번역이 없으면 분류도 없고, 분류가 없으면 세 번째 결과도 만들어지지 않는다.
- **아무도 부르지 않는 재시도 구현**
같은 형태의 공백이 다른 곳에서 나타난 사례다.
## 문제
재시도 코디네이터는 JpaPersistenceException 만 catch 한다. 그런데 executor 는 트랜잭션 템플릿을 돌리면서 아무것도 번역하지 않았다.
경합이 실제로 만들어 내는 실패는 이런 것들이다.
Hibernate 의 OptimisticLockException
Spring 의 OptimisticLockingFailureException
raw 직렬화 실패 또는 데드락 DataAccessException
셋 다 JpaPersistenceException 이 아니다. 번역되지 않은 채 executor 를 떠나면 코디네이터의 catch 에 걸리지 않는다. 그래서 프로덕션에서 경합은 재시도되지 않았다.
단위 픽스처는 초록불이었다. 픽스처가 이미 번역된 예외를 던졌기 때문이다.
## 결론
번역 사슬이 단일 지점으로 만들어졌고, 그 순서가 계약으로 고정됐다.
PersistenceFailureTranslatorChain 의 클래스 javadoc 이 이 회귀를 사후 기록으로 남긴다. 그리고 네 단계 순서를 계약이라고 명시한다.
1단계 : 이미 분류된 실패는 그대로 통과시킨다. 다시 번역하면 그 실패가 이미 들고 있는 attempt 와 key 와 completion 판정을 잃는다.
2단계 : 낙관적 충돌. provider 예외이고 SQLSTATE 가 없으므로 SQLSTATE 기반 번역기가 알아볼 수 없다.
3단계 : 벤더 SQLSTATE. 직렬화 실패와 데드락과 제약 계열을 덮는다.
4단계 : 나머지는 손대지 않고 반환한다. 도메인 예외나 assertion 실패나 NullPointerException 은 퍼시스턴스 실패가 아니며, 그것을 퍼시스턴스 실패로 포장하면 프로그래밍 에러가 재시도 가능한 것처럼 보인다.
모든 번역기는 한 번의 호출에서 같은 operation 과 attempt 와 elapsed 와 trace 값을 받는다. 두 번역기가 같은 시도를 다르게 서술할 수 없게 하기 위해서다.
## 검증 환경
OpenJDK : 21.0.12
Gradle : 9.0.0
Spring Boot : 4.0.8
근거 : 저장소의 javadoc 이 사후 기록으로 남긴 회귀
## 재현 조건
이 회귀 자체는 저장소가 이미 고쳤고, 그 기록이 코드 주석에 남아 있다. 현재 형태를 확인하는 절차는 다음과 같다.
1. PersistenceFailureTranslatorChain 의 클래스 javadoc 을 읽어 네 단계 순서와 그 근거를 확인한다.
2. 사슬 구현이 그 순서대로 실행되는지 확인한다.
3. 재시도 코디네이터가 catch 하는 예외 타입을 확인한다.
## 본문
<!-- body:start -->
재시도 코디네이터가 `JpaPersistenceException`만 catch하는데 executor는 아무것도 번역하지 않고 템플릿을 돌렸다.
## JpaPersistenceException 참조 위치
:::evidence key="untranslated-contention-bypassed-the-retry-catch" alt="코드베이스에서 JpaPersistenceException 를 검색한 출력 37줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="JpaPersistenceException 코드베이스 검색 — 37줄 · exit 0" zoom="true"
:::
## 경합이 실제로 만들어내는 실패 셋
Hibernate `OptimisticLockException`, Spring `OptimisticLockingFailureException`, raw 직렬화/데드락 `DataAccessException` — 이것들이 번역되지 않은 채 executor를 떠나 catch를 완전히 비껴갔고 **production 경합은 재시도되지 않았다.**
## 픽스처가 초록불이던 이유
단위 픽스처는 이미 번역된 예외를 던졌다.
## 확인하지 못한 것
현재 사슬이 네 단계 순서를 지키는지는 코드로 확인했으나, 경합을 실제로 일으켜 재시도가 일어나는 것을 관측하지 않았다.
<!-- body:end -->

Some files were not shown because too many files have changed in this diff Show More