docs(keycloak-session-store): import the session-storage lab as a new project

The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,63 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c04
title: 다섯 예산 계층 중 요청 계층만 배선돼 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c04
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c04.svg
- key: adapter-inbound-graphql-c04-diagram
file: ../../../final/assets/diagrams/adapter-inbound-graphql-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c04.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L354 이다.
module: adapter-inbound-graphql
---
# 다섯 예산 계층 중 요청 계층만 배선돼 있다
설계 §10이 다섯 계층을 정의하고 `GraphQlDeadlinePropagator`가 그 파생을 담는다. 실제로 배선된 것은 요청 계층 하나이고, 나머지 파생 메서드는 프로덕션 호출자가 없다.
## 본문
<!-- body:start -->
설계 §10이 다섯 계층을 정의하고 `GraphQlDeadlinePropagator`가 그 파생을 담는다. 실제 강제 상태는 이렇다.
| 계층 | 파생 지점 | 배선 |
|---|---|---|
| 전송 핸드셰이크 | — | (이 sub-scope 밖) |
| **요청** | `GraphQlPlatformWebInterceptor:135``GraphQlDeadline.after(policy.maxExecutionTime(), clock)` | **예** |
| 리졸버 | `GraphQlDeadlinePropagator.resolverBudget(...)` | 아니오 |
| DataLoader 배치 | `GraphQlDeadlinePropagator.dataLoaderBatchTimeout(...)` | 아니오 |
| 다운스트림(DB/HTTP) | `GraphQlDeadlinePropagator.downstreamDeadline(...)` | 아니오 |
| 구독 연결 | `GraphQlDeadlinePropagator.subscriptionDeadline(...)` | 아니오 |
## 요청 예산에서 파생되는 계층
:::evidence key="adapter-inbound-graphql-c04-diagram" alt="배선된 요청 데드라인 상자에서 나가는 화살표가 없고, 네 파생 계층이 파생 없음 이라고 이름 붙은 별도 영역 안에 빗금으로 놓인 구조" caption="요청 예산에서 파생되는 계층" zoom="false"
:::
## 참조가 갇혀 있는 범위
`GraphQlTimeoutPolicy``GraphQlResolverBudget`의 main 참조자를 전수하면 전부 `execution` 패키지 안(그리고 미배선 클러스터 안)이다.
```text
GraphQlTimeoutPolicy <- GraphQlRequestCancelledException, GraphQlDeadlinePropagator, GraphQlResolverBudget
GraphQlResolverBudget <- GraphQlResolverDescriptor, GraphQlResolverCatalog, GraphQlDeadlinePropagator, GraphQlExecutionProfileValidator
```
§12.1.
## GraphQlDeadlinePropagator 참조 위치
:::evidence key="adapter-inbound-graphql-c04" alt="코드베이스에서 GraphQlDeadlinePropagator 를 검색한 출력 5줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlDeadlinePropagator 코드베이스 검색 — 5줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: adapter-inbound-graphql-c05
title: 요청 데드라인이 실제로 실행을 끊는 경로가 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-graphql-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-graphql-c05
file: ../../../final/evidence/rendered/adapter-inbound-graphql-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-graphql-c05.txt
source:
- 원본 분석 절은 analysis/16-adapter-inbound-graphql.md#L376 이다.
module: adapter-inbound-graphql
---
# 요청 데드라인이 실제로 실행을 끊는 경로가 있다
`GraphQlCancellation`(93)이 세 곳에서 쓰인다. 요청 계층의 데드라인은 만들어지기만 하는 것이 아니라 실행을 실제로 끊는다.
## 본문
<!-- body:start -->
`GraphQlCancellation`(93)은 `cost/GraphQlRuntimeBudgetTracker` · `advanced/incremental` · `advanced/subscription` 세 곳에서 쓰인다.
## GraphQlCancellation 참조 위치
:::evidence key="adapter-inbound-graphql-c05" alt="코드베이스에서 GraphQlCancellation 를 검색한 출력 30줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="GraphQlCancellation 코드베이스 검색 — 30줄 · exit 0" zoom="true"
:::
## 요청 계층이 완결돼 있다는 뜻
요청 데드라인이 실제로 실행을 끊는 경로가 존재한다는 뜻이고, `GraphQlRequestContext.withDeadline`의 단조 조이기와 함께 요청 계층은 완결돼 있다.
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c05
title: 예산 게이트 프로퍼티가 자바 한 줄에만 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c05
file: ../../../final/evidence/rendered/adapter-inbound-web-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c05.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L666 이다.
module: adapter-inbound-web
---
# 예산 게이트 프로퍼티가 자바 한 줄에만 있다
`backend.web.budgets`를 저장소 전체에서 찾으면 자바 한 줄뿐이다. 어떤 `application.yml`에도 없고 `matchIfMissing`도 없으므로 이 핸들러는 기본 꺼짐이다.
## 본문
<!-- body:start -->
`backend.web.budgets`를 저장소 전체에서 찾으면 자바 한 줄뿐이다.
```text
main/.../mvc/budget/WebMvcBudgetExceptionHandler.java:40:@ConditionalOnProperty(prefix = "backend.web.budgets", name = "enabled", havingValue = "true")
```
어떤 `application.yml`에도 `backend.web.budgets`가 없고 `matchIfMissing`도 없으므로 이 핸들러는 **기본 꺼짐**이다.
## BudgetProblemMapper 참조 위치
:::evidence key="adapter-inbound-web-c05" alt="코드베이스에서 BudgetProblemMapper 를 검색한 출력 18줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="BudgetProblemMapper 코드베이스 검색 — 18줄 · exit 0" zoom="true"
:::
## 켜더라도 필요한 빈이 없다
그 생성자가 요구하는 `BudgetProblemMapper` 빈을 선언하는 코드가 main·app-bootstrap 어디에도 없다 — 참조자는 두 필터와 이 핸들러 자신뿐이고, 셋 다 빈 정의가 아니다.
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c14
title: forwarded 헤더를 해석하는 쪽은 피어를 검사하지 않는다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c14
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c14
file: ../../../final/evidence/rendered/adapter-inbound-web-c14.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c14.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L1098 이다.
module: adapter-inbound-web
---
# forwarded 헤더를 해석하는 쪽은 피어를 검사하지 않는다
신뢰 프록시 판정을 담은 `proxy` 패키지 421 LOC이 프로덕션 조립에 들어가지 않는다. 실제로 forwarded 헤더를 해석하는 것은 Spring Boot가 등록하는 필터이고, 그것은 피어가 신뢰된 프록시인지 검사하지 않는다.
## 본문
<!-- body:start -->
신뢰 프록시 판정을 담은 타입들의 참조를 세면 프로덕션 경로가 없다.
```text
TrustedProxyPolicy 6 test 1 testkit
NormalizedForwardedHeaders 4 main 8 test <- main 참조자는 proxy 패키지 내부
ForwardedHeaderSanitizer 2 test 1 testkit
```
`proxy` 패키지 421 LOC이 프로덕션 조립에 들어가지 않는다.
## 실제로 헤더를 해석하는 쪽
실제로 forwarded 헤더를 해석하는 것은 Spring Boot의 `server.forward-headers-strategy=framework`(app-bootstrap `application.yml:321` 기본값)가 등록하는 `ForwardedHeaderFilter`/`ForwardedHeaderTransformer`이고, 그것은 **피어가 신뢰된 프록시인지 검사하지 않는다**. §32.2.
## 분석 원문의 참조 집계
:::evidence key="adapter-inbound-web-c14" alt="분석 문서 analysis/14-adapter-inbound-web.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/14-adapter-inbound-web.md 발췌 — 15줄" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,46 @@
---
kind: CONCEPT
slug: adapter-inbound-web-c18
title: fileserver 매핑 검증은 회로가 닫혀 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-inbound-web-c18
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-inbound-web-c18
file: ../../../final/evidence/rendered/adapter-inbound-web-c18.svg
evidence:
- ../../../final/evidence/raw/adapter-inbound-web-c18.txt
source:
- 원본 분석 절은 analysis/14-adapter-inbound-web.md#L1336 이다.
module: adapter-inbound-web
---
# fileserver 매핑 검증은 회로가 닫혀 있다
`attestMapping`의 선언·구현·호출이 모두 존재하고, 그 구현을 만드는 자동설정도 있다. 이 leaf의 다른 sub-scope와 달리 회로가 닫혀 있다.
## 본문
<!-- body:start -->
`attestMapping`의 선언·구현·호출이 모두 존재한다.
```text
main/.../nginx/DefaultNginxInternalUriMapper.java:41 (구현)
main/.../nginx/NginxInternalUriMapper.java:32 (선언)
BOOT:autoconfigure/fileserver/FileserverStartupConfiguration.java:87 uriMapper.attestMapping()
```
## 빈을 만드는 자동설정
`FileserverPlatformAutoConfiguration``DefaultNginxInternalUriMapper`(`:215-216`) · `NginxDownloadStrategy`(`:221-223`) · `FileserverRequestContextFactory`(`:159-161`)를 만든다. 회로 닫힘.
## FileserverPlatformAutoConfiguration 참조 위치
:::evidence key="adapter-inbound-web-c18" alt="코드베이스에서 FileserverPlatformAutoConfiguration 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FileserverPlatformAutoConfiguration 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,45 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c04
title: 대칭 검사기 자신을 검사하는 메타 테스트가 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c04
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c04.svg
- key: adapter-outbound-cache-redis-c04-diagram
file: ../../../final/assets/diagrams/adapter-outbound-cache-redis-c04.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c04.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L245 이다.
module: adapter-outbound-cache-redis
---
# 대칭 검사기 자신을 검사하는 메타 테스트가 있다
`ReactiveRedisOperations`는 "Mirrors `RedisOperations` method for method"라고 주장하고 `ApiParityTest`가 그것을 반사로 강제한다. 그 위에 검사기가 고장 나 항상 통과하는 상태를 잡는 메타 테스트가 하나 더 있다.
## 본문
<!-- body:start -->
`ReactiveRedisOperations`는 "Mirrors `RedisOperations` method for method"라고 주장한다. `ApiParityTest`가 그것을 반사로 강제한다 — `PAIRS` 맵에 14쌍의 sync/reactive 인터페이스를 놓고 `everySyncOperationHasReactiveCounterpart`, `everyTypedSurfaceIsInParity`, `theTwoEntryPointsExposeTheSameStructureAccessors`, `everyReactiveMethodReturnsAPublisher`를 돌린다. 두 facade의 접근자 12개는 실제로 동일하다(diff 공백).
## 검사기에 대한 메타 검사
:::evidence key="adapter-outbound-cache-redis-c04-diagram" alt="두 진입점 인터페이스와 대칭 검사와 메타 테스트가 위에서 아래로 쌓이고 검사 방향 화살표가 아래로 그려진 구조" caption="검사기에 대한 메타 검사" zoom="false"
:::
두 가지가 특히 좋다. 첫째, **예외가 이유와 함께 목록에서 빠져 있다** — Pub/Sub은 sync가 핸들러+closeable subscription이고 reactive는 publisher 자신이 전달하며 취소로 구독을 끊으므로 "different shapes on purpose, so mechanical parity would be the wrong check for them". 둘째, `theInspectorDetectsADivergentReturnShape`라는 **검사기에 대한 메타 test**가 있다 — 대칭 검사기가 고장 나 항상 통과하는 상태를 잡는다.
## ReactiveRedisOperations 참조 위치
:::evidence key="adapter-outbound-cache-redis-c04" alt="코드베이스에서 ReactiveRedisOperations 를 검색한 출력 2줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ReactiveRedisOperations 코드베이스 검색 — 2줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c05
title: 렌더된 키 문자열을 받는 API가 없다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c05
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c05.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L324 이다.
module: adapter-outbound-cache-redis
---
# 렌더된 키 문자열을 받는 API가 없다
`QualifiedRedisKey`의 javadoc이 이 계층의 규칙이다 — 이미 렌더된 키 문자열을 받는 API가 없으므로 네임스페이스·슬롯·크기 규칙을 우회할 수 없다. 구조가 그것을 강제한다.
## 본문
<!-- body:start -->
`QualifiedRedisKey`의 javadoc이 이 계층의 규칙이다 — "This is the only key shape the SDK accepts. **There is no API that takes an already rendered key string**, so namespace, slot, and size rules cannot be bypassed."
## 타입이 강제하는 형태
`RedisTypedKey`는 9종만 허용하는 sealed interface고(`ValueKey`·`HashKey`·`ListKey`·`SetKey`·`SortedSetKey`·`BitmapKey`·`HyperLogLogKey`·`GeoKey`·`StreamKey`), 전부 `QualifiedRedisKey` + 코덱으로 구성된다. `QualifiedRedisKey``RedisNamespace`(토큰 3개) + `RedisKeyName`(entity 토큰 + identifier) + 선택적 `RedisSlotTag`다. 그리고 `RedisKeyRenderer`가 **중괄호를 쓰는 유일한 장소**라서 Cluster 해시 태그가 "the tag and nothing else"를 덮는다.
## 분석 원문의 규칙 서술
:::evidence key="adapter-outbound-cache-redis-c05" alt="분석 문서 analysis/10-adapter-outbound-cache-redis.md 에서 이 기록의 근거 절을 그대로 잘라낸 15줄. 코드베이스를 측정한 것이 아니라 원본 판정이 무엇을 적었는지를 보여 준다." caption="analysis/10-adapter-outbound-cache-redis.md 발췌 — 15줄" zoom="true"
:::
## 이 검사가 PII 방지의 완결이 아니라고 적는다
`RedisKeyRules`의 자기 한정도 정직하다 — 규칙은 "mechanical"이며 "Values that are indistinguishable from an ordinary surrogate identifier, such as a bare digit string, cannot be rejected here; those must be fingerprinted by the caller before they become a key part."
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: CONCEPT
slug: adapter-outbound-cache-redis-c12
title: 탈출구가 두 겹의 사전 승인으로 닫혀 있다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-cache-redis-c12
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-cache-redis-c12
file: ../../../final/evidence/rendered/adapter-outbound-cache-redis-c12.svg
- key: adapter-outbound-cache-redis-c12-diagram
file: ../../../final/assets/diagrams/adapter-outbound-cache-redis-c12.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-cache-redis-c12.txt
source:
- 원본 분석 절은 analysis/10-adapter-outbound-cache-redis.md#L684 이다.
module: adapter-outbound-cache-redis
---
# 탈출구가 두 겹의 사전 승인으로 닫혀 있다
`RedisRawGateway`에는 `execute(String, byte[]...)`가 없다. 원시 명령은 정책 카탈로그의 분류와 배포의 승인 등록 둘 다를 통과해야 하고, 어느 쪽도 요청 시점에 결정되지 않는다.
## 본문
<!-- body:start -->
`RedisRawGateway`의 javadoc이 존재 이유와 한계를 함께 적는다 — "There is no `execute(String, byte[]...)` here or anywhere else in the SDK. The escape hatch exists because **some commands genuinely have no typed form worth building**, not because arbitrary command execution is acceptable; every one of them is named, bounded, and audited before it can be sent."
## 원시 명령이 지나야 하는 두 문
:::evidence key="adapter-outbound-cache-redis-c12-diagram" alt="정책 카탈로그 분류에서 배포 승인 등록으로, 다시 원시 게이트웨이로 이어지는 왼쪽에서 오른쪽 흐름" caption="원시 명령이 지나야 하는 두 문" zoom="false"
:::
승인이 **두 개의 독립된 문**을 모두 통과해야 한다(`RawCommandApprovals`).
1. 명령이 정책 카탈로그에서 `RAW_ONLY`로 분류돼 있어야 한다 — "the organization's decision about which commands may ever leave through this door"
1. 배포가 그 명령에 대한 승인(`ApprovedRawCommand`)을 등록해야 한다
"Neither alone is enough, and neither is decided at request time." 그리고 R3/R4는 어느 쪽이든 거부된다.
## RedisRawGateway 참조 위치
:::evidence key="adapter-outbound-cache-redis-c12" alt="코드베이스에서 RedisRawGateway 를 검색한 출력 7줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RedisRawGateway 코드베이스 검색 — 7줄 · exit 0" zoom="true"
:::
## 승인이 배포 산출물인 이유
`ApprovedRawCommand`**배포 산출물**이다 — 명령 identity, 최대 인자 수, 요청/응답 바이트 상한, 타임아웃, 응답 디코더를 프로세스 시작 전에 고정한다. 토큰은 `RawCommandApprovals`만 발급하고, 검증은 (a) 토큰 타입이 내부 record인지, (b) **발급 레지스트리 인스턴스가 같은지**(`issued.origin != this`), (c) 정책 id가 일치하는지, (d) 제시된 승인이 등록된 것과 같은지 넷을 본다.
## 키 위치를 모르면 기본이 거부다
**`RawMovableKeys`가 이 패키지에서 가장 흥미롭다.** movable key spec(예: `SORT`)은 키 위치를 인자 목록이 결정하므로 정적으로 알 수 없고, 그러면 네임스페이스 검사를 할 수 없다. 기본은 여전히 거부다. 예외로 `SORT`/`SORT_RO` 파서 하나가 등록돼 있는데, 그 설계가 명시적이다 — "a parser that knows **exactly one command shape** and refuses everything else."
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-outbound-fileserver-c06
title: 거부 메시지가 역할 모델을 설명하지 않는다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-fileserver-c06
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-fileserver-c06
file: ../../../final/evidence/rendered/adapter-outbound-fileserver-c06.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-fileserver-c06.txt
source:
- 원본 분석 절은 analysis/08-adapter-outbound-fileserver.md#L507 이다.
module: adapter-outbound-fileserver
---
# 거부 메시지가 역할 모델을 설명하지 않는다
`RoleBasedFileAccessPolicy`는 열 개 연산을 READ/WRITE/ADMIN 세 계층으로 접고, 거부 메시지는 필요한 역할도 주체의 역할도 말하지 않는다.
## 본문
<!-- body:start -->
`RoleBasedFileAccessPolicy`는 열 개 연산을 READ/WRITE/ADMIN 세 계층으로 접는다. 근거가 적혀 있다 — 연산별 역할 맵은 `COPY`를 주고 `CREATE`를 안 주는 조합을 허용하는데 "a copy creates a file"이므로 제한처럼 보이고 제한이 아니다.
## admin이 write를 상속하지 않는다
삭제할 수 있다는 이유로 force-delete까지 되면 감사되는 관리 평면이 일반 데이터 평면으로 도달 가능해진다. 빈 admin 역할 집합은 생성자가 거부한다("would leave the management plane unreachable rather than protected").
## RoleBasedFileAccessPolicy 참조 위치
:::evidence key="adapter-outbound-fileserver-c06" alt="코드베이스에서 RoleBasedFileAccessPolicy 를 검색한 출력 12줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="RoleBasedFileAccessPolicy 코드베이스 검색 — 12줄 · exit 0" zoom="true"
:::
## 거부 메시지가 담지 않는 것
거부 메시지는 필요한 역할도 주체의 역할도 말하지 않는다 — "a denial that reported what was missing would turn every 403 into a readable description of the role model". `LocalStorageFailures`의 어떤 메시지도 경로·마운트·루트를 담지 않고, 감사 어댑터가 쓰는 필드는 전부 지문·코드·불투명 식별자다.
## 이름 자체가 장치인 클래스
`UnenforcedFileAccessPolicy`의 설계도 기록할 만하다. 이름 자체가 장치다 — composition root가 **타입 이름으로 매치해** production startup을 거부한다. "A permissive default that looked like a real policy would ship as one."
<!-- body:end -->
@@ -0,0 +1,56 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c01
title: 닿지 않는 설정을 무시하지 않고 거부한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c01
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c01.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c01.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L90 이다.
module: adapter-outbound-httpclient
---
# 닿지 않는 설정을 무시하지 않고 거부한다
`ClientProfileValidator`는 바인딩은 되지만 어떤 전송에도 닿지 않는 설정을 무시하지 않고 거부한다. 앞선 열 개 모듈에서 반복해 발견한 "선언되었으나 아무것도 하지 않는 설정" 패턴을 이 모듈은 명시적 거부로 처리한다.
## 본문
<!-- body:start -->
이 저장소에서 본 가장 조밀한 설정 검증기다. `validate(profile, environment)`가 11개 검사 그룹을 돌리고 결과를 정렬해 "a configuration error reports deterministically across runs and machines"를 보장한다.
## 소비자가 없던 설정을 거부로 바꾼 이유
특히 이 leaf에서만 보이는 태도가 하나 있다 — **바인딩은 되지만 어떤 전송에도 닿지 않는 설정을 무시하지 않고 거부한다.**
> "Three of them had no consumer anywhere: `timeout.dns`, `proxy.credential-provider` and `proxy.import-ambient-no-proxy`. An operator who set a DNS timeout believed resolution was bounded and it was not; one who named a proxy credential provider believed the proxy was authenticated and it was not… **the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing.**"
기본값은 통과시키므로 "only a deliberate, unmet request fails"다. 같은 논리가 관측 설정에도 적용된다 — `full-url-recording`은 아무도 읽지 않았고 `body-logging`은 actuator 보고에만 닿았다. "Leaving them that way is the worse of the two failure modes — an operator who set them believed the platform was recording full URLs or bodies, and an operator who left them false had no assurance that it was not." 지금은 production에서 둘 다 거부된다.
## ClientProfileValidator 참조 위치
:::evidence key="adapter-outbound-httpclient-c01" alt="코드베이스에서 ClientProfileValidator 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClientProfileValidator 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 나머지 검사가 막는 조용한 다운그레이드
- **`REACTIVE_REDIRECT_UNSUPPORTED`** — 엔진 리다이렉트는 모든 전송에서 꺼져 있고 hop별 재검증을 하는 coordinator는 블로킹 스택에만 있다. 리액티브 프로파일이 redirect를 켜면 "the caller received the 302 as an ordinary response and read its empty body as the answer." 거부가 정직한 결과다 — "a configured guarantee that silently does nothing is worse than one the platform declines to offer."
- **`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`** — `ProtocolIntent`가 "H2를 선호"와 "H2를 요구"를 구분한다. JDK 클라이언트는 `HTTP_2`를 선호로 다뤄 조용히 HTTP/1.1로 협상하고 Apache classic은 HTTP/1.1 전용이라, `HTTP_2`만 선언한 프로파일이 "ran happily over HTTP/1.1, and nothing anywhere said so."
- **`TLS_PROTOCOL_SET_REQUIRED`** — 빈 집합이 통과하면 JVM 기본값이 선택되어, "a profile that meant to pin a TLS floor got whatever the platform default happened to be."
- **`DYNAMIC_TARGET_PROXY_UNSUPPORTED`** — 포워드 프록시는 호스트명을 자기 쪽에서 다시 해석하므로 "The SSRF defence would be present, correct, and bypassed."
- **`RETRY_POLICY_CONTRADICTS_ATTEMPTS`** — `policy`를 실행 경로에서 아무도 읽지 않아 "the actuator could report `retryPolicy: none` for a profile that was retrying three times."
## 이 검증기는 실제로 조립돼 있다
`app-bootstrap``HttpClientStartupValidator:37`이 이 검증기를 생성한다(`168-...` §8.1). 이 leaf는 앞선 cache-redis와 달리 **실제로 조립돼 있다** — app-bootstrap에 이 leaf를 위한 auto-configuration 12개가 있다.
<!-- body:end -->
@@ -0,0 +1,56 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c05
title: 열린 회로가 토큰과 permit을 쓰기 전에 거절한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c05
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c05.svg
- key: adapter-outbound-httpclient-c05-diagram
file: ../../../final/assets/diagrams/adapter-outbound-httpclient-c05.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c05.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L323 이다.
module: adapter-outbound-httpclient
---
# 열린 회로가 토큰과 permit을 쓰기 전에 거절한다
`AttemptResiliencePipeline`이 물리 시도마다 Circuit Breaker → Rate Limiter → Bulkhead → HTTP 호출을 고정 순서로 적용하고 역순으로 해제한다. 순서는 장식이 아니다.
## 본문
<!-- body:start -->
`AttemptResiliencePipeline`이 물리 시도마다 **Circuit Breaker → Rate Limiter → Bulkhead → HTTP 호출**을 고정 순서로 적용하고 역순으로 해제한다.
## 시도마다 지나는 가드 순서
:::evidence key="adapter-outbound-httpclient-c05-diagram" alt="회로 차단기와 요금 제한기와 벌크헤드와 HTTP 호출이 왼쪽에서 오른쪽으로 이어지고 화살표에 허가와 토큰과 permit 이 붙은 구조" caption="시도마다 지나는 가드 순서" zoom="false"
:::
> "The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead permit is spent, otherwise **a dead upstream keeps consuming the quota and concurrency that healthy upstreams need.**"
> "A local rejection (rate limiter or bulkhead) is deliberately *not* recorded as a circuit error: the upstream never saw the request, and **counting our own back-pressure as upstream failure would open the breaker on a healthy dependency.**"
## 브레이커가 503을 보지 못하던 이력
이전에는 원시 전송만 파이프라인 안에서 돌고 응답→예외 매핑이 밖에서 일어나서 "a 503 completed the call normally, the breaker recorded a success, and **an upstream that answered nothing but 503 never opened its circuit. The thing the breaker is for was the one thing it could not see.**" 지금은 `remoteFailure` 분류기가 반환값을 보고 브레이커에 알린다.
## AttemptResiliencePipeline 참조 위치
:::evidence key="adapter-outbound-httpclient-c05" alt="코드베이스에서 AttemptResiliencePipeline 를 검색한 출력 16줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="AttemptResiliencePipeline 코드베이스 검색 — 16줄 · exit 0" zoom="true"
:::
## 규칙을 덮는 테스트와 프로토콜 증거
test 47개가 이 규칙들을 촘촘히 덮는다 — `appliesCircuitThenRateLimiterThenBulkheadPerAttempt`, `openCircuitDoesNotConsumeRateOrBulkheadPermit`, `bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError`, `answeredStatusesDoNotRetryANonIdempotentOperation`, `deniesOneShotBodyEvenForPut`, `honorsRetryAfterOnlyInsideDeadline`, `protocolProofOfNonProcessingWinsOverEverything`, `streamAfterGoAwayLastIdIsPeerNotProcessed` 등.
`Http2ProtocolEvidence`는 프로토콜 수준 증거를 다룬다 — `REFUSED_STREAM`과 GOAWAY의 last-stream-id보다 큰 스트림 id는 **피어가 처리하지 않았음의 증명**이라 `NOT_SENT`로 승격되고, 그 이하 id의 리셋은 여전히 모호하다(`streamAtOrBelowGoAwayLastIdStaysAmbiguous`, `aBareStreamResetProvesNothing`).
<!-- body:end -->
@@ -0,0 +1,48 @@
---
kind: CONCEPT
slug: adapter-outbound-httpclient-c08
title: 전송의 선언이 프로파일보다 약하면 startup이 실패한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-httpclient-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-httpclient-c08
file: ../../../final/evidence/rendered/adapter-outbound-httpclient-c08.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-httpclient-c08.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L573 이다.
module: adapter-outbound-httpclient
---
# 전송의 선언이 프로파일보다 약하면 startup이 실패한다
`TransportCapabilityValidator`가 프로파일이 요구하는 것과 전송이 선언한 것을 대조해 부족분을 이름으로 모아 거부한다. 메시지는 프로파일 설정과 능력 이름만 담고 URL·주소·비밀은 담지 않는다.
## 본문
<!-- body:start -->
`TransportCapabilityValidator`가 프로파일이 요구하는 것과 전송이 선언한 것을 대조해 부족분을 이름으로 모아 거부한다 — 프로토콜, route pool, 유계 pending 큐, proxy, mutual TLS, 동적 대상 안정성. 메시지는 "profile settings and capability names only — never a URL, address, or secret."
## 능력이 데이터로 선언된다
`ReactiveTransportCapabilities.reactorNetty()`는 9개 능력을 전부 `true`로, `jettyHttp3Experimental()`은 route pool·유계 큐·DNS 핀·동적 안정성을 `false`로 선언한다. HTTP/3는 `compileOnly` 의존이라 클래스가 없으면 `Http3CapabilityReport`가 전송을 거부한다 — "the failure mode is a startup error rather than a `NoClassDefFoundError` mid-call"(§0).
## TransportCapabilityValidator 참조 위치
:::evidence key="adapter-outbound-httpclient-c08" alt="코드베이스에서 TransportCapabilityValidator 를 검색한 출력 11줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="TransportCapabilityValidator 코드베이스 검색 — 11줄 · exit 0" zoom="true"
:::
## testkit이 별도 source set인 이유
계약을 담은 클래스 35개(`BlockingTransportContract`·`ReactiveTransportContract`·`RetrySafetyContract`·`ResourceLifecycleContract`·`ObservabilityContract`·`DynamicTargetSecurityContract`)를 test·performance·jmh 세 lane이 공유한다. `NettyLeakDetectionExtension`은 leak detector 레벨을 **믿지 않고 확인한다** — "asserts the level rather than trusting the flag reached the forked JVM"(§0).
## 성능 lane이 재지 않는 것
성능 lane 7개는 자원 상한을 검증한다 — `PoolSaturationPerformanceTest`·`RetryStormBudgetTest`·`RuntimeRotationDrainTest`·`OAuthRefreshContentionTest`·`LargeBodyResourceTest`·`Http2StreamSaturationTest`. §15에서 남긴 질문(`ObjectBody.replayability()`의 반사 비용을 재는 lane이 있는가)의 답은 **없다** — 풀·재시도·회전·토큰 경합·본문 크기·H2 스트림을 재고 본문 재생 가능성 판정 비용은 재지 않는다.
<!-- body:end -->
@@ -0,0 +1,50 @@
---
kind: CONCEPT
slug: adapter-outbound-objectstorage-c02
title: 레거시 경로 셋이 서로 다른 스위치로 서로를 배제한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:adapter-outbound-objectstorage-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: adapter-outbound-objectstorage-c02
file: ../../../final/evidence/rendered/adapter-outbound-objectstorage-c02.svg
evidence:
- ../../../final/evidence/raw/adapter-outbound-objectstorage-c02.txt
source:
- 원본 분석 절은 analysis/09-adapter-outbound-objectstorage.md#L92 이다.
module: adapter-outbound-objectstorage
---
# 레거시 경로 셋이 서로 다른 스위치로 서로를 배제한다
폐기 경로가 셋인데 서로 다른 스위치를 쓰고 서로를 배제한다. 겹침은 컴파일러가 거부한다.
## 본문
<!-- body:start -->
폐기 경로가 셋인데 서로 다른 스위치를 쓰고 서로를 배제한다.
| 경로 | 스위치 | 성격 |
|---|---|---|
| 선호 임시 활성화 | `app.object-storage.legacy.enabled=true` + 명시적 backend | `ObjectStoragePort`(whole-`byte[]`) 노출 |
| 구 alias | `ca-skeleton.objectstorage.*` | `LegacyObjectStorageActivationGuard` 조건, canonical과 혼용 시 실패 |
| 채택(adoption) | `app.object-storage.legacy-adoption.enabled=true` | raw locator 유지보수 전용, 별도 config 클래스 |
## 겹침을 거부하는 지점
`ObjectStorageBindingCompiler.rejectLegacyOverlap`가 legacy filesystem 루트와 canonical provider 루트가 **어느 방향으로든 포함 관계**면 거부한다.
## ObjectStorageBindingCompiler 참조 위치
:::evidence key="adapter-outbound-objectstorage-c02" alt="코드베이스에서 ObjectStorageBindingCompiler 를 검색한 출력 22줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ObjectStorageBindingCompiler 코드베이스 검색 — 22줄 · exit 0" zoom="true"
:::
## 채택 모드가 추가로 요구하는 것
`LegacyObjectAdoptionSettings``APPLY` 모드일 때 검토된 manifest 경로와 64자리 SHA-256을 요구하고, batch size 11000, timeout 5분 이내를 강제한다. legacy runtime은 `AutoCloseable` holder로 감싸 S3 client 수명을 정확히 소유하고, `@Bean(destroyMethod = "close")`로 등록된다.
<!-- body:end -->
@@ -0,0 +1,53 @@
---
kind: CONCEPT
slug: application-core-c08
title: 검증 권한과 삭제 권한을 분리한 staged lifecycle
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:application-core-c08
evidenceCapturedOn: 2026-09-01
assets:
- key: application-core-c08
file: ../../../final/evidence/rendered/application-core-c08.svg
evidence:
- ../../../final/evidence/raw/application-core-c08.txt
source:
- 원본 분석 절은 analysis/03-application-core.md#L190 이다.
module: application-core
---
# 검증 권한과 삭제 권한을 분리한 staged lifecycle
semantic objectstorage API는 provider/filesystem type을 노출하지 않고, lifecycle을 staged → verified → published로 분리하며, scanner 권한과 purge 권한을 나눠 검증 주체가 임의 삭제까지 할 수 없게 한다.
## 관계
- **legacy storage/notification compatibility surface의 제거 조건 추적**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
semantic objectstorage API는 provider/filesystem type을 노출하지 않는다. object identity/reference는 prefix + check digit를 포함한 opaque routed representation이고 redacted rendering을 제공한다. tampered/cross-prefix reference를 거부한다.
## content I/O가 무한 루프로 가지 않는 이유
content I/O는 bounded pull/push callback context와 budget/cancellation/chunk contract를 사용하며 callback lifetime 밖에서 context를 재사용할 수 없다. zero-progress가 무한 loop로 이어지지 않도록 bounded 후 실패한다.
## FullContentIdentity 참조 위치
:::evidence key="application-core-c08" alt="코드베이스에서 FullContentIdentity 를 검색한 출력 1줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="FullContentIdentity 코드베이스 검색 — 1줄 · exit 0" zoom="true"
:::
## 검증 주체가 삭제까지 하지 못하게 나눈다
lifecycle은 staged -> verified -> published를 분리한다. scanner verdict는 exact stage/version/operation/policy revision에 결합되고 publish/cleanup mutation은 exact-version/fencing을 요구한다. scanner 권한과 purge 권한은 분리돼 검증 주체가 임의 삭제까지 할 수 없게 한다.
## 상한과 신원 요구
transient bearer grant는 URI/header를 redaction하고 TTL은 최대 24시간으로 제한한다. multipart part count는 1..10000이고 completion은 expected content identity를 요구한다. `FullContentIdentity`는 SHA-256 기반으로 ETag를 content identity로 오인하지 않는다.
<!-- body:end -->
@@ -0,0 +1,51 @@
---
kind: CONCEPT
slug: grpc-advanced-bootstrap-c01
title: 능력을 하나씩 등급 매기는 것이 설계다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-bootstrap-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-bootstrap-c01
file: ../../../final/evidence/rendered/grpc-advanced-bootstrap-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-bootstrap-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-bootstrap.md#L53 이다.
module: grpc-advanced-bootstrap
---
# 능력을 하나씩 등급 매기는 것이 설계다
능력 15종을 한 깃발로 묶지 않고 각각 등급을 매긴다. 그렇게 하지 않으면 gRPC-Web을 켜는 결정과 xDS를 켜는 결정이 같은 결정이 된다.
## 본문
<!-- body:start -->
능력을 하나씩 등급 매기는 것이 설계다.
> "Bundling them under one 'advanced' flag makes enabling gRPC-Web — a compatibility bridge with a proxy in front of it — the same decision as enabling xDS, which brings a control plane and its outage modes. They are not the same decision, and a single switch is how the second one gets made by accident."
| 등급 | 시작 가능 | production 별도 승인 |
|---|---|---|
| `ADVANCED_STABLE` | 예 | 아니오 |
| `EXPERIMENTAL` | 예 | **예** |
| `WATCH` | 아니오 | — |
| `DISABLED` | 아니오 | — |
기본 등급 분포는 `ADVANCED_STABLE` 11, `EXPERIMENTAL` 3(`HEDGING`·`CUSTOM_LOAD_BALANCER`·`XDS`), `WATCH` 1(`EDITION_2026`)이다.
## EXPERIMENTAL에 두 번째 승인을 요구하는 근거
> "The flag says somebody wanted the feature; the approval says somebody accepted that its failure modes are not fully characterised, which is a different person's decision on most teams."
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-advanced-bootstrap-c01" alt="코드베이스에서 파일 목록을 만든 출력 9줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 9줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: grpc-advanced-streaming-c01
title: 상한 없는 request(n)은 단계만 늘린 무제한 버퍼링이다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:grpc-advanced-streaming-c01
evidenceCapturedOn: 2026-09-01
assets:
- key: grpc-advanced-streaming-c01
file: ../../../final/evidence/rendered/grpc-advanced-streaming-c01.svg
evidence:
- ../../../final/evidence/raw/grpc-advanced-streaming-c01.txt
source:
- 원본 분석 절은 analysis/grpc/grpc-advanced-streaming.md#L86 이다.
module: grpc-advanced-streaming
---
# 상한 없는 request(n)은 단계만 늘린 무제한 버퍼링이다
수동 흐름 제어는 승인이 record의 필드이고 거짓이면 생성자가 거부한다. 수요 상한과 교착 감시가 필수다.
## 본문
<!-- body:start -->
승인이 record 의 필드이고 거짓이면 생성자가 거부한다.
> "Approval is a field because this capability is granted per method, not per service. A method that reads a large result set benefits; the one next to it does not, and enabling both because they share a service is how the second one acquires a bug nobody was looking for."
수요 상한과 교착 감시가 필수다 — 상한 없는 `request(n)` 은 단계만 늘린 무제한 버퍼링이다.
## 감시견이 비교하는 두 시각
감시견은 잠들지 않고 두 시각을 비교한다 — 마지막으로 수요를 요청한 때와 마지막으로 메시지가 움직인 때. 둘 다 시간 제한만큼 멈춰 있으면 교착이다.
## 이 기록이 다루는 파일 범위
:::evidence key="grpc-advanced-streaming-c01" alt="코드베이스에서 파일 목록을 만든 출력 14줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 14줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,44 @@
---
kind: CONCEPT
slug: messaging-admin-api-c04
title: 실행 경로에서 같은 검사가 세 지점에 겹친다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-admin-api-c04
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-admin-api-c04
file: ../../../final/evidence/rendered/messaging-admin-api-c04.svg
evidence:
- ../../../final/evidence/raw/messaging-admin-api-c04.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-admin-api.md#L493 이다.
module: messaging-admin-api
---
# 실행 경로에서 같은 검사가 세 지점에 겹친다
계획·승인 발급·실행 세 경로 중 실행 경로에서 검사가 `verify` / `Approved*Plan` 생성자 / guard 세 지점에 걸쳐 겹친다. 방어적 중복이다.
## 본문
<!-- body:start -->
실행 경로가 셋으로 나뉜다.
- **경로 A — 계획** (승인 불필요, dry run 무료)
- **경로 B — 승인 발급** (이 리프 밖, 변경관리 시스템)
- **경로 C — 실행**
## ApprovalVerifier 참조 위치
:::evidence key="messaging-admin-api-c04" alt="코드베이스에서 ApprovalVerifier 를 검색한 출력 29줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ApprovalVerifier 코드베이스 검색 — 29줄 · exit 0" zoom="true"
:::
## 실행 경로에서 검사가 겹치는 세 지점
경로 C 에서 검사가 세 지점(`verify` / `Approved*Plan` / guard)에 걸쳐 겹친다. `ApprovalVerifier` javadoc 이 그 이유를 설명한다. 서명 검증이 통과하면 나머지는 이미 보장되지만, `ApprovedReplayPlan` 생성자와 guard 가 같은 것을 다시 본다. 방어적 중복이며 §12.3(a) 에서 다시 다룬다.
<!-- body:end -->
@@ -0,0 +1,58 @@
---
kind: CONCEPT
slug: messaging-policy-c05
title: 배치 상한이 개수와 바이트 두 축인 이유
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-policy-c05
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-policy-c05
file: ../../../final/evidence/rendered/messaging-policy-c05.svg
evidence:
- ../../../final/evidence/raw/messaging-policy-c05.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-policy.md#L423 이다.
module: messaging-policy
---
# 배치 상한이 개수와 바이트 두 축인 이유
개수 상한만으로는 큰 메시지 몇 개가 브로커 프레임을 넘고, 바이트 상한만으로는 아주 많은 작은 메시지가 요청 타임아웃을 넘는다. `checkBatch`가 각 항목에 `checkPayload`도 부르므로 셋이 함께 적용된다.
## 관계
- **구성 오류는 한 예외 타입과 안정 코드로 보고한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **저장소 밖 문서를 절 번호로 인용하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **부팅 경로의 알고리즘 복잡도는 문서화한다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
**배치 상한이 두 축인 이유**가 적혀 있다.
```java
// PayloadLimitGuard.java:16-18
* <p>Batches are limited by count <em>and</em> bytes. A count limit alone lets a handful of large
* messages exceed the broker's frame; a byte limit alone lets a huge number of tiny messages exceed
* its request timeout.
```
`checkBatch`가 각 항목에 대해 `checkPayload`도 부르므로 **개별 상한 · 개수 상한 · 총합 상한** 셋이 함께 적용된다.
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-policy-c05" alt="코드베이스에서 파일 목록을 만든 출력 25줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 25줄 · exit 0" zoom="true"
:::
## 프로파일 검증 실패가 MessagingException 밖인 이유
프로파일 검증 실패는 `IllegalArgumentException`이다 — `MessagingException` 계층 밖이다. 시작 시점의 구성 오류이지 메시지 실패가 아니므로 일관적이다. 다만 `MessagingConfigurationException`("Raised at startup wherever possible")이 존재하는데 쓰이지 않는다 — §17의 P3.
<!-- body:end -->
@@ -0,0 +1,55 @@
---
kind: CONCEPT
slug: messaging-schema-avro-c07
title: 바이트 상한이 잘못된 공격에 적용돼 있었다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-schema-avro-c07
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-schema-avro-c07
file: ../../../final/evidence/rendered/messaging-schema-avro-c07.svg
evidence:
- ../../../final/evidence/raw/messaging-schema-avro-c07.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-schema-avro.md#L457 이다.
module: messaging-schema-avro
---
# 바이트 상한이 잘못된 공격에 적용돼 있었다
테스트 클래스 javadoc이 세 결함을 보존한다. 세 번째는 배열 원소 수 주장을 신뢰하고 할당하던 상태이고, 그때 유일하게 있던 방어가 바이트 상한이었다.
## 관계
- **모드 enum을 분기 조건으로 쓰면 각 분기에 테스트를 둔다**
같은 분석 리프에서 끌어낸 규칙이다.
- **컬렉션 순서가 계약이면 양쪽에서 같은 방향으로 적는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **안정 코드는 판단 단위로 정하고 구현 단위로 정하지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
테스트 클래스 javadoc이 세 결함을 보존한다.
| 위치 | 이전 상태 | 그것이 만든 실패 |
|---|---|---|
| `AvroRegistryBoundsTest` javadoc | 중첩 맵에 `Map.copyOf`(얕은 복사) | 호출자가 생성 후 스키마 교체 가능 → Avro는 실패하지 않고 그럴듯한 쓰레기를 만듦 |
| `AvroRegistryBoundsTest` javadoc | `decodeEvolved`에 크기 검사 없음 | producer가 앞서 나간 뒤 **모든 메시지**가 지나는 경로가 무제한 입력을 수용 |
| `AvroHostileInputTest` javadoc | 배열 원소 수 주장을 신뢰하고 할당 | 5바이트로 4억 원소 배열 → `OutOfMemoryError`, codec이 분류할 수 없는 실패, consumer 스레드에서 프로세스 사망 |
## 세 번째가 형태상 흥미로운 이유
**바이트 상한이라는 올바른 도구가 잘못된 공격에 적용되어 있었다.** 테스트 javadoc이 그것을 한 문장으로 적는다: "The byte limit is the wrong instrument for this attack and was the only one in place."
## 이 기록이 다루는 파일 범위
:::evidence key="messaging-schema-avro-c07" alt="코드베이스에서 파일 목록을 만든 출력 2줄. 이 기록이 다루는 범위가 그 목록이다." caption="코드베이스 파일 목록 — 2줄 · exit 0" zoom="true"
:::
<!-- body:end -->
@@ -0,0 +1,76 @@
---
kind: CONCEPT
slug: messaging-spring-cloud-stream-bridge-c03
title: 보장에 의존하는 순간 브리지를 거절한다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:messaging-spring-cloud-stream-bridge-c03
evidenceCapturedOn: 2026-09-01
assets:
- key: messaging-spring-cloud-stream-bridge-c03
file: ../../../final/evidence/rendered/messaging-spring-cloud-stream-bridge-c03.svg
- key: messaging-spring-cloud-stream-bridge-c03-diagram
file: ../../../final/assets/diagrams/messaging-spring-cloud-stream-bridge-c03.svg
evidence:
- ../../../final/evidence/raw/messaging-spring-cloud-stream-bridge-c03.txt
source:
- 원본 분석 절은 analysis/messaging/messaging-spring-cloud-stream-bridge.md#L146 이다.
module: messaging-spring-cloud-stream-bridge
---
# 보장에 의존하는 순간 브리지를 거절한다
브리지는 상호운용을 위해 존재하고 그 위험은 구체적이다 — Stream이 자기 binder 설정을 소유하므로 목적지 프로파일이 모르는 직렬화기·오류 처리·확인 모드를 바인딩이 조용히 얻을 수 있다. 그래서 플랫폼의 보장에 의존하지 않는 목적지만 허용한다.
## 관계
- **허용 의존 목록은 상한이므로 미사용을 잡지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **등록을 받는 컴포넌트는 해제도 제공한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **함께 읽히는 두 맵은 한 값으로 묶는다**
같은 분석 리프에서 끌어낸 규칙이다.
- **에러 메시지가 지시하는 설정은 그 설정을 읽는 코드와 함께 존재해야 한다**
같은 분석 리프에서 끌어낸 규칙이다.
- **한 개념의 등록 상태를 두 객체가 나눠 갖지 않는다**
같은 분석 리프에서 끌어낸 규칙이다.
## 본문
<!-- body:start -->
브리지의 위험이 무엇인지 javadoc이 먼저 적는다.
> "The bridge exists for interoperability with existing Spring Cloud Stream bindings, and its risk is specific: Stream owns its own binder configuration, so a binding can quietly acquire its own serializer, its own error handling, and its own acknowledgement mode — none of which the destination profile knows about."
**세 거절이 `DestinationProfile`의 세 필드를 직접 본다.**
| 조건 | 코드 |
|---|---|
| `profile.isOrdered()``orderingScope != NONE` | `STREAM_BRIDGE_ORDERING_UNSUPPORTED` |
| `profile.retry().mode() != RetryMode.NONE` | `STREAM_BRIDGE_RETRY_UNSUPPORTED` |
| `profile.deadLetter().enabled()` | `STREAM_BRIDGE_DLQ_UNSUPPORTED` |
## 브리지가 허용되는 범위
:::evidence key="messaging-spring-cloud-stream-bridge-c03-diagram" alt="가드 경계 안에 순서 없음과 재시도 없음과 DLQ 없음 세 조건이 들어 있고 production 목적지가 경계 밖 점선 상자로 놓인 구조" caption="브리지가 허용되는 범위" zoom="false"
:::
세 코드 전부 `MessagingConfigurationException`이고 안정 코드를 갖는다 — `messaging-kafka-share-experimental`이 두 거절에 다른 예외 타입을 쓴 것(그쪽 §17)과 대비된다. `!enabled`도 같은 예외 타입이다. 에러 메시지가 **두 선택지를 명시한다** — "remove it from the binding or move the destination to the native adapter". 무엇을 하라고만 하지 않고 어느 쪽을 포기할지를 준다.
## DestinationProfile 참조 위치
:::evidence key="messaging-spring-cloud-stream-bridge-c03" alt="코드베이스에서 DestinationProfile 를 검색한 출력 8줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="DestinationProfile 코드베이스 검색 — 8줄 · exit 0" zoom="true"
:::
## 네 번째 게이트
**production 목적지는 무조건 거절한다.** guard의 세 조건을 통과한 목적지(순서 없음·재시도 없음·DLQ 없음)라도 production이면 막는다. 바인딩 이름 패턴 `[a-zA-Z][a-zA-Z0-9-]{0,63}` — 언더스코어와 점을 배제한다.
## gaps()가 플래그가 아니라 문장을 만드는 이유
**"nothing at runtime will show it"**이 이 record가 존재하는 이유다. 네 boolean과 두 factory: `gaps()`가 각 `false`마다 **문장 하나**를 만든다. **각 문장이 결과까지 적는다** — "indistinguishable", "loses the message". 상태 플래그가 아니라 운영자가 읽는 진술이다. `isFullyGuaranteed()``gaps().isEmpty()`다 — 매 호출마다 네 문장을 다시 만든다. 성능 문제는 아니지만 순수 조회가 문자열을 할당한다.
<!-- body:end -->
@@ -0,0 +1,40 @@
---
kind: CONCEPT
slug: shared-contract-c02
title: Permission의 정규화는 문법 제한이 아니다
topic: admission-budget-and-backpressure
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: concept:shared-contract-c02
evidenceCapturedOn: 2026-09-01
assets:
- key: shared-contract-c02
file: ../../../final/evidence/rendered/shared-contract-c02.svg
evidence:
- ../../../final/evidence/raw/shared-contract-c02.txt
source:
- 원본 분석 절은 analysis/02-shared-contract.md#L69 이다.
module: shared-contract
---
# Permission의 정규화는 문법 제한이 아니다
`Permission`은 정확히 한 개의 colon으로 `resource:action`을 분리하고 trim/lowercase normalization을 수행한다. 그러나 component 내부 character set은 제한하지 않는다.
## 본문
<!-- body:start -->
`Permission`은 정확히 한 개의 colon으로 `resource:action`을 분리하고 trim/lowercase normalization을 수행한다. 테스트는 mixed case, surrounding whitespace, blank component, 0/2+ colon을 검증한다.
## Permission 참조 위치
:::evidence key="shared-contract-c02" alt="코드베이스에서 Permission 를 검색한 출력 21줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="Permission 코드베이스 검색 — 21줄 · exit 0" zoom="true"
:::
## 정규화와 문법의 차이
source는 component 내부 character set을 제한하지 않는다. 즉 "lowercase colon-delimited"는 normalization 결과이지 `[a-z0-9-]+` 같은 strict grammar는 아니다. 현재 test 역시 이를 요구하지 않으므로 observed contract로만 기록한다.
<!-- body:end -->