docs(clean-architecture-backend-template): 제1부가 채택한 것만 글감으로 남기고 다시 고른다
글감 1,001개 중 제1부(§3~§11) 앵커를 하나라도 가진 것은 112개뿐이었다. 나머지 889개는
제2부 모듈 분석 65편의 절 제목에서 나온 것이고, 그것이 재판정이 필요했던 이유다.
주제 44 → 16 (43개가 독자 질문 없이 있었다. 지금은 전부 있다)
글감 1,001 → 123 (제1부 앵커 112 + 제1부가 채택했는데 비어 있던 자리 11)
후보 965 → 1,088 · PENDING 905 → 0
error 3,042 → 0
내려온 889개는 후보 대장에 KEEP_IN_SSOT 로 남는다 — 버린 것이 아니라 분석에 남기고 독립
기록으로 만들지 않기로 한 것이다. 그 글감을 받치던 기록 파일 828개는 지웠다. 계약이 정본이고,
파일이 남아 있다는 이유로 계약에서 뺀 주제가 되살아나면 안 된다. 이력에는 그대로 있다 —
git checkout a0ca2bb -- <경로>.
제1부가 채택했는데 글감이 없던 자리 열하나를 채웠다: mongo high-water mark 가 재전달 이벤트를
삼킨 P1, admin plane 이 가드만 켜고 서비스는 켜지 않은 것과 그 짝인 결정, 실패 어휘 세 층과
SQLState 매트릭스 병합 규칙, 부하 아래에서만 새는 admission 경계, 발행 증거와 완료 판정의
분리, keyset·JSONB 결정 둘.
Concept 17개에 basis-version 을 채우고, 계약 제목과 기록 제목이 갈라져 있던 23건을 기록 쪽에
맞췄다. candidateScope 에 excludedAnchorPattern 을 적어 제2부 앵커만 가진 글감이 다시 올라올
수 없게 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a0ca2bb72a
commit
1f04117bbf
@@ -35,7 +35,8 @@ Concept 에 담고 `관계`로 가리킨다. `references/record-kinds.md`
|
||||
**`tech-log-tree.json` 에 노드가 없는 글은 쓰지 않는다** — 트리에 먼저 올리고, 그 노드의
|
||||
후보가 `PROMOTE` 이면서 `dispositionReview: CONFIRMED` 인지 확인한 뒤에 쓴다. `PENDING` 은
|
||||
사람이 다시 읽지 않았다는 뜻이라, 그 위에 쓴 글은 과분류를 그대로 물려받는다. 계약 밖에서 쓴
|
||||
기록은 색인에 `unlisted` 로 남는다.
|
||||
기록은 색인에 `unlisted` 로 남는다. 그 노드의 `ssot-assets`·`ssot-evidence` 도 함께 본다 —
|
||||
SSOT 가 이미 그린 그림과 이미 돌린 측정 가운데 이 글감에 배정된 것이 거기 적혀 있다.
|
||||
1. **종류 선택** — 위 표. 애매하면 "재현했나"를 묻는다.
|
||||
2. **칸 채우기** — 칸과 게시 조건은 `references/record-kinds.md`.
|
||||
3. **본문 작성**(Case·Concept) — **종류마다 무엇을 어떤 순서로 쓰는지는
|
||||
@@ -45,9 +46,14 @@ Concept 에 담고 `관계`로 가리킨다. `references/record-kinds.md`
|
||||
남겨 두고 나중에 걷어내는 순서가 아니다. **문체를 손보기 전에 문장을 고른다** — 설명이 끝난
|
||||
뒤에 붙은 평가·예고·되풀이·독자 오해 가정을 먼저 뺀다(`ai-tells.md` 첫 절). 문체 규칙의
|
||||
정본은 `ai-tells.md` 다.
|
||||
순서·경계·상태 전이처럼 문장만으로 따라가기 어려운 관계가 있으면 그때
|
||||
`technical-visualizer` 로 그림을 만든다. 손으로 SVG 를 그리지 않고, 모든 글에 그림을 만들지도
|
||||
않는다.
|
||||
순서·경계·상태 전이처럼 문장만으로 따라가기 어려운 관계가 있으면 **`final/assets/` 에 그
|
||||
그림이 이미 있는지부터 본다.** SSOT 를 만들 때 그려 둔 것이 있고 계약의 `ssot-assets` 가 이
|
||||
글감에 배정해 두었으면 그것을 쓴다 — `final/assets/tech-log-studio/` 로 복사하고 기록의
|
||||
`assets` 가 그 사본을 가리킨다. **없을 때만** `technical-visualizer` 로 새로 만든다. 손으로
|
||||
SVG 를 그리지 않고, 모든 글에 그림을 만들지도 않는다.
|
||||
인용할 측정도 같다. `final/evidence/` 에 있는 원문을 가리키고 같은 것을 다시 돌리지 않는다.
|
||||
**그림과 측정을 그 자리에서 만들어 내기 전에 SSOT 가 이미 가진 것을 먼저 찾는다** — keycloak
|
||||
에서는 그러지 않아 정본까지 갖춘 그림 13 장이 남고 이름이 다른 그림 5 장이 새로 만들어졌다.
|
||||
4. **검사** — 셋 다 돌린다. 파서와 문장과 증빙은 각각 다른 것을 본다.
|
||||
- `scripts/check_body.mjs` — Case 본문이 Studio 파서를 통과하는지. 같은 파서를 그대로 부른다.
|
||||
- `rewriting-technical-prose-naturally/scripts/check_prose.mjs` — 문장 규범. **error 0 이 될 때까지 고친다.**
|
||||
@@ -116,6 +122,8 @@ SSOT 에 없는데 필요한 인용이라면 순서가 반대다 — `final/docu
|
||||
| 문서마다 같은 문형·같은 길이 | `references/ai-tells.md` |
|
||||
| 표를 `:::table`로 감쌈 | 그냥 파이프로 쓴다 |
|
||||
| `` | Asset으로 올려 `/api/v1/public/media/…` |
|
||||
| SSOT 에 있는 그림을 두고 새로 그림 | `final/assets/` 를 먼저 본다. `ssot-assets` 가 배정한 것을 쓴다 |
|
||||
| SSOT 에 있는 측정을 두고 다시 돌림 | `final/evidence/` 의 원문을 가리킨다 |
|
||||
| Decision에 근거 없음 | 관계 1개 이상 연결 |
|
||||
| 측정 안 한 검증일 | 비워 둔다 |
|
||||
| 설명 직후에 「~증거다」「~가 아니다」로 평가 | 지운다. 앞 문장이 사실을 말했으면 거기서 끝낸다 |
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|---|---|
|
||||
| 코드·설정·실행 증거 | 사실의 근거 |
|
||||
| `final/document.md` | **글감 범위의 SSOT** — 후보를 발견하는 유일한 입력 |
|
||||
| `final/assets/` · `final/.techviz/` | 이미 그린 그림과 그 정본. 새 후보를 내지 않고 글감에 배정된다 |
|
||||
| `final/evidence/` | 이미 실행한 측정의 원문. 마찬가지로 배정된다 |
|
||||
| `analysis/**/*.md` | final이 이미 채택한 주장을 상세히 확인하는 보조 근거 |
|
||||
| `tech-log-tree.json` | 사람이 고른 글감. 분해 계약이자 색인이고 이 파일이 정본이다 |
|
||||
|
||||
@@ -36,6 +38,37 @@
|
||||
|
||||
범위 밖의 앵커는 후보가 아니라 근거다. 제1부에서 나온 글감의 `source`로 건다.
|
||||
|
||||
## 그림과 증거는 후보가 아니라 배정 대상이다
|
||||
|
||||
`final/assets/`의 그림과 `final/evidence/`의 측정은 글감을 새로 만들지 않는다. 이미 정해진
|
||||
글감에 붙는다. 그래서 처분을 매기는 자리가 아니라 **배정하는 자리**이고, 계약의
|
||||
`ssot-assets`·`ssot-evidence`가 그 자리다.
|
||||
|
||||
글감을 다 고른 뒤 두 폴더를 한 번 훑는다. 물음은 하나다.
|
||||
|
||||
> **이 그림이나 이 측정은 어느 글감의 것인가. 붙을 글감이 없으면 왜 없는가.**
|
||||
|
||||
```json
|
||||
"ssot-assets": ["ap3-bff-session-flow"],
|
||||
"ssot-evidence": ["raw/explain/l3-cartesian-join-plan.txt"]
|
||||
```
|
||||
|
||||
배정한 것은 기록의 `assets`·`evidence`가 실제로 가리켜야 한다. 배정해 놓고 쓰지 않으면
|
||||
`verify-tech-log-tree.py`가 error로 센다.
|
||||
|
||||
**배정하지 않으면 글을 쓸 때 같은 그림을 새로 그린다.** keycloak이 그렇게 됐다. SSOT에
|
||||
`ap3-bff-session-flow`, `ap4-edge-forward-auth-flow`를 포함한 그림 13장이 `.techviz` 정본까지
|
||||
갖춘 채 있었는데, 기록 24편은 그중 한 장도 가리키지 않고 이름이 다른 그림 5장을 새로 만들어
|
||||
썼다. 새로 만든 5장에는 정본이 없어서 고칠 수도 없다.
|
||||
|
||||
붙을 글감이 없는 그림도 있다. 패턴 넷을 나란히 놓고 비교하는 그림은 Reference에 붙어야 맞는데
|
||||
Reference에는 본문이 없다. 그런 그림은 그대로 두고, 왜 두는지 계약에 적는다 — 「Reference에만
|
||||
쓸 자리가 있어 본문 있는 종류에 담지 못한다」처럼. `verify-project-layout.py`가 「기록이 쓰지
|
||||
않는 SSOT 그림」으로 세므로, 센 숫자가 설명되지 않은 채 남지 않게 한다.
|
||||
|
||||
증거도 같다. 재료로만 쓰고 인용하지 않기로 한 측정은 정상이다. 「기록이 인용하지 않는 raw 증거」가
|
||||
전부 설명되는지만 본다.
|
||||
|
||||
## 왜 먼저 나누는가
|
||||
|
||||
긴 글을 앞에서부터 잘라 기록으로 만들면 절 하나가 기록 하나가 된다. 그러면 Case의 칸도
|
||||
|
||||
@@ -127,4 +127,6 @@
|
||||
- [ ] Decision 에 근거가 하나 이상 있고, 무엇을 보고 정했는지가 적혀 있는가
|
||||
- [ ] 지어낸 경험·실패·동기·감정이 없는가
|
||||
- [ ] 그림이 실제 asset 파일을 가리키고, 있어야 할 이유가 있는가
|
||||
- [ ] `final/assets/` 에 이미 있는 그림을 두고 같은 것을 새로 그리지 않았는가
|
||||
- [ ] 계약이 `ssot-assets`·`ssot-evidence` 로 배정한 것을 기록이 가리키는가
|
||||
- [ ] 그림이 관측하지 않은 사건을 만들어 내지 않았는가
|
||||
|
||||
@@ -83,7 +83,55 @@ whose candidate is `PROMOTE` and `CONFIRMED`.
|
||||
`readiness` · `source` · `code` · `evidence` · `classification` · `relations` and the rest
|
||||
of each kind's fields are written by a person. `build-tech-log-tree.py` never touches them.
|
||||
It refreshes only what it can read from the record files — `file`, `publication`, `status`,
|
||||
`studioId`, `assets`, `evidenceFiles` — and lists records that have no node in `unlisted`.
|
||||
`studioId`, `assets`, `assetFiles`, `evidenceFiles` — and lists records that have no node in
|
||||
`unlisted`.
|
||||
|
||||
### `ssot-assets` · `ssot-evidence`
|
||||
|
||||
The SSOT is not only `final/document.md`. `final/assets/` holds diagrams that were already
|
||||
drawn, each with its canonical `final/.techviz/<name>/`, and `final/evidence/` holds
|
||||
measurements that were already run. Neither produces candidates — both are **assigned** to
|
||||
candidates that already exist, and these two fields hold the assignment.
|
||||
|
||||
```json
|
||||
"ssot-assets": ["ap3-bff-session-flow"],
|
||||
"ssot-evidence": ["raw/explain/l3-cartesian-join-plan.txt"]
|
||||
```
|
||||
|
||||
`ssot-assets` names diagrams by file stem; the file must exist somewhere under
|
||||
`final/assets/`. `ssot-evidence` takes paths relative to `final/evidence/`. Both are
|
||||
written by a person and both are optional — a node that needs no picture and cites no
|
||||
measurement leaves them out.
|
||||
|
||||
What they are not optional about is follow-through. Once a node is assigned a diagram and
|
||||
its record is written, the record's `assets` must point at that file and its `evidence` at
|
||||
that path; `verify-tech-log-tree.py` reports the gap as an error. Assigning and then not
|
||||
using is the failure these fields exist to catch — without them a writer draws the picture
|
||||
again instead of finding the one that is already there.
|
||||
|
||||
`verify-project-layout.py` counts the other direction: SSOT diagrams and raw evidence that
|
||||
no record cites at all. Some of that count is correct — a four-pattern comparison diagram
|
||||
belongs to a Reference, and Reference has no body to render it in. The count is meant to be
|
||||
explained, not driven to zero.
|
||||
|
||||
### `assetLedger`
|
||||
|
||||
That explanation lives at the top level of the index, next to `candidateScope`. It names
|
||||
what was assigned and, for everything left over, why it is left over.
|
||||
|
||||
```json
|
||||
"assetLedger": {
|
||||
"assigned": ["ap3-bff-session-flow", "ap3-csrf-boundary"],
|
||||
"unassigned": [
|
||||
{"asset": ["four-pattern-request-boundaries"],
|
||||
"reason": "네 패턴을 비교하는 그림이라 붙을 자리가 Reference 인데 Reference 에는 본문이 없다"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A diagram left out for a reason is a normal outcome, the same way `KEEP_IN_SSOT` is. What
|
||||
is not normal is a leftover nobody looked at — that is the state where the next writer
|
||||
draws the picture again. Write the ledger when the count first appears, not when it grows.
|
||||
|
||||
### Case
|
||||
|
||||
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a16#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 -->
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a16#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 -->
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a14#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 -->
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a14#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="분석 문서 final/document.md#a14 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a14 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
<!-- body:end -->
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a14#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 -->
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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 -->
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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="분석 문서 final/document.md#a10 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a10 발췌 — 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 -->
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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 -->
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a08#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 -->
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a11#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 -->
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a11#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 -->
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a11#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 -->
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a09#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 1–1000, timeout 5분 이내를 강제한다. legacy runtime은 `AutoCloseable` holder로 감싸 S3 client 수명을 정확히 소유하고, `@Bean(destroyMethod = "close")`로 등록된다.
|
||||
|
||||
<!-- body:end -->
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a03#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 -->
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-advanced-bootstrap#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 -->
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-advanced-streaming#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 -->
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-admin-api#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 -->
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-policy#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 -->
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-schema-avro#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 -->
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-spring-cloud-stream-bridge#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 -->
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a02#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 -->
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a06#L1069 이다. 등급은 P2 다. 주석의 전제가 더 이상 사실이 아니라는 판정, 형제 불리언과의 대비표, 검증기 분기가 도달 불가라는 사실, 그리고 실패가 장애 조치 런북으로 분류된다는 서술이 그 절에 있다.
|
||||
- 같은 문서 `#L1015` 는 소비자가 도는 조건을 포크가 다섯을 공급하는 경우로 한정한다. 이 저장소 안에서는 그 다섯의 구현이 전부 시험 픽스처다.
|
||||
- 같은 문서 `#L156` 은 같은 코드를 P3 으로 판정하면서 변경 스트림 실행체가 애초에 출하되지 않는다는 것을 근거로 든다. 이 리비전에서 그 근거가 성립하지 않으므로 그 절에 붙은 "현재 잘못된 동작을 만들지는 않는다"는 유지될 수 없고, 실질 등급은 이 절의 P2 로 흡수된다.
|
||||
- 설정 빈이 속성을 받고도 거짓을 보고한다는 것, 그 두 경우의 빈 집합이 같다는 것, 검사 빈 자체에 조건이 있다는 것, 그리고 런북 전체에 단독 서버·오플로그·해당 오류 코드가 없다는 것은 이 기록에서 확인했다.
|
||||
---
|
||||
|
||||
# 플래그는 고정 거짓이라 능력 검사를 끄지만, 조립 조건이 아니라서 소비자 빈은 그대로 생성된다
|
||||
|
||||
설정 주석은 드라이버 쪽 구현이 출하되지 않아 빈이 0 이라는 것을 근거로 플래그를 고정 거짓으로 만든다. 이 리비전에서 그 구현에는 빈 선언이 있고, 배포가 다섯을 공급하면 소비자도 선다. 조립 조건 어디에도 그 플래그는 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **거부라고 적힌 처리가 폐기이고, 그 값을 읽는 시작 검사는 켤 방법이 없다**
|
||||
같은 플래그를 다른 절에서 다룬 기록이다. 거부와 폐기의 차이는 그 기록이 다룬다.
|
||||
- **하위 시스템 전체가 미배선인데 그것을 켜는 플래그는 시작 검사를 수행한다**
|
||||
형제 불리언 쪽 기록이다. 트랜잭션 계수와 그 결과는 그 기록이 다룬다.
|
||||
- **@Bean이 있다는 것은 조립 증거가 아니다**
|
||||
플래그와 조립의 관계를 확인하는 규칙이다.
|
||||
|
||||
## 문제
|
||||
|
||||
시작 검증기에는 인접한 두 분기가 있다. 하나는 트랜잭션이 켜져 있는데 토폴로지가 지원하지 않으면 던지고, 다른 하나는 변경 스트림에 대해 같은 일을 한다. 두 좌항은 같은 설정 타입의 형제 불리언이고 자동 구성의 인접한 두 줄이 넘긴다.
|
||||
|
||||
두 불리언은 같은 검증기에서 서로 다르게 끝난다. 앞의 값은 배포가 넣은 대로 도착하고, 뒤의 값은 컴팩트 생성자가 이미 거짓으로 바꾼 뒤다.
|
||||
|
||||
## 결론
|
||||
|
||||
플래그가 고정 거짓이므로 검증기의 변경 스트림 분기는 실행되지 않는다. 조립은 그와 무관하게 진행된다. 소비자 빈의 조건은 배포가 공급해야 하는 타입 다섯이고, 그 목록에 이 플래그는 없다. 자동 구성 파일 전체에서 그 이름이 나오는 줄은 검증기 인자 하나뿐이다.
|
||||
|
||||
전체 자동 구성을 올린 스프링 컨텍스트로 확인했다. 설정 빈은 change-streams=true 를 받고도 거짓을 보고하고, 그 두 경우의 빈 집합이 같다. 모듈 opt-in 을 켜고 리액티브 템플릿이 있으면 드라이버 쪽 구현 빈은 만들어지고 소비자 빈은 만들어지지 않는다. 다섯을 함께 넣으면 소비자 빈도 만들어진다. opt-in 을 켜지 않으면 셋 다 없다.
|
||||
|
||||
주석은 이 코드가 있기 전 상태를 서술한다. 빈이 0 이고 스레드가 0 이라는 근거는 이 리비전에서 성립하지 않는다.
|
||||
|
||||
남는 것은 능력 검사만 꺼진 상태다. 다만 그 검사가 열리는 조건이 따로 있다. 검사 빈은 토폴로지 프로브를 조건으로 걸고, 보안 프로파일과 관리 자격 참조와 스키마 버전 범위 중 하나라도 없으면 부분 검증 대신 예외로 닫는다. 그리고 검증기는 선언 토폴로지와 실제를 능력 검사보다 먼저 대조한다. 변경 스트림 분기는 그 둘을 통과한 배포에서만 차례를 얻는데, 그 차례가 와도 좌항이 거짓이다.
|
||||
|
||||
그 다음 실패는 커서를 여는 시점의 드라이버 오류다. 복구 정책은 서버 코드가 이력 소실이 아니고 재개 가능 라벨도 아니면 실패로 확정하며 장애 조치 런북을 붙인다. 런북 어디를 봐도 그 세 낱말이 없다. 이 연쇄는 형제 기록이 오플로그 없는 서버에서 실행으로 확인했다.
|
||||
|
||||
수정은 셋 중 하나다. 플래그를 되살려 조립 조건으로 쓰거나, 소비자 빈이 설 때 능력을 기동에서 확인하거나, 최소한 주석을 현재 사실로 고치는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12
|
||||
확인 방식 : 전체 자동 구성을 올린 스프링 컨텍스트에서 빈 집합과 바인딩 값 비교, 코드베이스 정적 검색
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. 시작 검증기의 인접한 두 분기와 그 좌항을 넘기는 두 줄을 읽는다.
|
||||
2. 그 검사를 만드는 빈의 조건과 입력이 빠졌을 때의 처리를 읽는다.
|
||||
3. 컴팩트 생성자의 플래그 강제와 그 주석을 읽는다.
|
||||
4. 소비자 빈에 붙은 조건과 자동 구성 파일에서 그 플래그가 나오는 줄 수를 확인한다.
|
||||
5. MongoPlatformAutoConfiguration 전체와 블로킹·리액티브 템플릿 빈을 등록한 컨텍스트를 ca-skeleton.persistence-mongo.enabled=true 로 띄우고 두 빈과 설정 빈의 유무, 그리고 바인딩된 플래그 값을 읽는다.
|
||||
6. 조건 다섯을 함께 넣고 같은 것을 읽는다.
|
||||
7. 두 경우를 change-streams=true 를 넣은 상태에서 반복한다.
|
||||
8. opt-in 을 켜지 않은 경우와 리액티브 템플릿이 없는 경우를 각각 읽는다.
|
||||
9. 복구 정책의 분류와 그것이 붙이는 런북 전체를 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
시작 검증기에는 능력을 요구하는 분기가 둘 있고, 두 좌항은 같은 설정 타입의 형제 불리언이다.
|
||||
|
||||
## 두 분기가 읽는 값이 오는 자리
|
||||
|
||||
:::evidence key="a06-f018-changestreams-false" alt="시작 검증기의 인접한 두 능력 분기와 그 좌항을 넘기는 자동 구성의 두 줄, 그 검사를 만드는 빈의 조건과 입력이 빠졌을 때 닫는 처리, 컴팩트 생성자의 플래그 강제와 그 주석, 자동 구성 파일에서 그 플래그가 나오는 줄 수, 소비자 빈에 붙은 조건 전체, 복구 정책의 분류 메서드, 그리고 그것이 붙이는 런북의 증상 절 전체와 그 런북에서 단독 서버·오플로그·해당 오류 코드가 나오는 줄 수를 출력한 터미널 기록." caption="두 분기의 좌항은 형제 불리언이고 인접한 두 줄이 넘김 · 검사 빈은 토폴로지 프로브 조건이고 입력이 빠지면 닫음 · 변경 스트림은 생성자에서 고정 거짓이고 자동 구성 파일에 그 이름이 나오는 줄은 1 · 소비자 조건은 @ConditionalOnMissingBean 과 타입 다섯 · 실패는 FAILED 와 장애 조치 런북 · 그 런북에 단독 서버·오플로그·40573 은 0줄 — 91줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
```java
|
||||
if (transactionsEnabled && !capabilities.isStable(MongoCapability.TRANSACTION)) {
|
||||
...
|
||||
if (changeStreamsEnabled && !capabilities.isStable(MongoCapability.CHANGE_STREAM)) {
|
||||
```
|
||||
|
||||
자동 구성의 인접한 두 줄이 그 좌항을 넘긴다.
|
||||
|
||||
```java
|
||||
properties.transactions(),
|
||||
properties.changeStreams(),
|
||||
```
|
||||
|
||||
앞의 값은 배포가 넣은 대로 도착한다. 그 값에서 무슨 일이 벌어지는지는 형제 기록이 다룬다. 여기서는 뒤의 값이 이미 거짓이라는 것과, 그런데도 조립은 진행된다는 것만 본다.
|
||||
|
||||
## 조립 조건
|
||||
|
||||
주석이 근거로 든 것은 빈이 0 이라는 사실이다.
|
||||
|
||||
```java
|
||||
// Experimental, and therefore not a switch (MNG-INT-003). The driver-side source — watch,
|
||||
// resumeAfter/startAfter, cursor lifetime, reconnection — is not shipped; what exists is policy
|
||||
// and value objects that do not add up to a running consumer. Accepting the flag and ignoring …
|
||||
...
|
||||
changeStreams = false;
|
||||
```
|
||||
|
||||
소비자 빈에 붙은 조건은 `@ConditionalOnMissingBean` 과 타입 다섯의 `@ConditionalOnBean` 이다.
|
||||
|
||||
```java
|
||||
@org.springframework.boot.autoconfigure.condition.ConditionalOnBean({
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamSubscription.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeTokenCodec.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeProjector.class,
|
||||
dev.caskeleton.adapter.outbound.mongo.changestream.projector.MongoChangeDeduplicationStore
|
||||
.class
|
||||
})
|
||||
```
|
||||
|
||||
다섯 다 배포가 공급해야 하는 타입이다. 이 플래그는 목록에 없고, 자동 구성 파일 전체에서 그 이름이 나오는 줄은 검증기 인자 하나뿐이다.
|
||||
|
||||
## 컨텍스트를 띄운 결과
|
||||
|
||||
:::evidence key="a06-f018-changestreams-false-wiring" alt="전체 자동 구성을 등록한 스프링 컨텍스트를 모듈 opt-in 없이, opt-in 과 리액티브 템플릿만으로, opt-in 과 배포가 공급해야 하는 다섯을 함께, 그리고 opt-in 과 리액티브 템플릿 없이 각각 띄워 드라이버 쪽 구현 빈과 소비자 빈과 설정 빈의 유무, 그리고 바인딩된 플래그 값을 change-streams 를 넣지 않은 경우와 넣은 경우에 대해 읽은 터미널 기록." caption="opt-in 없으면 셋 다 없음 · opt-in 과 템플릿이면 드라이버 쪽만 섬 · 다섯을 넣으면 소비자도 섬 · 설정 빈은 change-streams=true 를 받고도 changeStreams()=false · 두 경우의 빈 집합이 같음 — 13줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`MongoPlatformAutoConfiguration` 전체를 등록하고 블로킹·리액티브 템플릿을 넣어 띄웠다.
|
||||
|
||||
```text
|
||||
[opt-in, 리액티브 템플릿만]
|
||||
기본 드라이버쪽=있음 소비자=없음 설정빈=있음 changeStreams()=false
|
||||
change-streams=true 드라이버쪽=있음 소비자=없음 설정빈=있음 changeStreams()=false
|
||||
|
||||
[opt-in, 배포가 공급해야 하는 다섯을 함께]
|
||||
기본 드라이버쪽=있음 소비자=있음 설정빈=있음 changeStreams()=false
|
||||
change-streams=true 드라이버쪽=있음 소비자=있음 설정빈=있음 changeStreams()=false
|
||||
```
|
||||
|
||||
설정 빈은 컨텍스트에 있고 속성을 받는다. 받고도 거짓을 보고하므로 조립 조건에 닿기 전에 이미 값이 정해져 있다. 소비자가 서는 조건은 다섯을 공급했는지 하나다.
|
||||
|
||||
모듈 opt-in 이 없으면 세 빈이 모두 만들어지지 않고, opt-in 이 있어도 리액티브 템플릿이 없으면 두 빈이 만들어지지 않는다.
|
||||
|
||||
```text
|
||||
[모듈 opt-in 없이]
|
||||
기본 드라이버쪽=없음 소비자=없음 설정빈=없음
|
||||
|
||||
[opt-in, 리액티브 템플릿 없이]
|
||||
기본 드라이버쪽=없음 소비자=없음 설정빈=있음 changeStreams()=false
|
||||
```
|
||||
|
||||
## 능력 검사가 열리는 조건
|
||||
|
||||
검사가 꺼진 것과 검사가 애초에 만들어지지 않는 것은 다르다. 검사 빈부터 조건이 있다.
|
||||
|
||||
```java
|
||||
@Bean
|
||||
@ConditionalOnBean(MongoTopologyProbe.class)
|
||||
public InitializingBean mongoPlatformStartupCheck(
|
||||
...
|
||||
if (security == null || admin == null || versions == null) {
|
||||
// Fail closed rather than validate a subset. A partial startup check reports success for
|
||||
// the parts nobody supplied, which is the shape the missing wiring already had.
|
||||
```
|
||||
|
||||
토폴로지 프로브가 있어야 만들어지고, 보안 프로파일과 관리 자격 참조와 스키마 버전 범위가 다 있어야 돈다. 그리고 검증기는 능력 검사보다 먼저 선언 토폴로지와 실제를 대조한다. 그 둘을 통과한 배포에서만 변경 스트림 분기가 자기 차례를 얻고, 그 차례에서 좌항이 거짓이다.
|
||||
|
||||
## 그 다음 실패가 가는 곳
|
||||
|
||||
```java
|
||||
if (failure.hasLabel("ResumableChangeStreamError")) {
|
||||
return MongoChangeStreamRecoveryDecision.resume();
|
||||
}
|
||||
return MongoChangeStreamRecoveryDecision.halt(MongoChangeStreamState.FAILED, FAILURE_RUNBOOK);
|
||||
...
|
||||
private static boolean isHistoryLost(int serverCode) {
|
||||
return serverCode == 286 || serverCode == 280;
|
||||
```
|
||||
|
||||
토폴로지가 복제 셋이 아니라는 오류는 286 도 280 도 아니고 재개 가능 라벨도 없으므로 셋째 갈래다. 붙는 런북의 증상 절은 네 항목이고 전부 프라이머리 선출과 서버 선택 지연이다.
|
||||
|
||||
```text
|
||||
- `MongoServerSelectionException` / `MongoConnectionException` spike, then recovery within seconds.
|
||||
- `MongoSdamObservationListener` reports a topology change (primary removed, new primary elected).
|
||||
- `MongoPoolObservationListener` shows checkout wait times rising while server-side command duration
|
||||
stays flat — the wait is topology, not query cost.
|
||||
- Latency spike on writes with no corresponding rise in read latency.
|
||||
```
|
||||
|
||||
증상 절뿐 아니라 그 런북 전체에서 단독 서버도 오플로그도 해당 오류 코드도 나오지 않는다. 이 연쇄를 실제 서버에서 이은 것은 형제 기록이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
다섯을 공급한 포크의 배포를 오플로그 없는 토폴로지에 올려 기동 통과와 커서 열기 실패를 이어서 재현하지는 않았다. 그 연쇄는 형제 기록이 단독 서버에서 실행으로 확인했다. 여기서는 조립 조건과 검사가 열리는 조건까지 확인했다.
|
||||
|
||||
<!-- body:end -->
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a05 §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="분석 문서 final/document.md#a05 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a05 발췌 — 16줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 리포트와 컨텍스트가 어긋났다
|
||||
|
||||
capability 리포트는 transaction retry·completion evidence·observability를 Stable로 나열했고 **돌고 있는 컨텍스트에는 그중 아무것도 없었다.** 개발자가 재시도되지 않는 재시도에 의존하는 코드를 배포할 수 있었다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
당시 능력 리포트의 출력을 직접 보지 않았다. 이 기록은 저장소가 javadoc 에 남긴 사후 기록에 근거한다.
|
||||
|
||||
없음 — 수정 후 형태를 코드로 확인했다
|
||||
|
||||
<!-- body:end -->
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a05 §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="분석 문서 final/document.md#a05 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a05 발췌 — 16줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 여덟 빈이 조용히 사라졌다
|
||||
|
||||
아무것도 그중 어느 것에도 의존하지 않아 아무것도 보고하지 않았다.
|
||||
|
||||
## 드러난 시점
|
||||
|
||||
datasource validator가 caller에 배선되고 Compose 레인이 "No qualifying bean"이라고 답했을 때다. 같은 함정을 피하려고 루트의 검사가 validator를 주입받지 않고 직접 생성한다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
현재 리비전의 다른 조건부 빈들이 각각 어느 시점에 평가되는지 런타임에서 확인하지 않았다. debug 부팅의 조건 평가 리포트가 그것을 답한다.
|
||||
|
||||
현재 리비전에서 재발하지 않는지 ConditionEvaluationReport로 확인하지 않았다
|
||||
|
||||
<!-- body:end -->
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a05 §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 -->
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a05 §14.4 이다.
|
||||
---
|
||||
|
||||
# 조건부 빈의 평가 시점 — 파싱 시점과 등록 시점
|
||||
|
||||
같은 `@ConditionalOnBean`이라도 그 클래스가 자동설정으로 등록되는지 `@Import`로 들어오는지에 따라 평가 시점이 다르다. 그 차이가 조건을 항상 거짓으로 만들 수 있다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **ConditionalOnBean(DataSource)이 클래스 파싱 시점에 평가되어 여덟 빈이 사라졌다**
|
||||
이 개념이 실제로 문제가 된 사례다.
|
||||
- **ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다**
|
||||
이 개념에서 나온 확인 규칙이다.
|
||||
- **ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다**
|
||||
현재 리비전에 대한 미해결 질문이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`@ConditionalOnBean`은 그 클래스가 **언제 평가되는가**에 따라 답이 달라진다. `@AutoConfiguration`으로 등록되면 다른 자동설정 이후에 평가되지만, plain `@Configuration`이 `@Import`로 들어오면 **클래스가 파싱되는 동안 — 대상 빈 정의가 존재하기 전에** 평가된다.
|
||||
|
||||
## 등록 방식이 평가 시점을 정한다
|
||||
|
||||
:::evidence key="when-conditions-are-evaluated" alt="분석 문서 final/document.md#a05 에서 이 기록의 근거 절을 그대로 잘라낸 16줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a05 발췌 — 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 -->
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
kind: QUESTION
|
||||
slug: conditional-evaluation-order-unverified
|
||||
title: '@ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다'
|
||||
title: @ConditionalOnBean 사슬의 실제 평가 순서를 확인하지 않았다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: a-bean-is-not-composition-evidence
|
||||
title: '@Bean이 있다는 것은 조립 증거가 아니다'
|
||||
title: @Bean이 있다는 것은 조립 증거가 아니다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: conditionalonbean-must-be-satisfiable
|
||||
title: '@ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다'
|
||||
title: @ConditionalOnBean은 조건이 만족될 수 있는지까지 확인해야 한다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
---
|
||||
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가 있나로 묻는다**
|
||||
이 규칙을 채택한 결정이다.
|
||||
- **꺼짐은 조건의 반복이 아니라 구조여야 한다**
|
||||
같은 목표의 짝 규칙이다.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: off-must-be-structural
|
||||
title: '"꺼짐"은 조건의 반복이 아니라 구조여야 한다'
|
||||
title: "꺼짐"은 조건의 반복이 아니라 구조여야 한다
|
||||
topic: assembly-ownership
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
---
|
||||
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 -->
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
---
|
||||
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 -->
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a05 §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="분석 문서 final/document.md#a05 에서 이 기록의 근거 절을 그대로 잘라낸 12줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a05 발췌 — 12줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 각 순서가 막는 것
|
||||
|
||||
`MAX_ENCODED_LENGTH(4096)` 검사가 첫 줄에 없으면 decode가 caller가 보낸 크기만큼 할당한다. MAC 길이 확인이 없으면 `MessageDigest.isEqual`의 상수 시간 보장이 깨진다. 서명 검증 전에 파싱하면 서명 없는 토큰이 애플리케이션 JSON 파서에 도달한다.
|
||||
|
||||
## MAC이 버전과 payload를 함께 덮는다
|
||||
|
||||
prefix 재작성으로 옛 포맷으로 다운그레이드하는 것을 막는다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
타이밍 공격이나 크기 공격을 실제로 시도해 보지 않았다. 이 기록은 각 단계가 무엇을 막도록 배치되었는지에 대한 것이며, 그 방어의 실효성을 측정한 것은 아니다.
|
||||
|
||||
없음
|
||||
|
||||
<!-- body:end -->
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
---
|
||||
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는 바인딩한다**
|
||||
거절 대상을 정하는 규칙이다.
|
||||
|
||||
-224
@@ -1,224 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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 -->
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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 -->
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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 -->
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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 -->
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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="분석 문서 final/document.md#a10 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a10 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 데이터 위험은 없다
|
||||
|
||||
없는 타입은 잘못된 답을 주지 않는다. P2.
|
||||
|
||||
## 위험은 API 계약의 신뢰다
|
||||
|
||||
이 leaf의 공개 표면 중 가장 먼저 읽히는 두 타입이 구현되지 않은 상태이고, 대칭 test가 그 사실을 가리지 못한다 — 인터페이스끼리만 비교하기 때문이다. sub-scope 01의 §5와 방향이 반대이면서 원인은 같다: 조립이 절반이다.
|
||||
|
||||
## 수정
|
||||
|
||||
이미 존재하는 26개 구현을 묶는 `LettuceRedisOperations` / `LettuceReactiveRedisOperations` 두 클래스를 추가하고, `ApiParityTest`에 "두 facade는 구현을 가진다"는 검사를 더하는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
두 진입점이 과거에 구현체를 가졌는지 이력에서 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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 -->
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a10#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 -->
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a14#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="분석 문서 final/document.md#a14 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a14 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
<!-- body:end -->
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a14#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 -->
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a09#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`). 그리고 도중에 실패하면 이미 만든 것을 **역순으로** 닫는다(`:53–56`). `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 -->
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a09#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 -->
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a05#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 -->
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a18#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 -->
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a18#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 -->
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a18#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 -->
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-advanced-diagnostics#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 -->
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-kafka-share-experimental#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 -->
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-testkit#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 -->
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
---
|
||||
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 -->
|
||||
-133
@@ -1,133 +0,0 @@
|
||||
---
|
||||
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 -->
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
---
|
||||
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 -->
|
||||
-185
@@ -1,185 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 `final/document.md#a05` §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 -->
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-core-api §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="분석 문서 final/document.md#a19-messaging-core-api 에서 이 기록의 근거 절을 그대로 잘라낸 18줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a19-messaging-core-api 발췌 — 18줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 틀린 문단이 권위로 지목하는 문서는 이미 정정을 마쳤다
|
||||
|
||||
`src/messaging/CLAUDE.md` 는 같은 사실을 고쳤고 "정확한 목록은 registry 가 소유하므로 여기서 세지 않는다 — 세는 순간 다시 drift 한다" 는 결론까지 적었다. 그 결론이 지원 매트릭스에는 적용되지 않았다.
|
||||
|
||||
## 운영자가 문서에서 알 수 없는 것
|
||||
|
||||
배포 아티팩트가 실제로 이 리프들을 싣고 `app.messaging.enabled` 하나로 켜진다는 사실이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
없다. 레지스트리와 두 문서를 전수 대조했다.
|
||||
|
||||
<!-- body:end -->
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-pulsar-experimental §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 -->
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
---
|
||||
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:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-core-api §4.12 · final/document.md#a99 §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 -->
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
---
|
||||
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 의 지연 배달이 참인데, 그 지연을 만드는 토폴로지가 조립되지 않는다.
|
||||
|
||||
셋 다 형태가 같다. 조건을 아는 코드가 같은 리프에 있고, 상수가 그것을 참조하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **능력 선언의 세 출처와 그것이 파생되지 않을 때**
|
||||
이 규칙이 나온 구조다.
|
||||
- **능력 상수가 프로파일 조건보다 오래 살아서 중복 제거 없는 목적지가 가드를 통과한다**
|
||||
이 규칙을 어긴 사례 중 가장 무거운 것이다.
|
||||
- **브로커 트랜잭션을 무조건 참으로 선언하고, 그 조건을 검사하는 검증기는 기동 시 돌지 않는다**
|
||||
같은 규칙을 어기면서 검증기까지 함께 빠진 사례다.
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
---
|
||||
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`를 먼저 읽고 심각도를 정한다**
|
||||
같은 계열의 판정 순서 규칙이다.
|
||||
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-inbound-graphql-c03
|
||||
title: 미배선 인터셉터는 누락이 아니라 중복이다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-inbound-graphql-c03
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-inbound-graphql-c03
|
||||
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c03.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-inbound-graphql-c03.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a16#L336 이다.
|
||||
module: adapter-inbound-graphql
|
||||
---
|
||||
|
||||
# 미배선 인터셉터는 누락이 아니라 중복이다
|
||||
|
||||
`GraphQlOperationNameInterceptor.apply(...)`(미배선)와 `runtime/GraphQlOperationSelectionHandler`(autoconf=2, 배선됨)가 같은 일을 하는데 배선된 쪽이 더 많이 한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GraphQlOperationNameInterceptor.apply(...)`(미배선)와 `runtime/GraphQlOperationSelectionHandler`(autoconf=2, 배선됨)가 같은 일을 한다. **배선된 쪽이 더 많이 한다**: 익명 연산 거부 · 다중 연산 시 `operationName` 요구 · 연산 정체성 정규화가 전부 배선된 경로에 있다.
|
||||
|
||||
## 정규화가 거부가 아니라 익명으로 떨어지는 이유
|
||||
|
||||
규칙에 근거가 붙어 있다 — "any name that cannot survive normalisation becomes the anonymous identity rather than being rejected — **a naming convention is not a reason to refuse an otherwise valid request**."
|
||||
|
||||
## GraphQlOperationNamePolicy 참조 위치
|
||||
|
||||
:::evidence key="adapter-inbound-graphql-c03" alt="코드베이스에서 GraphQlOperationNamePolicy 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlOperationNamePolicy 코드베이스 검색 — 16줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 정책 객체까지 함께 미배선이다
|
||||
|
||||
따라서 미배선 인터셉터는 **누락이 아니라 중복**이다. 다만 그것이 쓰는 `GraphQlOperationNamePolicy`(85줄, 참조자 = 인터셉터와 자기 자신뿐)도 함께 미배선이고, 배선된 핸들러는 다른 정책 객체를 쓴다. §12.2.
|
||||
|
||||
<!-- body:end -->
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-inbound-graphql-c06
|
||||
title: 매니페스트 조회를 설계했는데 단일 빈이 대신 주입된다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-inbound-graphql-c06
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-inbound-graphql-c06
|
||||
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c06.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-inbound-graphql-c06.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a16#L471 이다.
|
||||
module: adapter-inbound-graphql
|
||||
---
|
||||
|
||||
# 매니페스트 조회를 설계했는데 단일 빈이 대신 주입된다
|
||||
|
||||
설계는 `GraphQlClientPolicyManifest`에서 프로파일을 해석하는 것을 말하지만, 자동설정은 `GraphQlClientPolicy.defaults(...)` 단일 빈을 만들어 여덟 개 빈에 주입한다. 매니페스트는 만들어지지 않는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
설계는 매니페스트 조회를 말한다.
|
||||
|
||||
> `GraphQlClientPolicyManifest` — "The design keeps benchmarked limits in an environment manifest rather than in application code, so **this is the one place a profile is resolved from**. An unknown profile is a startup or request failure rather than a silent fallback to a permissive default."
|
||||
|
||||
자동설정은 단일 빈을 만든다.
|
||||
|
||||
```java
|
||||
// GraphQlPlatformAutoConfiguration:302-303
|
||||
@Bean public GraphQlClientPolicy graphQlClientPolicy(GraphQlPlatformSettings properties) {
|
||||
return GraphQlClientPolicy.defaults(...);
|
||||
}
|
||||
```
|
||||
|
||||
그리고 그 하나가 여덟 개 빈(`:123` · `:237` · `:380` · `:389` · `:397` · `:453` · `:463` …)에 주입된다. 매니페스트는 만들어지지 않는다. §16.2.
|
||||
|
||||
## GraphQlPlatformWebInterceptor 참조 위치
|
||||
|
||||
:::evidence key="adapter-inbound-graphql-c06" alt="코드베이스에서 GraphQlPlatformWebInterceptor 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlPlatformWebInterceptor 코드베이스 검색 — 10줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 프로파일은 여전히 신뢰된 경로에서 온다
|
||||
|
||||
**프로파일 자체는 신뢰된 경로에서 온다** — `GraphQlAuthenticationContextFactory:59`가 `principal.clientProfile()`을 쓰고(검증된 principal), 미인증 호출자에는 `GraphQlPlatformWebInterceptor`의 `anonymousProfile`이 붙는다. 즉 `GraphQlClientProfileResolver`가 막으려는 노출(호출자가 자기 프로파일을 지정)은 배선된 경로에서도 발생하지 않는다. 그 타입은 중복이다.
|
||||
|
||||
<!-- body:end -->
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-inbound-graphql-c09
|
||||
title: 선언한 전송 프로파일과 실제 응답을 만드는 쪽이 다르다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-inbound-graphql-c09
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-inbound-graphql-c09
|
||||
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c09.svg
|
||||
- key: adapter-inbound-graphql-c09-diagram
|
||||
file: ../../../final/assets/diagrams/adapter-inbound-graphql-c09.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-inbound-graphql-c09.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a16#L660 이다.
|
||||
module: adapter-inbound-graphql
|
||||
---
|
||||
|
||||
# 선언한 전송 프로파일과 실제 응답을 만드는 쪽이 다르다
|
||||
|
||||
`http` 패키지 19개 파일 중 자동설정이 값으로 소비하는 둘을 빼면 나머지는 실행되지 않는다. 실제로 응답을 만드는 것은 Spring GraphQL이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`http` 패키지 19개 파일 중 자동설정이 값으로 소비하는 둘(`GraphQlHttpProfile` autoconf=2, `GraphQlJsonStructurePolicy` autoconf=4)을 빼면, 나머지는 실행되지 않는다.
|
||||
|
||||
## 응답을 실제로 만드는 쪽
|
||||
|
||||
:::evidence key="adapter-inbound-graphql-c09-diagram" alt="Spring GraphQL 경계 안에 상태 코드 규칙과 미디어 타입 협상이 들어 있고 http 패키지가 경계 밖에 빗금 상자로 놓인 구조" caption="응답을 실제로 만드는 쪽" zoom="false"
|
||||
:::
|
||||
|
||||
GraphQL-over-HTTP에서 상태 코드 규칙은 미디어 타입에 달려 있다 — `application/json`은 실행 오류에도 200을, `application/graphql-response+json`은 실제 상태를 쓴다. 그 규칙을 `GraphQlHttpStatusMapper`와 `GraphQlAcceptHeader`가 담고 있고, 실제로 응답을 만드는 것은 Spring GraphQL이다.
|
||||
|
||||
## GraphQlHttpProfile 참조 위치
|
||||
|
||||
:::evidence key="adapter-inbound-graphql-c09" alt="코드베이스에서 GraphQlHttpProfile 를 검색한 출력 30줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlHttpProfile 코드베이스 검색 — 30줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 노출이 아니라 통제권의 문제다
|
||||
|
||||
Spring GraphQL 자신이 GraphQL-over-HTTP 스펙을 구현하므로 동작은 합리적이다. 잃는 것은 (a) 이 플랫폼이 선언한 프로파일(`V1`)이 실제 동작과 일치한다는 보장, (b) 사전 파싱 한계 중 봉투 검증기에만 있는 부분, (c) "새 결과 종류가 임의 상태를 갖고 한 호출 지점에 생기는 것"을 막겠다는 단일 팩토리의 목적.
|
||||
|
||||
## 운영자가 문서대로 클라이언트를 쓸 때
|
||||
|
||||
운영자가 `GraphQlPlatformConfigurationReport`(§8.1을 고쳐 발행하게 된 뒤)에서 `httpProfile=V1`을 읽고 그 프로파일 문서대로 클라이언트를 작성한다. 실제 응답 상태와 미디어 타입은 Spring GraphQL이 정하며, 두 문서가 다른 지점에서 클라이언트가 깨진다.
|
||||
|
||||
## 두 갈래 권고
|
||||
|
||||
(a) 프레임워크 전송을 정본으로 인정하고 `http` 패키지에서 전송 기계를 제거한 뒤 `GraphQlHttpProfile`을 프레임워크 동작의 서술로 좁힌다. (b) `WebGraphQlInterceptor`(`GraphQlPlatformWebInterceptor`가 이미 그 자리에 있다)에서 봉투 검증과 응답 정책을 적용해 프로파일을 실제로 강제한다. 지금은 선언과 실행이 분리돼 있다.
|
||||
|
||||
## 무엇이 미배선인가
|
||||
|
||||
`GraphQlAcceptHeader`(151), `GraphQlRequestEnvelopeValidator`(152), `GraphQlHttpResponseFactory`(84), `GraphQlMediaTypes`(100), 그리고 봉투·결과·확장 정책 타입 470줄이 실행되지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-inbound-web-c02
|
||||
title: 실패를 보여 준 적 없는 경계 테스트는 잘못된 디렉터리를 스캔한 것과 구별되지 않는다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-inbound-web-c02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-inbound-web-c02
|
||||
file: ../../../final/evidence/rendered/adapter-inbound-web-c02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-inbound-web-c02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a14#L140 이다.
|
||||
module: adapter-inbound-web
|
||||
---
|
||||
|
||||
# 실패를 보여 준 적 없는 경계 테스트는 잘못된 디렉터리를 스캔한 것과 구별되지 않는다
|
||||
|
||||
`WebModuleBoundaryTest`가 다섯 개의 긍정 규칙마다 부정 픽스처를 붙이고, 스캔이 아무것도 못 찾으면 통과가 아니라 실패하도록 만들었다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`WebModuleBoundaryTest`가 다섯 개의 긍정 규칙과 **네 개의 부정 픽스처**를 갖는다.
|
||||
|
||||
| 규칙 | 부정 픽스처 |
|
||||
|---|---|
|
||||
| 모든 프로덕션 패키지가 선언된 모듈 정체성을 가진다 | `ROOT.undeclared` 패키지를 만들어 거부되는지 확인 |
|
||||
| 선언된 모든 모듈이 트리에 존재한다 | — |
|
||||
| 모든 교차 모듈 import가 선언된 edge다 | `conditional → ratelimit` 위반을 만들어 확인 |
|
||||
| CORE 모듈은 프레임워크 자유다 | `cursor`가 `@Component`를 import하게 만들어 확인 |
|
||||
| 스캔이 아무것도 못 찾으면 통과가 아니라 실패다 | 빈 디렉터리로 `IllegalStateException` 확인 |
|
||||
|
||||
## 부정 픽스처가 있는 이유
|
||||
|
||||
"A boundary test that has never been shown to fail is indistinguishable from one that scans the wrong directory." 그리고 프로덕션 스캔에 `fileCount() > 100` 하한과 `packages()`에 특정 패키지 두 개가 있어야 한다는 확인이 함께 붙는다.
|
||||
|
||||
## WebModuleBoundaryTest 참조 위치
|
||||
|
||||
:::evidence key="adapter-inbound-web-c02" alt="코드베이스에서 WebModuleBoundaryTest 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WebModuleBoundaryTest 코드베이스 검색 — 2줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 탐지 정규식에 Jackson 두 버전이 함께 있는 이유
|
||||
|
||||
"this repository runs on Spring 7, whose message converters take Jackson 3 — so a CORE module could have imported a mapper without this detector noticing, which is **a hole in exactly the check that is supposed to have none**."
|
||||
|
||||
이것은 이 저장소에서 확인한 경계 강제 중 가장 강하다. notification의 `EndpointGuardCallSiteTest`(호출처 목록이 가드 javadoc과 달랐던)와 달리, 여기서는 목록 자체가 스캔으로 생성된다.
|
||||
|
||||
<!-- body:end -->
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-inbound-web-c12
|
||||
title: compileOnly로 막았지만 컨버터를 등록하는 코드도 없다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-inbound-web-c12
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-inbound-web-c12
|
||||
file: ../../../final/evidence/rendered/adapter-inbound-web-c12.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-inbound-web-c12.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a14#L986 이다.
|
||||
module: adapter-inbound-web
|
||||
---
|
||||
|
||||
# compileOnly로 막았지만 컨버터를 등록하는 코드도 없다
|
||||
|
||||
`build.gradle`이 XML·CBOR 백엔드를 `compileOnly`로 두는 이유는 명확하고 의도된 설계다. 그런데 배포가 그 백엔드를 추가하더라도 메시지 컨버터를 등록하는 코드가 없다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`build.gradle`이 두 백엔드를 `compileOnly`로 두고 그 이유를 길게 적는다(§2) — `implementation`이었을 때 "silently began parsing `application/xml` request bodies... an XXE surface nobody chose"였기 때문이다. 의도된 설계다.
|
||||
|
||||
## WebXmlMapperFactory 참조 위치
|
||||
|
||||
:::evidence key="adapter-inbound-web-c12" alt="코드베이스에서 WebXmlMapperFactory 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WebXmlMapperFactory 코드베이스 검색 — 4줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 백엔드를 추가해도 등록하는 코드가 없다
|
||||
|
||||
그 잭슨 백엔드를 배포가 추가하더라도 메시지 컨버터를 등록하는 코드가 없다. `WebXmlMapperFactory`·`WebCborMapperFactory`·`RepresentationNegotiationPolicy`를 참조하는 파일은 자기 패키지와 테스트뿐이고, 두 자동설정(MVC 12빈 · WebFlux 11빈)에도 없다. `WebRepresentation.available()`이 "absent backend를 문장으로 바꾼다"는 장치는 그 문장을 낼 호출자가 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-inbound-web-c13
|
||||
title: 나중에 도는 필터가 클라이언트 값으로 덮어쓴다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-inbound-web-c13
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-inbound-web-c13
|
||||
file: ../../../final/evidence/rendered/adapter-inbound-web-c13.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-inbound-web-c13.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a14#L1071 이다.
|
||||
module: adapter-inbound-web
|
||||
---
|
||||
|
||||
# 나중에 도는 필터가 클라이언트 값으로 덮어쓴다
|
||||
|
||||
두 필터 모두 서블릿 배포에서 등록되고 둘 다 `X-Request-Id` 응답 헤더를 쓰는데, 신뢰 정책이 반대다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
두 필터 모두 서블릿 배포에서 등록되고 둘 다 `X-Request-Id` 응답 헤더를 쓰는데, 신뢰 정책이 반대다.
|
||||
|
||||
```java
|
||||
// mvc/filter/WebMvcRequestIdFilter.java:105-112 (기본 trustInboundRequestId = false)
|
||||
private WebRequestId resolveRequestId(HttpServletRequest request) {
|
||||
if (!trustInboundRequestId) {
|
||||
return new WebRequestId(UUID.randomUUID().toString()); // 클라이언트 값을 보지 않는다
|
||||
}
|
||||
return sanitized(request.getHeader(REQUEST_ID_HEADER)) ...
|
||||
}
|
||||
```
|
||||
|
||||
## WebMvcRequestIdFilter 참조 위치
|
||||
|
||||
:::evidence key="adapter-inbound-web-c13" alt="코드베이스에서 WebMvcRequestIdFilter 를 검색한 출력 27줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="WebMvcRequestIdFilter 코드베이스 검색 — 27줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 순서가 만드는 결과
|
||||
|
||||
순서상 `WebMvcRequestIdFilter`(`HIGHEST_PRECEDENCE + 10`)가 먼저 돌아 새 UUID를 헤더에 쓰고, `RequestLoggingFilter`(`LOWEST_PRECEDENCE`)가 나중에 돌아 **클라이언트가 보낸 값으로 덮어쓴다**. MDC의 `request_id`와 접근 로그도 클라이언트 값이다. §32.1.
|
||||
|
||||
<!-- body:end -->
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-inbound-websocket-c01
|
||||
title: 세 설정 접두사 중 하나에 소비자가 없다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-inbound-websocket-c01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-inbound-websocket-c01
|
||||
file: ../../../final/evidence/rendered/adapter-inbound-websocket-c01.svg
|
||||
- key: adapter-inbound-websocket-c01-diagram
|
||||
file: ../../../final/assets/diagrams/adapter-inbound-websocket-c01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-inbound-websocket-c01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a17#L24 이다.
|
||||
module: adapter-inbound-websocket
|
||||
---
|
||||
|
||||
# 세 설정 접두사 중 하나에 소비자가 없다
|
||||
|
||||
`META-INF` 자동설정 리소스가 없고 조립은 전적으로 컴포넌트 스캔에 달려 있는데, 스캔이 잡을 수 있는 Spring 애노테이션을 가진 파일이 169개 중 7개다. 그리고 세 설정 접두사 중 하나에는 그것을 읽어 조립하는 `@Configuration`이 없다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
여섯 소스셋(inbound-web과 같은 형태)이고 `META-INF` 자동설정 리소스가 **없다**. 조립은 전적으로 컴포넌트 스캔에 달려 있으며, 컴포지션 루트는 이 leaf를 스캔에서 제외하지 **않는다**(graphql과 반대). 그런데 스캔이 잡을 수 있는 Spring 애노테이션을 가진 파일이 **169개 중 7개**다.
|
||||
|
||||
```text
|
||||
config/WebSocketPlatformSettings.java @ConfigurationProperties(prefix = "backend.websocket")
|
||||
stomp/WebSocketProperties.java @ConfigurationProperties(prefix = "ca-skeleton.websocket")
|
||||
stomp/WebSocketConfig.java @Configuration + @ConditionalOnProperty("ca-skeleton.websocket.enabled")
|
||||
advanced/sockjs/SockJsConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.sockjs"
|
||||
advanced/stomp/StompConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.stomp"
|
||||
advanced/stomp/StompDefaultsConfiguration.java @Configuration + prefix "app.websocket-platform.advanced.stomp"
|
||||
advanced/stomp/rabbit/RabbitBrokerRelayConfiguration.java prefix "app.websocket-platform.advanced.stomp.relay"
|
||||
```
|
||||
|
||||
## 설정 접두사와 소비자
|
||||
|
||||
:::evidence key="adapter-inbound-websocket-c01-diagram" alt="설정 접두사에서 두 Configuration 으로만 화살표가 가고, backend.websocket 은 읽는 Configuration 없음 이라고 이름 붙은 별도 영역 안에 화살표 없이 놓인다" caption="설정 접두사와 소비자" zoom="false"
|
||||
:::
|
||||
|
||||
세 번째가 이 모듈의 핵심 사실이다. `backend.websocket` 네임스페이스가 규정하는 "플랫폼"이 main 169 파일 중 약 90개를 차지하고, 그것을 조립하는 `@Configuration`이 하나도 없다.
|
||||
|
||||
## 분석 원문의 접두사 표
|
||||
|
||||
:::evidence key="adapter-inbound-websocket-c01" alt="분석 문서 final/document.md#a17 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a17 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
<!-- body:end -->
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-outbound-cache-redis-c01
|
||||
title: 꺼져 있을 때 아무것도 기여하지 않는다는 말이 문자 그대로다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-outbound-cache-redis-c01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-outbound-cache-redis-c01
|
||||
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c01.svg
|
||||
- key: adapter-outbound-cache-redis-c01-diagram
|
||||
file: ../../../final/assets/diagrams/adapter-outbound-cache-redis-c01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a10#L85 이다.
|
||||
module: adapter-outbound-cache-redis
|
||||
---
|
||||
|
||||
# 꺼져 있을 때 아무것도 기여하지 않는다는 말이 문자 그대로다
|
||||
|
||||
`RedisSdkSettings`가 애플리케이션 전역 `@ConfigurationPropertiesScan`이 아니라 `RedisSdkAutoConfiguration`의 `@Bean`으로만 존재한다. 그래서 스위치가 꺼져 있으면 속성이 묶이지도 않는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`RedisSdkAutoConfiguration`의 javadoc이 규칙을 적는다.
|
||||
|
||||
> "`app.redis.enabled` is the whole switch. While it is false this class contributes nothing, and because `RedisSdkSettings` is registered here rather than by the application-wide `@ConfigurationPropertiesScan`, **'contributes nothing' is literal**: the properties are not bound, the cross-field rules are not run, no credential is resolved, and no policy resource, TLS material, client, connection or thread is created."
|
||||
|
||||
## 조립이 고정된 순서
|
||||
|
||||
:::evidence key="adapter-outbound-cache-redis-c01-diagram" alt="설정 바인딩과 교차 필드 검증과 클라이언트 생성이 왼쪽에서 오른쪽으로 이어지고 화살표에 bound settings 와 검증 통과가 붙은 구조" caption="조립이 고정된 순서" zoom="false"
|
||||
:::
|
||||
|
||||
`RedisSdkSettings`는 `@ConfigurationPropertiesScan` 대상이 아니라 이 클래스의 `@Bean` + `@ConfigurationProperties`로만 존재한다. 그래서 Redis를 쓰지 않는 배포는 Redis 설정을 들고 다니지 않고, **켠 적 없는 잘못된 Redis 설정 때문에 벌을 받지도 않는다**. test가 그 넷을 이름으로 고정한다 — `absentSwitchRegistersNothing`, `disabledRegistersNothing`, `disabledIgnoresMalformedRedisConfiguration`, `disabledNeverAsksForASecretOrAConnection`.
|
||||
|
||||
## RedisSdkAutoConfiguration 참조 위치
|
||||
|
||||
:::evidence key="adapter-outbound-cache-redis-c01" alt="코드베이스에서 RedisSdkAutoConfiguration 를 검색한 출력 30줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RedisSdkAutoConfiguration 코드베이스 검색 — 30줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 검증이 bean factory 메서드 안에 있는 이유
|
||||
|
||||
순서도 bind → validate → build로 고정된다. 검증이 `@PostConstruct`나 리스너가 아니라 **bean factory 메서드 안**에 있어서, 설정 오류가 "그 bean을 만들지 못했다"는 실패로 보고되고 그 아래 어떤 것도 검증되지 않은 settings를 잡을 수 없다. 그리고 `redisSdkSettingsValidation`이 별도 bean인 이유도 적혀 있다 — Spring은 factory 메서드가 **반환한 뒤에** binder를 돌리므로 검증이 `redisSdkSettings()` 안에 있을 수 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-outbound-cache-redis-c02
|
||||
title: 기본값이 가리키는 리소스가 없고 그것이 시작 실패로 잡힌다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-outbound-cache-redis-c02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-outbound-cache-redis-c02
|
||||
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a10#L107 이다.
|
||||
module: adapter-outbound-cache-redis
|
||||
---
|
||||
|
||||
# 기본값이 가리키는 리소스가 없고 그것이 시작 실패로 잡힌다
|
||||
|
||||
`RedisSdkSettings.Raw.policyResource` 기본값 `classpath:redis-sdk/raw-command-allowlist.yml`은 저장소에 없는 파일이다. 결함이 아니라 이미 잡혀 있는 함정이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`RedisSdkSettings.Raw.policyResource` 기본값은 `classpath:redis-sdk/raw-command-allowlist.yml`인데, 저장소에 그 파일은 **없다**(`159-...` §8.3b, `git ls-files` 매치 0. 이 leaf의 main resource는 `AutoConfiguration.imports`와 `redis-sdk/redis-command-policy.yml` 둘뿐).
|
||||
|
||||
## 결함이 아니라 이미 잡혀 있는 함정이다
|
||||
|
||||
`requireRawPolicyResource`가 그 사실과 과거 증상을 함께 적는다.
|
||||
|
||||
> "`validate()` only checks that the setting is non-blank, and the default points at … a resource this module does not ship. So enabling the raw gateway passed configuration validation and then **failed at the first raw command, from inside a request, against a live connection.** The allowlist is the entire authorisation model for that gateway; not being able to read it is a startup failure."
|
||||
|
||||
test `enabledRejectsAMissingRawAllowlistResource`와 `enabledAcceptsAReadableRawAllowlistResource`가 양쪽을 고정한다.
|
||||
|
||||
## 분석 원문의 확인 절차
|
||||
|
||||
:::evidence key="adapter-outbound-cache-redis-c02" alt="분석 문서 final/document.md#a10 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a10 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
<!-- body:end -->
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-outbound-fileserver-c01
|
||||
title: 적재는 import filter가 아니라 명시적 component scan이다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-outbound-fileserver-c01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-outbound-fileserver-c01
|
||||
file: ../../../final/evidence/rendered/adapter-outbound-fileserver-c01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-outbound-fileserver-c01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a08#L127 이다.
|
||||
module: adapter-outbound-fileserver
|
||||
---
|
||||
|
||||
# 적재는 import filter가 아니라 명시적 component scan이다
|
||||
|
||||
이 leaf에는 `AutoConfiguration.imports`가 없다. 실제 적재는 `CaSkeletonApplication`의 명시적 `@ComponentScan`이 하고, bean 생성만 `@ConditionalOnProperty`로 막힌다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
이 leaf에는 `META-INF/spring/…AutoConfiguration.imports`가 **없다**(`142-...` §8.1). `FileExportConfig`/`FileserverR2Config`를 leaf 밖에서 이름으로 참조하는 production 코드도 없고, 유일한 외부 참조는 app-bootstrap의 test(`OptionalAdapterBeanGatingTest`)다.
|
||||
|
||||
## FileExportConfig 참조 위치
|
||||
|
||||
:::evidence key="adapter-outbound-fileserver-c01" alt="코드베이스에서 FileExportConfig 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FileExportConfig 코드베이스 검색 — 7줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 실제 적재 경로
|
||||
|
||||
`CaSkeletonApplication`의 명시적 `@ComponentScan`이 `dev.caskeleton.adapter.outbound.fileserver`를 목록에 올려서 이루어진다(`:75`). 즉 CLAUDE.md의 "never activates unexpectedly when merely present on the classpath"는 **classpath 존재만으로 bean이 생기지 않는다**는 뜻으로는 정확하지만, 기전은 import filter가 아니라 "@Configuration은 스캔되고 bean 생성만 `@ConditionalOnProperty`로 막힌다"이다.
|
||||
|
||||
## 기전이 다른 것을 기록해 두는 이유
|
||||
|
||||
fail-closed는 성립한다 — 기록해 두는 이유는 mongo leaf의 4중 opt-in(§sub-scope 01, 06번 문서)과 기전이 다르기 때문이다.
|
||||
|
||||
<!-- body:end -->
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-outbound-notification-c01
|
||||
title: 이름 없던 상태에 이름을 붙인 세 자리
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-outbound-notification-c01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-outbound-notification-c01
|
||||
file: ../../../final/evidence/rendered/adapter-outbound-notification-c01.svg
|
||||
- key: adapter-outbound-notification-c01-diagram
|
||||
file: ../../../final/assets/diagrams/adapter-outbound-notification-c01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-outbound-notification-c01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a13#L84 이다.
|
||||
module: adapter-outbound-notification
|
||||
---
|
||||
|
||||
# 이름 없던 상태에 이름을 붙인 세 자리
|
||||
|
||||
세 클래스가 각각 이전에는 구분되지 않던 두 상황을 구분한다 — 공급자가 없는 플랫폼, 알 수 없는 공급자 타입, 그리고 능력별로 필요한 비밀 키.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
세 클래스가 각각 이전에는 **구분되지 않던 두 상황**을 구분한다.
|
||||
|
||||
## 이름 없던 상태에 이름 붙이기
|
||||
|
||||
:::evidence key="adapter-outbound-notification-c01-diagram" alt="이전과 지금 두 열에 세 상태가 같은 높이로 놓이고 이전 쪽 세 상자만 빗금으로 표시된 구조" caption="이름 없던 상태에 이름 붙이기" zoom="false"
|
||||
:::
|
||||
|
||||
**`NotificationPlatformMode`** — 공급자가 하나도 조립되지 않은 플랫폼이 공급자가 있는 플랫폼과 똑같이 보였다.
|
||||
|
||||
> "A platform with no assembled provider used to look identical to one with providers: the same beans, the same scheduler, the same readiness. **Requests were accepted durably and then sat in the queue with no eligible route.** Naming the state makes it a decision an operator takes rather than a situation they discover."
|
||||
|
||||
`INGEST_ONLY`는 **명시적으로 선택해야** 하고("A deployment that reaches zero providers by accident is a misconfiguration, and the whole point of this enum is that the two are told apart"), `NotificationProviderAssembly:183`이 그것을 강제한다 — 경로가 비었는데 모드가 `INGEST_ONLY`가 아니면 조립을 거부하고 메시지로 그 모드를 안내한다.
|
||||
|
||||
## NotificationPlatformMode 참조 위치
|
||||
|
||||
:::evidence key="adapter-outbound-notification-c01" alt="코드베이스에서 NotificationPlatformMode 를 검색한 출력 23줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NotificationPlatformMode 코드베이스 검색 — 23줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
app-bootstrap 쪽에서도 `NotificationPlatformWorkerConfig`가 "everything that starts a thread, and therefore everything `INGEST_ONLY` must not have"를 그 모드로 가른다.
|
||||
|
||||
## 자유 문자열이던 타입이 닫힌 enum이 됐다
|
||||
|
||||
**`ProviderType`** — 설정이 타입을 자유 문자열로 날랐고 "the only thing that read it was a" 비교였다. 지금은 닫힌 enum이라 알 수 없는 타입이 startup 바인딩 실패가 되고 채널도 타입에서 유도된다.
|
||||
|
||||
## 여덟 키를 항상 요구하던 것이 잘못된 방향이었던 이유
|
||||
|
||||
**`NotificationSecretRequirements`** — 이전에는 여덟 개 키를 **항상** 요구했다.
|
||||
|
||||
> "That is **fail-closed in the wrong direction**: it made every deployment provision and rotate keys for capabilities it had switched off — a Web Push signing key for a platform with no Web Push profile… and **a key that exists but is never used is a key nobody notices leaking.** It also made the eight look equally load-bearing."
|
||||
|
||||
지금은 네 개(`CONTACT_ENCRYPTION`·`CONTACT_LOOKUP_HMAC`·`PAYLOAD_ENCRYPTION`·`PROVIDER_REQUEST_LOOKUP_HMAC`)가 모든 모드에 필요하고 — **수용 경로**에 있으므로 `INGEST_ONLY`에서도 필요하다 — 나머지 넷은 능력을 따라간다. 약해지면 안 되는 방향은 명시된다: "a capability that is switched *on* and whose key is missing still refuses the boot, because the alternative is discovering it on a user's notification."
|
||||
|
||||
<!-- body:end -->
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-outbound-notification-c02
|
||||
title: 같은 종료 절차를 쓰지만 대상이 다르다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-outbound-notification-c02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-outbound-notification-c02
|
||||
file: ../../../final/evidence/rendered/adapter-outbound-notification-c02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-outbound-notification-c02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a13#L306 이다.
|
||||
module: adapter-outbound-notification
|
||||
---
|
||||
|
||||
# 같은 종료 절차를 쓰지만 대상이 다르다
|
||||
|
||||
`NotificationSchedulerWorker.close`와 `NotificationBackgroundWorkers.close` 둘 다 취소 → shutdown → awaitTermination(grace) → shutdownNow를 수행한다. 형태는 같지만 대상이 다르다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`NotificationSchedulerWorker.close`와 `NotificationBackgroundWorkers.close` 둘 다 "취소 → shutdown → awaitTermination(grace) → shutdownNow"를 수행한다. 형태는 같지만 대상이 다르다(폴링 스레드 + virtual-thread executor 대 단일 데몬 scheduler). 중복 아님.
|
||||
|
||||
## NotificationSchedulerWorker 참조 위치
|
||||
|
||||
:::evidence key="adapter-outbound-notification-c02" alt="코드베이스에서 NotificationSchedulerWorker 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NotificationSchedulerWorker 코드베이스 검색 — 7줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 인터럽트가 닿지 않는 한 지점
|
||||
|
||||
스케줄러의 `close()`는 폴링 스레드를 `interrupt()`하지만(`:173`), `runOnce`의 `globalConcurrency.acquireUninterruptibly()`(`:90`)는 인터럽트에 반응하지 않는다. 주석(`:169`)은 "인터럽트가 poll-interval sleep을 깬다"고만 말하고 그 점은 정확하다.
|
||||
|
||||
## 그래도 결함으로 보지 않은 이유
|
||||
|
||||
세마포어는 in-flight 작업이 `finally`에서 반납하므로 결국 풀리고, 최악의 경우 `join(shutdownGrace)`가 만료된 뒤 종료가 계속된다. 결함 아님.
|
||||
|
||||
<!-- body:end -->
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-outbound-persistence-jpa-c56
|
||||
title: 항상 설치되는 스캔이 opt-in package를 끌고 오지 않는다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-outbound-persistence-jpa-c56
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-outbound-persistence-jpa-c56
|
||||
file: ../../../final/evidence/rendered/adapter-outbound-persistence-jpa-c56.svg
|
||||
- key: adapter-outbound-persistence-jpa-c56-diagram
|
||||
file: ../../../final/assets/diagrams/adapter-outbound-persistence-jpa-c56.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-outbound-persistence-jpa-c56.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a05#L4143 이다.
|
||||
module: adapter-outbound-persistence-jpa
|
||||
---
|
||||
|
||||
# 항상 설치되는 스캔이 opt-in package를 끌고 오지 않는다
|
||||
|
||||
`PersistenceJpaConfig`는 persistence root를 통째로 스캔하지 않고 20개 package를 열거한다. opt-in 두 개는 각자의 조건부 configuration이 자기 package만 스캔한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`PersistenceJpaConfig`는 persistence root를 통째로 스캔하지 않고 20개 package를 열거한다. 빠진 것은 `config`, `h2`(JPA stereotype 없음)와 opt-in 두 개(`notification`, `fileserver`)다. 두 opt-in은 각자의 `@ConditionalOnProperty` configuration이 자기 package만 스캔한다.
|
||||
|
||||
## 두 스캔을 가르는 선
|
||||
|
||||
:::evidence key="adapter-outbound-persistence-jpa-c56-diagram" alt="PersistenceJpaConfig 경계 안에 열거된 스무 개 package 가 들어 있고 notification 과 fileserver 가 경계 밖 점선 상자로 놓인 구조" caption="두 스캔을 가르는 선" zoom="false"
|
||||
:::
|
||||
|
||||
이 배치의 이유는 javadoc과 `PersistenceEntityScanCoverageTest`에 기록돼 있다 — 과거에 root를 스캔해서 capability를 끈 배포가 `ddl-auto=validate`에서 `notification_request` / `fs_cleanup_item`을 요구하며 부팅에 실패했다.
|
||||
|
||||
## PersistenceJpaConfig 참조 위치
|
||||
|
||||
:::evidence key="adapter-outbound-persistence-jpa-c56" alt="코드베이스에서 PersistenceJpaConfig 를 검색한 출력 18줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="PersistenceJpaConfig 코드베이스 검색 — 18줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 측정 결과 경계가 유지되고 있다
|
||||
|
||||
`@Entity` 25개 중 opt-in package(`notification` 13, `fileserver` 6) 밖의 4개는 `idempotency_record`, `outbox_event`, `live_event_log`, `durable_operation`이고, 이 네 테이블은 모두 default location `db/migration/postgresql`(V1/V3/V11/V12)이 만든다. `postgresql` package는 scan 대상이지만 그 안의 candidate adapter들(`inbox`, `outbox` v2, `idempotency` v2)은 `@Entity`가 아니라 native SQL 기반이라 persistence unit에 들어오지 않는다.
|
||||
|
||||
## 커버리지 테스트가 한쪽만 막는다
|
||||
|
||||
opt-in configuration 두 개에 대해서는 `@EntityScan` 목록과 `@EnableJpaRepositories` 목록이 **정확히 같은지** `containsExactly`로 검사한다("entities without repositories is half a scan, and fails at the first query"). 그런데 always-install `PersistenceJpaConfig`에 대해서는 `@EntityScan` 목록만 읽어 디스크와 대조하고, 두 목록의 일치는 검사하지 않는다. 현재 두 목록은 20개로 동일하다.
|
||||
|
||||
<!-- body:end -->
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-outbound-persistence-mongo-c01
|
||||
title: 네 겹이 같은 스위치를 읽고 각각 다른 실패를 막는다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-outbound-persistence-mongo-c01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-outbound-persistence-mongo-c01
|
||||
file: ../../../final/evidence/rendered/adapter-outbound-persistence-mongo-c01.svg
|
||||
- key: adapter-outbound-persistence-mongo-c01-diagram
|
||||
file: ../../../final/assets/diagrams/adapter-outbound-persistence-mongo-c01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-outbound-persistence-mongo-c01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a06#L98 이다.
|
||||
module: adapter-outbound-persistence-mongo
|
||||
---
|
||||
|
||||
# 네 겹이 같은 스위치를 읽고 각각 다른 실패를 막는다
|
||||
|
||||
opt-in이 네 겹이고 전부 `ca-skeleton.persistence-mongo.enabled=true`를 읽는다. 중복이 아니라 계층별 차단이다 — filter는 Boot의 후보군을, 나머지 셋은 자기 bean 그래프를 담당한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
네 겹 모두 `ca-skeleton.persistence-mongo.enabled=true`라는 같은 조건을 읽는다(`evidence/raw/123-...` §8.2). 이것은 중복이 아니라 계층별 차단이다: filter는 Boot의 후보군, 나머지 셋은 자기 bean 그래프를 담당한다.
|
||||
|
||||
## 옵트인을 이루는 네 겹
|
||||
|
||||
:::evidence key="adapter-outbound-persistence-mongo-c01-diagram" alt="import filter 와 root 자동설정과 infrastructure 설정과 platform 자동설정이 위에서 아래로 쌓이고 같은 스위치 화살표가 아래로 그려진 구조" caption="옵트인을 이루는 네 겹" zoom="false"
|
||||
:::
|
||||
|
||||
Mongo starter는 classpath만으로 auto-configuration 후보를 등록하고 project condition은 후보 선정 **뒤에** 평가되므로, 후보 단계에서 9개 Boot Mongo auto-configuration을 빼지 않으면 평범한 `@EnableAutoConfiguration` 앱이 client와 template을 만든다. `MongoPersistenceConfig`의 `@ImportAutoConfiguration`은 **명시적** import라 `spring.autoconfigure.exclude`의 영향을 받지 않는다.
|
||||
|
||||
## MongoPersistenceConfigTest 참조 위치
|
||||
|
||||
:::evidence key="adapter-outbound-persistence-mongo-c01" alt="코드베이스에서 MongoPersistenceConfigTest 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MongoPersistenceConfigTest 코드베이스 검색 — 1줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
`MongoPersistenceConfigTest`가 실제 `@EnableAutoConfiguration` context로 default/false에서 `MongoClient`·`MongoTemplate` 부재를, `enabled=true` + mock client에서 `MongoTemplate` 단일 bean을 확인한다.
|
||||
|
||||
## 남은 미연결의 성격이 다른 이유
|
||||
|
||||
`MongoPlatformAutoConfiguration`(443줄)은 이 leaf에서 가장 밀도가 높은 파일이고, 거의 모든 `@Bean`의 javadoc이 **과거에 "shipped했지만 아무 configuration도 만들지 않던" 경로**를 기록한다 — atomic/bulk template, reactive 실행 경로 일체, change-stream source와 consumer, startup validator, client generation registry, health indicator. 이 leaf는 그 미연결들을 한 번 훑어 고친 이력을 갖고 있고, 그 사실이 이 sub-scope의 판단 기준을 바꾼다: 남아 있는 미연결은 "아직 안 한 것"이 아니라 "훑고도 남은 것"이다.
|
||||
|
||||
## 조건이 곧 탈출구가 되지 않게 하는 장치
|
||||
|
||||
`mongoPlatformStartupCheck`는 `MongoTopologyProbe` bean이 있을 때만 돌지만, 그 조건이 곧 탈출구가 되는 것을 막기 위해 `mongoTopologyProbeRequirement`가 **probe 조건 없이** 등록되어 "platform profile이 있는데 probe가 없으면" 실패시킨다. javadoc이 그 이유를 한 줄로 적는다 — "a requirement that only applies when the thing it requires is present is not a requirement".
|
||||
|
||||
<!-- body:end -->
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-outbound-persistence-mongo-c08
|
||||
title: phase를 code table 위에 두는 순서까지 논증돼 있다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-outbound-persistence-mongo-c08
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-outbound-persistence-mongo-c08
|
||||
file: ../../../final/evidence/rendered/adapter-outbound-persistence-mongo-c08.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-outbound-persistence-mongo-c08.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a06#L1142 이다.
|
||||
module: adapter-outbound-persistence-mongo
|
||||
---
|
||||
|
||||
# phase를 code table 위에 두는 순서까지 논증돼 있다
|
||||
|
||||
`MongoFailureClassifier`와 `MongoFailureTranslator`는 auto-configuration의 실제 bean이고 imperative·reactive 두 executor가 모두 그것을 통해 번역한다. 규칙 사슬은 label → phase → 적용 가능성 → code table → fail closed다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MongoFailureClassifier`와 `MongoFailureTranslator`는 auto-configuration의 실제 bean이고(`MongoPlatformAutoConfiguration:85·92`), imperative·reactive 두 executor가 모두 그것을 통해 번역한다. 규칙 사슬도 순서까지 논증돼 있다 — **label → phase → 적용 가능성 → code table → fail closed**.
|
||||
|
||||
> Phase sits above the code table because a failure that never reached a server is safe to repeat whatever code accompanies it, and a commit failure is unsafe to replay whatever code accompanies it — **both were decided by the code table before, and the code table knows neither.**
|
||||
|
||||
## MongoFailureClassifier 참조 위치
|
||||
|
||||
:::evidence key="adapter-outbound-persistence-mongo-c08" alt="코드베이스에서 MongoFailureClassifier 를 검색한 출력 31줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MongoFailureClassifier 코드베이스 검색 — 31줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 고쳐진 결함 네 가지
|
||||
|
||||
- **번역기가 phase를 버렸다.** `DefaultMongoFailureTranslator`가 operationType을 들고도 context-free overload를 불러서, "FIND의 응답 유실 = 재현 가능한 읽기 / UPDATE의 같은 유실 = 결과 불명 쓰기"라는 구분이 **transaction이 아닌 모든 경로에서** 버려졌다 — 즉 모든 평범한 연산에서. 실패한 읽기가 ambiguous write로 보고됐다.
|
||||
- **server-selection이 terminal이었다.** label도 code도 없는 실패가 `UNCLASSIFIED`로 떨어져 재시도 불가로 처리됐다 — 재시도가 명백히 안전한 유일한 경우인데.
|
||||
- **Spring 래핑이 분류를 통째로 건너뛰었다.** `MongoFailureExtractor`가 그 수리다. cause 사슬을 깊이 16까지, `IdentityHashMap`으로 순환 안전하게 탐색한다("a cycle is about the same object appearing twice").
|
||||
- **message는 절대 읽지 않는다.** `MongoDriverFailureView`가 driver 예외를 label·code·boolean 둘로 좁히는 지점이고, 그 이후 어느 계층도 나머지에 닿을 수 없다 — "no later layer can reach the rest, because no later layer is ever handed it".
|
||||
|
||||
## 생성자가 조합을 좁히는 지점
|
||||
|
||||
`MongoFailureClassification`의 생성자가 `COMMIT_ONLY`를 `TRANSACTION_COMMIT_UNKNOWN`에만 허용하는 것도 §15의 불변식과 맞물린다.
|
||||
|
||||
<!-- body:end -->
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: adapter-outbound-support-c01
|
||||
title: 허용된 의존과 실제 의존이 다르다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:adapter-outbound-support-c01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: adapter-outbound-support-c01
|
||||
file: ../../../final/evidence/rendered/adapter-outbound-support-c01.svg
|
||||
- key: adapter-outbound-support-c01-diagram
|
||||
file: ../../../final/assets/diagrams/adapter-outbound-support-c01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/adapter-outbound-support-c01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a04#L52 이다.
|
||||
module: adapter-outbound-support
|
||||
---
|
||||
|
||||
# 허용된 의존과 실제 의존이 다르다
|
||||
|
||||
`adapter-outbound-support`의 프로덕션 표면은 타입 셋뿐이다. 레지스트리가 허용하는 project dependency는 셋이지만 실제 project dependency는 0개다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`adapter-outbound-support`는 application port를 구현하는 하나의 기술 adapter라기보다 **여러 outbound adapter가 공유할 수 있는 기술적 보조 seam**이다.
|
||||
|
||||
## 이 리프의 프로덕션 표면
|
||||
|
||||
:::evidence key="adapter-outbound-support-c01-diagram" alt="리프 경계 안에 상관 식별자 조회와 fail-open 로거와 기본 bean 설정 세 상자가 나란히 들어 있는 구조" caption="이 리프의 프로덕션 표면" zoom="false"
|
||||
:::
|
||||
|
||||
`OutboundCorrelation`은 SLF4J MDC에서 `correlation_id`를 조회하고 값이 없거나 blank면 `"unknown"`을 반환한다. `FailOpenDependencyLogger`는 optional/fail-open outbound 호출의 success/failure observation을 공통 포맷으로 기록한다 — success는 DEBUG, failure는 WARN이다. `OutboundSupportConfig`는 `FailOpenDependencyLogger` default bean을 제공하고, `@ConditionalOnMissingBean`으로 fork/application이 같은 타입을 override할 수 있게 한다.
|
||||
|
||||
## OutboundCorrelation 참조 위치
|
||||
|
||||
:::evidence key="adapter-outbound-support-c01" alt="코드베이스에서 OutboundCorrelation 를 검색한 출력 15줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="OutboundCorrelation 코드베이스 검색 — 15줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 허용된 의존과 실제 의존
|
||||
|
||||
`src/config/architecture/modules.json`은 support leaf가 `domain-core`·`application-core`·`shared-contract`를 project dependency로 **허용**한다. 그러나 현재 `build.gradle`과 fresh `compileClasspath` 결과를 보면 실제 project dependency는 **0개**이고, 실제 compile dependency는 `spring-boot-autoconfigure` 4.0.8과 `slf4j-api` 2.0.18뿐이다.
|
||||
|
||||
즉 registry의 `allowed_dependencies`는 가능한 최대 경계를 나타내고, 현재 source graph가 그 edge를 모두 사용한다는 뜻이 아니다. support는 현 snapshot에서 domain/application/shared 타입과도 결합하지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: grpc-advanced-bootstrap-c02
|
||||
title: 세 조건을 하나의 boolean으로 접지 않는다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:grpc-advanced-bootstrap-c02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: grpc-advanced-bootstrap-c02
|
||||
file: ../../../final/evidence/rendered/grpc-advanced-bootstrap-c02.svg
|
||||
- key: grpc-advanced-bootstrap-c02-diagram
|
||||
file: ../../../final/assets/diagrams/grpc-advanced-bootstrap-c02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/grpc-advanced-bootstrap-c02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-advanced-bootstrap#L76 이다.
|
||||
module: grpc-advanced-bootstrap
|
||||
---
|
||||
|
||||
# 세 조건을 하나의 boolean으로 접지 않는다
|
||||
|
||||
게이트가 깃발 설정 여부, 등급의 시작 가능 여부, 운영 별도 승인을 순서대로 따로 본다. 접으면 처방이 서로 다른 세 상황이 같은 메시지를 받는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
게이트가 세 조건을 순서대로 본다.
|
||||
|
||||
```java
|
||||
if (!flags.flagSet(capability)) → "its feature flag is not set"
|
||||
if (!grade.startable()) → "it is graded WATCH, which cannot start"
|
||||
if (production && requiresApproval && !approved) → "production needs a separate approval"
|
||||
```
|
||||
|
||||
> "Collapsing them into one boolean produces a 'not enabled' message for three situations with three different remedies."
|
||||
|
||||
## 게이트가 보는 세 조건
|
||||
|
||||
:::evidence key="grpc-advanced-bootstrap-c02-diagram" alt="깃발 설정 여부와 등급의 시작 가능 여부와 운영 별도 승인이 왼쪽에서 오른쪽으로 이어지는 구조" caption="게이트가 보는 세 조건" zoom="false"
|
||||
:::
|
||||
|
||||
## 이 기록이 다루는 파일 범위
|
||||
|
||||
:::evidence key="grpc-advanced-bootstrap-c02" alt="코드베이스에서 파일 목록을 만든 출력 9줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 9줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 같은 불변식의 런타임 확인은 실행되지 않는다
|
||||
|
||||
`requireStableStarterIsClean` 이 같은 불변식을 런타임에서도 확인한다 — 팻 자, 셰이드 산출물, 테스트 하네스처럼 다른 방식으로 조립된 런타임을 위해서다. **다만 그 메서드를 부르는 런타임이 없다**(§12.1). 지금 그 검사를 실행하는 것은 이 리프의 자기 테스트뿐이다.
|
||||
|
||||
<!-- body:end -->
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: grpc-observability-c02
|
||||
title: 네 타입을 쓰지만 그 관측을 만드는 코드가 없다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:grpc-observability-c02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: grpc-observability-c02
|
||||
file: ../../../final/evidence/rendered/grpc-observability-c02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/grpc-observability-c02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-observability#L59 이다.
|
||||
module: grpc-observability
|
||||
---
|
||||
|
||||
# 네 타입을 쓰지만 그 관측을 만드는 코드가 없다
|
||||
|
||||
`grpc-core-api`에서 쓰는 타입은 `GrpcMethodName`, `GrpcStatusCode`, `RpcType`, `GrpcCompletionOutcome` 넷이고 전부 `GrpcRpcObservation`의 record 성분이다. 그런데 그 관측을 만드는 production 코드가 없다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`grpc-core-api` 에서 쓰는 타입은 넷이다 — `GrpcMethodName`, `GrpcStatusCode`, `RpcType`, `GrpcCompletionOutcome`. 네 타입 모두 `GrpcRpcObservation` 의 record 성분이다. `GrpcStreamObservation` 은 `GrpcMethodName` 하나만 쓴다.
|
||||
|
||||
## GrpcMethodName 참조 위치
|
||||
|
||||
:::evidence key="grpc-observability-c02" alt="코드베이스에서 GrpcMethodName 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcMethodName 코드베이스 검색 — 7줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 관측을 만드는 production 코드가 없다
|
||||
|
||||
배선 없음(`EVD-325`). `runtime_memberships` 가 비어 있고, 저장소 어디에서도 `new GrpcObservationConvention(...)` 을 만드는 production 코드가 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: messaging-admin-runtime-c02
|
||||
title: 선언한 여섯 의존 중 셋이 import 0건이다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:messaging-admin-runtime-c02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: messaging-admin-runtime-c02
|
||||
file: ../../../final/evidence/rendered/messaging-admin-runtime-c02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/messaging-admin-runtime-c02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-admin-runtime#L78 이다.
|
||||
module: messaging-admin-runtime
|
||||
---
|
||||
|
||||
# 선언한 여섯 의존 중 셋이 import 0건이다
|
||||
|
||||
`build.gradle`이 여섯 project를 `api`로 선언하는데 실측 import는 셋이 0건이다. 지금까지 본 리프 중 가장 많다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`build.gradle`이 여섯 project를 `api`로 선언하는데 실측 import(`EVD-308`)는 셋이 0건이다. 지금까지 본 리프 중 가장 많다.
|
||||
|
||||
| 선언 | 패키지 | import | 판정 |
|
||||
|---|---|---:|---|
|
||||
| `messaging-admin-api` | `…messaging.admin` | 45 | O |
|
||||
| `messaging-core-api` | `…messaging.api` | 6 | O |
|
||||
| `messaging-observability` | `…messaging.observation` | 1 | O |
|
||||
| `messaging-policy` | `…messaging.policy` | **0** | X |
|
||||
| `messaging-transport-spi` | `…messaging.transport` | **0** | X |
|
||||
| `messaging-security` | `…messaging.security` | **0** | X |
|
||||
|
||||
## MessagingAdminService 참조 위치
|
||||
|
||||
:::evidence key="messaging-admin-runtime-c02" alt="코드베이스에서 MessagingAdminService 를 검색한 출력 4줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingAdminService 코드베이스 검색 — 4줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 빈이 되는 것은 둘뿐이다
|
||||
|
||||
배선은 starter 한 곳뿐이고, 이 리프에서 빈이 되는 것은 **둘**이다(`EVD-307`). `MessagingAdminService`, `ReplayService`, `RedriveService`, `DestructiveMessagingAdmin` — 넷 다 빈이 없다. starter 는 그중 하나에 대해서만 이유를 밝힌다. 나머지 셋의 부재에 대한 설명은 어디에도 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: messaging-kafka-share-experimental-c01
|
||||
title: 이 리프의 실질은 세 가지 거절이다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:messaging-kafka-share-experimental-c01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: messaging-kafka-share-experimental-c01
|
||||
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-c01.svg
|
||||
- key: messaging-kafka-share-experimental-c01-diagram
|
||||
file: ../../../final/assets/diagrams/messaging-kafka-share-experimental-c01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/messaging-kafka-share-experimental-c01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-kafka-share-experimental#L54 이다.
|
||||
module: messaging-kafka-share-experimental
|
||||
---
|
||||
|
||||
# 이 리프의 실질은 세 가지 거절이다
|
||||
|
||||
Kafka Share Group(KIP-932, 경쟁 소비자 work queue)을 실험적 어댑터로 감싼다. 190줄 중 실제 동작을 하는 코드는 거의 없고 세 가지를 거절한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
Kafka **Share Group**(KIP-932, 경쟁 소비자 work queue)을 실험적 어댑터로 감싼다. `runtime_memberships: []`이고 이름 자체가 `-experimental`이다. 이 leaf의 실질은 **거절**이다 — 190줄 중 실제 동작을 하는 코드는 거의 없고, 세 가지를 거절한다.
|
||||
|
||||
| 거절 | 코드 | 이유 |
|
||||
|---|---|---|
|
||||
| 비활성 상태의 사용 | `KAFKA_SHARE_DISABLED` | experimental이 기본 켜지지 않게 |
|
||||
| 순서 보장 목적지 | `IllegalArgumentException` | share group이 순서를 줄 수 없음 |
|
||||
| pause/resume | `KAFKA_SHARE_NO_PAUSE`/`_NO_RESUME` | 일시정지할 파티션 할당이 없음 |
|
||||
|
||||
## 이 리프가 실제로 하는 일
|
||||
|
||||
:::evidence key="messaging-kafka-share-experimental-c01-diagram" alt="리프 경계 안에 프로파일 검증기와 능력 선언이 들어 있고 소비자 구성이 경계 밖 빗금 상자로 놓인 구조" caption="이 리프가 실제로 하는 일" zoom="false"
|
||||
:::
|
||||
|
||||
## 순서를 낮춰 주지 않고 거절하는 이유
|
||||
|
||||
핵심 진술이 validator javadoc에 있다.
|
||||
|
||||
> "A share group hands individual records to competing consumers and acknowledges them individually. That is a work queue, and it is fundamentally incompatible with partition ordering: two consumers in the same share group can process records from one partition concurrently and finish in either order. Configuring an ordered destination on a share group would therefore advertise a guarantee the broker is not providing, so it is refused rather than degraded."
|
||||
|
||||
## 기본 꺼짐이 drift 방지 수단이다
|
||||
|
||||
두 번째 문단이 이 저장소의 experimental 정책을 한 문장으로 담는다 — "The adapter is also off unless explicitly enabled, so an Experimental capability cannot drift into a Stable deployment by default."
|
||||
|
||||
## 이 기록이 다루는 파일 범위
|
||||
|
||||
:::evidence key="messaging-kafka-share-experimental-c01" alt="코드베이스에서 파일 목록을 만든 출력 4줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 4줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
<!-- body:end -->
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: messaging-kafka-share-experimental-c02
|
||||
title: 소비자 없음과 membership 없음과 조립 없음이 서로 맞는다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:messaging-kafka-share-experimental-c02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: messaging-kafka-share-experimental-c02
|
||||
file: ../../../final/evidence/rendered/messaging-kafka-share-experimental-c02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/messaging-kafka-share-experimental-c02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-kafka-share-experimental#L84 이다.
|
||||
module: messaging-kafka-share-experimental
|
||||
---
|
||||
|
||||
# 소비자 없음과 membership 없음과 조립 없음이 서로 맞는다
|
||||
|
||||
나가는 의존이 하나도 없고 `runtime_memberships`가 비어 있으며 Spring 주석이 0개다. incubating leaf의 올바른 상태다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다 — 그것을 잡으려면 별도 검사가 필요하다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **leaf의 각 public 클래스는 자기 레인에 테스트를 갖는다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
들어오는 것(project): `messaging-core-api`, `messaging-policy`, `messaging-transport-spi`, `messaging-kafka` — 넷 다 `api`. 들어오는 것(vendor): `org.apache.kafka:kafka-clients`(`implementation`) — **어떤 소스도 import하지 않는다**(§12.4). 나가는 것: **없다.** 어떤 leaf의 `allowed_dependencies`에도 이 leaf가 없고 starter 목록에도 없다.
|
||||
|
||||
## 이 기록이 다루는 파일 범위
|
||||
|
||||
:::evidence key="messaging-kafka-share-experimental-c02" alt="코드베이스에서 파일 목록을 만든 출력 4줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 4줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 세 가지 부재가 서로 맞는다
|
||||
|
||||
런타임 배선: 없음. `runtime_memberships: []`. bean 없음(Spring 주석 0개). **소비자 없음·membership 없음·조립 없음의 삼중 정합**이다 — `messaging-schema-avro`·`messaging-schema-protobuf`와 같은 형태이고, incubating leaf의 올바른 상태다.
|
||||
|
||||
## 선언했지만 쓰이지 않는 의존
|
||||
|
||||
네 project 의존 중 둘, 벤더 의존 하나가 미사용이다. §17.
|
||||
|
||||
<!-- body:end -->
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: messaging-nats-experimental-c02
|
||||
title: 검증기가 불리지 않는 지금 실제로 도는 게이트는 생성자다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:messaging-nats-experimental-c02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: messaging-nats-experimental-c02
|
||||
file: ../../../final/evidence/rendered/messaging-nats-experimental-c02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/messaging-nats-experimental-c02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-nats-experimental#L94 이다.
|
||||
module: messaging-nats-experimental
|
||||
---
|
||||
|
||||
# 검증기가 불리지 않는 지금 실제로 도는 게이트는 생성자다
|
||||
|
||||
`NatsJetStreamProfile`은 record이고 압축 생성자가 이 어댑터의 불변식을 전부 들고 있다. 검증기가 호출되지 않는 지금 실제로 실행되는 유일한 게이트가 거기다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`NatsJetStreamProfile` 은 record 이고, 압축 생성자가 이 어댑터의 불변식을 전부 들고 있다. 검증기가 호출되지 않는 지금, **실제로 실행되는 유일한 게이트가 여기다.**
|
||||
|
||||
## 거부 사유를 enum이 문장으로 들고 있다
|
||||
|
||||
`ackMode` 거부 사유는 `NatsAckMode` 자신이 문장으로 들고 있고(`rejectionReason()`), 프로파일이 그 문장을 예외 메시지에 그대로 싣는다. `NONE` 은 "forgotten", `ALL` 은 "still in flight" — 테스트가 그 두 낱말로 각각 걸어 잠근다.
|
||||
|
||||
## NatsJetStreamProfile 참조 위치
|
||||
|
||||
:::evidence key="messaging-nats-experimental-c02" alt="코드베이스에서 NatsJetStreamProfile 를 검색한 출력 17줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="NatsJetStreamProfile 코드베이스 검색 — 17줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 임계값의 정의가 한 곳에만 있다
|
||||
|
||||
`PARKING_HEADROOM = 1` 상수와 `parkAtDelivery() = maxDeliver - PARKING_HEADROOM` 가 §2 의 시점 선택을 숫자로 못 박는다. `NatsMaxDeliverParkingWorkflow.parkingThreshold()` 는 이 값을 그대로 위임한다 — 임계값의 정의가 한 곳에만 있다.
|
||||
|
||||
## 팩토리가 안전하다는 사실이 구멍을 닫지 않는다
|
||||
|
||||
**주의할 비대칭.** 편의 팩토리 `durable(subject, stream, durableName)` 는 중복 제거 창을 `Optional.of(2분)` 으로 채운다. 즉 팩토리를 거친 프로파일은 §17.1 의 구멍에 빠지지 않는다. 그러나 팩토리에도 호출자가 없고(저장소 전역 grep 0건), 정규 생성자는 빈 창을 정상값으로 받는다.
|
||||
|
||||
<!-- body:end -->
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: messaging-runtime-core-c07
|
||||
title: 이 리프는 통째로 하나의 수정이다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:messaging-runtime-core-c07
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: messaging-runtime-core-c07
|
||||
file: ../../../final/evidence/rendered/messaging-runtime-core-c07.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/messaging-runtime-core-c07.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-runtime-core#L631 이다.
|
||||
module: messaging-runtime-core
|
||||
---
|
||||
|
||||
# 이 리프는 통째로 하나의 수정이다
|
||||
|
||||
MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다. 이 리프가 채운 것은 기능이 아니라 조립의 빈칸이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **만들어 두고 흘리지 않는 진단값은 진단이 아니다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **증가한다고 문서화한 값이 리터럴이면 그 사실을 적는다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **안정 코드는 운영자의 행동이 갈리는 지점마다 나눈다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **`CompletionStage`를 반환하는 메서드는 동기적으로 던지지 않는다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
이 leaf는 **통째로 하나의 수정**이다. MSG-INT-003이라는 식별자가 세 파일의 javadoc에 나온다(`DeclaredDestinationAccess`, `TransportMessagingRuntime`, `MessagingCoreAutoConfiguration:461`).
|
||||
|
||||
| 위치 | 이전 상태 | 그것이 만든 실패 |
|
||||
|---|---|---|
|
||||
| `build.gradle` 주석 | `MessagePublisher` 구현 없음 | 자동설정이 없는 bean 위에 DLQ·facade bean을 쌓음 |
|
||||
| `TransportMessagingRuntime` javadoc | `MessagingRuntime` 구현 없음 | registry가 빈 채로 만들어져 모든 발행이 `PUBLISH_RUNTIME_UNAVAILABLE` — 목적지 해석·접근 확인·인코딩을 **전부 마친 뒤에** |
|
||||
| `DestinationProfileRegistry` javadoc | 논리 이름→프로파일 해석 없음 | 어댑터는 해석된 프로파일을 받는데 그것을 만들 publisher가 없었음 |
|
||||
| `DefaultDeliveryProcessor` javadoc | `HandleResult`→정산 연결 없음 | 각 어댑터가 retry/dead-letter의 뜻을 각자 결정 |
|
||||
| `withDeadline` javadoc | transport가 마감을 무시 | 확인이 오지 않는 Rabbit publish에 마감이 없어 호출자 스레드가 완료 불가능한 stage에 묶임 |
|
||||
|
||||
## DeclaredDestinationAccess 참조 위치
|
||||
|
||||
:::evidence key="messaging-runtime-core-c07" alt="코드베이스에서 DeclaredDestinationAccess 를 검색한 출력 3줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DeclaredDestinationAccess 코드베이스 검색 — 3줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 가짜로 빈칸을 채우면 안 되는 이유
|
||||
|
||||
`build.gradle` 주석의 마지막 문장이 이 leaf 전체의 교훈이다 — "A starter that filled the gap with an application-supplied fake would pass a context test while running none of them."
|
||||
|
||||
<!-- body:end -->
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: messaging-security-c02
|
||||
title: 이 리프는 messaging family에서 배선이 가장 잘 된 축이다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:messaging-security-c02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: messaging-security-c02
|
||||
file: ../../../final/evidence/rendered/messaging-security-c02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/messaging-security-c02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-security#L91 이다.
|
||||
module: messaging-security
|
||||
---
|
||||
|
||||
# 이 리프는 messaging family에서 배선이 가장 잘 된 축이다
|
||||
|
||||
들어오는 것은 `messaging-core-api` 하나이고 나가는 것은 일곱이다. 어댑터 두 곳이 직접 소비하고, 이 리프 자체는 Spring 주석을 갖지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **배선된 게이트는 자기 leaf 레인에서 검증한다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **같은 술어가 두 타입에 있으면 하나가 다른 하나를 부른다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **가변 필드로 상태 전이를 표현하면 가시성을 함께 정한다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **맵 갱신 함수 안에서 I/O를 하면 그 지연이 락 범위가 된다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
들어오는 것: `messaging-core-api`(api) 하나. 나가는 것: `messaging-runtime-core`, `messaging-kafka`, `messaging-rabbit`, `messaging-admin-runtime`, `messaging-pulsar-experimental`, `messaging-nats-experimental`, `messaging-spring-boot-starter`.
|
||||
|
||||
## 실제로 소비하는 곳
|
||||
|
||||
| 소비자 | 무엇을 쓰는가 |
|
||||
|---|---|
|
||||
| `messaging-kafka/KafkaSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry`, `CredentialProvider` |
|
||||
| `messaging-rabbit/RabbitSecurityConfigurer` | `BrokerTlsPolicy`, `CredentialRuntimeRegistry` |
|
||||
| `messaging-runtime-core/DefaultMessagePublisher` | `DestinationAccessPolicy` |
|
||||
| `messaging-runtime-core/DeclaredDestinationAccess` | `DestinationAccessPolicy` |
|
||||
| starter `MessagingCoreAutoConfiguration` | `MessageSecurityValidator`·`BrokerTlsPolicy`·`CredentialRuntimeRegistry` bean |
|
||||
| starter `MessagingCredentialRequirementValidator` | `CredentialProvider` |
|
||||
|
||||
## 이 기록이 다루는 파일 범위
|
||||
|
||||
:::evidence key="messaging-security-c02" alt="코드베이스에서 파일 목록을 만든 출력 12줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 12줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 리프 자체는 프레임워크를 모른다
|
||||
|
||||
이 leaf 자체는 Spring 주석을 갖지 않는다. 배선은 전부 소비자 쪽에서 이루어진다.
|
||||
|
||||
<!-- body:end -->
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: messaging-spring-boot-starter-c01
|
||||
title: 등록되어 있다는 것과 조립할 수 있다는 것을 분리했다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:messaging-spring-boot-starter-c01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: messaging-spring-boot-starter-c01
|
||||
file: ../../../final/evidence/rendered/messaging-spring-boot-starter-c01.svg
|
||||
- key: messaging-spring-boot-starter-c01-diagram
|
||||
file: ../../../final/assets/diagrams/messaging-spring-boot-starter-c01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/messaging-spring-boot-starter-c01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-spring-boot-starter#L94 이다.
|
||||
module: messaging-spring-boot-starter
|
||||
---
|
||||
|
||||
# 등록되어 있다는 것과 조립할 수 있다는 것을 분리했다
|
||||
|
||||
`MessagingProviderSelection`에 지도가 셋이고 셋째 지도가 이 클래스의 판단이다. 오늘 조립 가능한 전송은 `kafka` 하나다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **검증기는 발행이 아니라 주입이 강제다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`MessagingProviderSelection` 에 지도가 셋이다.
|
||||
|
||||
```java
|
||||
REGISTERED_BROKERS = {kafka: org.apache.kafka.clients.producer.Producer,
|
||||
rabbit: com.rabbitmq.client.Channel}
|
||||
PROVIDER_CONFIGURATIONS = {kafka: KafkaMessagingAutoConfiguration,
|
||||
rabbit: RabbitMessagingAutoConfiguration}
|
||||
BROKERS_WITHOUT_A_TRANSPORT = {rabbit: "…ships its validators and security configuration but no
|
||||
MessagingTransport…"}
|
||||
```
|
||||
|
||||
## 등록과 조립의 분리
|
||||
|
||||
:::evidence key="messaging-spring-boot-starter-c01-diagram" alt="선택 레지스트리에서 kafka 와 rabbit 으로 화살표가 나가고 rabbit 상자만 빗금으로 표시된 구조" caption="등록과 조립의 분리" zoom="false"
|
||||
:::
|
||||
|
||||
셋째 지도가 이 클래스의 판단이다. 등록되어 있다는 것과 조립할 수 있다는 것을 분리했고, 그 이유를 적었다 — Rabbit 을 고르면 핵심 설정 깊은 곳에서 `MessagingTransport` 빈이 없다는 오류가 나는데, 그것은 운영자에게 빈이 없다고만 말하지 고른 전송이 완성되지 않았다고는 말하지 않는다.
|
||||
|
||||
## MessagingProviderSelection 참조 위치
|
||||
|
||||
:::evidence key="messaging-spring-boot-starter-c01" alt="코드베이스에서 MessagingProviderSelection 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MessagingProviderSelection 코드베이스 검색 — 6줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 오늘 조립 가능한 전송은 하나다
|
||||
|
||||
결과로 오늘 조립 가능한 전송은 `kafka` 하나다. `RabbitMessagingAutoConfiguration` 98줄은 선택 단계에서 거부되므로 **어떤 경로로도 도달하지 않는다**(§12.3).
|
||||
|
||||
<!-- body:end -->
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: messaging-spring-cloud-stream-bridge-c01
|
||||
title: 플랫폼 보장과 헷갈릴 만큼 닮은 것이 이 리프의 위협 모델이다
|
||||
topic: composition-and-lifecycle-models
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: concept:messaging-spring-cloud-stream-bridge-c01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: messaging-spring-cloud-stream-bridge-c01
|
||||
file: ../../../final/evidence/rendered/messaging-spring-cloud-stream-bridge-c01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/messaging-spring-cloud-stream-bridge-c01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a19-messaging-spring-cloud-stream-bridge#L56 이다.
|
||||
module: messaging-spring-cloud-stream-bridge
|
||||
---
|
||||
|
||||
# 플랫폼 보장과 헷갈릴 만큼 닮은 것이 이 리프의 위협 모델이다
|
||||
|
||||
Spring Cloud Stream 바인딩을 이미 쓰는 서비스가 같은 논리 목적지에 닿게 하는 상호운용 seam이다. 바인더 의미론을 플랫폼 보장으로 승격하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **등록을 받는 컴포넌트는 해제도 제공한다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **함께 읽히는 두 맵은 한 값으로 묶는다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
- **한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다**
|
||||
같은 분석 리프에서 끌어낸 규칙이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
Spring Cloud Stream 바인딩을 이미 쓰는 서비스가 같은 논리 목적지에 닿게 하는 **상호운용 seam**이다.
|
||||
|
||||
> "The bridge is an interoperability seam, not a second messaging API… Binder semantics are never promoted to platform guarantees. Stream's binder has its own retry, its own dead-letter, and its own acknowledgement mode, and they look enough like the platform's to be mistaken for them — so a destination that actually relies on the platform's versions is refused by `StreamBridgePolicyGuard` rather than served with the binder's."
|
||||
|
||||
**"look enough like the platform's to be mistaken for them"**이 이 leaf 전체의 위협 모델이다.
|
||||
|
||||
## 차이를 드러내는 세 층
|
||||
|
||||
브리지는 기능을 추가하지 않고 **차이를 드러낸다.**
|
||||
|
||||
| 층 | 무엇을 |
|
||||
|---|---|
|
||||
| `StreamBridgePolicyGuard` | 플랫폼 보장에 의존하는 목적지를 아예 거절 |
|
||||
| `BindingProfileValidator` | 바인더 확장 속성이 프로파일 결정을 덮는 것을 거절 |
|
||||
| `BindingCapabilityReport` | 남은 차이를 **문장으로** 기록 |
|
||||
|
||||
세 번째가 특이하다 — 거절할 수 없는 차이를 문서화 가능한 값으로 만든다.
|
||||
|
||||
## 이 기록이 다루는 파일 범위
|
||||
|
||||
:::evidence key="messaging-spring-cloud-stream-bridge-c01" alt="코드베이스에서 파일 목록을 만든 출력 6줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 6줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
<!-- body:end -->
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a18-f001
|
||||
title: 출하되는 web 어댑터의 스위치가 활성화 모델 밖에 있다
|
||||
topic: composition-root-and-bootstrap
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a18-f001
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: analysis-finding-a18-f001
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a18-f001.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a18-f001.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a18#L214 이다.
|
||||
---
|
||||
|
||||
# 출하되는 web 어댑터의 스위치가 활성화 모델 밖에 있다
|
||||
|
||||
이 어댑터는 두 런타임의 구성원이라 빌드 전용 예외가 아니다. 그런데 네 스위치가 조건 안의 문자열 리터럴로만 존재해 마스터 스위치 자바독이 경계하는 상태다. 다만 이 어댑터는 성질이 달라 그 모델에 그대로 넣을 수 없다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **이 leaf의 리액티브 절반 29개 파일은 어떤 출하 배포에서도 활성화될 수 없다**
|
||||
같은 어댑터의 조립 미해결 사례다.
|
||||
- **능력 프로퍼티 이름을 만드는 코드와 실제 게이트가 다른 접두사를 쓴다**
|
||||
같은 계열의 스위치 이름 사례다.
|
||||
- **무엇을 스위치로 부를지 정하기 전에는 활성화 모델에 넣을 수 없다**
|
||||
이 사례가 그 규칙의 형태다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 조립 루트는 선택 어댑터의 활성화를 명시적 스위치 모델로 다룬다.
|
||||
|
||||
전부 기본 꺼짐이고, 환경 키 파일에 행이 있고, 삼자 일치 테스트가 강제한다.
|
||||
|
||||
웹 어댑터가 그 모델 안에 있는지 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
밖에 있다.
|
||||
|
||||
이 어댑터는 두 런타임의 구성원이므로 빌드 전용 예외에 해당하지 않는다.
|
||||
|
||||
그런데 그 네 스위치가 조건 안의 문자열 리터럴로만 존재한다.
|
||||
|
||||
마스터 스위치의 자바독이 경계하는 상태다. 조건 곳곳에 문자열 리터럴로 흩어지면 이름 변경이 조용한 활성화 변경이 된다는 것이다.
|
||||
|
||||
다만 이 어댑터는 다른 넷과 성질이 다르다.
|
||||
|
||||
두 전송 스위치가 없으면 참으로 처리되도록 되어 있어 기본이 켜짐이다. 그러므로 선택 어댑터가 아니다.
|
||||
|
||||
마스터 스위치가 규정하는 명시적 스위치 모델에 그대로 넣을 수 없다.
|
||||
|
||||
그리고 그 어댑터의 조립 자체가 미해결이다. 훑기에서 다섯 패키지를 빼고 넘겨받는 자동 설정을 만들지 않은 상태다.
|
||||
|
||||
기록하는 것은 순서다.
|
||||
|
||||
이 어댑터의 스위치를 활성화 모델에 넣는 것은 어떤 자동 설정이 무엇을 소유하는가를 먼저 정한 뒤에 할 수 있는 일이다.
|
||||
|
||||
지금은 무엇을 스위치로 부를지가 결정되지 않았다.
|
||||
|
||||
판정은 P3 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Spring Boot : 4.0.8
|
||||
확인 방식 : 스위치 선언 위치 확인과 활성화 모델 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/188 계열에 있다.
|
||||
|
||||
1. 활성화 모델의 규칙 셋을 확인한다.
|
||||
2. 이 어댑터의 런타임 구성원 목록을 확인한다.
|
||||
3. 네 스위치가 어디에 선언되어 있는지 확인한다.
|
||||
4. 두 전송 스위치의 기본값을 확인한다.
|
||||
5. 마스터 스위치 자바독의 경계 문장을 읽는다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`adapter-inbound-web`은 두 런타임 멤버이므로 build-only 예외에 해당하지 않는다(§4.1).
|
||||
|
||||
## MasterSwitch 참조 위치
|
||||
|
||||
:::evidence key="analysis-finding-a18-f001" alt="코드베이스에서 MasterSwitch 를 검색한 출력 17줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="MasterSwitch 코드베이스 검색 — 17줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 네 스위치가 문자열 리터럴로만 존재한다
|
||||
|
||||
`backend.web.mvc.enabled` · `backend.web.webflux.enabled`(둘 다 `matchIfMissing=true`, 기본 켜짐) · `backend.web.budgets.enabled` · `app.web-platform.durable-operations.enabled`가 `MasterSwitch`에도 `env-keys.yaml` 341개 키에도 없다. `MasterSwitch`의 javadoc이 경계하는 상태다 — "Spread across conditions as string literals, a rename becomes a silent activation change."
|
||||
|
||||
## 다만 web은 다른 넷과 성질이 다르다
|
||||
|
||||
MVC/WebFlux 스위치는 `matchIfMissing = true`로 기본 켜짐이므로 "옵션 어댑터"가 아니고, `MasterSwitch`가 규정하는 explicit switch 모델(전부 기본 꺼짐, `env-keys.yaml`에 행이 있고 삼자 일치 테스트가 강제)에 그대로 넣을 수 없다. 그리고 모듈 14 §8.1이 확인했듯 web의 조립 자체가 미해결이다.
|
||||
|
||||
## 기록하는 것은 순서다
|
||||
|
||||
web의 스위치를 활성화 모델에 넣는 것은 모듈 14 §8.1(어떤 자동설정이 무엇을 소유하는가)을 먼저 정한 뒤에 할 수 있는 일이다. 지금은 "무엇을 스위치로 부를지"가 결정되지 않았다. P3.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
스위치 이름을 바꿔 조용한 활성화 변경이 일어나는지 재현하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: analysis-finding-a18-f002
|
||||
title: 실패는 환경 원인이며, 그 테스트의 도구 가드가 불완전하다
|
||||
topic: composition-root-and-bootstrap
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:analysis-finding-a18-f002
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
body: case-analysis-finding-a18-f002.body.md
|
||||
assets:
|
||||
- key: analysis-finding-a18-f002
|
||||
file: ../../../final/evidence/rendered/analysis-finding-a18-f002.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/analysis-finding-a18-f002.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a18#L549 이다.
|
||||
---
|
||||
|
||||
# 실패는 환경 원인이며, 그 테스트의 도구 가드가 불완전하다
|
||||
|
||||
이 묶음의 유일한 실패는 저장소 결함이 아니다. 분석 환경에 도구 하나가 없고 위임된 스크립트가 그것을 요구하며 정직하게 실패한다. 기록하는 것은 같은 테스트가 첫째 도구는 건너뛰고 둘째 도구는 실패로 다룬다는 비대칭이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **아무것도 발견하지 못한 레인은 성공이 아니라 실패여야 한다**
|
||||
이 저장소가 가진 반대 방향 원칙이다.
|
||||
- **초록일 수 없는 게이트는 사람들이 건너뛰는 법을 배우게 만든다**
|
||||
같은 계열의 규칙이다.
|
||||
- **한 test 안에서 도구별로 갈리지 않는 편이 낫다**
|
||||
권고의 형태다.
|
||||
|
||||
## 문제
|
||||
|
||||
부트스트랩 묶음을 돌렸다.
|
||||
|
||||
천열다섯 테스트가 통과하고 하나가 실패하고 넷이 건너뛰어졌다.
|
||||
|
||||
그 하나를 확인했다.
|
||||
|
||||
## 결론
|
||||
|
||||
저장소 결함이 아니다.
|
||||
|
||||
실패 메시지가 원인을 그대로 적는다. 위임된 스크립트가 도구 하나를 요구하고 없어서 종료 코드 칠십팔로 끝났다는 것이다.
|
||||
|
||||
분석 환경에 그 도구가 없다.
|
||||
|
||||
기록하는 것은 가드의 비대칭이다.
|
||||
|
||||
테스트는 컨테이너 도구 부재를 가정으로 처리해 건너뛴다.
|
||||
|
||||
같은 스크립트가 요구하는 다른 도구의 부재는 실패로 나타난다.
|
||||
|
||||
도구가 없는 기계에서 이 테스트는 계약 위반처럼 읽히는 실패를 낸다.
|
||||
|
||||
메시지가 원인을 드러내므로 오해가 오래가지는 않는다. 다만 이미 건너뛰기를 선택한 테스트가 두 번째 도구에 대해서만 다르게 행동한다.
|
||||
|
||||
이 저장소는 반대 방향의 원칙도 갖고 있다.
|
||||
|
||||
역방향 프록시 레인이 건너뛰기를 거부하며 그 이유를 적는다. 컨테이너 실행기가 없을 때 조용히 통과하는 레인은 그 실행기가 마지막으로 망가진 이래로 아무것도 인증하지 않은 레인이라는 것이다.
|
||||
|
||||
두 원칙 중 어느 쪽을 택하든 한 테스트 안에서 도구별로 갈리지는 않는 편이 낫다.
|
||||
|
||||
판정은 P3 다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Gradle : 9.0.0
|
||||
확인 방식 : 묶음 실행과 실패 메시지 확인, 도구 존재 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/evidence/raw/188 계열에 있다.
|
||||
|
||||
1. 부트스트랩 묶음을 실행한다.
|
||||
2. 실패한 테스트와 메시지를 확인한다.
|
||||
3. 위임된 스크립트가 요구하는 도구를 확인한다.
|
||||
4. 그 도구가 환경에 있는지 확인한다.
|
||||
5. 테스트의 가정 처리 대상을 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
테스트 실패의 형태는 다음이다.
|
||||
|
||||
```
|
||||
org.opentest4j.AssertionFailedError: [verify-compose-profile-contracts.sh said:
|
||||
jq is required
|
||||
]
|
||||
expected: 0 but was: 78
|
||||
at ComposeMergeCharacterizationTest.everyLaneMatchesItsContract(ComposeMergeCharacterizationTest.java:62)
|
||||
|
||||
$ which jq -> NO_JQ
|
||||
```
|
||||
|
||||
## 테스트 실패의 형태
|
||||
|
||||
:::evidence key="analysis-finding-a18-f002" alt="분석 문서 final/document.md#a18 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a18 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 기록하는 것은 가드의 비대칭이다
|
||||
|
||||
```java
|
||||
@Test
|
||||
void everyLaneMatchesItsContract() {
|
||||
Assumptions.assumeTrue(dockerComposeIsAvailable(), "docker compose is not on this machine");
|
||||
ProcessResult result = run(List.of("./scripts/verify-compose-profile-contracts.sh"));
|
||||
assertThat(result.exitCode()).isZero();
|
||||
}
|
||||
```
|
||||
|
||||
docker compose는 가정으로 확인하고 `jq`는 확인하지 않는다. P3.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
없는 도구를 설치하지 않았다. 분석 환경을 바꾸지 않는다는 원칙 때문이다. 계약 일치 자체는 독립 구현으로 따로 확인했다.
|
||||
|
||||
저장소 결함이 아니다. 분석 컨테이너에 jq가 없고, 위임된 스크립트가 그것을 요구하며 exit 78로 정직하게 실패한다.
|
||||
|
||||
<!-- body:end -->
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: grpc-advanced-diagnostics-f03
|
||||
title: "실환경 증거" 가 두 리프에 반씩 있고 서로 만나지 않는다
|
||||
topic: contract-domain-and-bounds
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:grpc-advanced-diagnostics-f03
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: grpc-advanced-diagnostics-f03
|
||||
file: ../../../final/evidence/rendered/grpc-advanced-diagnostics-f03.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/grpc-advanced-diagnostics-f03.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-advanced-diagnostics#L218 이다.
|
||||
module: grpc-advanced-diagnostics
|
||||
priority: P3
|
||||
---
|
||||
|
||||
# "실환경 증거" 가 두 리프에 반씩 있고 서로 만나지 않는다
|
||||
|
||||
이 리프가 능력별로 무엇이 실환경인지 정의한다. 그리고 grpc-advanced-bootstrap 이 승격 증거로 그것을 요구한다.
|
||||
|
||||
## 문제
|
||||
|
||||
이 리프가 능력별로 무엇이 실환경인지 정의한다.
|
||||
|
||||
그리고 grpc-advanced-bootstrap 이 승격 증거로 그것을 요구한다.
|
||||
|
||||
## 결론
|
||||
|
||||
GrpcAdvancedPromotionGate.evaluate 는 그 불리언이 거짓이면 "xds has no real environment test" 를 차단 사유로 낸다.
|
||||
|
||||
그 불리언을 무엇으로 채워야 하는지는 그쪽에서 답하지 않고, 답하는 코드가 이 리프에 있는데 두 쪽이 서로를 부르지 않는다.
|
||||
|
||||
결과: GrpcAdvancedPromotionEvidence.complete(XDS, 7일) 은 realEnvironmentTest = true 를 그냥 넣는다.
|
||||
|
||||
xDS 통제 평면이 실제로 있었는지와 무관하다.
|
||||
|
||||
이 리프의 javadoc 이 경계한 상태 — "a suite that runs without the infrastructure passes and establishes nothing" — 를 승격 게이트가 그대로 통과시킬 수 있다.
|
||||
|
||||
두 리프 모두 배선되지 않았고 승격은 사람이 수행한다.
|
||||
|
||||
다만 이 두 조각이 존재하는 이유가 "그 판단을 코드로 적어 두는 것" 이므로, 판단의 절반이 다른 절반을 부르지 않는 것은 그 목적에 어긋난다.
|
||||
|
||||
grpc-advanced-edition §17.2 가 같은 가족에서 같은 모양을 기록했다 — 두 승격 게이트가 서로를 부르지 않는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12 java -version 으로 확인
|
||||
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
|
||||
확인 방식 : GrpcAdvancedPromotionGate 참조 14건 검색과 두 리프의 실환경 증거 정의 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/document.md#a20-grpc-advanced-diagnostics#L218 에 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
이 리프가 능력별로 무엇이 실환경인지 정의한다.
|
||||
|
||||
```java
|
||||
public static Set<Infrastructure> requiredFor(GrpcAdvancedCapability capability) { … }
|
||||
public static List<String> missingInfrastructure(GrpcAdvancedCapability capability, Set<Infrastructure> available) { … }
|
||||
```
|
||||
|
||||
그리고 `grpc-advanced-bootstrap` 이 승격 증거로 그것을 요구하는데, 증거는 불리언 하나다.
|
||||
|
||||
## GrpcAdvancedPromotionGate 참조 위치
|
||||
|
||||
:::evidence key="grpc-advanced-diagnostics-f03" alt="코드베이스에서 GrpcAdvancedPromotionGate 를 검색한 출력 14줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcAdvancedPromotionGate 코드베이스 검색 — 14줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 두 쪽이 서로를 부르지 않는다
|
||||
|
||||
`GrpcAdvancedPromotionGate.evaluate` 는 그 불리언이 거짓이면 "xds has no real environment test" 를 차단 사유로 낸다. 그 불리언을 무엇으로 채워야 하는지는 그쪽에서 답하지 않고, 답하는 코드가 이 리프에 있다. 결과적으로 `GrpcAdvancedPromotionEvidence.complete(XDS, 7일)` 은 `realEnvironmentTest = true` 를 그냥 넣는다 — xDS 통제 평면이 실제로 있었는지와 무관하게. 이 리프의 javadoc 이 경계한 상태("a suite that runs without the infrastructure passes and establishes nothing")를 승격 게이트가 그대로 통과시킬 수 있다.
|
||||
|
||||
## 왜 P3 인가
|
||||
|
||||
두 리프 모두 배선되지 않았고 승격은 사람이 수행한다. 다만 이 두 조각이 존재하는 이유가 "그 판단을 코드로 적어 두는 것" 이므로, 판단의 절반이 다른 절반을 부르지 않는 것은 그 목적에 어긋난다. `grpc-advanced-edition` §17.2 가 같은 가족에서 같은 모양을 기록했다.
|
||||
|
||||
## 수정
|
||||
|
||||
`GrpcAdvancedPromotionEvidence.realEnvironmentTest` 를 불리언 대신 `Set<Infrastructure> availableInfrastructure` 로 바꾸고, 게이트가 `missingInfrastructure(capability, available)` 를 불러 그 결과를 차단 사유에 합친다. 그러면 "실환경 테스트를 했다" 가 선언이 아니라 능력별 목록에 대한 대조가 된다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
두 리프를 실제로 이어 승격 증거가 흐르는지 확인하지 않았다. 각 리프가 선언한 항목의 대조로 판정했다.
|
||||
|
||||
<!-- body:end -->
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: grpc-advanced-resilience-f02
|
||||
title: 대체 선택기는 사용자 정의 선택기가 받는 보호를 받지 않는다
|
||||
topic: contract-domain-and-bounds
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:grpc-advanced-resilience-f02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: grpc-advanced-resilience-f02
|
||||
file: ../../../final/evidence/rendered/grpc-advanced-resilience-f02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/grpc-advanced-resilience-f02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-advanced-resilience#L155 이다.
|
||||
module: grpc-advanced-resilience
|
||||
priority: P3
|
||||
---
|
||||
|
||||
# 대체 선택기는 사용자 정의 선택기가 받는 보호를 받지 않는다
|
||||
|
||||
fallback.pick(selectable) 은 감싸이지 않는다. 대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.
|
||||
|
||||
## 문제
|
||||
|
||||
fallback.pick(selectable) 은 감싸이지 않는다.
|
||||
|
||||
대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.
|
||||
|
||||
## 결론
|
||||
|
||||
기본 생성자는 플랫폼의 라운드 로빈을 대체로 쓰므로 지금은 안전하다.
|
||||
|
||||
그러나 두 인자 생성자가 임의의 선택기를 대체로 받고, 그 인자에는 아무 제약이 없다.
|
||||
|
||||
이 클래스의 존재 이유가 "선택기 버그가 가용성이 아니라 균형을 저하시키게 하는 것" 인데, 대체 선택기의 버그는 가용성을 저하시킨다.
|
||||
|
||||
수정은 대체 호출도 같은 검사를 지나게 하거나(그 결과가 널이거나 목록 밖이면 플랫폼 라운드 로빈으로 한 번 더 떨어진다), 두 인자 생성자를 없애 대체를 플랫폼 것으로 고정하는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12 java -version 으로 확인
|
||||
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
|
||||
확인 방식 : 사용자 정의 선택기와 대체 선택기의 호출 지점에 걸린 방어 코드 대조
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/document.md#a20-grpc-advanced-resilience#L155 에 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`fallback.pick(selectable)` 은 감싸이지 않는다. 대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.
|
||||
|
||||
## 대체 선택기가 감싸이지 않는다
|
||||
|
||||
:::evidence key="grpc-advanced-resilience-f02" alt="분석 문서 final/document.md#a20-grpc-advanced-resilience 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a20-grpc-advanced-resilience 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 지금은 안전하다
|
||||
|
||||
기본 생성자는 플랫폼의 라운드 로빈을 대체로 쓴다. 그러나 두 인자 생성자가 임의의 선택기를 대체로 받고, 그 인자에는 아무 제약이 없다.
|
||||
|
||||
## 클래스의 존재 이유가 뒤집힌다
|
||||
|
||||
"선택기 버그가 가용성이 아니라 균형을 저하시키게 하는 것" 인데, 대체 선택기의 버그는 가용성을 저하시킨다. 수정은 대체 호출도 같은 검사를 지나게 하거나(결과가 널이거나 목록 밖이면 플랫폼 라운드 로빈으로 한 번 더 떨어진다), 두 인자 생성자를 없애 대체를 플랫폼 것으로 고정하는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
대체 선택기가 널이나 목록 밖 엔드포인트를 돌려주는 상황을 실행으로 재현하지 않았다. 두 호출 지점의 감싸기 유무로 판정했다.
|
||||
|
||||
<!-- body:end -->
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: grpc-advanced-streaming-f01
|
||||
title: 클래스가 비판한 무제한 증가를 형제 맵이 그대로 한다
|
||||
topic: contract-domain-and-bounds
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:grpc-advanced-streaming-f01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: grpc-advanced-streaming-f01
|
||||
file: ../../../final/evidence/rendered/grpc-advanced-streaming-f01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/grpc-advanced-streaming-f01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-advanced-streaming#L125 이다.
|
||||
module: grpc-advanced-streaming
|
||||
priority: P3
|
||||
---
|
||||
|
||||
# 클래스가 비판한 무제한 증가를 형제 맵이 그대로 한다
|
||||
|
||||
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session". 체크포인트는 그 비판을 지킨다.
|
||||
|
||||
## 문제
|
||||
|
||||
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session".
|
||||
|
||||
체크포인트는 그 비판을 지킨다.
|
||||
|
||||
## 결론
|
||||
|
||||
세션당 항목 하나이고 순번만 앞으로 간다.
|
||||
|
||||
형제 맵은 지키지 않는다.
|
||||
|
||||
제거는 endSession 뿐이고, 그때 그 세션의 접두를 가진 키를 전부 지운다.
|
||||
|
||||
그러므로 결과 참조를 기록하는 세션에서는 적용된 메시지 수만큼 항목이 쌓인다.
|
||||
|
||||
상한도 만료도 없다.
|
||||
|
||||
클래스 javadoc 은 다르게 말한다.
|
||||
|
||||
작은 창이 코드에 없다.
|
||||
|
||||
체크포인트가 앞으로 가도 그 이전 결과들은 남는다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12 java -version 으로 확인
|
||||
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
|
||||
확인 방식 : 체크포인트와 형제 맵의 제거 경로 유무 대조, 클래스 javadoc 의 거부 근거 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/document.md#a20-grpc-advanced-streaming#L125 에 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session".
|
||||
|
||||
## javadoc 이 집합 방식을 거부한 이유
|
||||
|
||||
:::evidence key="grpc-advanced-streaming-f01" alt="분석 문서 final/document.md#a20-grpc-advanced-streaming 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="final/document.md#a20-grpc-advanced-streaming 발췌 — 15줄" zoom="true"
|
||||
:::
|
||||
|
||||
## 체크포인트는 그 비판을 지키고 형제 맵은 지키지 않는다
|
||||
|
||||
체크포인트는 세션당 항목 하나이고 순번만 앞으로 간다. 형제 맵의 제거는 `endSession` 뿐이고, 그때 그 세션의 접두를 가진 키를 전부 지운다. 그러므로 결과 참조를 기록하는 세션에서는 적용된 메시지 수만큼 항목이 쌓인다 — 상한도 만료도 없다. 클래스 javadoc 은 다르게 말한다: 작은 창이 코드에 없다.
|
||||
|
||||
## 실제로 필요한 창은 좁다
|
||||
|
||||
판정이 `alreadyApplied(sequence)` 로 재생을 결정하고, 재생 응답에 쓰이는 것은 그 순번의 결과 하나다. 체크포인트보다 한참 뒤처진 순번의 결과가 필요할 상황은 재개 직후의 좁은 구간뿐이다. 수정은 창을 실제로 만드는 것이다 — 세션당 최근 N개만 유지하거나, 체크포인트가 앞으로 갈 때 그보다 오래된 항목을 지운다. 후자가 자바독의 서술과 정확히 같다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
장시간 실행으로 이 맵의 증가를 측정하지 않았다. 제거 경로가 없다는 것으로 판정했다.
|
||||
|
||||
<!-- body:end -->
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: grpc-advanced-streaming-f02
|
||||
title: 클라이언트 스트림 정책의 네 상한 중 둘은 읽는 코드가 없다
|
||||
topic: contract-domain-and-bounds
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:grpc-advanced-streaming-f02
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: grpc-advanced-streaming-f02
|
||||
file: ../../../final/evidence/rendered/grpc-advanced-streaming-f02.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/grpc-advanced-streaming-f02.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-advanced-streaming#L159 이다.
|
||||
module: grpc-advanced-streaming
|
||||
priority: P3
|
||||
---
|
||||
|
||||
# 클라이언트 스트림 정책의 네 상한 중 둘은 읽는 코드가 없다
|
||||
|
||||
GrpcClientStreamPolicy javadoc 이 네 상한을 모두 든다. 저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
|
||||
|
||||
## 문제
|
||||
|
||||
GrpcClientStreamPolicy javadoc 이 네 상한을 모두 든다.
|
||||
|
||||
저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
|
||||
|
||||
## 결론
|
||||
|
||||
Advanced 가족이 미배선이라는 사실과는 별개다 — 이 리프 안에도 그 값을 쓰는 코드가 없다.
|
||||
|
||||
수요 상한을 강제하는 GrpcDemandController 는 GrpcManualFlowControlPolicy 를 쓰고, 이 정책을 보지 않는다.
|
||||
|
||||
wholeStreamRetryAllowed() 는 항상 거짓을 돌려주는 형태이므로 그 자체가 문서화 장치다.
|
||||
|
||||
나머지 둘은 강제 지점이 필요하다.
|
||||
|
||||
수정은 상한을 강제하는 지점을 만들거나(수신 경로에 속도·미확인 수 검사), 강제되지 않는 값이 강제되는 것처럼 읽히지 않도록 자바독을 낮추는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12 java -version 으로 확인
|
||||
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
|
||||
확인 방식 : GrpcClientStreamPolicy 참조 10건 검색으로 네 상한의 접근자 호출 수 집계
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/document.md#a20-grpc-advanced-streaming#L159 에 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcClientStreamPolicy` javadoc 이 네 상한을 모두 든다. 저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
|
||||
|
||||
## GrpcClientStreamPolicy 참조 위치
|
||||
|
||||
:::evidence key="grpc-advanced-streaming-f02" alt="코드베이스에서 GrpcClientStreamPolicy 를 검색한 출력 10줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcClientStreamPolicy 코드베이스 검색 — 10줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## Advanced 미배선과는 별개다
|
||||
|
||||
이 리프 안에도 그 값을 쓰는 코드가 없다. 수요 상한을 강제하는 `GrpcDemandController` 는 `GrpcManualFlowControlPolicy` 를 쓰고, 이 정책을 보지 않는다.
|
||||
|
||||
## 넷 중 하나는 그 자체가 문서화 장치다
|
||||
|
||||
`wholeStreamRetryAllowed()` 는 항상 거짓을 돌려주는 형태다. 나머지 둘은 강제 지점이 필요하다. 수정은 상한을 강제하는 지점을 만들거나(수신 경로에 속도·미확인 수 검사), 강제되지 않는 값이 강제되는 것처럼 읽히지 않도록 자바독을 낮추는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제 스트림을 열어 두 상한이 무시되는 것을 관측하지 않았다. 배선 경로가 없다.
|
||||
|
||||
<!-- body:end -->
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: grpc-core-api-f05
|
||||
title: 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다
|
||||
topic: contract-domain-and-bounds
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:grpc-core-api-f05
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: grpc-core-api-f05
|
||||
file: ../../../final/evidence/rendered/grpc-core-api-f05.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/grpc-core-api-f05.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-core-api#L219 이다.
|
||||
module: grpc-core-api
|
||||
priority: P3
|
||||
---
|
||||
|
||||
# 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다
|
||||
|
||||
GrpcMetadataBudget 은 세 성분을 갖는다 — maxTotalBytes·maxUserDefinedBytes·maxEntries. check(...) 가 보는 것은 뒤의 둘뿐이다.
|
||||
|
||||
## 문제
|
||||
|
||||
GrpcMetadataBudget 은 세 성분을 갖는다 — maxTotalBytes·maxUserDefinedBytes·maxEntries.
|
||||
|
||||
check(...) 가 보는 것은 뒤의 둘뿐이다.
|
||||
|
||||
## 결론
|
||||
|
||||
저장소 전체에서 이 접근자를 부르는 곳은 compact constructor 의 순서 가드와 테스트 단언 하나뿐이다.
|
||||
|
||||
자바독은 그 이유를 설명한다 — 하드 총계를 넘기는 것은 프레임워크가 던지는 전송 거절이고, 여기서 함께 검사하면 "고칠 수 있는 쪽" 과 "고칠 수 없는 쪽" 이 한 자리에서 발견된다는 것.
|
||||
|
||||
판단은 옳다.
|
||||
|
||||
다만 그 결과로 이 record 는 자기가 쓰지 않는 수를 성분으로 들고 있고, 이름은 그것이 강제된다고 읽힌다.
|
||||
|
||||
성분 이름은 ...Bytes 인데 세는 것은 String.length(), 즉 UTF-16 코드 단위다.
|
||||
|
||||
키는 [a-z0-9._-] 로 제한되어 ASCII 지만 값에는 문자 집합 제약이 없다.
|
||||
|
||||
다중 바이트 문자를 담은 값은 실제 프레임보다 적게 계산된다.
|
||||
|
||||
gRPC 의 ASCII 메타데이터 값은 프로토콜 상 인쇄 가능 ASCII 여야 하므로 실무에서는 대개 일치한다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12 java -version 으로 확인
|
||||
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
|
||||
확인 방식 : GrpcMetadataBudget 참조 26건 검색과 check 가 실제로 보는 성분 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/document.md#a20-grpc-core-api#L219 에 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcMetadataBudget` 은 세 성분을 갖는다 — `maxTotalBytes`·`maxUserDefinedBytes`·`maxEntries`. `check(...)` 가 보는 것은 뒤의 둘뿐이다. 저장소 전체에서 `maxTotalBytes` 접근자를 부르는 곳은 compact constructor 의 순서 가드와 테스트 단언 하나뿐이다.
|
||||
|
||||
## GrpcMetadataBudget 참조 위치
|
||||
|
||||
:::evidence key="grpc-core-api-f05" alt="코드베이스에서 GrpcMetadataBudget 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcMetadataBudget 코드베이스 검색 — 26줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 그 판단은 옳다
|
||||
|
||||
자바독이 이유를 설명한다 — 하드 총계를 넘기는 것은 프레임워크가 던지는 전송 거절이고, 여기서 함께 검사하면 "고칠 수 있는 쪽" 과 "고칠 수 없는 쪽" 이 한 자리에서 발견된다. 다만 그 결과로 이 record 는 자기가 쓰지 않는 수를 성분으로 들고 있고, 이름은 그것이 강제된다고 읽힌다.
|
||||
|
||||
## 이름은 Bytes 인데 세는 것은 문자다
|
||||
|
||||
`String.length()`, 즉 UTF-16 코드 단위다. 키는 `[a-z0-9._-]` 로 제한되어 ASCII 지만 값에는 문자 집합 제약이 없으므로, 다중 바이트 문자를 담은 값은 실제 프레임보다 적게 계산된다.
|
||||
|
||||
## 실무에서는 대개 일치한다
|
||||
|
||||
gRPC 의 ASCII 메타데이터 값은 프로토콜 상 인쇄 가능 ASCII 여야 한다. 다만 그 제약을 이 클래스가 검사하지 않으므로, 일치는 보장이 아니라 관행이다. 수정은 둘 다 작다 — `value.getBytes(StandardCharsets.US_ASCII).length` 로 세거나 값의 문자 집합을 `GrpcMetadataKey.Kind.ASCII` 에 맞춰 검증하고, `maxTotalBytes` 는 성분에서 빼고 javadoc 의 서술로 남긴다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
다국어 헤더 값으로 문자 수와 바이트 수의 차이를 실행으로 관측하지 않았다. 계산 대상의 코드 형태로 판정했다.
|
||||
|
||||
<!-- body:end -->
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
---
|
||||
kind: CASE
|
||||
slug: grpc-discovery-f01
|
||||
title: 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다
|
||||
topic: contract-domain-and-bounds
|
||||
project: clean-architecture-backend-template
|
||||
status: 게시 전
|
||||
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
|
||||
rootTreeNode: case:grpc-discovery-f01
|
||||
evidenceCapturedOn: 2026-09-01
|
||||
assets:
|
||||
- key: grpc-discovery-f01
|
||||
file: ../../../final/evidence/rendered/grpc-discovery-f01.svg
|
||||
evidence:
|
||||
- ../../../final/evidence/raw/grpc-discovery-f01.txt
|
||||
source:
|
||||
- 원본 분석 절은 final/document.md#a20-grpc-discovery#L152 이다.
|
||||
module: grpc-discovery
|
||||
priority: P3
|
||||
---
|
||||
|
||||
# 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다
|
||||
|
||||
GrpcKubernetesProfile 은 세 시간 값을 다룬다. 검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반.
|
||||
|
||||
## 문제
|
||||
|
||||
GrpcKubernetesProfile 은 세 시간 값을 다룬다.
|
||||
|
||||
검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반.
|
||||
|
||||
## 결론
|
||||
|
||||
셋째는 비교 대상에 없다.
|
||||
|
||||
그래서 headlessStreaming()(재접속 예산 5초, 배수 유예 30초)에서 갱신 주기는 여전히 30초다.
|
||||
|
||||
롤아웃으로 스트림이 끊긴 클라이언트가 5초 예산 안에 재접속하려 할 때, 그 클라이언트의 DNS 캐시는 최대 30초 동안 사라진 파드 주소를 들고 있을 수 있다.
|
||||
|
||||
그 실패가 GrpcResolverProfile 자신의 javadoc 이 서술한 것이다 — "A channel that resolved once at startup keeps sending to addresses that stopped existing an hour ago; the calls fail with UNAVAILABLE and the deployment looks unhealthy long after it finished." 수정은 갱신 주기를 재접속 예산에서 파생시키거나(예: 예산 이하), 검증기에 세 값의 순서 규칙을 추가하는 것이다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
OpenJDK : 21.0.12 java -version 으로 확인
|
||||
Gradle : 9.0.0 src/gradle/wrapper/gradle-wrapper.properties 의 distributionUrl 로 확인
|
||||
확인 방식 : GrpcKubernetesProfile 참조 26건 검색과 검증기가 비교하는 시간 값 쌍 확인
|
||||
소스 수정 : x
|
||||
|
||||
## 재현 조건
|
||||
|
||||
원문은 final/document.md#a20-grpc-discovery#L152 에 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
`GrpcKubernetesProfile` 은 세 시간 값을 다룬다. 검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반.
|
||||
|
||||
## GrpcKubernetesProfile 참조 위치
|
||||
|
||||
:::evidence key="grpc-discovery-f01" alt="코드베이스에서 GrpcKubernetesProfile 를 검색한 출력 26줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GrpcKubernetesProfile 코드베이스 검색 — 26줄 · exit 0" zoom="true"
|
||||
:::
|
||||
|
||||
## 셋째는 비교 대상에 없다
|
||||
|
||||
그래서 `headlessStreaming()`(재접속 예산 5초, 배수 유예 30초)에서 갱신 주기는 여전히 30초다.
|
||||
|
||||
## 그 조합이 만드는 상황
|
||||
|
||||
롤아웃으로 스트림이 끊긴 클라이언트가 5초 예산 안에 재접속하려 할 때, 그 클라이언트의 DNS 캐시는 최대 30초 동안 사라진 파드 주소를 들고 있을 수 있다. 그 실패가 `GrpcResolverProfile` 자신의 javadoc 이 서술한 것이다 — "A channel that resolved once at startup keeps sending to addresses that stopped existing an hour ago; the calls fail with `UNAVAILABLE` and the deployment looks unhealthy long after it finished."
|
||||
|
||||
## 수정
|
||||
|
||||
갱신 주기를 재접속 예산에서 파생시키거나(예: 예산 이하), 검증기에 세 값의 순서 규칙을 추가하는 것이다.
|
||||
|
||||
## 확인하지 못한 것
|
||||
|
||||
실제 DNS 리졸버로 헤드리스 레코드를 조회해 갱신 주기와 재접속 예산의 관계를 관측하지 않았다. 이 리프는 주소 수를 입력으로 받는다.
|
||||
|
||||
<!-- body:end -->
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user