Files
document-haness/docs/clean-architecture-backend-template/analysis/14-adapter-inbound-web.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

1938 lines
159 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# adapter-inbound-web — 코드베이스 분석
## SSOT identity — 2026-08-31 재검증
- registered leaf id: `adapter-inbound-web`
- canonical state `analysisFile`: `analysis/14-adapter-inbound-web.md` (이 문서) — 이 leaf의 단일 SSOT
- source path: `src/adapter/inbound/web` · Gradle `:adapter:inbound:web`
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract"]`
- registry `runtime_memberships`: `["app-bootstrap", "sample-portfolio"]`
- coverage ledger: `FULL_READ` **638** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / `UNCLASSIFIED` **0**
- 최초 분석 revision `a24ece9c` → 재검증 revision `21234e38` · 이 리프의 변경 파일 **0**
- 재검증 증거: `EVD-333`(소스 드리프트 0), `EVD-334`(lane 재실행)
> 재검증이 확인한 것은 대상이 움직이지 않았다는 사실이지, 아래 서술이 옳다는 보증이 아니다.
> 이번 사이클에서 코드에 대고 다시 확인한 항목은 이 문서의 검증 절과 위 증거가 가리키는 범위다.
---
> **분석 대상** `src/adapter/inbound/web` · revision `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
> **분모** 638 tracked files (main 400 · test 150 · 대체 소스셋 84 · governance 4)
> **LOC** main Java 27,473 · test Java 18,319
> **근거** `evidence/raw/188-inbound-web-module-inventory.txt` 이하
## 0. 이 모듈의 크기와 형태
지금까지 분석한 13개 모듈 중 가장 크다. 두 번째로 큰 notification(240 files)의 2.7배이고, main Java LOC만 27,473이다.
소스셋이 **여섯 개**라는 점이 이 모듈의 첫 번째 특징이다:
| source set | files | 무엇인가 |
|---|---:|---|
| `main` | 400 | 어댑터 본체 (Java 397 + resources 3) |
| `test` | 150 | 단위·슬라이스 테스트 |
| `testkit` | 54 | 다른 소스셋들이 공유하는 계약 하네스 |
| `webfluxContractTest` | 16 | Reactor Netty 위에서 같은 계약을 재실행 |
| `jettyCompatTest` | 9 | Jetty 위에서 같은 계약을 재실행 |
| `nginxProxyTest` | 5 | 실제 Nginx 뒤에서 프록시 헤더 계약 |
`testkit`이 별도 소스셋이고 뒤의 세 소스셋이 그것을 재사용한다는 구조는, "같은 계약을 서로 다른 런타임에서 돌린다"는 의도를 빌드 수준에 박아 둔 것이다. notification의 `ProviderAdapterContract`가 상속으로만 했던 일(그래서 8종 중 3종에만 적용됐던 일)을 여기서는 소스셋 분리로 한다.
main 패키지가 **74개**다. 이 분석은 그것을 12개 bounded sub-scope로 나눈다.
## 1. 커버리지 원장
| # | sub-scope | main | test | 기타 | 합 | 상태 |
|---|---|---:|---:|---:|---:|---|
| 1 | governance + `config`·`settings`·`core`·`contract`·`moduleboundary`·`*/autoconfigure` | 30 | 17 | 4 | 51 | **COMPLETE** |
| 2 | `error` + `validation` + `envelope` | 23 | 10 | | 33 | **COMPLETE** |
| 3 | `auth` + `authz` + `security` | 27 | 17 | | 44 | **COMPLETE** |
| 4 | `ratelimit` + `admission` + `budget` + `*/throttle` | 41 | 9 | | 50 | **COMPLETE** |
| 5 | `idempotency` + `operation` + `operationasync` + `evidence` | 40 | 10 | | 50 | **COMPLETE** |
| 6 | `pagination` + `cursor` + `conditional` + `cache` + `versioning` | 42 | 12 | | 54 | **COMPLETE** |
| 7 | `http` + `json` + `advanced/codec` + `openapi` | 34 | 11 | | 45 | **COMPLETE** |
| 8 | `observability` + `proxy` + `filter` + `mvc/*`·`webflux/*` 잔여 | 38 | 15 | | 53 | **COMPLETE** |
| 9 | `advanced/**` (stream · patch · functional · virtualthread · blockingbridge · release) | 52 | 13 | | 65 | **COMPLETE** |
| 10 | `fileserver/**` | 51 | 22 | | 73 | **COMPLETE** |
| 11 | `notification/platform/**` + `admin/**` | 22 | 4 | | 26 | **COMPLETE** |
| 12 | `testkit` + `webfluxContractTest` + `jettyCompatTest` + `nginxProxyTest` | 0 | 10 | 84 | 94 | **COMPLETE** |
| | **TOTAL** | **400** | **150** | **88** | **638** | **12 / 12** |
분할은 `evidence/raw/188-...`의 패키지 트리에서 기계적으로 계산했고, 각 파일이 정확히 한 sub-scope에 속한다(중복 0, 미할당 0).
---
# Sub-scope 01 — governance + `config`·`settings`·`core`·`contract`·`moduleboundary`·`*/autoconfigure` (51 files)
> 내부 상태: COMPLETE — **51 / 51 FULL_READ** · 근거 `evidence/raw/189-inbound-web-governance-probes.txt` (`file_count=51`)
## 2. 무엇을 하는 코드인가
**`build.gradle`(258줄)이 이 모듈에서 가장 밀도 높은 문서다.** 여섯 소스셋과 다섯 커스텀 레인을 선언하면서, 각 결정마다 "그렇게 하지 않으면 레인이 무엇을 인증하게 되는가"를 적는다:
- `jettyCompatTest`가 자기 소스셋인 이유 — "Two servers in one source set means Spring Boot picks one and the 'Jetty' lane **silently runs on Tomcat** — a compatibility matrix that certifies the same container twice."
- `webfluxContractTest``inherits()`(아무것도 상속하지 않음)인 이유 — "the default is to extend `testImplementation`, which extends the leaf's own `implementation` and therefore carries spring-boot-starter-web — and with Tomcat on the classpath Boot deduces a servlet application, starts a servlet container, and **the reactive gate certifies the servlet stack while reporting itself green**."
- `nginxProxyTest``test`에 들어가지 않는 이유 — "folding it into `test` would make every developer's `check` depend on a container runtime, and the usual outcome of that is an **`@Disabled` that nobody notices has been there for months**."
- CBOR·XML이 `compileOnly`인 이유 — `implementation`이었을 때 "Spring Boot's Jackson auto-configuration registers an `xmlMapper` and a `cborMapper` the moment each backend is on the runtime classpath... So every deployment got three ObjectMapper beans... and, worse, **silently began parsing `application/xml` request bodies**. An Advanced capability that is off by default had turned XML deserialization on for everybody, which is the opposite of what the flag promises and **an XXE surface nobody chose**."
- `test``web-parity` 태그를 제외하는 이유 — 세 레인의 기록을 비교하는 게이트인데 `test` 단독으로는 하나만 존재하므로 "a gate that fails because the others have not run yet is a gate people learn to ignore."
**모듈 경계.** 이 leaf는 설계상 23개 Gradle 모듈이어야 하는 것을 하나의 등록 leaf 안 74개 패키지로 담는다. 그 대체가 정직하려면 경계가 기계로 확인되어야 하고, `WebStableModule`(539줄 enum)이 각 모듈의 **id · 패키지 · 순도 등급 · 허용 의존 집합**을 선언한다. `WebModuleBoundaryTest`가 실제 소스 트리를 스캔해 양방향으로 대조한다.
**`core` 9종**은 프레임워크 자유(`CORE` 순도)이고 각 타입이 자기 불변식을 생성자로 강제한다. `ActorContext`가 대표적이다 — 인증된 액터를 만드는 유일한 경로가 subject를 요구하는 `authenticated`이고, 다른 생성 경로 `anonymous()`는 subject를 담을 수 없다. 그래서 "A header value therefore has no path into this type that does not go through authentication first"가 주석이 아니라 타입 사실이다. `!authenticated && !subject.isBlank()` 조합을 거부하는 이유도 적혀 있다 — "that pairing is how an unverified identifier reaches an audit record looking verified."
`ExternalRequestContext`는 프록시 뒤에서 URL을 만들기 위해 scheme·host·port·prefix **만** 보관하고 나머지는 버린다 — "anything retained here would become a way for a caller to choose where a `Location` header points." prefix는 `..` 순회까지 거부한다.
`WebRequestContext`의 deadline이 duration이 아니라 **절대 시각**인 이유: "A budget expressed as 'three seconds' restarts at every hop that reads it, so a request with a three second budget can spend nine; an instant cannot be accidentally renewed."
## 3. Negative-space probes — sub-scope 01
### 3.1 (8.1) 도달성 — 다섯 커스텀 레인이 실제로 실행되는가
`build.gradle`은 "A release compatibility gate that is not wired to a task is a document"라고 적고 다섯 개 `Test` 태스크를 등록한다. 그러나 등록은 실행이 아니다. Gradle 쪽 참조를 전수했다:
```
$ grep -rn 'webCrossStackParityTest|webFluxContractTest|webJettyCompatTest|webNginxProxyTest|webAdvancedTest' \
--include=*.gradle --include=*.groovy . | grep -v 'adapter/inbound/web/build.gradle' | wc -l
0
```
`check`가 의존하는 것은 `webSecurityBoundaryTest` 하나뿐이다. 나머지 넷은 Gradle 그래프 어디에서도 참조되지 않는다.
**CI가 닫는다.** 다섯 워크플로가 다섯 레인을 전부 이름으로 호출한다:
```
web-pr.yml:77 :adapter:inbound:web:webCrossStackParityTest
web-pr.yml:112 :adapter:inbound:web:webNginxProxyTest
web-nightly.yml:45,46 webJettyCompatTest · webFluxContractTest
web-release.yml:45,46 webCrossStackParityTest · webNginxProxyTest
web-advanced-nightly.yml:46 webAdvancedTest
web-advanced-release.yml:45,46,53 webJettyCompatTest · webFluxContractTest · webAdvancedTest
```
`webCrossStackParityTest``dependsOn 'test', 'webJettyCompatTest', 'webFluxContractTest'`이므로 PR 게이트 하나가 네 레인을 끌고 온다. **회로 닫힘.** cache-redis의 `RedisCommandMetadataDiff`(main 참조 0 · lane 참조 0 · Gradle 태스크 없음)와 정확히 대조되는 상태다.
기록할 것: 결합이 **Gradle이 아니라 YAML**에 있다. `./gradlew :adapter:inbound:web:check`를 로컬에서 도는 개발자는 `test` + `webSecurityBoundaryTest`만 얻는다. Docker를 요구하는 `webNginxProxyTest`에는 그 결정의 근거가 적혀 있지만, Docker가 필요 없는 `webJettyCompatTest`·`webFluxContractTest`·`webCrossStackParityTest`에는 없다. §4.1.
### 3.2 (8.2) 조건 형제 비교 — 두 자동설정의 게이트
```
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`(옵트인)인 것과 반대인데, 이유가 다르다: 저쪽은 선택적 능력이고 이쪽은 웹 애플리케이션의 본체다. 상호배타성은 프로퍼티가 아니라 `@ConditionalOnWebApplication`의 타입 수준에서 온다 — "a reactive application cannot accidentally activate the servlet filters even if both artifacts are on the classpath."
두 자동설정이 등록하는 빈은 MVC 12개, WebFlux 11개다. `AutoConfiguration.imports`에는 이 둘만 있다.
### 3.3 (8.3) 배선 — main 397개 파일 중 무엇이 실제로 컨텍스트에 들어가는가
app-bootstrap이 이 leaf에서 import하는 **서로 다른 타입은 19개**이고, 그중 18개가 `fileserver.*`, 나머지 하나가 `auth.RestrictedPathRule`이다. 즉 조립의 대부분은 명시적 배선이 아니라 **컴포넌트 스캔**이다:
```
@ComponentScan(basePackages = { ..., "dev.caskeleton.adapter", ... }) // CaSkeletonApplication
@ConfigurationPropertiesScan(basePackages = { ..., "dev.caskeleton.adapter.inbound.web", ... })
```
스캔에 직접 걸리는 것은 `@RestController` 18 · `@RestControllerAdvice` 6 · `@Component` 7 · `@Configuration` 23 = 54개 파일이고, `@AutoConfiguration` 2개가 별도로 들어온다. 나머지 ~340개는 그 빈들이 전이적으로 쓰는 라이브러리 타입이다. 이 sub-scope에서는 그 형태만 확인하고, 실제 미도달 여부는 각 sub-scope에서 판정한다.
`CaSkeletonApplication`의 스캔 설계에도 이 저장소의 자기고발이 있다: "The asymmetry that existed before — **beans gated, settings not** — is why a notification settings object bound itself in a deployment whose notification master was off. A capability whose beans are gated but whose settings still bind is gated only where somebody remembered to gate it."
### 3.4 (8.4) 문서/구현 드리프트 — 모듈 경계 선언과 실제 트리
`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()`에 특정 패키지 두 개가 있어야 한다는 확인이 함께 붙는다.
프레임워크 탐지 정규식에는 Jackson 2와 3이 **둘 다** 들어 있고 그 근거가 적혀 있다: "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과 달랐던)와 달리, 여기서는 목록 자체가 스캔으로 생성된다.
### 3.5 (8.4b) CORS 검증
`CorsSettings``enabled && allowedOrigins.isEmpty()``allowCredentials && allowedOrigins.contains("*")` 둘을 생성자에서 거부한다. 소비처를 확인했다 — `SecurityConfig:151``cfg.setAllowedOrigins(...)`이고 `setAllowedOriginPatterns`가 아니다. 패턴 API였다면 `"https://*"` 같은 항목이 `contains("*")`를 빠져나가면서 Spring에서는 허용되어 자격증명 포함 임의 origin 반사가 됐을 것이다. 정확한 API 짝이다. 결함 아님.
## 4. Sub-scope 01 findings
### 4.1 P3/기록 — 네 레인의 결합이 Gradle이 아니라 다섯 개 워크플로 YAML에 있다
`check`에서 도달 가능한 것은 `test` + `webSecurityBoundaryTest`뿐이고, `webCrossStackParityTest`·`webFluxContractTest`·`webJettyCompatTest`·`webNginxProxyTest`·`webAdvancedTest``.github/workflows` 다섯 파일이 이름으로 호출할 때만 돈다(§3.1). 태스크 이름이 바뀌면 Gradle 구성 시점이 아니라 CI 실행 시점에 깨지고, 워크플로 다섯 곳을 모두 고쳐야 한다.
Docker를 요구하는 `webNginxProxyTest``check`에서 빠진 근거가 build.gradle에 명시돼 있다. 나머지 셋에는 없다 — 특히 `webCrossStackParityTest``dependsOn`으로 세 레인을 이미 묶고 있어 Gradle 수준의 집계 지점이 이미 존재한다. 결함이 아니라 결합 위치의 기록이다.
### 4.2 P3/기록 — `WebRequestId`·`WebTraceId`가 문법을 갖지 않고, 그 불변식이 두 필터에 복제되어 있다
`WebTraceId`의 javadoc은 이렇게 주장한다:
> "Held as a value rather than a raw header string so **the one place that decides** whether an inbound `traceparent` may be believed is a constructor rather than every call site that reads a header."
그러나 생성자는 `null`/blank와 길이 128만 본다. 문자 문법이 없다. 같은 `core` 패키지의 형제들은 전부 anchored 문법을 갖는다 — `WebOperationName``[a-z][a-z0-9.-]{2,127}`, `TenantContext``[A-Za-z0-9][A-Za-z0-9_.-]{0,63}`.
실제 결정은 두 필터에 있고, **둘 다 올바르다**:
```java
// mvc/filter/WebMvcRequestIdFilter.java:48 webflux/context/WebFluxRequestContextFilter.java:45
private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z0-9._-]{1,128}");
```
신뢰되지 않은 입력에서 이 두 타입을 만드는 지점은 이 둘뿐이고(생성 지점 전수 확인), 양쪽 다 anchored `matches()`를 쓰며, `trustInboundRequestId`는 기본 `false`다. traceparent는 `[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}`로만 채택된다. 현재 노출은 없다.
기록하는 것은 **불변식의 위치**다. 같은 정규식이 두 파일에 리터럴로 복제돼 있고(WebFlux 자동설정 javadoc이 "duplicating six `@Bean` methods is the cheaper of the two costs"로 복제 자체는 의도했다고 밝힌다), 타입은 그것을 강제하지 않는다. httpclient에서 확인한 P3 — `validatedDnsPinning` 검사가 블로킹 오버로드에만 있고 리액티브에는 없던 — 과 같은 형태의 위험이며, 이 모듈은 아직 양쪽이 일치한다.
## 5. Sub-scope 01 완료 조건
- denominator 51 / 51 FULL_READ (probe가 `file_count=51` 확인)
- §8.1~§8.4 수행 — 레인 도달성 확인(회로 닫힘), 두 자동설정 게이트 비교, 배선 형태 확인, 경계 강제 검증
- 기록 2건, 결함 0건
- 소스 미변경
---
# Sub-scope 02 — `error` + `validation` + `envelope` (33 files, main 23 + test 10)
> 내부 상태: COMPLETE — **33 / 33 FULL_READ** · 근거 `evidence/raw/190-inbound-web-error-probes.txt` (`file_count=33`)
## 6. 무엇을 하는 코드인가
에러 번역 — 실패가 HTTP 상태와 본문이 되는 곳. 그런데 **이 sub-scope에는 그런 곳이 두 개 있다.**
**계약 A — `Envelope`.** `GlobalExceptionHandler`(417줄, `@RestControllerAdvice`, `extends ResponseEntityExceptionHandler`)가 22개 `@ExceptionHandler` + 6개 프레임워크 오버라이드로 모든 실패를 `Envelope<Void>`(`{success, data|error, traceId}`)로 만든다. 코드 어휘는 `OperationalError`. 함께 `EnvelopeBodyAdvice`**성공 응답도 전부** `Envelope`로 감싼다.
**계약 B — RFC 9457 `problem+json`.** `WebProblemFactory` · `ProblemCatalog` · `WebProblem` · `WebProblemSanitizer` · `SafeProblemDetailExtensions` · `ProblemCode`와 두 전송 핸들러(`WebMvcProblemExceptionHandler` 182줄 · `WebFluxProblemExceptionHandler` 173줄)가 `application/problem+json`을 만든다. 코드 어휘는 `ProblemCode`.
계약 B의 위생 규칙은 정교하다. `WebProblemSanitizer`는 **허용목록이 아니라 제거목록**인데 그 선택을 정직하게 설명한다 — 여섯 패턴(스택 프레임 · 패키지 한정 타입명 · URL · 자격증명 · SQL · 파일 경로)을 지운 뒤 제어문자와 중복 공백을 접고 카탈로그가 정한 길이로 자른다. 자격증명 패턴에는 자기고발이 붙어 있다:
> "An earlier version anchored on the keyword alone, which removed the word `Bearer` and **published the token after it** — a redaction that reads as if it worked."
`WebProblem`은 8개 고정 컴포넌트 record라 확장 멤버가 구조적으로 불가능하고, `SafeProblemDetailExtensions`가 그 이유를 적는다 — "Somebody adds `cause` 'just for debugging' and the exception message ships to every caller."
계약 A의 위생 규칙도 있다. `ClientSafeErrorMessages`가 코드별 **고정 문구**만 반환하고 예외 메시지를 절대 통과시키지 않는다. README가 그 규칙을 명시한다 — "클라이언트 메시지는 allowlist다. 예외 메시지, validation interpolated message, rejected request value, raw request URL은 넣지 않는다."
두 계약 각각은 잘 만들어져 있다. 문제는 둘이 같은 애플리케이션에 함께 있다는 것이다.
## 7. Negative-space probes — sub-scope 02
### 7.1 (8.1) 도달성 — 두 advice 가 한 컨텍스트에 함께 등록되는가
둘 다 `@RestControllerAdvice`이지만 **컴포지션 루트에서의 운명이 다르다**. `CaSkeletonApplication`의 컴포넌트 스캔이 정규식으로 다섯 web 패키지를 제외하고, 그중 하나가 problem 핸들러의 패키지다:
```java
// CaSkeletonApplication.AUTO_CONFIGURED_PACKAGES (@ComponentScan excludeFilters, FilterType.REGEX)
...
|dev\.caskeleton\.adapter\.inbound\.web\.mvc\.error\..*
|dev\.caskeleton\.adapter\.inbound\.web\.mvc\.budget\..*
|dev\.caskeleton\.adapter\.inbound\.web\.mvc\.operation\..*
|dev\.caskeleton\.adapter\.inbound\.web\.webflux\.error\..*
|dev\.caskeleton\.adapter\.inbound\.web\.webflux\.operation\..*
```
| | 컴포넌트 스캔 | 자동설정 등록 | 출하 컨텍스트 |
|---|---|---|---|
| `GlobalExceptionHandler` (`...web.error`) | 잡힘 | | **등록됨** |
| `WebMvcProblemExceptionHandler` (`...web.mvc.error`) | **제외됨** | 없음 | **등록되지 않음** |
| `WebFluxProblemExceptionHandler` (`...web.webflux.error`) | **제외됨** | 없음 | 등록되지 않음 |
| `WebMvcBudgetExceptionHandler` (`...web.mvc.budget`) | **제외됨** | 없음 | 등록되지 않음 |
| `OperationHttpController` (`...web.mvc.operation`) | **제외됨** | 없음 | 등록되지 않음 |
| `ReactiveOperationHttpController` (`...web.webflux.operation`) | **제외됨** | 없음 | 등록되지 않음 |
`AutoConfiguration.imports`에는 두 플랫폼 자동설정만 있고 그중 어느 것도 이 여섯을 `@Import`하거나 `@Bean`으로 만들지 않는다. app-bootstrap이 이 타입들을 참조하는 횟수도 0이다(`evidence/raw/203-composition-root-scan-boundary.txt`).
### 7.2 (8.2) 조건 형제 비교 — 겹치는 예외 타입
다섯 예외 타입을 두 핸들러가 모두 선언한다:
| 예외 | `GlobalExceptionHandler` | `WebMvcProblemExceptionHandler` |
|---|---|---|
| `MethodArgumentNotValidException` | `:285` (오버라이드) | `:77` |
| `HttpMessageNotReadableException` | `:301` | `:99` |
| `HttpRequestMethodNotSupportedException` | `:315` | `:121` |
| `HttpMediaTypeNotSupportedException` | `:341` | `:110` |
| `NoResourceFoundException` | `:404` | `:158` |
problem 핸들러가 `@Order(HIGHEST_PRECEDENCE + 10)`이고 `GlobalExceptionHandler`가 무순서(`LOWEST_PRECEDENCE`)이므로, **둘이 함께 등록된 컨텍스트에서는** problem 쪽이 이긴다. 그런 컨텍스트는 두 패키지를 모두 스캔하는 테스트 슬라이스와 픽스처 애플리케이션이고, **출하 컴포지션 루트는 그런 컨텍스트가 아니다**(§7.1). §8.1.
### 7.3 (8.3) 문서가 선언하는 것
`README.md`의 절 제목이 `## error — 에러 → Envelope 변환`이고, 그 아래 결정이 명시적이다:
```
README.md:168
- **D5: RFC 7807 `ProblemDetail` 표현은 거부**하고 자체 `Envelope` 형식을 쓴다.
```
그리고 계약 B는 문서에 **존재하지 않는다**:
```
$ grep -c 'problem+json\|RFC 9457\|ProblemCode' README.md CLAUDE.md
README.md:0
CLAUDE.md:0
```
23개 main 파일이 구현하는 계약이 463줄 README와 201줄 CLAUDE.md 어디에도 언급되지 않는다.
`CLAUDE.md:92-93`은 순서 대역까지 배정해 둔다:
> "Domain `@RestControllerAdvice` in a consuming module must be annotated `@Order(Ordered.HIGHEST_PRECEDENCE)` (or otherwise ordered ahead of this ...)"
`WebMvcProblemExceptionHandler``HIGHEST_PRECEDENCE + 10`을 쓰므로, **CLAUDE.md가 시킨 대로 `HIGHEST_PRECEDENCE`를 쓴 소비 모듈의 도메인 advice가 그것보다도 앞선다**. 어떤 계약이 응답하는지는 결국 채택자가 문서를 따랐는지에 달린다.
### 7.4 (8.4) 테스트가 두 advice 를 함께 세우는가
```
$ grep -rln 'WebMvcProblemExceptionHandler' src/test src/testkit
src/testkit/java/dev/caskeleton/webtestkit/ContractFixtureApplication.java # GlobalExceptionHandler 참조 0
```
**leaf의 어떤 테스트도 두 advice 를 한 컨텍스트에 세우지 않는다.** 각각 자기 슬라이스에서만 검증된다. §8.1의 실패 시나리오가 초록색 스위트 아래에서 성립하는 이유다.
### 7.5 (8.4b) 미도달 유틸
| 심볼 | 프로덕션 호출자 | 판정 |
|---|---|---|
| `WebProblemSanitizer.alreadySafe` | **0** (테스트 포함 0) | 죽은 public 메서드. 게다가 본문의 `input.trim().toLowerCase(ROOT).isEmpty()` 삼항은 `!input.isBlank()` 뒤에서 항상 false라 조건 자체가 죽어 있다 |
| `SafeProblemDetailExtensions.allowed/requireAllowed` | **0** | 결함 아님 — `WebProblem`이 8개 고정 컴포넌트 record라 확장 멤버 경로가 애초에 없다. 타입이 이미 강제하는 불변식의 문서화 |
| `WebProblemFactory.requireStatusAgreement` | 3 (`BudgetProblemMapper:73` · `IdempotentResponseWriter:68` · `WebFluxIdempotentInvoker:154`) | javadoc은 "Called on the way out, before serialization, by **every transport adapter**"라고 하지만 두 problem 핸들러는 부르지 않는다. 그쪽은 `ResponseEntity.status(problem.status())`로 상태를 본문에서 직접 가져오므로 불일치가 구조적으로 불가능 — 과장된 주석이고 결함은 아님 |
## 8. Sub-scope 02 findings
### 8.1 P1 — RFC 9457 계약 23개 파일이 출하 애플리케이션에 등록되지 않는다. 두 플랫폼 자동설정은 협력자 빈만 소유하고, 스캔에서 제외된 여섯 컴포넌트는 소유하지 않는다
`CaSkeletonApplication`이 컴포넌트 스캔에서 다섯 web 패키지를 정규식으로 제외하고(§7.1), 그 이유를 명시한다:
> "The web platform's error, budget and operation packages are here for a third reason. Their advices and controllers need beans that only exist when the corresponding platform auto-configuration is active, and a component scan finds them regardless — so an all-off or partially configured deployment failed to start on an unsatisfied dependency rather than simply not installing the control. **Ownership by auto-configuration is what ties a control's presence to its dependency's.**"
진단도 조치의 방향도 옳다. 자동설정도 **존재하고 `.imports`에 등록돼 있다**`app-bootstrap/build.gradle:98``implementation project(':adapter:inbound:web')`로 이 leaf를 물고, 저장소의 유일한 `AutoConfigurationImportFilter`는 JPA 전용(`JpaOffAutoConfigurationImportFilter`)이므로 두 자동설정은 출하 컨텍스트에 실제로 import된다. 그런데 **그 자동설정이 소유하는 것은 협력자이고, 스캔에서 제외된 여섯 컴포넌트가 아니다**:
```
$ cat .../META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
dev.caskeleton.adapter.inbound.web.mvc.autoconfigure.WebMvcPlatformAutoConfiguration
dev.caskeleton.adapter.inbound.web.webflux.autoconfigure.WebFluxPlatformAutoConfiguration
$ grep -rn 'WebMvcProblemExceptionHandler|WebFluxProblemExceptionHandler|WebMvcBudgetExceptionHandler|OperationHttpController|ReactiveOperationHttpController' app-bootstrap/src/main -> 0
$ grep -rn 'mvc.error|webflux.error|mvc.budget|mvc.operation|webflux.operation' --include=*.imports --include=*.factories -> 0
$ grep -rln 'WebProblemFactory' app-bootstrap/src/main -> 0
```
두 플랫폼 자동설정은 `@Bean` **13개**(MVC)와 **10개**(WebFlux)를 등록하고 `@Import`를 하나도 갖지 않는다. 등록되는 것은 전부 **협력자**다:
```
MVC : InMemoryWebOperationCatalog · WebBudgetCatalog · ProblemCatalog · WebProblemFactory ·
WebValidationExceptionMapper · WebValidationIssueMapper · WebWireTypeManifest ·
WebUriPolicy · WebMethodPolicy · webStrictObjectMapper ·
WebMvcRequestIdFilter · WebMvcEvidenceFilter · webMvcRequestContextConfigurer
Flux : WebFluxRequestContextFilter · InMemoryWebOperationCatalog · WebBudgetCatalog ·
ProblemCatalog · WebProblemFactory · WebValidationExceptionMapper ·
WebUriPolicy · WebMethodPolicy · webStrictObjectMapper (+1)
```
스캔에서 제외된 다섯 패키지의 컴포넌트는 여섯 개이고, 전부 스테레오타입 애노테이션을 갖는데 main 참조가 0이다:
| 컴포넌트 | 패키지 | 애노테이션 | main 참조 |
|---|---|---|---|
| `WebMvcProblemExceptionHandler` | `mvc.error` | `@RestControllerAdvice @Order` | **0** |
| `WebFluxProblemExceptionHandler` | `webflux.error` | `@RestControllerAdvice @Order` | **0** |
| `WebMvcBudgetExceptionHandler` | `mvc.budget` | `@RestControllerAdvice @Order` | **0** |
| `WebMvcBudgetFilter` | `mvc.budget` | (없음) | **0** |
| `OperationHttpController` | `mvc.operation` | `@RestController` | **0** |
| `ReactiveOperationHttpController` | `webflux.operation` | `@RestController` | **0** |
`ProblemCatalog``WebProblemFactory`는 빈으로 존재하고, **그것을 사용하는 `@RestControllerAdvice`가 존재하지 않는다.** 합성 루트의 javadoc이 말한 "Ownership by auto-configuration is what ties a control's presence to its dependency's"에서 **dependency 쪽만 소유되고 control 쪽은 소유되지 않았다.**
**출하 컨텍스트의 실제 상태:**
| 계약 | 구성 요소 | 등록 |
|---|---|---|
| `Envelope` | `GlobalExceptionHandler`(22 handler + 6 override) · `EnvelopeBodyAdvice` · `ErrorResponseFactory` · `ClientSafeErrorMessages` | **등록됨** — 모든 실패에 응답 |
| RFC 9457 `problem+json` | `WebProblemFactory` · `ProblemCatalog` · `WebProblem` · `WebProblemSanitizer` · `ProblemCode` · `SafeProblemDetailExtensions` · `ValidationIssue` · `BudgetProblemMapper` · `ThrottleProblemWriter` · 두 전송 핸들러 (**23 main files**) | **등록되지 않음** |
즉 이 leaf가 만든 두 에러 계약 중 **하나만 출하되고**, 위생 규칙이 정교한 쪽(`WebProblemSanitizer`의 여섯 제거 패턴, 닫힌 확장 집합, 카탈로그 기반 상태 일치 검사)이 등록되지 않는 쪽이다.
**실패 시나리오** — 팀이 `ProblemCode.VALIDATION_FAILED`로 분기하는 클라이언트 SDK를 작성한다. `WebProblemFactoryTest`(122줄) · `ProblemCatalogTest`(99줄) · `WebValidationExceptionMapperTest`(125줄)가 전부 통과하고, `/v3/api-docs`에도 problem 스키마가 기여되지 않아(§27.2) 계약 불일치를 볼 방법이 없다. 배포된 API는 422 대신 `Envelope`의 400을 내고 `code` 필드는 `ProblemCode`가 아니라 `OperationalError` 어휘다. SDK의 모든 분기가 빗나간다.
**두 번째 결과**`WebMvcBudgetExceptionHandler`와 두 durable-operation 컨트롤러도 같은 정규식에 제외된다. §16.3과 §20.2에서 "게이트를 켜면 미충족 의존성으로 부팅 실패"라고 기록한 것은 정확히는 **게이트를 켜도 빈이 생기지 않는다**가 맞다 — 스캔이 잡지 않으므로 조건 평가에도 이르지 못한다. 결과(능력이 켜지지 않음)는 같다.
**왜 지금까지 드러나지 않았는가** — problem 계약을 검증하는 테스트는 전부 그 패키지를 명시적으로 `@Import`하거나 스캔하는 슬라이스에서 돈다. 출하 루트의 스캔 경계를 재현하는 테스트가 없다. 반대로 `NoResourceFoundErrorHandlingTest``GlobalExceptionHandler``@Import`하는데, 그것이 **우연히** 출하 동작과 일치한다:
```java
@WebMvcTest(controllers = Probe.class, excludeAutoConfiguration = SecurityAutoConfiguration.class)
@Import({ Probe.class, GlobalExceptionHandler.class, EnvelopeBodyAdvice.class })
class NoResourceFoundErrorHandlingTest {
@Test void missingResourceUsesSafeRouteNotFoundEnvelope() throws Exception {
mvc.perform(get("/assets/" + secret + ".js"))
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value("ROUTE_NOT_FOUND"))
...
```
이 테스트는 problem 핸들러를 컨텍스트에서 빼고 `Envelope`을 단언한다. 출하 루트에서도 problem 핸들러가 없으므로 결과적으로 프로덕션 동작을 맞게 서술한다 — 그러나 그 일치는 테스트가 스캔 경계를 재현해서가 아니라 **두 컨텍스트가 우연히 같은 것을 빼서** 생긴다. problem 핸들러가 언젠가 자동설정으로 등록되면 이 테스트는 여전히 통과하면서 틀린 답을 단언하게 된다.
**권고** — 하나를 고른다. `Envelope`을 유지한다면 RFC 9457 23개 파일과 그 테스트를 제거하고 스캔 제외에서 `mvc.error`/`webflux.error`를 뺀다. `problem+json`으로 간다면 두 플랫폼 자동설정이 두 핸들러와 `WebProblemFactory`·`ProblemCatalog`를 등록하고, `GlobalExceptionHandler`에서 겹치는 다섯 `@ExceptionHandler`를 제거하며, README의 D5와 `## error — 에러 → Envelope 변환` 절을 교체한다.
어느 쪽이든 **출하 루트의 스캔·자동설정 경계를 재현하는 테스트**가 필요하다(§48.1의 권고와 같은 장치). 그것이 없으면 "제외했는데 넘겨받지 않았다"가 다시 성립한다.
### 8.2 P3 — `WebProblemSanitizer.alreadySafe`가 죽은 메서드이고 그 안의 조건도 죽어 있다
```java
public boolean alreadySafe(String input, int maxLength) {
return input != null && !input.isBlank()
&& sanitize(input, maxLength)
.equals(input.trim().toLowerCase(Locale.ROOT).isEmpty() ? REDACTED : input.trim());
}
```
호출자 0(프로덕션·테스트 모두). 그리고 `!input.isBlank()`가 이미 통과했으므로 `input.trim()`은 비어 있을 수 없고, `toLowerCase`는 공백 여부를 바꾸지 않는다 — 삼항의 `REDACTED` 가지는 도달 불가다. javadoc이 약속하는 용도("for asserting a message is already safe")를 수행하는 코드가 없다.
### 8.3 P3/기록 — `requireStatusAgreement`의 javadoc이 호출 범위를 과장한다
§7.5. "every transport adapter"가 부르는 것이 아니라 세 곳이 부르고, 두 problem 핸들러는 상태를 본문에서 직접 읽어 불일치가 구조적으로 불가능하다. `WebBudgetOutcome:8`은 이 검사가 실제로 한 번 무언가를 잡았다고 기록한다 — 동작하는 검사이고, 서술만 넓다.
## 9. Sub-scope 02 완료 조건
- denominator 33 / 33 FULL_READ (probe가 `file_count=33` 확인)
- §8.1~§8.4 수행 — 도달성에서 P1 1건, 미도달 유틸에서 P3 1건, 기록 1건
- 소스 미변경
---
# Sub-scope 03 — `auth` + `authz` + `security` (44 files, main 27 + test 17)
> 내부 상태: COMPLETE — **44 / 44 FULL_READ** · 근거 `evidence/raw/191-inbound-web-security-probes.txt` (`file_count=44`)
## 10. 무엇을 하는 코드인가
여기에도 두 벌이 있다.
**배선된 것 — `auth` (11 files).** `SecurityConfig``SecurityFilterChain`을 짓고, JWT 모드와 Redis-세션 모드를 배타적으로 분기한다. JWT 모드는 `csrf.disable()` + `STATELESS` + `oauth2ResourceServer(jwt)`, 세션 모드는 `CookieCsrfTokenRepository`(secure · httpOnly=false · sameSite/path from settings) + `IF_REQUIRED` + `migrateSession()` + 커스텀 `SecurityContextRepository`. 신원은 `JwtToAuthenticatedPrincipalConverter`가 Keycloak식 `realm_access.roles` `resource_access[*].roles` 평면 `roles`를 합쳐 `AuthenticatedPrincipal`로 만든다. 이 경로가 실제로 요청을 인증한다.
세부는 꼼꼼하다 — `requestCache(cache -> cache.disable())`("This is an API boundary: never persist framework SavedRequest graphs in a session"), 진입점과 거부 처리기를 `exceptionHandling``oauth2ResourceServer` **양쪽에** 설정("so every filter resolves to the same Envelope writer"), `BearerTokenAuthenticationFilter`의 실패 처리기까지 `ObjectPostProcessor`로 같은 진입점에 연결.
**배선되지 않은 것 — `security` (11 files).** 프레임워크 자유 신원 모델이다: `AuthenticationView`(보안 계층이 확정한 것의 읽기 전용 뷰) → `WebActorContextResolver` / `WebTenantContextResolver``WebSecurityContextBridge``SecurityIdentity`(= `ActorContext` + `TenantContext`). 설계 논증이 정확하다:
> `WebActorContextResolver` — "It takes `AuthenticationView` and nothing else — no request, no headers, no parameters. **That is the whole enforcement**: there is no argument here through which a caller-supplied value could reach an `ActorContext`."
> `WebTenantContextResolver.rejectTenantInput` — `x-tenant-id` · `tenant-id` · `tenantid` · `tenant` 중 하나라도 요청에 있으면 `UntrustedTenantInputException`. "ignoring leaves a cross-tenant attempt invisible, and the same client keeps trying."
`authz` (5 files)는 `@RequiresPermission``AuthorizationPort`에 위임한다. `AuthorizationPort``Supplier`로 늦게 푸는 이유까지 적혀 있다 — "an advisor is built while BeanPostProcessors are still registering, and resolving the port there instantiates it — and its whole role/permission chain — too early to be post-processed." 미인증·미인식 principal은 전부 fail-closed.
## 11. Negative-space probes — sub-scope 03
### 11.1 (8.1) 도달성 — 신원 모델의 프로덕션 참조 수
```
WebSecurityContextBridge : main_refs=0 test_refs=1
WebActorContextResolver : main_refs=1 test_refs=0 ← 참조자는 WebSecurityContextBridge 하나
WebTenantContextResolver : main_refs=1 test_refs=1 ← 같음
AuthenticationView : main_refs=3 test_refs=1 ← 전부 위 세 파일
SecurityIdentity : main_refs=1 test_refs=1
rejectTenantInput : main_refs=2 test_refs=1 ← 선언 + 브리지 오버로드. 세 번째 호출자 없음
WebCorsPolicyValidator : main_refs=0 test_refs=1
WebCsrfPolicyResolver : main_refs=0 test_refs=1
```
`AuthenticationView`를 만드는 코드도 테스트뿐이다:
```
$ grep -rn 'AuthenticationView.(authenticated|anonymous)' --include=*.java src | grep -v security/AuthenticationView.java
test/.../security/WebSecurityContextBridgeTest.java:27, 42, 64, 75, 90
```
`security` 패키지 전체가 **자기 안에서만 서로를 부르는 닫힌 섬**이고, 바깥에서 들어오는 화살표가 없다. 교차 테넌트 가드 `rejectTenantInput`은 그 섬 안에만 있다.
### 11.2 (8.2) 조건 형제 비교 — 두 전송의 `WebRequestContext` 생산자
```
$ grep -rn 'new WebRequestContext(' --include=*.java src app-bootstrap/src
main/.../webflux/context/WebFluxRequestContextFilter.java:81 ← main 유일
testkit/.../fault/FaultFixtureController.java:143
testkit/.../fault/ReactiveFaultFixtureController.java:141
testkit/.../throttle/ThrottleFixtureSupport.java:63
testkit/.../operation/OperationFixtureSupport.java:123
test/... (6곳)
```
| 전송 | 생산자 | 소비자 |
|---|---|---|
| WebFlux | `WebFluxRequestContextFilter:78-88` | `WebFluxRequestContextAccessor` (Reactor context) |
| **MVC** | **없음** | `WebMvcRequestContextArgumentResolver``WebMvcRequestContextHolder.require()` |
```
$ grep -rn 'WebMvcRequestContextHolder.store' --include=*.java . → 0
$ grep -rn 'WebMvcRequestContextHolder' --include=*.java src app-bootstrap/src | grep -v Holder.java
main/.../mvc/context/WebMvcRequestContextArgumentResolver.java:40: return WebMvcRequestContextHolder.require(request);
```
§12.1.
### 11.3 (8.3) 필터 체인 순서 — `publicPaths` 대 `RestrictedPathRule`
```java
// SecurityConfig.java:83-94
.authorizeHttpRequests(auth -> {
if (publicPaths.length > 0) { auth.requestMatchers(publicPaths).permitAll(); } // ← 먼저
for (RestrictedPathRule rule : restricted) { // ← 나중
auth.requestMatchers(rule.pathPattern()).hasAnyAuthority(rule.authorities());
}
auth.anyRequest().authenticated();
})
```
Spring Security는 첫 일치가 이긴다. 주석은 "Ordered before the authenticated catch-all: **a management path must be refused at the transport**"라고 하는데, 그 순서는 `anyRequest()`에 대해서만 성립하고 `publicPaths`에 대해서는 반대다. §12.3.
프로덕션 `RestrictedPathRule` 생산자는 하나다 — `FileserverAdminPlaneConfiguration:36`이 fileserver 관리 경로를 등록한다. `publicPaths`의 기본값은 `${SECURITY_PUBLIC_PATHS:${PRESENTATION_API_BASE_PATH:/v1}/healthcheck}`로, 환경변수 하나로 전체가 대체된다.
### 11.4 (8.4) 익명 액터가 무엇을 만드는가
```java
// OperationAccessPolicy.java:31-33
if (!context.actor().authenticated()) {
return false;
}
```
fail-closed다. §12.1의 두 번째 결과.
## 12. Sub-scope 03 findings
### 12.1 P1 — 플랫폼 요청 컨텍스트가 서블릿에는 생산자가 없고, 리액티브에는 익명 액터로 고정되어 있다
**서블릿 절반.** `WebMvcRequestContextHolder.store(...)`의 호출자가 저장소 전체에서 **0**이다. 그런데 그것을 읽는 쪽은 자동설정이 등록한다:
```java
// WebMvcPlatformAutoConfiguration.java:158-166
@Bean @ConditionalOnMissingBean(name = "webMvcRequestContextConfigurer")
public WebMvcConfigurer webMvcRequestContextConfigurer() {
return new WebMvcConfigurer() {
@Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new WebMvcRequestContextArgumentResolver());
}
};
}
```
그리고 그 리졸버가 하는 일은 `WebMvcRequestContextHolder.require(request)` 하나이며, 속성이 없으면 던진다:
```java
// WebMvcRequestContextHolder.java require(...)
throw new IllegalStateException(
"no web request context on this request; a fabricated one here would make an"
+ " unauthenticated call look like an anonymous actor somebody chose");
```
속성을 놓는 코드가 없으므로 이 예외는 **항상** 던져진다. 서블릿 컨트롤러가 `WebRequestContext` 파라미터를 선언하면 그 엔드포인트는 언제나 500이다. 컨트롤러의 서명에서 `HttpServletRequest`를 몰아내려고 만든 기능이, 쓰면 반드시 실패하는 기능이다.
**리액티브 절반.** 생산자가 하나 있고, 열 개 컴포넌트 중 넷을 상수로 채운다:
```java
// WebFluxRequestContextFilter.java:78-88
WebRequestContext context =
new WebRequestContext(
requestId,
resolveTraceId(request),
operationName(request),
new ApiMajorVersion(1), // 고정
ActorContext.anonymous(), // 고정
TenantContext.none(), // 고정
Locale.ENGLISH, // 고정
receivedAt,
receivedAt.plus(requestBudget),
externalRequest(request));
```
이 필터는 `ReactiveSecurityContextHolder`도, `WebSecurityContextBridge`도, `AuthenticationView`도 참조하지 않는다. **인증 결과가 요청 컨텍스트에 도달하는 경로가 없다.** 인증된 호출자든 아니든 컨텍스트의 액터는 익명이고 테넌트는 없음이다.
같은 저장소가 이 정확한 위험을 서블릿 쪽에서는 이름 붙여 거부한다 — `require`의 메시지가 "a fabricated one here would make an unauthenticated call look like **an anonymous actor somebody chose**"다. 리액티브 생산자가 하는 일이 정확히 그 fabrication이다. 두 전송이 같은 위험에 정반대로 대응했고, 한쪽의 javadoc이 다른 쪽의 동작을 규탄한다.
**실패 시나리오 (리액티브)** — 인증된 사용자가 `GET /operations/{id}`로 자기 비동기 작업 결과를 조회한다. `ReactiveOperationHttpController``OperationAccessPolicy.mayAccess(operation, context)`를 부르고, `context.actor().authenticated()`가 항상 false이므로 **모든 조회가 거부된다**. 정책의 주석은 "Absent and forbidden are answered identically on purpose"이므로 클라이언트는 404를 받는다. 비동기 작업 기능이 리액티브 전송에서 동작하지 않는다.
**실패 시나리오 (서블릿)** — 같은 엔드포인트가 `WebRequestContext`를 파라미터로 받으면 리졸버가 던져 500. 받지 않으면 컨텍스트를 얻을 경로가 없다.
**방향은 fail-closed다.** 데이터 유출이 아니라 기능 불능이다. 그래서 보안 사고가 아니라 **지금 틀린 동작**으로 P1이다.
**왜 테스트가 잡지 못하는가**`WebRequestContext`를 만드는 다른 열한 곳이 전부 테스트와 testkit이고, 전부 **손으로 채운다**. `OperationAccessPolicyTest:54,110` · `WebMvcIdempotentInvokerTest:237` · `WebFluxIdempotentInvokerTest:212` · testkit의 `FaultFixtureController:143` · `ThrottleFixtureSupport:63` · `OperationFixtureSupport:123`. 계약 레인의 픽스처 컨트롤러조차 리졸버를 쓰지 않고 자기 컨텍스트를 만든다. 네 개 런타임을 가로지르는 크로스 스택 게이트가 있어도, 그 게이트가 도는 픽스처가 생산자를 우회한다.
**권고** — (1) 서블릿에 `store`를 부르는 필터를 추가한다(`WebMvcEvidenceFilter`가 이미 요청당 한 번 도는 자리다). (2) 두 생산자가 보안 컨텍스트에서 액터·테넌트를 읽게 한다 — `WebSecurityContextBridge`가 그 목적으로 이미 존재한다(§12.2). (3) 픽스처가 손으로 컨텍스트를 만드는 대신 리졸버/필터를 지나게 한다. (3) 없이는 같은 상태가 다시 성립한다.
### 12.2 P2 — 프레임워크 자유 신원 모델과 교차 테넌트 가드가 프로덕션에서 한 번도 참조되지 않는다
§11.1. `security` 패키지 11개 파일이 서로만 참조하고 바깥에서 들어오는 화살표가 없다. `AuthenticationView`를 만드는 프로덕션 코드가 없으므로 `WebSecurityContextBridge.resolve(...)`가 호출될 수 있는 상태 자체가 없다.
가장 값이 큰 부분이 그 안에 있다:
```java
// WebTenantContextResolver.java
private static final Set<String> TENANT_INPUT_NAMES =
Set.of("x-tenant-id", "tenant-id", "tenantid", "tenant");
public void rejectTenantInput(Map<String, String> requestInput) { ... throw new UntrustedTenantInputException(); }
```
클라이언트가 테넌트를 제안하는 헤더를 거부하는 가드다. 도달 경로는 `WebSecurityContextBridge.resolve(authentication, requestInput)` 오버로드 하나뿐이고, 그 오버로드의 호출자는 테스트뿐이다. 그래서 지금 이 플랫폼은 `X-Tenant-Id` 헤더에 대해 **거부도 무시도 하지 않는다** — 그 헤더를 보는 코드가 아예 없다.
노출은 아니다: 테넌트를 소비하는 유일한 지점(`OperationAccessPolicy:37-40`)이 `context.tenant()`를 읽고, 그 값은 §12.1 때문에 항상 비어 있다. 헤더가 테넌트가 되는 경로가 없으므로 교차 테넌트 읽기도 없다. 그러나 그것은 가드가 작동해서가 아니라 **테넌트 기능 전체가 배선되지 않아서**다. §12.1을 고치면서 이 가드를 함께 연결하지 않으면, 그때 노출이 생긴다.
이 패키지에는 `WebCorsPolicyValidator`(96줄)와 `WebCsrfPolicyResolver`(43줄)도 있고 둘 다 프로덕션 참조 0이다. 실제 CORS·CSRF 결정은 `SecurityConfig`가 Spring Security API로 직접 내린다. 같은 질문에 대한 두 번째 구현이 검증만 되고 쓰이지 않는다.
### 12.3 P3 — `publicPaths`가 `RestrictedPathRule`보다 먼저 등록되어, 넓은 공개 경로 하나가 관리 평면 규칙을 조용히 덮는다
§11.3. `RestrictedPathRule`의 javadoc은 이 규칙이 존재하는 이유를 "an application-level policy consulted later cannot recover from **a transport that already let the request through**"로 설명한다. 그런데 `permitAll(publicPaths)`이 그 규칙보다 먼저 등록되어 정확히 그 일을 한다.
**실패 시나리오** — 운영자가 마이그레이션 중 `SECURITY_PUBLIC_PATHS=/api/**`를 설정한다. fileserver 관리 경로가 `/api/` 아래 있으면 `FileserverAdminPlaneConfiguration`이 등록한 `RestrictedPathRule`은 도달하지 않고, 관리 평면이 무인증으로 열린다. 부팅은 아무 경고도 내지 않는다.
`RestrictedPathRule`의 생성자는 "a rule that requires nothing is weaker than the authenticated default it replaces"를 이유로 빈 권한 목록을 거부한다 — 규칙 자신이 약해지는 것은 막으면서, 규칙이 통째로 우회되는 것은 막지 않는다.
**권고** — 제한 규칙을 `publicPaths`보다 **먼저** 등록하거나, 두 패턴 집합이 겹치면 부팅에서 거부한다. 후자가 이 leaf의 다른 게이트들과 형태가 같다.
### 12.4 P3/기록 — `auth-mode` 값 철자에 따라 컨텍스트가 시작하지 못한다
`SecurityConfig:57-64`의 세션 저장소 빈은 `@ConditionalOnProperty(name = "ca-skeleton.security.auth-mode", havingValue = "redis-session")`로 게이트되고, `:138``sessionSecurityContextRepository.getObject()`로 그것을 **요구**한다. 한편 `SecuritySettings.authMode`는 같은 프로퍼티를 enum으로 **relaxed binding**한다.
두 메커니즘의 허용 철자가 다르다. `ca-skeleton.security.auth-mode=REDIS_SESSION`이면 relaxed binding은 `AuthenticationMode.REDIS_SESSION`으로 묶어 else 분기로 보내지만, `@ConditionalOnProperty``equalsIgnoreCase("redis-session")``_``-` 차이로 일치하지 않아 빈이 없다 → `getObject()``NoSuchBeanDefinitionException`으로 부팅을 실패시킨다.
fail-closed이고 부팅 시점이라 위험은 작다. 기록하는 것은 같은 프로퍼티에 대한 두 해석기가 서로 다른 문법을 갖는다는 점이다.
## 13. Sub-scope 03 완료 조건
- denominator 44 / 44 FULL_READ (probe가 `file_count=44` 확인)
- §8.1~§8.4 수행 — 도달성에서 P1 1건·P2 1건, 순서 비교에서 P3 1건, 기록 1건
- 소스 미변경
---
# Sub-scope 04 — `ratelimit` + `admission` + `budget` + `*/throttle` (50 files, main 41 + test 9)
> 내부 상태: COMPLETE — **50 / 50 FULL_READ** · 근거 `evidence/raw/193-inbound-web-capacity-probes.txt` (`file_count=50`), 패키지 도달성 지도 `evidence/raw/192-inbound-web-package-reachability.txt`
## 14. 무엇을 하는 코드인가
용량 보호 계층 전체 — 요청/응답 하드 바운드(`budget`), 부하 차단(`admission`), 속도 제한(`ratelimit`), 그리고 그것들을 전송에 붙이는 네 개 필터.
설계 논증이 정확하다. `WebStableModule``ADMISSION``RATELIMIT`과 분리한 이유를 적는다 — "This module knows what the service is doing and nothing about who is calling; that separation is what keeps **a capacity 503 from being reported as a quota 429**."
`WebMvcBudgetExceptionHandler`는 필터와 나란히 있어야 하는 이유를 자기고발로 설명한다:
> "The filter sees the cheap dimensions before dispatch and the response overrun after; a body bound crossed while the handler is reading the stream is thrown *inside* the dispatcher, which resolves it into a 500 before the filter's catch is ever reached. That is how the first draft of this feature **answered 500 to an oversized chunked body while its unit tests were green**."
그리고 §8.1(SS2)에서 본 교훈을 반영한다 — `@ConditionalOnBean`을 쓰지 않는 이유가 명시돼 있다: "on a component-scanned type `@ConditionalOnBean` is evaluated before the configuration that declares the bean has necessarily run, so the handler disappears without a word."
`ratelimit`의 세부도 촘촘하다. `EdgeRateLimitTransportBridge`가 주체를 `VersionedEdgeSubjectPseudonymizer`로 가명화하고, 클라이언트 IP는 `remote-addr-only`가 기본이며 `forwarded-headers-trusted`는 "trusted ingress only"로 표시된다. `RateLimitWebConfig`는 활성화된 브리지가 정확히 하나의 `EdgeRateLimitPort`를 요구하고 **로컬 폴백을 절대 설치하지 않는다**.
## 15. Negative-space probes — sub-scope 04
### 15.1 (8.1) 도달성 — 네 필터와 admission controller 의 등록 지점
```
-- WebMvcBudgetFilter
testkit/java/dev/caskeleton/webtestkit/BudgetFixtureApplication.java:43 FilterRegistrationBean<WebMvcBudgetFilter> budgetFilter()
-- WebFluxBudgetFilter
webfluxContractTest/.../testkit/budget/ReactiveBudgetFixtureApplication.java:38 WebFluxBudgetFilter budgetFilter()
-- WebMvcThrottleFilter
testkit/java/dev/caskeleton/webtestkit/ThrottleFixtureApplication.java:47 FilterRegistrationBean<WebMvcThrottleFilter> throttleFilter(...)
-- WebFluxThrottleFilter
webfluxContractTest/.../testkit/throttle/ReactiveThrottleFixtureApplication.java:43
-- SemaphoreAdmissionController
testkit/.../testkit/throttle/ThrottleFixtureSupport.java:38
```
**다섯 개 전부 픽스처 애플리케이션에서만 생성된다.** `src/main`에도 `app-bootstrap`에도 등록 지점이 없다. §16.1.
### 15.2 (8.2) 조건 형제 비교 — 속도 제한이 두 벌이다
| 메커니즘 | 배선 | 기본값 |
|---|---|---|
| `RateLimitInterceptor``EdgeRateLimitTransportBridge``EdgeRateLimitPort` | `RateLimitWebConfig`(`@Configuration`)가 `addInterceptors`로 등록 | `app.rate-limit.enabled=${APP_RATE_LIMIT_ENABLED:false}` → 비활성 시 `RateLimitInterceptor.disabled(...)` |
| `WebRateLimiter` | 참조자는 두 throttle 필터뿐이고 그 둘이 미등록 | — |
MVC 인터셉터 경로는 배선돼 있고 옵트인이다(정상). `WebRateLimiter` 경로는 배선 자체가 없다. 같은 질문에 대한 두 구현 중 하나만 회로가 닫혀 있다.
WebFlux 쪽에는 인터셉터 대응물이 없다 — `RateLimitWebConfig``WebMvcConfigurer`다. 따라서 리액티브 전송에는 **어떤 속도 제한 경로도** 없다.
### 15.3 (8.3) `WebBudgetCatalog` 소비자
```
$ grep -rn 'WebBudgetCatalog' src/main app-bootstrap/src/main | grep -v WebBudgetCatalog.java
mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java:66 catalog 생성
webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java:84 catalog 생성
```
두 자동설정이 각각 `WebBudgetCatalog`를 빈으로 등록하고 `standard()` 프로파일을 넣는다. **읽는 코드가 없다.** 카탈로그를 소비할 필터가 등록되지 않았기 때문이다(§15.1).
### 15.4 (8.4) 게이트 프로퍼티가 존재하는가
```
$ grep -rn 'backend.web.budgets' --include=*.yml --include=*.yaml --include=*.properties --include=*.java .
main/.../mvc/budget/WebMvcBudgetExceptionHandler.java:40:@ConditionalOnProperty(prefix = "backend.web.budgets", name = "enabled", havingValue = "true")
```
자바 한 줄뿐이다. 어떤 `application.yml`에도 `backend.web.budgets`가 없고 `matchIfMissing`도 없으므로 이 핸들러는 **기본 꺼짐**이다. 그리고 켜더라도 그 생성자가 요구하는 `BudgetProblemMapper` 빈을 선언하는 코드가 main·app-bootstrap 어디에도 없다 — 참조자는 두 필터와 이 핸들러 자신뿐이고, 셋 다 빈 정의가 아니다.
## 16. Sub-scope 04 findings
### 16.1 P1 — 용량 보호 계층 전체(41 main files)가 자기 테스트 픽스처 안에서만 실행된다
프로덕션 배포에서 실제로 설치되는 것과 설치되지 않는 것:
| 능력 | 구현 | 프로덕션 등록 |
|---|---|---|
| 요청/응답 바이트 바운드 · 데드라인 | `WebMvcBudgetFilter`(122) · `WebFluxBudgetFilter`(122) · `BoundedHttpServletRequest`(115) · `BoundedHttpServletResponse`(151) · `BoundedServerWebExchange`(79) · `WebBudgetMeter`(89) · `WebRequestBudget`(141) | **없음** |
| 부하 차단(제한된 동시성 + 제한된 큐 + 503) | `SemaphoreAdmissionController`(118) · `AdmissionProfile`(80) · `AdmissionDecision`(59) · `AdmissionPermit`(21) | **없음** |
| 429 속도 제한 (`WebRateLimiter` 경로) | `WebMvcThrottleFilter`(136) · `WebFluxThrottleFilter`(142) | **없음** |
| 429 속도 제한 (인터셉터 경로) | `RateLimitInterceptor``RateLimitWebConfig` | 있음, 기본 비활성(`APP_RATE_LIMIT_ENABLED:false`) |
| 예산 초과 문제 문서 | `WebMvcBudgetExceptionHandler`(84) | 기본 꺼짐 + 의존 빈 미선언 |
즉 이 플랫폼을 그대로 배포하면 **요청 본문 크기 상한도, 응답 크기 상한도, 요청 데드라인도, 동시성 상한도, 큐 상한도 없다.** `WebRequestBudget.standard()`가 정의하고 `WebBudgetCatalog`가 담고 있는 값들은 아무도 읽지 않는다(§15.3).
**실패 시나리오** — 배포된 API에 무제한 청크 본문이 도착한다. `WebMvcBudgetFilter`가 필터 체인에 없으므로 `BoundedHttpServletRequest`가 스트림을 감싸지 않고, `WebBudgetMeter`가 바이트를 세지 않는다. 컨테이너 기본값(Tomcat `maxPostSize``multipart/form-data`와 폼 인코딩에만 적용되고 임의 본문에는 적용되지 않는다) 외에 상한이 없다. 같은 요청에 대해 동시성 상한도 없으므로 `SemaphoreAdmissionController`가 내기로 되어 있던 503도 나오지 않는다.
**왜 드러나지 않는가 — 그리고 이것이 이 모듈의 핵심 형태다.** 이 leaf는 크로스 스택 게이트를 갖고 있다. `webCrossStackParityTest`가 Tomcat·Jetty·Reactor Netty 세 런타임의 와이어 계약을 비교하고, `webJettyCompatTest`·`webFluxContractTest`가 각각을 돌린다(§3.1). 그 레인들이 예산과 스로틀을 **실제로 검증한다**`JettyWebBudgetIT` · `ReactiveWebBudgetIT` · `JettyWebThrottleIT` · `ReactiveWebThrottleIT`가 있다.
그런데 그 IT들이 띄우는 것은 `BudgetFixtureApplication` · `ThrottleFixtureApplication` · `ReactiveBudgetFixtureApplication` · `ReactiveThrottleFixtureApplication`이고, **그 픽스처들이 `FilterRegistrationBean`으로 필터를 손수 등록한다**. 레인은 "필터가 올바르게 동작하는가"를 세 런타임에서 증명하고, "플랫폼이 필터를 설치하는가"는 어디서도 묻지 않는다.
이것은 이 저장소가 다른 곳에서 이미 이름 붙인 형태다 — `EndpointGuardCallSiteTest`(notification)의 javadoc이 정확히 그 문장을 갖고 있다: "It proved that for months while the function had no caller... **A green test on a control nothing invokes is the shape this repository keeps finding, and testing the helper again would not have caught it.**" 여기서는 그 형태가 41개 파일 규모로 반복된다.
**권고**`WebMvcPlatformAutoConfiguration`·`WebFluxPlatformAutoConfiguration`이 이미 `WebBudgetCatalog`를 등록하므로 자리는 있다. 네 필터와 `SemaphoreAdmissionController`, `BudgetProblemMapper`를 같은 자동설정에서 `backend.web.budgets.enabled` 게이트 아래 등록한다. 그리고 **픽스처가 아니라 자동설정이 세운 컨텍스트에서** 하나 이상의 IT를 돌린다 — 그것이 없으면 같은 상태가 다시 성립한다.
### 16.2 P2 — 리액티브 전송에는 속도 제한 경로가 하나도 없다
§15.2. 배선된 유일한 속도 제한기 `RateLimitInterceptor``WebMvcConfigurer.addInterceptors`로 붙는 MVC 전용 장치다. `WebFluxThrottleFilter`가 리액티브 대응물이지만 등록되지 않는다(§16.1). `webflux/autoconfigure/WebFluxPlatformAutoConfiguration`의 11개 빈에도 없다.
따라서 `backend.web.webflux`로 리액티브 전송을 쓰는 배포는 `APP_RATE_LIMIT_ENABLED=true`를 설정해도 속도 제한이 걸리지 않는다. 프로퍼티는 받아들여지고 `EdgeRateLimitPort` 빈 유일성까지 검증되지만, 그것을 소비하는 인터셉터가 리액티브 체인에 존재하지 않는다.
MVC 배포에서는 §16.1과 무관하게 이 경로가 동작한다 — 이 발견은 리액티브 전송에 한정된다.
### 16.3 P3/기록 — `WebMvcBudgetExceptionHandler`를 켜면 컨텍스트가 시작하지 못한다
§15.4. `backend.web.budgets.enabled=true`를 설정하면 이 `@RestControllerAdvice`가 등록되고 생성자가 `BudgetProblemMapper`를 요구하는데, 그 빈을 선언하는 코드가 없다 → 미충족 의존성으로 부팅 실패.
바로 위 주석이 이 정확한 실패를 다른 원인으로 한 번 겪었다고 기록한다 — "the all-off deployment is not a servlet application at all, so the factory is absent while the property condition still matched, and **the context failed to start on an unsatisfied dependency**." 조건은 그때 고쳤고, 의존 빈은 여전히 없다. §16.1을 고치면 함께 닫힌다.
## 17. Sub-scope 04 완료 조건
- denominator 50 / 50 FULL_READ (probe가 `file_count=50` 확인)
- §8.1~§8.4 수행 — 도달성에서 P1 1건, 전송 비대칭에서 P2 1건, 기록 1건
- 소스 미변경
---
# Sub-scope 05 — `idempotency` + `operation` + `operationasync` + `evidence` (50 files, main 40 + test 10)
> 내부 상태: COMPLETE — **50 / 50 FULL_READ** · 근거 `evidence/raw/194-inbound-web-idempotency-probes.txt` (`file_count=50`)
## 18. 무엇을 하는 코드인가
**`idempotency` (9)** — 전송 중립 멱등 승인. `WebIdempotencyGate`가 키를 읽고 범위와 의미 지문을 만들어 `application-core``IdempotencyStorePort`에 청구한다. 전송별 차이(헤더 읽기, 응답 쓰기)만 `mvc/idempotency``webflux/idempotency`에 있다 — "two copies of this logic would be two chances for one of them to answer a fingerprint mismatch with a replay."
지문 설계가 이 sub-scope에서 가장 잘 논증된 부분이다. `SemanticRequestFingerprintFactory`는 원시 바이트 해시를 거부하고 **의미** 지문을 만든다 — 네 입력(연산 · 정규화된 경로 식별자 · 정규 요청 모델 · 선택된 헤더). 경로 식별자를 넣는 이유가 구체적이다:
> "`POST /accounts/1/transfers` and `POST /accounts/2/transfers` have identical bodies when the amount is the same, so a fingerprint over the body alone would let **a caller's transfer from one account be answered with the receipt from another**."
`FingerprintHeaderPolicy`는 짧은 허용목록이고 두 종류의 배제 이유를 구분한다 — `traceparent`·`X-Request-Id`·`User-Agent`는 재시도마다 달라지므로 넣으면 "a legitimate retry looks like a different request and the idempotency key stops working — silently, and only under the network conditions that make retries happen"; `Authorization`·`Cookie`는 다른 이유로 배제된다 — "the fingerprint is stored, and **a stored digest of a credential is a credential in the database**."
**`operation` (11)** — 라우트별 프로파일(멱등성 정책 · 승인 프로파일 · 캐시 정책 · 사전조건 정책 · 응답 프로파일 · 변경 종류)의 카탈로그.
**`operationasync` (9)** — 202 영속 작업 리소스. `OperationAccessPolicy`가 객체 수준 권한을 담당하고 "Absent and forbidden are answered identically on purpose"로 열거 오라클을 막는다.
**`evidence` (6)** — 3축 실행 증거(승인 · 애플리케이션 · 응답). `WebExecutionEvidenceTracker`가 main에서 실제로 생성된다 — 이 sub-scope에서 배선된 유일한 부분이다.
## 19. Negative-space probes — sub-scope 05
### 19.1 (8.1) 도달성 — 생성 지점
```
WebIdempotencyGate 5 test 2 testkit
WebMvcIdempotentInvoker 1 test 1 testkit
WebFluxIdempotentInvoker 1 test 1 testkit
IdempotentResponseWriter 1 test 1 testkit
SemanticRequestFingerprintFactory (동일)
OperationQueryService 1 testkit
OperationResourceFactory 1 testkit
WebExecutionEvidenceTracker 1 main <- 유일한 배선
```
`app-bootstrap`이 이 leaf의 멱등성 타입을 참조하는 횟수: **0**. app-bootstrap의 `bootstrap/idempotency/*` 다섯 파일은 애플리케이션 계층 멱등성(PostgreSQL 제공자)을 배선하며 웹 게이트와 접점이 없다.
`IdempotencyKeySupport``@Component`라 스캔되지만 main·sample-portfolio에서 참조 **0**이다.
### 19.2 (8.2) durable-operation HTTP 표면의 두 게이트
```java
// mvc/operation/OperationHttpController.java:35-38 (webflux/operation 도 동일)
@ConditionalOnProperty(prefix = "app.web-platform.durable-operations", name = "enabled",
havingValue = "true") // matchIfMissing 없음 -> 기본 꺼짐
public OperationHttpController(OperationQueryService operations) { ... }
```
`app.web-platform.durable-operations` 문자열은 저장소의 어떤 yaml에도 없다. 그리고 켜더라도 생성자가 요구하는 `OperationQueryService` 빈을 선언하는 코드가 main·app-bootstrap에 없다(testkit에만 생성). §16.3과 같은 형태 — 게이트를 켜면 부팅이 실패한다.
### 19.3 (8.3) `WebOperationCatalog`를 읽는 쪽
두 자동설정이 `InMemoryWebOperationCatalog`를 빈으로 등록한다. 읽는 쪽은 셋이다:
```
admin/route/WebRouteInventory.java:50 requireRegisteredOperations(WebOperationCatalog)
advanced/functional/FunctionalRoutePolicyValidator.java:24
advanced/functional/FunctionalRouteRegistry.java:25
```
패키지 도달성 지도(`192-...`)에서 `admin.route``advanced.functional`은 둘 다 `in=0 ext=0`이다 — 자기들도 아무도 부르지 않는다. 등록된 카탈로그 빈은 **비어 있는 채로 아무도 읽지 않는다**(연산을 등록하는 코드도 없다).
### 19.4 (8.4) 지문 정규화가 길이 프레이밍인가
`SemanticRequestFingerprintFactory`는 U+001F 한 글자를 구분자로 쓰고, 경로 변수는 `SEP + name + "=" + value`, 헤더는 `SEP + name + ":" + value`로 이어붙인다. 값에 대한 이스케이프나 길이 접두사가 없다.
같은 저장소의 다른 다이제스트들(notification `NotificationCatalogException.update`, messaging의 도메인 분리 상수)은 **4바이트 길이 프레이밍**을 쓰고, 그 이유를 "인접 필드 연결로 인한 충돌이 구조적으로 불가능"으로 적는다. §20.3.
## 20. Sub-scope 05 findings
### 20.1 P1 — 멱등 실행 계층과 durable-operation 표면이 픽스처에서만 조립된다
§19.1·§19.2. 이 sub-scope에서 프로덕션 컨텍스트에 들어가는 것은 `WebExecutionEvidenceTracker`(두 요청 필터가 만든다)와 빈 `InMemoryWebOperationCatalog` 둘뿐이다. 나머지 38개 main 파일 — 게이트, 두 invoker, 응답 writer, 지문 공장, 헤더 정책, 명령 인코더, 승인 판정, 응답 계획, 코덱, 그리고 `operationasync` 9종 전부 — 는 테스트와 testkit에서만 생성된다.
**실패 시나리오** — 클라이언트가 `Idempotency-Key`를 붙여 결제 생성을 POST한다. 네트워크가 끊겨 같은 키로 재시도한다. `WebIdempotencyGate`가 필터 체인에도 인터셉터에도 컨트롤러 조언에도 없으므로 헤더는 읽히지 않고, 두 번째 요청은 첫 번째와 무관하게 그대로 실행된다. 결제가 두 번 생성된다. `Idempotency-Key`를 받아들이는 것처럼 보이는 API가 그것을 지키지 않으며, 헤더가 거부되지도 않으므로 클라이언트는 지켜졌다고 믿는다.
**같은 형태의 반복** — SS3(요청 컨텍스트 생산자 없음), SS4(용량 계층 미등록)와 같다. 이 세 sub-scope에서 미조립된 main 파일은 41 + 38 + `security` 11 = 90개다.
**왜 드러나지 않는가**`WebIdempotencyGate`는 테스트 5곳·testkit 2곳에서 생성되고, testkit의 픽스처 애플리케이션이 그것을 손수 배선해 계약 레인에서 돌린다. SS4와 동일하게, 레인은 "게이트가 올바른가"를 증명하고 "플랫폼이 게이트를 설치하는가"는 묻지 않는다.
### 20.2 P3/기록 — durable-operation을 켜면 컨텍스트가 시작하지 못한다
§19.2. `app.web-platform.durable-operations.enabled=true`를 설정하면 두 컨트롤러가 등록되고 `OperationQueryService` 빈을 요구하는데 그 빈이 없다. §16.3(budgets)과 같은 형태이고 같은 수정으로 닫힌다.
### 20.3 P3 — 의미 지문이 길이 프레이밍 없이 구분자로 만들어진다
§19.4. 경로 변수 값과 헤더 값이 이스케이프 없이 구분자로 이어붙는다. 값 자체에 그 구분자가 들어가면(퍼센트 인코딩 `%1F`를 Spring이 디코딩해 `@PathVariable`로 전달한다) 서로 다른 두 요청이 같은 정규 문자열을 만들 수 있다 — 경로 변수가 둘 이상인 연산에서 하나를 통제하면 구성 가능하다.
악용 가치는 낮다. 멱등 레코드는 `principal` + `tenant`로 범위가 잡히므로 충돌시킬 수 있는 것은 **자기 자신의** 이전 요청뿐이고, 그것으로 얻는 것이 없다. 그리고 게이트 자체가 미조립이다(§20.1).
기록하는 이유는 일관성이다. 이 저장소는 다른 세 모듈에서 같은 문제를 길이 프레이밍으로 닫았고 그 이유를 명시했다. 여기서는 구분자를 골랐고 그 선택에 대한 근거가 없다. 값 앞에 길이를 붙이는 형태 하나면 닫힌다.
## 21. Sub-scope 05 완료 조건
- denominator 50 / 50 FULL_READ (probe가 `file_count=50` 확인)
- §8.1~§8.4 수행 — 도달성에서 P1 1건, 정규화에서 P3 1건, 기록 1건
- 소스 미변경
---
# Sub-scope 06 — `pagination` + `cursor` + `conditional` + `cache` + `versioning` (54 files, main 42 + test 12)
> 내부 상태: COMPLETE — **54 / 54 FULL_READ** · 근거 `evidence/raw/195-inbound-web-representation-probes.txt` (`file_count=54`)
## 22. 무엇을 하는 코드인가
앞의 세 sub-scope와 성격이 다르다. 여기 있는 것은 플랫폼이 **설치하는** 필터가 아니라 소비 모듈의 컨트롤러가 **부르는** 라이브러리다. 그래서 "등록되지 않았다"가 곧바로 결함은 아니고, 물어야 할 것은 "부르는 쪽이 있는가"다.
**`pagination` (19)** — 키셋 페이지네이션의 어휘 전체. 정렬 필드·필터 필드·필터 연산자·프로젝션 프로파일을 각각 카탈로그로 닫고, 커서는 HMAC으로 서명한다(`HmacWebCursorCodec` + `WebCursorKeyRing`). `FilterFingerprint`가 커서와 질의 어휘를 묶어 커서 재사용이 다른 필터로 넘어가지 못하게 한다.
**`cursor` (2)** — 두 번째 커서 코덱. `pagination``WebCursorCodec`/`HmacWebCursorCodec`과 별개다.
**`conditional` (10)** — ETag 값과 사전조건. `ETags`(91) · `EntityTag`(83) · `EntityTagCodec`(72) · 읽기용 `ConditionalReadEvaluator` · 쓰기용 `MutationPreconditionEvaluator`.
**`cache` (4, 310 LOC)** — 발행된 캐시 프로파일과 지시자, `Vary` 규칙. `WebStableModule`이 CORE 순도로 선언하고 그 근거를 적는다: "Keeping it free of Spring is what lets **the same profile be applied by the servlet writer, the reactive writer and the OpenAPI document** without three renderings of it."
**`versioning` (7)** — 주 버전 해석, 폐기 정책, `Deprecation`/`Sunset` 헤더 작성.
## 23. Negative-space probes — sub-scope 06
### 23.1 (8.1) 도달성 — 라이브러리 타입의 소비자
```
HmacWebCursorCodec 5 test
CursorCodec 2 test
WebCollectionRequestParser 1 test
ConditionalReadEvaluator 1 test
MutationPreconditionEvaluator 1 test
ETags 3 sample 12 test <- 유일한 소비 모듈 사용
WebCachePolicyCatalog 10 test
WebVaryPolicy 4 main 8 test <- 참조자는 cache 패키지 내부
ApiVersionCatalog 4 test
DeprecationHeaderWriter 1 test
PathApiVersionResolver 2 test
WebPageSizePolicy 1 test
```
다섯 패키지 중 소비 모듈이 실제로 부르는 것은 `ETags` 하나다 — `sample-portfolio``WorkLogController`가 세 곳에서 쓴다(`:144` `If-None-Match` 비교, `:213` 버전에서 약한 ETag 생성, `:226` `If-Match` 검사).
나머지는 전부 테스트 전용이다. 라이브러리이므로 그 자체가 결함은 아니지만, 페이지네이션 어휘 19개 파일·버전 관리 7개 파일이 **한 번도 컨트롤러에 붙어 본 적이 없다**는 사실은 기록해 둘 값이 있다 — 이 저장소가 다른 곳에서 "타입은 있고 호출자가 없다"를 반복해서 결함으로 취급했기 때문이다.
### 23.2 (8.2) 조건 형제 비교 — 캐시 정책이 두 벌이다
배선된 것:
```java
// filter/CacheControlFilter.java (24 lines)
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 20)
public class CacheControlFilter extends OncePerRequestFilter {
static final String DEFAULT_CACHE_CONTROL = "no-store";
static final String DEFAULT_VARY = "Accept, Accept-Encoding, Authorization";
protected void doFilterInternal(...) {
response.setHeader(ApiHeaders.CACHE_CONTROL, DEFAULT_CACHE_CONTROL);
response.setHeader(ApiHeaders.VARY, DEFAULT_VARY);
chain.doFilter(request, response);
}
}
```
`@Component`이므로 컴포넌트 스캔이 잡고 Spring이 `Filter` 빈을 체인에 넣는다. **모든 응답에 `no-store`를 붙인다.**
배선되지 않은 것: `cache` 패키지 4개 파일 310 LOC. `web.cache.` 패키지를 참조하는 파일이 자기 패키지 밖에 **0개**다.
`SecurityConfig:80`이 Spring Security의 기본 캐시 헤더 작성기를 끄면서 그 이유를 적는다 — "`CacheControlFilter` **owns the cache header policy**". 소유자는 24줄짜리 상수 두 개이고, 프로파일·지시자·`Vary` 규칙을 갖춘 310줄은 소유하지 않는다.
### 23.3 (8.3) 중복 메커니즘 — 커서 코덱도 두 벌
`pagination/WebCursorCodec`(인터페이스) + `pagination/HmacWebCursorCodec`(135, 서명된 구현) + `pagination/WebCursorPayload` + `pagination/WebCursorKeyRing`, 그리고 별도로 `cursor/CursorCodec`(100) + `cursor/CursorException`. `WebStableModule`은 둘을 다른 모듈로 선언한다(`CURSOR` = "Opaque keyset cursor encoding and its failure type", `PAGINATION`). 둘 다 프로덕션 소비자가 없어 어느 쪽이 정본인지 코드로는 판정할 수 없다.
### 23.4 (8.4) `no-store`와 조건부 읽기의 충돌
`WorkLogController.getOne`은 ETag를 계산하고 `If-None-Match`가 맞으면 304를 낸다. 그리고 `Cache-Control`을 설정하지 않는다 — sample-portfolio main 전체에서 `CacheControl` 참조 0. 따라서 `CacheControlFilter``no-store`가 그대로 남는다. §24.1.
## 24. Sub-scope 06 findings
### 24.1 P2 — 배선된 캐시 필터의 `no-store`가 배선된 조건부 읽기 경로를 무력화하고, 둘을 조정하려고 만든 패키지는 참조 0이다
`CacheControlFilter`가 **모든 응답**에 `Cache-Control: no-store`를 붙인다. RFC 9111에서 `no-store`는 "어떤 캐시에도 저장하지 말라"는 지시다. 규격을 지키는 클라이언트는 응답을 보관하지 않으므로, 나중에 그 리소스에 대해 `If-None-Match`를 보낼 근거(저장된 표현과 그 ETag)를 갖지 못한다.
그런데 같은 배포에서 `WorkLogController.getOne`은 ETag를 발행하고 `If-None-Match` 분기를 갖는다:
```java
// sample-portfolio WorkLogController.java:144-148
if (ETags.matches(ifNoneMatch, etag)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).eTag(etag).build();
}
return ResponseEntity.ok().eTag(etag).body(...);
```
**실패 시나리오** — 대역폭을 아끼려고 조건부 GET을 구현한 클라이언트가 첫 응답에서 `ETag: W/"3"``Cache-Control: no-store`를 함께 받는다. 규격대로 저장하지 않으므로 다음 요청에 `If-None-Match`를 붙일 수 없고, 서버는 매번 전체 표현을 보낸다. 304 분기는 `no-store`를 무시하는 클라이언트에서만 실행된다. ETag 계산·비교 코드는 매 요청 실행되고 절감은 발생하지 않는다.
쓰기 쪽 `If-Match`(낙관적 동시성, `:226`)는 영향이 없다 — 클라이언트가 방금 받은 ETag를 같은 세션에서 되돌려 보내므로 캐시 저장이 필요 없다. 결함은 읽기 경로에 한정된다.
**조정하려고 만든 것이 있다.** `cache` 패키지가 정확히 이 문제를 풀도록 설계돼 있다 — 이름 붙은 프로파일(`WebCachePolicyCatalog`, 120줄), 지시자 집합(`WebCachePolicy`, 72줄), 그리고 `Vary` 규칙(`WebVaryPolicy`, 90줄). `WebStableModule`의 근거가 "the same profile be applied by the servlet writer, the reactive writer and the OpenAPI document"인데, 세 적용자 중 어느 것도 존재하지 않는다(§23.2).
**권고**`CacheControlFilter`가 상수 두 개 대신 `WebCachePolicyCatalog`를 읽게 하고, 연산별 프로파일이 없을 때만 `no-store`로 떨어지게 한다. 그러면 `no-store` 기본값(민감한 API에 옳다)과 조건부 읽기가 공존할 수 있다.
### 24.2 P3/기록 — 커서 코덱과 페이지네이션 어휘 26개 파일에 소비자가 없다
§23.1·§23.3. `pagination` 19 + `cursor` 2 + `versioning` 7 = 28개 파일 중 소비 모듈이 부르는 것은 0개다(`ETags``conditional`에 있다). 서명된 커서(`HmacWebCursorCodec` + `WebCursorKeyRing`), 닫힌 정렬/필터/프로젝션 어휘, 폐기 헤더 작성기가 전부 테스트에서만 실행된다.
라이브러리 패키지이므로 SS4·SS5의 P1과 성격이 다르다 — 플랫폼이 설치해야 할 것을 설치하지 않은 것이 아니라, 채택자가 아직 쓰지 않은 것이다. 다만 커서 코덱이 두 벌(§23.3)이라는 사실은 소비자가 생기는 시점에 결정을 요구하며, 지금은 어느 쪽이 정본인지 코드가 말하지 않는다.
### 24.3 P3/기록 — `UnsupportedApiVersionException`은 main에서 던져지지 않는다
throw 지점 전수: `PageValidationException` 10 · `WebCursorException` 10 · `CursorException` 7 · `UnsupportedQueryVocabularyException` 6 · `SunsetViolationException` 3 · `WebPreconditionFailedException` 1 · **`UnsupportedApiVersionException` 0**.
`versioning` 패키지가 정의하고 어디서도 만들지 않는 실패 타입이다. `PathApiVersionResolver`(60줄)가 버전을 해석하지만 미지원 버전을 이 예외로 거부하지 않는다.
## 25. Sub-scope 06 완료 조건
- denominator 54 / 54 FULL_READ (probe가 `file_count=54` 확인)
- §8.1~§8.4 수행 — 캐시 충돌에서 P2 1건, 기록 2건
- 소스 미변경
---
# Sub-scope 07 — `http` + `json` + `advanced/codec` + `openapi` (45 files, main 34 + test 11)
> 내부 상태: COMPLETE — **45 / 45 FULL_READ** · 근거 `evidence/raw/196-inbound-web-codec-probes.txt` (`file_count=45`)
## 26. 무엇을 하는 코드인가
**`http` (12)** — 이 leaf에서 가장 잘 연결된 패키지다(패키지 지도 `in=9 ext=2`). 헤더 이름, 경로 정규형, 메서드 허용목록, URI 정책, 그리고 외부 URL 생성기(`ExternalUriBuilder`/`ExternalUriPolicy`/`ExternalOrigin`/`ExternalPrefix`).
**`json` (4)** — 요청 본문을 읽는 엄격한 리더. 두 파일의 논증이 이 sub-scope의 핵심이다.
`BoundedJsonFactory`는 제한을 **파싱 후가 아니라 스트리밍 파서에** 거는 이유를 적는다: "A depth limit applied to a parsed tree has already paid for the tree; a nesting bomb is cheap to send and expensive to hold, so **the only limit that helps is one the streaming parser refuses to exceed**."
`WebObjectMapperFactory`는 Jackson 3(`tools.jackson`)을 쓰는 이유를 자기고발로 적는다: "An earlier version of this class used `com.fasterxml`, which is also on this classpath — the mapper was correct, strict, unit tested, and **never consulted by the framework for a single request**."
`WebJsonProfile`은 여덟 개 관용 기본값을 각각 왜 끄는지 설명한다 — 중복 키("the last one wins and the client believes the first one did"), 미지 속성("a typo'd field name is silently dropped and the request 'succeeds' without doing what was asked"), 후행 토큰("a concatenated second document is ignored").
**`advanced/codec` (6)** — XML·CBOR 표현. `SecureXmlInputFactory`가 DTD와 외부 엔티티를 끄고 거부하는 리졸버까지 단다. javadoc이 정확하다 — "Neither produces an error when it fires; the parse succeeds and **the document contains something it should not**." `secure(XMLInputFactory)` 검사기를 별도로 두는 이유도 명시된다: "The failure this guards is a configuration path that constructs its own factory and never reaches `create()`."
**`openapi` (7) + `advanced/openapi` (5)** — 문서 생성, 스키마 기여, 파괴적 변경 정책, 릴리스 게이트.
## 27. Negative-space probes — sub-scope 07
### 27.1 (8.1) 도달성 — `WebJsonProfile` 여덟 필드 중 강제되는 것
```
rejectUnknownProperties json/WebObjectMapperFactory.java:66
rejectDuplicateKeys json/BoundedJsonFactory.java:40
rejectTrailingTokens json/WebObjectMapperFactory.java:68
caseSensitiveEnums json/WebObjectMapperFactory.java:69, :71
rejectScalarCoercion json/WebObjectMapperFactory.java:75
maxDepth json/BoundedJsonFactory.java:37 -> StreamReadConstraints.maxNestingDepth
maxArrayElements (없음)
maxStringBytes json/BoundedJsonFactory.java:38 -> StreamReadConstraints.maxStringLength
```
일곱은 강제되고 하나는 읽는 코드가 없다. §28.1.
### 27.2 (8.2) 조건 형제 비교 — `OpenApiCustomizer` 가 두 개다
배선된 것 — `config/OpenApiContractConfig`(37줄, `@Configuration`)가 익명 람다 `OpenApiCustomizer` 하나를 빈으로 등록한다. 하는 일은 `ApiError.details` 스키마를 `ObjectSchema`로 되돌리는 것 한 가지다.
배선되지 않은 것 — `openapi/WebOpenApiCustomizer`(74줄)와 그것이 쓰는 `ProblemSchemaContributor`(88) · `CursorSchemaContributor`(47) · `WebOpenApiProfile`(72) · `WebOpenApiBreakingPolicy`(187) · `WebOpenApiReleaseGate`(100) · `WebOpenApiDiffResult`(39). 합 607줄. 빈으로 등록하는 코드가 main·app-bootstrap에 없고, 참조는 자기들끼리와 테스트뿐이다.
즉 springdoc이 생성하는 문서에는 RFC 9457 problem 스키마도 커서 스키마도 기여되지 않는다 — 그 기여자들이 커스터마이저에 도달하지 않기 때문이다. SS2에서 확인한 "problem 계약이 문서에 없다"(§7.3)와 같은 방향의 사실이 스키마 쪽에서도 성립한다.
### 27.3 (8.3) XML/CBOR 표현의 런타임 배선
`build.gradle`이 두 백엔드를 `compileOnly`로 두고 그 이유를 길게 적는다(§2) — `implementation`이었을 때 "silently began parsing `application/xml` request bodies... an XXE surface nobody chose"였기 때문이다. 의도된 설계다.
그런데 그 잭슨 백엔드를 배포가 추가하더라도 메시지 컨버터를 등록하는 코드가 없다. `WebXmlMapperFactory`·`WebCborMapperFactory`·`RepresentationNegotiationPolicy`를 참조하는 파일은 자기 패키지와 테스트뿐이고, 두 자동설정(MVC 12빈 · WebFlux 11빈)에도 없다. `WebRepresentation.available()`이 "absent backend를 문장으로 바꾼다"는 장치는 그 문장을 낼 호출자가 없다.
### 27.4 (8.4) `maxStringBytes` 가 무엇에 적용되는가
```java
// BoundedJsonFactory.java:36-39
.streamReadConstraints(StreamReadConstraints.builder()
.maxNestingDepth(profile.maxDepth())
.maxStringLength(profile.maxStringBytes())
.build());
```
Jackson의 `maxStringLength`**문자 수** 상한이고 필드 이름은 `maxStringBytes`다. UTF-8에서 문자당 최대 4바이트이므로 선언된 1,048,576이 실제로는 최대 4 MiB를 허용한다. §28.3.
## 28. Sub-scope 07 findings
### 28.1 P2 — `maxArrayElements`가 선언만 되고 강제되지 않으며, 바이트 예산 백스톱도 없다
`WebJsonProfile`이 필드를 선언하고 문서화한다:
```java
* @param maxArrayElements most elements accepted in one array
public static WebJsonProfile strict() {
return new WebJsonProfile(true, true, true, true, true, 64, 100_000, 1_048_576);
} ^^^^^^^
```
읽는 코드가 저장소 전체에 **0개**다(§27.1). Jackson 3의 `StreamReadConstraints`에는 배열 원소 수 상한이 없으므로 `BoundedJsonFactory`가 넘길 자리도 없고, 매퍼 쪽에서도 검사하지 않는다.
**실패 시나리오** — 클라이언트가 `[0,0,0, … ]` 형태로 원소 1억 개짜리 배열을 보낸다. `maxNestingDepth(64)`는 깊이만 보고, `maxStringLength`는 문자열 하나의 길이만 본다. 파서는 배열 전체를 스트리밍으로 읽어 `List`로 materialize한다. 200 MB 남짓의 요청 본문이 수 GB의 힙이 된다.
**백스톱이 없다.** 이 위험을 막을 상위 장치가 `WebMvcBudgetFilter`의 요청 바이트 상한인데, SS4에서 확인했듯 그 필터는 등록되지 않는다(§16.1). 서블릿 컨테이너의 기본값도 임의 본문에는 적용되지 않는다. 따라서 지금 이 플랫폼에는 **JSON 배열 원소 수에 대한 상한이 어느 계층에도 없다.**
`BoundedJsonFactory`의 javadoc이 정확히 이 상황을 서술한다 — "a nesting bomb is cheap to send and expensive to hold, so the only limit that helps is one the streaming parser refuses to exceed." 깊이와 문자열 길이는 그 원칙대로 걸었고, 원소 수는 값만 선언하고 걸지 않았다.
**권고** — Jackson에 해당 제약이 없으므로 파서 수준에서는 걸 수 없다. `@Size(max = …)` 를 컬렉션 필드에 요구하는 계약 규칙(이미 sample-portfolio의 `BatchCreateRequest``@Size(max = MAX_BATCH_SIZE)`로 그렇게 한다)으로 옮기거나, `maxArrayElements` 필드를 제거해 강제되지 않는 한도가 강제되는 것처럼 읽히지 않게 한다. 어느 쪽이든 §16.1의 바이트 예산을 배선하는 것이 실질적인 백스톱이다.
### 28.2 P3/기록 — OpenAPI 기여자 607줄이 커스터마이저에 도달하지 않는다
§27.2. 등록된 커스터마이저는 익명 람다 하나이고, 이 leaf가 작성한 커스터마이저와 두 스키마 기여자·프로파일·파괴적 변경 정책·릴리스 게이트는 빈이 되지 않는다. `/v3/api-docs`가 내는 문서에는 problem 스키마도 커서 스키마도 없다.
`WebOpenApiReleaseGate`(100줄)와 `WebOpenApiBreakingPolicy`(187줄)는 성격이 다르다 — 릴리스 게이트는 빌드 태스크나 테스트에서 부르는 것이 자연스럽고, 실제로 `WebOpenApiReleaseGateTest`·`WebOpenApiSnapshotTest`가 부른다. 문제는 커스터마이저와 두 기여자다.
### 28.3 P3/기록 — `maxStringBytes`가 바이트가 아니라 문자에 적용된다
§27.4. 이름과 문서는 바이트를 말하고, Jackson의 `maxStringLength`는 문자를 센다. 비ASCII 본문에서 실효 상한이 선언값의 최대 4배가 된다. 같은 레코드의 `maxDepth`·`maxArrayElements`는 단위 모호성이 없으므로 이 하나만 이름을 `maxStringChars`로 바꾸면 닫힌다.
## 29. Sub-scope 07 완료 조건
- denominator 45 / 45 FULL_READ (probe가 `file_count=45` 확인)
- §8.1~§8.4 수행 — 미강제 한도에서 P2 1건, 기록 2건
- 소스 미변경
---
# Sub-scope 08 — `observability` + `proxy` + `filter` + `mvc/*`·`webflux/*` 잔여 (53 files, main 38 + test 15)
> 내부 상태: COMPLETE — **53 / 53 FULL_READ** · 근거 `evidence/raw/197-inbound-web-observability-probes.txt`
## 30. 무엇을 하는 코드인가
**배선된 필터 다섯.** 이 sub-scope에 이 leaf의 실제 필터 체인이 전부 있다:
| 필터 | 등록 | 순서 |
|---|---|---|
| `mvc/filter/WebMvcRequestIdFilter` | MVC 자동설정 `@Bean` | `HIGHEST_PRECEDENCE + 10` |
| `mvc/filter/WebMvcEvidenceFilter` | MVC 자동설정 `@Bean` | — |
| `filter/CacheControlFilter` | `@Component` | `HIGHEST_PRECEDENCE + 20` |
| `filter/RequestLoggingFilter` | `@Component` | 없음 → `LOWEST_PRECEDENCE` |
| `webflux/context/WebFluxRequestContextFilter` | WebFlux 자동설정 `@Bean` | `HIGHEST_PRECEDENCE + 10` |
**`observability` (12)** — MDC 키, 헤더 위생, 접근 로그·감사 이벤트 모델, 지표 태그 카디널리티 정책. `RequestLoggingFilter`가 인증된 주체를 `UserPrincipalPseudonymizerPort`로 가명화한 뒤에만 MDC에 넣는다 — "The raw `idpUserId()` is never written to MDC or logs."
**`proxy` (4, 421 LOC)** — 신뢰 프록시 정책(`TrustedProxyPolicy` 161), 정규화된 forwarded 헤더(`NormalizedForwardedHeaders` 158), 위생기(`ForwardedHeaderSanitizer` 72).
**`webflux/guard` (2)** — 리액티브 체인에서 블로킹 호출을 탐지.
**`advanced/mvc`·`advanced/webflux` (12)** — 스트리밍 쓰기, 연결 끊김 탐지, SSE 어댑터와 하트비트, 느린 소비자 종료, 가상 스레드 설정. 이 중 `MvcStreamingExecutorConfiguration``VirtualThreadMvcConfiguration``@Configuration` + `@ConditionalOnProperty` + `@ConditionalOnWebApplication`으로 게이트된 실제 배선 지점이다.
## 31. Negative-space probes — sub-scope 08
### 31.1 (8.2) 조건 형제 비교 — `X-Request-Id`에 대해 배선된 두 필터가 반대 정책을 쓴다
두 필터 모두 서블릿 배포에서 등록되고 둘 다 `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)) ...
}
```
```java
// filter/RequestLoggingFilter.java:57-59, 91-95
String requestId = resolveOrGenerate(req.getHeader(HEADER_REQUEST_ID)); // 항상 클라이언트 값을 본다
res.setHeader(HEADER_REQUEST_ID, requestId);
...
private static String resolveOrGenerate(String inbound) {
String clean = HeaderSanitizer.sanitize(inbound, MAX_ID_LENGTH);
return (clean == null || clean.isBlank()) ? UUID.randomUUID().toString() : clean;
}
```
순서상 `WebMvcRequestIdFilter`(`HIGHEST_PRECEDENCE + 10`)가 먼저 돌아 새 UUID를 헤더에 쓰고, `RequestLoggingFilter`(`LOWEST_PRECEDENCE`)가 나중에 돌아 **클라이언트가 보낸 값으로 덮어쓴다**. MDC의 `request_id`와 접근 로그도 클라이언트 값이다. §32.1.
### 31.2 (8.1) 도달성 — forwarded 헤더 신뢰 정책
```
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.
### 31.3 (8.3) 중복 메커니즘 — 상관 식별자가 세 벌이다
| 메커니즘 | 헤더 | 저장 위치 |
|---|---|---|
| `WebMvcRequestIdFilter` | `X-Request-Id`, `traceparent` | 요청 속성(`WebRequestId`/`WebTraceId`) |
| `RequestLoggingFilter` | `X-Request-Id`, `X-Correlation-Id`, `traceparent` | MDC |
| `WebFluxRequestContextFilter` | `X-Request-Id`, `traceparent` | Reactor context |
세 번째는 전송이 달라 공존이 정상이다. 앞의 둘은 같은 서블릿 체인에서 같은 헤더를 두 번 처리한다. `traceparent`도 마찬가지로 두 번 파싱되며, `RequestLoggingFilter`는 응답에도 `traceparent`를 쓰고(`:68`) `WebMvcRequestIdFilter`는 쓰지 않는다.
### 31.4 (8.4) `ExternalRequestContext.prefix` 는 항상 비어 있다
```java
// webflux/context/WebFluxRequestContextFilter.java:140-146
private ExternalRequestContext externalRequest(ServerHttpRequest request) {
var uri = request.getURI();
String scheme = uri.getScheme() == null ? "http" : uri.getScheme();
int port = uri.getPort() > 0 ? uri.getPort() : ("https".equalsIgnoreCase(scheme) ? 443 : 80);
String host = uri.getHost() == null ? "localhost" : uri.getHost();
return new ExternalRequestContext(scheme, host, port, ""); // prefix 하드코딩
}
```
`forward-headers-strategy=framework` 덕분에 scheme·host·port는 외부 값이 맞다. `prefix`만 항상 빈 문자열이다 — `ExternalRequestContext`에서 검증이 가장 정교한 필드(슬래시로 시작·끝나지 않음·`..` 순회 금지)가 어떤 값도 받지 않는다. `ForwardedHeaderTransformer``X-Forwarded-Prefix`를 경로에 접어 넣으므로 기능적 손실은 없고, 필드가 죽어 있다.
`observability``WebAuditPublisher`는 참조 **0**이다(인터페이스, 구현도 호출자도 없음). `WebAccessLogger`는 main 참조 1(자기 패키지 내부)뿐이고, 실제 접근 로그는 `RequestLoggingFilter`가 SLF4J로 직접 쓴다 — 여기서도 모델과 구현이 갈린다.
## 32. Sub-scope 08 findings
### 32.1 P2 — 요청 식별자를 클라이언트가 고를 수 없다는 정책이, 뒤에 도는 다른 배선 필터에 의해 뒤집힌다
§31.1. `WebMvcRequestIdFilter`의 javadoc이 그 정책의 이유를 적는다:
> "Trust is also configurable and defaults to off for the request id. **A caller that can choose its own request id can make two different requests share one identity, which is how a support investigation ends up reading somebody else's exchange.**"
그리고 자동설정은 그 기본값을 그대로 쓴다 — `WebMvcPlatformSettings.trustInboundRequestId`가 기본 false다. 그러나 같은 컨텍스트의 `RequestLoggingFilter`가 항상 인바운드 헤더를 채택하고, 순서상 나중이라 응답 헤더를 덮어쓴다.
**실패 시나리오** — 클라이언트가 서로 다른 100개 요청에 `X-Request-Id: shared-id`를 붙여 보낸다. 응답은 전부 `X-Request-Id: shared-id`를 돌려주고, 접근 로그 100줄과 MDC 100건이 같은 `request_id`를 갖는다. 지원 조사에서 그 id로 검색하면 서로 다른 호출자의 100개 교환이 함께 나온다 — 인용된 javadoc이 서술한 바로 그 결과다.
로그 인젝션은 아니다. `HeaderSanitizer.sanitize``< 0x20` 문자를 전부 제거하고 200자로 자른다(0x7F와 U+2028/U+2029는 남지만 SLF4J 한 줄 로그에서는 개행이 아니다).
**세 번째 사실이 이것을 더 뚜렷하게 만든다** — 두 필터 중 요청 컨텍스트에 값을 넣는 쪽은 `WebMvcRequestIdFilter`이고, 그 값은 SS3(§12.1)에서 확인했듯 아무도 읽지 않는다. 실제로 관측 가능한 곳(응답 헤더 · MDC · 접근 로그)에 도달하는 값은 전부 `RequestLoggingFilter`의 것, 즉 클라이언트가 고른 것이다.
**권고** — 하나를 남긴다. `RequestLoggingFilter``WebMvcRequestIdFilter`가 요청 속성에 넣은 값을 읽게 하면(`WebMvcRequestIdFilter.requestId(request)`가 이미 그 접근자다) 정책이 한 곳에 남고 MDC·로그·응답 헤더가 일치한다.
### 32.2 P2 — forwarded 헤더 신뢰 판정이 Nginx 설정에만 있고, 그것을 위해 쓴 Java 정책 421 LOC은 배선되지 않는다
`server.forward-headers-strategy=framework`(기본값)에서 Spring이 `X-Forwarded-Proto`·`X-Forwarded-Host`·`X-Forwarded-Port`·`X-Forwarded-Prefix`**보낸 피어가 누구든** 반영한다. 그 값이 `request.getURI()`를 바꾸고, 그것이 `ExternalRequestContext`가 되고(§31.4), 그것으로 `Location` 헤더와 페이지네이션 링크가 만들어진다.
스푸핑을 막는 것은 `nginxProxyTest` 레인이 증명하는 **Nginx 설정**이다:
```
NginxProxyContractIT:63 attackerCannotOverrideForwardedHost() X-Forwarded-Host: evil.example
NginxProxyContractIT:79 attackerCannotDowngradeTheForwardedScheme()
NginxProxyContractIT:93 attackerCannotForgeTheClientAddress()
NginxProxyContractIT:143 clientCannotInjectAPrefix()
// "X-Forwarded-Prefix is set per location, so a client's value is replaced."
```
이 보증의 근거는 `nginxProxyTest/resources/nginx/proxy_headers.conf`가 location마다 헤더를 **덮어쓴다**는 사실이다. 애플리케이션은 검사하지 않는다.
`TrustedProxyPolicy`(161줄, CIDR 기반 피어 허용목록)가 애플리케이션 쪽 검사를 위해 존재하고, 프로덕션에서 생성되지 않는다. testkit의 `ProxyFixtureController:53``TrustedProxyPolicy.of("10.0.0.0/8", …)`를 직접 만들어 픽스처에 붙인다 — SS4·SS5와 같은 형태다.
**실패 시나리오** — 배포가 그 Nginx 설정을 쓰지 않거나(다른 인그레스, 서비스 메시, k8s 내부에서 파드 IP로 직접 도달), 인그레스를 우회하는 경로가 하나라도 있으면, 클라이언트가 `X-Forwarded-Host: evil.example`을 보내 그 요청이 만드는 모든 절대 URL을 자기 도메인으로 돌린다. 비밀번호 재설정 링크나 `Location` 헤더가 그 URL을 담으면 그대로 피싱 벡터가 된다.
**이것을 방어로 쓰는 것 자체는 정당하다** — 인그레스에서 덮어쓰는 것이 표준 관행이다. 기록하는 것은 두 가지다: (1) 그 의존이 코드나 문서에 명시돼 있지 않고 레인의 `.conf` 파일에만 있다, (2) 애플리케이션 쪽 이중 방어로 쓰라고 421줄을 작성해 두고 연결하지 않았다.
**권고**`TrustedProxyPolicy``forward-headers-strategy` 앞단에 배선하거나(피어가 목록 밖이면 forwarded 헤더를 버린다), 최소한 README에 "이 플랫폼은 인그레스가 `X-Forwarded-*`를 덮어쓴다고 전제한다"를 명시하고 `proxy` 패키지를 제거한다. 지금 상태는 그 전제를 아무 데도 적지 않은 채 그것을 대체할 코드를 갖고 있다.
### 32.3 P3/기록 — `ExternalRequestContext.prefix`가 항상 빈 문자열이고 `WebAuditPublisher`는 참조 0이다
§31.4. prefix 검증 로직(슬래시 규칙 · `..` 순회 거부)은 어떤 값도 받지 않는다. `WebAuditPublisher`는 인터페이스이고 구현도 호출자도 없다 — 감사 이벤트 모델(`WebAuditEvent` 55줄 · `WebAuditAction` 41줄)이 발행 경로 없이 존재한다.
## 33. Sub-scope 08 완료 조건
- denominator 53 / 53 FULL_READ
- §8.1~§8.4 수행 — 정책 충돌에서 P2 1건, 신뢰 경계에서 P2 1건, 기록 1건
- 소스 미변경
---
# Sub-scope 09 — `advanced/**` (stream · patch · functional · virtualthread · blockingbridge · release) (65 files, main 52 + test 13)
> 내부 상태: COMPLETE — **65 / 65 FULL_READ** · 근거 `evidence/raw/198-inbound-web-advanced-probes.txt`
## 34. 무엇을 하는 코드인가
Stable 밖의 능력들. `build.gradle`이 이들의 위치를 정확히 규정한다:
> "Every capability is off unless a deployment names it, so **none of them is exercised by anything a production deployment runs** — which makes a lane that runs them all the only place a break is noticed before whoever enables it notices."
즉 이 sub-scope의 파일들이 프로덕션 컨텍스트에 없는 것은 **설계대로**이고, SS4·SS5의 P1과 성격이 다르다. 여기서 물어야 할 것은 "배포가 켜기로 하면 켜지는가"다.
**`advanced` (2)** — 능력 카탈로그. `WebAdvancedFeature` enum이 능력마다 하나씩 플래그를 갖고, 왜 하나로 묶지 않는지 적는다:
> "One flag per capability, not one for 'advanced'. They have nothing in common operationally: virtual threads change how every request is scheduled, streaming changes how long a response holds a connection, XML adds a parser with a decades-long history of entity-expansion attacks. **A single switch would make those one decision, and a deployment that wanted the first would be given the third.**"
`WebAdvancedFeatureFlags`는 "무엇이 켜져 있는가"를 운영자가 출력할 수 있는 하나의 값으로 만든다.
**`advanced/patch` (13, 886 LOC)** — RFC 6902 JSON Patch와 RFC 7396 Merge Patch. `JsonPointerAuthorization`(95)과 `PatchFieldAuthorization`(69)이 포인터 단위 권한을 담당한다 — 패치 문서가 권한 밖 필드를 건드리지 못하게 하는 부분으로, 이 패키지에서 가장 값이 큰 코드다.
**`advanced/stream` + `encoding` + `replay` (25)** — 스트림 세션 레지스트리, 종료 정책, 증거, NDJSON·RFC 7464 프레이밍, 그리고 재개 커서와 중복/누락 가드(`GapAndDuplicateGuard`).
**`advanced/virtualthread` (3)** — 승인 가드와 프로파일. `VirtualThreadProfile.requiredObservations()`가 운영자가 봐야 할 네 신호를 문장으로 적어 둔다("`jdk.VirtualThreadPinned` JFR events: a synchronized block held across a blocking call pins the carrier thread, and enough pinned carriers is a deadlock the thread dump does not obviously show").
**`advanced/release` (2)** — 승격 게이트와 릴리스 매니페스트.
## 35. Negative-space probes — sub-scope 09
### 35.1 (8.4) 카운트 드리프트 — 선언된 능력 11개, 활성화 게이트 2개
`WebAdvancedFeature`의 상수:
```
MVC_VIRTUAL_THREADS · WEBFLUX_BLOCKING_BRIDGE · JSON_MERGE_PATCH · JSON_PATCH · SSE ·
NDJSON · JSON_SEQUENCE · FUNCTIONAL_WEBFLUX · CBOR · XML · RATELIMIT_DRAFT_HEADERS = 11
```
`advanced/**` 전체에서 프로덕션 `@Configuration`은 셋이고(`MvcStreamingExecutorConfiguration` · `VirtualThreadMvcConfiguration` · `VirtualThreadSettings`) 실제 `@ConditionalOnProperty` 접두사는 둘이다:
```
backend.web.advanced.mvc-virtual-threads -> MVC_VIRTUAL_THREADS
backend.web.advanced.ndjson -> NDJSON
```
나머지 아홉(`WEBFLUX_BLOCKING_BRIDGE` · `JSON_MERGE_PATCH` · `JSON_PATCH` · `SSE` · `JSON_SEQUENCE` · `FUNCTIONAL_WEBFLUX` · `CBOR` · `XML` · `RATELIMIT_DRAFT_HEADERS`)에는 프로퍼티도, `@Configuration`도, 빈도 없다. §36.1.
### 35.2 (8.1) 도달성 — 플래그 값 자체를 읽는 코드
```
$ grep -rn 'WebAdvancedFeatureFlags\|WebAdvancedFeature\b' src app-bootstrap/src | grep -v advanced/WebAdvancedFeature
test/.../advanced/release/WebAdvancedRollbackIT.java:5, 88, 91, 93, 148
test/.../advanced/release/WebAdvancedReleaseTest.java:6, 7, 21, 23, 34
```
"운영자가 출력할 수 있는 하나의 값"으로 설계된 `WebAdvancedFeatureFlags`를 읽는 프로덕션 코드가 없다. 실제 활성화는 `@ConditionalOnProperty` 두 개로 이루어지고, 그 둘은 이 값과 무관하게 동작한다.
### 35.3 (8.2) 조건 형제 비교 — 같은 스위치의 세 가지 철자
| 출처 | 문자열 | 상태 |
|---|---|---|
| `WebAdvancedFeature.propertyName()` (`:60`) | `backend.web.advanced.mvc-virtual-threads.enabled` (계산됨) | 테스트만 호출 |
| `VirtualThreadMvcConfiguration:33` | `backend.web.advanced.mvc-virtual-threads` | **실제 게이트** |
| `VirtualThreadProfile.propertyName()` (`:75`) | `backend.web.advanced.virtual-threads.enabled` | 호출자 0, 그리고 **`mvc-` 접두사가 없어 위 둘과 불일치** |
§36.2.
### 35.4 (8.3) 중복 메커니즘 — 하나의 스위치가 두 능력을 켠다
```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` 스위치 하나가 둘을 함께 켠다. `WebAdvancedFeature`의 javadoc이 금지한 형태다 — "A single switch would make those one decision."
## 36. Sub-scope 09 findings
### 36.1 P2 — 선언된 Advanced 능력 11개 중 9개는 켜는 방법이 없다
§35.1. `webAdvancedTest` 레인이 이 능력들을 전부 돌리고(`web-advanced-nightly.yml:46` · `web-advanced-release.yml:53`), `WebAdvancedRollbackIT`가 능력마다 플래그를 켰다 껐다 하며 롤백을 검증한다. 그러나 그 검증은 `WebAdvancedFeatureFlags.of(feature)`라는 **테스트 전용 값**에 대한 것이고, 배포가 실제로 조작할 수 있는 스위치는 두 개뿐이다.
**실패 시나리오** — 팀이 JSON Patch를 쓰기로 한다. `WebAdvancedFeature.JSON_PATCH.propertyName()`이 알려 주는 `backend.web.advanced.json-patch.enabled=true`를 설정한다. 아무 일도 일어나지 않는다 — 그 프로퍼티를 읽는 조건이 없고, `application/json-patch+json`을 처리할 메시지 컨버터나 컨트롤러 조언을 등록하는 코드도 없다. `advanced/patch`의 13개 파일 886 LOC(포인터 단위 권한 검사 포함)은 여전히 도달 불가다. 오류도 경고도 없다.
같은 것이 SSE(`advanced/webflux/WebFluxSseAdapter` 117줄), 함수형 라우팅(`WebFunctionalHandlerAdapter` 102줄), 블로킹 브리지, CBOR·XML 표현(SS7 §27.3), 그리고 draft rate-limit 헤더(SS4의 `advanced/ratelimit` 3파일)에 적용된다.
**이것이 SS4·SS5의 P1과 다른 점** — 저기서는 플랫폼이 **설치해야 할 것을 설치하지 않았다**(기본 켜짐이어야 할 예산·멱등성). 여기서는 능력이 옵트인인 것이 맞고, **옵트인할 수단이 없다**. 그래서 P1이 아니라 P2다.
**권고**`WebAdvancedFeature`가 이미 프로퍼티 이름을 계산한다. 능력마다 그 이름으로 게이트된 `@Configuration`을 두거나, 아직 배선할 수 없는 상수를 enum에서 빼서 "선언된 능력"과 "켤 수 있는 능력"이 같아지게 한다. `WebAdvancedReleaseTest:48``JSON_MERGE_PATCH.propertyName()`을 단언하고 있으므로, 테스트는 이미 그 이름이 의미를 갖는다고 전제한다.
### 36.2 P3 — `VirtualThreadProfile.propertyName()`이 아무것도 게이트하지 않는 이름을 반환한다
§35.3. `VirtualThreadProfile:75``"backend.web.advanced.virtual-threads.enabled"`를 하드코딩한다. 실제 게이트는 `mvc-virtual-threads`이고, 같은 능력에 대해 enum이 계산하는 이름도 `mvc-virtual-threads`다. 이 메서드는 호출자가 0이므로 지금 오작동을 만들지는 않지만, "The property that turns this on"이라는 javadoc과 함께 잘못된 이름을 발행한다 — 운영자가 이 문서를 보고 설정하면 켜지지 않는다.
### 36.3 P3/기록 — `ndjson` 스위치가 `JSON_SEQUENCE`도 함께 켠다
§35.4. enum이 두 능력을 분리하고 그 분리의 이유를 명시하는데, 실제 게이트는 하나다. `MvcStreamingExecutorConfiguration`의 javadoc이 "NDJSON and RFC 7464 JSON text sequences"를 함께 배선한다고 정직하게 적고 있으므로 은폐는 아니고, 카탈로그와 게이트의 입도가 다르다는 기록이다.
## 37. Sub-scope 09 완료 조건
- denominator 65 / 65 FULL_READ
- §8.1~§8.4 수행 — 카운트 드리프트에서 P2 1건, 이름 불일치에서 P3 1건, 기록 1건
- 소스 미변경
---
# Sub-scope 10 — `fileserver/**` (73 files, main 51 + test 22)
> 내부 상태: COMPLETE — **73 / 73 FULL_READ** · 근거 `evidence/raw/199-inbound-web-fileserver-probes.txt`
## 38. 무엇을 하는 코드인가
이 leaf에서 **실제로 조립되는 유일한 큰 하위 트리**다. app-bootstrap이 이 leaf에서 import하는 19개 타입 중 18개가 여기 있고(§3.3), `FileserverPlatformAutoConfiguration``FileserverStartupConfiguration`이 그것들을 빈으로 만든다.
일곱 개 `@RestController`(업로드 · 다운로드 · TUS · draft-12 · 라이프사이클 · 관리 · 문제 핸들러)가 전부 `@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")`로 게이트되고, `MvcTransferExecutorConfiguration``FileserverReactiveConfiguration`이 실행기를 배선한다.
**보안 코드의 품질이 이 모듈 최고 수준이다.**
`DefaultNginxInternalUriMapper``X-Accel-Redirect` 위임 — 애플리케이션이 내는 헤더가 프록시에서 파일시스템 조회가 되는 경로 — 를 세 겹으로 막는다:
```java
private static final Pattern SHARDED_KEY = Pattern.compile("[a-z0-9]{2}/[a-z0-9]{2}/[a-z0-9_-]{12,190}");
public String mapUnchecked(String rawKey) {
if (rawKey == null || !SHARDED_KEY.matcher(rawKey).matches()) { throw InvalidPathException...; }
String uri = properties.internalPrefix() + rawKey + properties.objectSuffix();
if (uri.contains("..") || uri.contains("//") || uri.indexOf('\\') >= 0) { throw ...; }
return uri;
}
```
앵커된 정규식 + 구성 후 재검사이고, 그 이유가 적혀 있다 — "a header that reaches Nginx as an internal redirect is effectively a filesystem lookup: **a traversal that survived to this point would be served, not rejected**."
그리고 시작 시 검증이 있다. `attestMapping()`이 대표 키를 실제 접두사·접미사로 왕복시켜 매핑 형태를 확인하고, 그 이유가 이 저장소에서 가장 정확한 실패 서술 중 하나다:
> "The failure this catches is silent by nature: a prefix the proxy does not resolve makes the server answer `200` with an empty body, **so the client believes it received the file**. Better to refuse to start."
**그리고 그것은 실제로 호출된다**`app-bootstrap/.../FileserverStartupConfiguration.java:87`. 이 모듈에서 "장치가 있고 회로가 닫힌" 사례다.
`FileserverRequestContextFactory`도 마찬가지다. `SecurityContextHolder`를 읽어 프레임워크 자유 `FileAccessSubject`를 만들고, 미인증은 null이 아니라 익명 주체가 되며, 주입된 접근 정책이 그것을 허용할지 결정한다. **이것이 SS3의 `WebSecurityContextBridge`가 하려던 일이고, 이쪽은 배선되어 있다.**
`TusChecksumVerifier`는 클라이언트가 보낸 체크섬을 **서버가 계산한 다이제스트와 비교만** 하고 대체하지 않는다 — "A checksum the server did not compute proves nothing, and accepting one would let a client declare corrupt bytes to be intact."
`ZeroCopyEligibility`는 TLS 연결에서 `sendfile`을 거부한다 — "TLS has to see the plaintext, so a `sendfile` would bypass the very layer that must transform it."
## 39. Negative-space probes — sub-scope 10
### 39.1 (8.1) 도달성 — 시작 검증과 조립
```
$ grep -rn 'attestMapping' --include=*.java adapter app-bootstrap sample-portfolio
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`)를 만든다. 회로 닫힘.
### 39.2 (8.2) 조건 형제 비교 — 두 전송의 fileserver
| | 조건 | 파일 |
|---|---|---|
| 서블릿 | `@ConditionalOnProperty(app.fileserver-platform.enabled=true)` + `@RestController` | `controller` 2 · `tus` 1 · `draft12` 1 · `lifecycle` 1 · `admin` 1 · `problem` 1 |
| 리액티브 | `@ConditionalOnWebApplication(type = REACTIVE)` + 같은 프로퍼티 | `reactive` 10 |
리액티브 쪽 조건이 §40.1의 대상이다.
### 39.3 (8.3) 중복 메커니즘 — 없음
MIME 조립·문제 문서·요청 컨텍스트가 각각 한 벌이다. `FileserverProblemFactory`/`FileserverExceptionHandler`는 SS2의 두 계약과 별개인 **세 번째** 에러 형식(`FileserverProblem`)이지만, `@ConditionalOnProperty`로 이 능력에만 붙고 경로가 겹치지 않는다. 능력별 문제 문서로 정당하다.
### 39.4 (8.4) 문서/구현 드리프트 — 리액티브 활성화 조건
`build.gradle`이 리액티브 핸들러의 활성화 조건을 이렇게 적는다:
> "DispatcherServlet stays present, so Spring Boot's WebApplicationType deduction keeps resolving SERVLET; **the reactive handlers are wired only when the fileserver reactive profile is selected**."
실제 조건은 프로파일이 아니다:
```java
// fileserver/reactive/FileserverReactiveConfiguration.java:38-39
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnProperty(prefix = "app.fileserver-platform", name = "enabled", havingValue = "true")
```
§40.1.
## 40. Sub-scope 10 findings
### 40.1 P1 — 이 leaf의 리액티브 절반 29개 파일은 어떤 출하 배포에서도 활성화될 수 없다
`@ConditionalOnWebApplication(type = REACTIVE)`는 Spring Boot의 `WebApplicationType``REACTIVE`일 때만 참이다. `deduceFromClasspath()``DispatcherServlet``ServletContainerInitializer`가 있으면 **WebFlux가 함께 있어도** `SERVLET`을 고른다.
**이 저장소의 클래스패스는 SERVLET을 고정한다:**
- 이 leaf의 `build.gradle``spring-boot-starter-web`(Tomcat + DispatcherServlet)을 `implementation`으로 선언하고, WebFlux는 `spring-webflux` + `reactor-core`만 — 즉 서버 없는 프레임워크만 — 가져온다. 그 선택의 근거도 적혀 있다("which would put a second embedded server (reactor-netty) on the runtime classpath").
- `app-bootstrap/gradle.lockfile``spring-boot-starter-tomcat`/`spring-webmvc` 항목이 있다.
- `sample-portfolio/build.gradle:45``spring-boot-starter-web`을 선언한다.
- main 소스 어디에도 `setWebApplicationType(REACTIVE)`가 없다(테스트 하네스 3곳에만 `WebApplicationType` 참조가 있고 전부 `NONE`/`SERVLET`).
따라서 `WebApplicationType`은 항상 `SERVLET`이고, 이 leaf의 모든 `@ConditionalOnWebApplication(REACTIVE)`는 영구히 거짓이다.
**꺼진 채로 남는 것 (main 29 파일):**
| 패키지 | 파일 | 잃는 것 |
|---|---|---|
| `webflux/autoconfigure` | 2 | 리액티브 플랫폼 빈 11개 |
| `webflux/context` | 2 | **`WebFluxRequestContextFilter` — 이 leaf의 유일한 프로덕션 `WebRequestContext` 생산자** |
| `webflux/error` · `budget` · `guard` · `idempotency` · `operation` · `throttle` | 8 | 리액티브 문제 문서 · 예산 · 블로킹 가드 · 멱등성 · 작업 조회 · 스로틀 |
| `fileserver/reactive` | 10 | 리액티브 업로드/다운로드 핸들러, IO 스케줄러, 라우터 |
| `advanced/webflux` | 7 | SSE 어댑터 · 하트비트 · 스트림 승인 · 블로킹 브리지 |
**SS3 §12.1이 이것으로 완결된다.** 거기서 "서블릿에는 `WebRequestContext` 생산자가 없고 리액티브에는 익명 고정 생산자가 있다"고 기록했다. 이 발견을 합치면 **출하되는 어떤 배포에도 생산자가 없다** — 리액티브 생산자가 등록되는 조건이 성립하지 않기 때문이다. `WebMvcRequestContextArgumentResolver`는 자동설정이 등록하고 항상 던진다.
**`build.gradle`의 서술이 이 상태를 반쯤 알고 있다.** "the reactive handlers are wired only when the fileserver reactive profile is selected"라고 적었는데, Spring 프로파일은 `WebApplicationType` 추론을 바꿀 수 없다. 그것을 바꾸는 것은 클래스패스이거나 `SpringApplication.setWebApplicationType(...)` 뿐이고, 둘 다 이 저장소에 없다.
**그 클래스패스를 만드는 곳은 하나뿐이다 — 테스트 레인.** 같은 파일이 그 사실을 정확히 적는다:
> `webfluxContractTest { … inherits() }` — "Inherits nothing. The default is to extend `testImplementation`, which extends the leaf's own `implementation` and therefore carries spring-boot-starter-web — and with Tomcat on the classpath **Boot deduces a servlet application, starts a servlet container, and the reactive gate certifies the servlet stack** while reporting itself green."
이 주석은 레인에 대해 완전히 옳고, 같은 사실이 프로덕션에 대해서도 성립한다는 점만 적히지 않았다. `webfluxContractTest` 소스셋은 **리액티브 코드가 실행될 수 있는 유일한 클래스패스를 만들기 위해** 존재하고, 그 클래스패스는 출하 아티팩트에 없다.
**실패 시나리오** — 팀이 `app.fileserver-platform.enabled=true`로 fileserver를 켜고 리액티브 전송을 쓰기로 한다. 문서(build.gradle 주석)가 말하는 "fileserver reactive profile"을 찾지만 그런 프로파일은 없다. 프로퍼티를 무엇으로 설정해도 `FileserverReactiveConfiguration`은 활성화되지 않고, 서블릿 컨트롤러가 계속 응답한다. 오류도 경고도 없고 `webFluxContractTest` 레인은 초록색이다.
**권고** — 두 선택지가 있고 어느 쪽이든 문서가 따라가야 한다. (1) 리액티브를 실제 선택지로 만든다: 별도 배포 아티팩트가 `spring-boot-starter-webflux`를 쓰고 `spring-boot-starter-web`을 제외하도록 조립 경로를 만든다(레인이 이미 그 형태를 갖고 있다). (2) 리액티브를 지원하지 않는다고 선언하고 29개 파일과 레인을 제거한다. 지금은 셋 다 아니다 — 코드가 있고, 레인이 초록이고, 배포는 그것을 켤 수 없다.
### 40.2 P3/기록 — 리액티브 활성화 조건에 대한 `build.gradle` 서술이 코드와 다르다
§39.4. "the reactive handlers are wired only when the fileserver reactive profile is selected" — 실제 조건은 `@ConditionalOnWebApplication(REACTIVE)`이고 프로파일과 무관하다. §40.1의 일부이지만 문서 수정만으로 닫히지 않는다는 점에서 별도로 기록한다.
## 41. Sub-scope 10 완료 조건
- denominator 73 / 73 FULL_READ
- §8.1~§8.4 수행 — 활성화 조건에서 P1 1건, 문서 드리프트 1건
- 서블릿 fileserver 경로는 이 모듈에서 유일하게 완전히 조립된 하위 트리로 확인됨(결함 0)
- 소스 미변경
---
# Sub-scope 11 — `notification/platform/**` + `admin/**` (26 files, main 22 + test 4)
> 내부 상태: COMPLETE — **26 / 26 FULL_READ** · 근거 `evidence/raw/200-inbound-web-notification-admin-probes.txt`
## 42. 무엇을 하는 코드인가
**`notification/platform` (16)** — 알림 제출/템플릿 HTTP 표면과 provider 콜백 수신. 두 전송 모두 지원한다(`callback` MVC 5 + `callback/reactive` 4). `ca-skeleton.notification.platform``...callbacks` 두 프로퍼티로 게이트되고, **자기 의존을 스스로 공급한다**:
```java
// CallbackRequestConfiguration.java (@ConditionalOnProperty(...callbacks.enabled=true))
@Bean @ConditionalOnMissingBean(ExternalRequestUrlResolver.class)
public ExternalRequestUrlResolver externalRequestUrlResolver(
@Value("${ca-skeleton.notification.platform.callbacks.trusted-proxies:}") Set<String> trustedProxies) { ... }
@Bean @ConditionalOnMissingBean(CallbackRequestFactory.class)
public CallbackRequestFactory callbackRequestFactory(ExternalRequestUrlResolver urlResolver, Clock clock) { ... }
```
javadoc이 이 형태를 택한 이유를 자기고발로 적는다 — "...reference got a deployment that would not boot." SS4 §16.3과 SS5 §20.2에서 확인한 "게이트를 켜면 미충족 의존성으로 부팅 실패"가 여기서는 이미 고쳐져 있다.
**신뢰 프록시 결정이 여기서는 배선되어 있다.** 기본값이 빈 집합이고 그 이유가 명확하다:
> "The trusted-proxy set is empty by default, and that default is the safe one rather than the convenient one: with no entry, forwarded headers are never honoured and the resolver uses what the container observed. **Honouring them unconditionally would let any caller choose the URL that gets signature-verified, which defeats the signature.**"
SS8 §32.2에서 확인한 `proxy` 패키지(421 LOC, 미배선)와 정확히 대조된다 — 같은 판단이 필요한 두 곳 중 콜백 서명 검증 쪽은 연결했고 플랫폼 전역 쪽은 연결하지 않았다.
**`admin` (6)** — 런타임 라우트 목록(`WebRouteInventory` · `WebRouteContract` · `SpringMvcRouteInventoryCollector`)과 플랫폼 스냅샷·시작 검증(`WebPlatformSnapshot` · `WebPlatformStartupValidator`).
## 43. Negative-space probes — sub-scope 11
### 43.1 (8.1) 도달성 — `admin` 여섯 파일
```
SpringMvcRouteInventoryCollector 저장소 전체 참조: 자기 파일 2줄뿐 (테스트도 0)
WebPlatformStartupValidator test 5, main/boot 0
WebRouteInventory main 2, test 7
WebPlatformSnapshot main 1, test 3
```
§44.1·§44.2.
### 43.2 (8.2) 조건 형제 비교 — 시작 검증 두 개의 운명
| 장치 | 무엇을 확인하는가 | 시작 시 호출 |
|---|---|---|
| `NginxInternalUriMapper.attestMapping()` (SS10) | 내부 URI 매핑 형태 | **예**`FileserverStartupConfiguration:87` |
| `WebPlatformStartupValidator` | 필수 플랫폼 구성 요소의 존재 | **아니오** — 테스트에서만 생성 |
같은 종류의 장치가 한쪽은 회로가 닫혔고 한쪽은 열려 있다.
### 43.3 (8.3) 중복 메커니즘 — 신뢰 프록시 판정
`ExternalRequestUrlResolver`(71줄, 배선됨, 기본 빈 신뢰 집합)와 `proxy/TrustedProxyPolicy`(161줄, 미배선). 전자는 콜백 서명 검증용 URL에만 적용되고 후자는 플랫폼 전역용이다. 범위가 다르므로 중복은 아니지만, 같은 문제에 대한 두 답 중 하나만 연결돼 있다는 사실은 §32.2와 함께 읽힌다.
### 43.4 (8.4) 게이트 프로퍼티가 존재하는가
```
$ grep -rh 'prefix = "' notification/ -> ca-skeleton.notification.platform
-> ca-skeleton.notification.platform.callbacks
$ grep -rn 'web-platform.notification|notification.*web-platform' --include=*.yml . -> 없음
```
어떤 `application.yml`에도 이 프로퍼티가 없다 — 기본 꺼짐이고, 켜는 방법은 명확하며(§42), 켜면 의존이 갖춰진다. SS4·SS5·SS9와 달리 여기는 **닫힌 옵트인**이다.
## 44. Sub-scope 11 findings
### 44.1 P3 — `SpringMvcRouteInventoryCollector` 138줄에 참조가 하나도 없다
저장소 전체에서 이 타입 이름이 등장하는 곳은 자기 파일의 클래스 선언과 생성자 두 줄뿐이다. 테스트도 없다.
이 leaf에서 확인한 미조립 사례 대부분(§16.1 · §20.1 · §32.2)은 최소한 테스트나 픽스처가 생성했다 — "검증되었으나 배선되지 않은" 형태였다. 이것은 그것보다 한 단계 더 나아간, **작성되었고 어디서도 인스턴스화되지 않은** 138줄이다. `WebRouteInventory`(104줄)가 `requireRegisteredOperations(WebOperationCatalog)`로 릴리스 게이트 역할을 하도록 설계돼 있고(SS5 §19.3), 그 목록을 실제 Spring MVC 라우트에서 수집하는 것이 이 클래스의 역할인데, 수집이 일어나지 않으므로 목록도 비교도 없다.
### 44.2 P3 — `WebPlatformStartupValidator`가 시작 시 실행되지 않는다
§43.1·§43.2. 이름이 약속하는 시점에 아무도 부르지 않는다. 같은 leaf의 fileserver 하위 트리는 같은 종류의 시작 검증을 app-bootstrap의 `@Bean`으로 연결했고(SS10 §39.1), 그 근거를 "Better to refuse to start"로 적었다. 플랫폼 쪽 검증기에는 그 연결이 없다.
이 두 발견은 `admin` 패키지 6개 파일 중 4개가 운영 가시성 장치이면서 운영 시점에 도달하지 않는다는 하나의 사실이다.
### 44.3 — `notification/platform` 16개 파일: 결함 없음
게이트, 의존 공급, 신뢰 프록시 기본값, 두 전송 대칭이 모두 갖춰져 있다. 리액티브 절반(`callback/reactive` 4파일)은 §40.1의 `@ConditionalOnWebApplication(REACTIVE)` 문제를 공유하지만, 그것은 이 sub-scope의 결함이 아니라 모듈 전체의 조건이다.
## 45. Sub-scope 11 완료 조건
- denominator 26 / 26 FULL_READ
- §8.1~§8.4 수행 — 도달성에서 P3 2건
- 소스 미변경
---
# Sub-scope 12 — `testkit` + `webfluxContractTest` + `jettyCompatTest` + `nginxProxyTest` (94 files)
> 내부 상태: COMPLETE — **94 / 94 FULL_READ** · 근거 `evidence/raw/201-inbound-web-testkit-probes.txt`
## 46. 무엇을 하는 코드인가
네 개의 별도 소스셋. `testkit`(54)이 계약과 하네스를 담고, 나머지 셋(16 + 9 + 5)이 그것을 서로 다른 런타임에서 재실행한다.
**계약 클래스가 소스셋 분리로 공유된다.** `WebBudgetContract` · `WebThrottleHttpContract` · `OperationHttpContract` · `WebPipelineOrderContract` · `WebLoadAndShutdownContract` · `IdempotencyResponseLossContract` · `WebPlatformContractSuite` — 각각이 추상 계약이고, Tomcat(`test`) · Jetty(`jettyCompatTest`) · Reactor Netty(`webfluxContractTest`) 세 레인이 구현한다. 그리고 `webCrossStackParityTest`가 세 레인이 남긴 기록을 비교한다(`WebPlatformContractRecording` · `WireOutcome` · `WireProbe`).
이것은 notification 모듈의 `ProviderAdapterContract`(상속 3/8, §29.2)보다 강한 형태다 — 상속에 의존하지 않고 **소스셋과 태스크 의존이 강제**한다.
**`testkit/arch` 4개는 회로가 닫혀 있다.** `WebArchitectureRules`가 7개 ArchUnit 규칙을 발행하고, `app-bootstrap``WebProductionArchitectureTest:47``WebArchitectureRules.all()`을 프로덕션 트리에 적용한다. `build.gradle`이 그 배선의 이유를 적는다 — "A rule pack that only its own fixture tests import is verified as library code and applied to nothing — **the shape the JPA testkit had to be corrected out of**." 그리고 `WebArchitectureRulesTest:86-88`이 규칙 개수(7 / 6 / 1)를 고정해 규칙이 조용히 사라지는 것을 막는다.
**`ProviderFaultHarness` 계열이 실제 소켓을 쓴다.** `HttpResponseLossFixture` · `ResponseLossFixture` · `WebFaultInjector`가 본문 커밋 후 연결 절단을 재현한다 — 목으로는 만들 수 없는 조건이다.
**`nginxProxyTest`(5)** 는 Testcontainers로 진짜 Nginx를 띄우고 프록시·접두사·스푸핑 계약을 검증한다(§32.2).
## 47. Negative-space probes — sub-scope 12
### 47.1 (8.1) 도달성 — 픽스처 애플리케이션이 조립하는 것
각 픽스처 애플리케이션이 `new`로 만드는 플랫폼 타입:
```
BudgetFixtureApplication BudgetProblemMapper WebMvcBudgetFilter
ReactiveBudgetFixtureApplication BudgetProblemMapper WebFluxBudgetFilter
ThrottleFixtureApplication ThrottleProblemWriter WebMvcThrottleFilter CountingRateLimiter
ReactiveThrottleFixtureApplication ThrottleProblemWriter WebFluxThrottleFilter CountingRateLimiter
ContractFixtureApplication WebProblemFactory
ReactiveContractFixtureApplication WebProblemFactory WebProblemSanitizer
ReactiveSseFixtureApplication WebFluxSseAdapter WebStreamPolicy StreamId StreamSequence
PipelineOrderFixtureApplication WebPipelineRecorder + 익명 Filter/HandlerInterceptor
```
이 목록의 왼쪽 열이 §48.1의 내용이다.
### 47.2 (8.2) 조건 형제 비교 — 두 개의 계약 강제 형태
| | notification `ProviderAdapterContract` | web `WebBudgetContract` 외 6종 |
|---|---|---|
| 강제 수단 | 상속(강제 없음) | 소스셋 + `dependsOn` 태스크 그래프 |
| 실제 적용 | 8종 중 3종 | 세 런타임 전부 |
| 빠진 것을 잡는 장치 | 없음 | `webCrossStackParityTest`가 세 기록을 비교하고, 하나라도 없으면 실패 |
web 쪽이 구조적으로 우월하다. `build.gradle`이 그 이유를 적는다 — "a parity check that compares whatever happens to be present would report agreement across a matrix with a hole in it."
### 47.3 (8.3) 중복 메커니즘 — 없음
`WebArchitectureRules`가 유일한 규칙 팩이고 소비자가 둘(자기 테스트 + app-bootstrap 프로덕션 적용)이다. 계약 클래스도 각 능력당 하나다.
### 47.4 (8.4) 카운트 고정
`WebArchitectureRulesTest:86-88``all()` 7 · `webScopedRules()` 6 · `crossLeafRules()` 1을 단언한다. 규칙이 추가되거나 사라지면 테스트가 먼저 깨진다. 이 leaf에서 발견한 카운트 드리프트(§35.1의 능력 11 대 게이트 2)와 반대되는, 고정이 작동하는 사례다.
## 48. Sub-scope 12 findings
### 48.1 P1 — 크로스 스택 게이트가 검증하는 조립은 픽스처의 조립이고, 플랫폼의 조립이 아니다
이 leaf는 이 저장소에서 가장 정교한 검증 장치를 갖고 있다 — 여섯 소스셋, 다섯 커스텀 레인, 세 런타임 패리티 비교, 실제 Nginx 컨테이너, 실제 소켓 고장 주입. 그리고 §47.1이 보여주듯 **그 장치가 세우는 애플리케이션은 픽스처 애플리케이션이다.**
`BudgetFixtureApplication``FilterRegistrationBean<WebMvcBudgetFilter>`를 손수 등록한다. `JettyWebBudgetIT` · `ReactiveWebBudgetIT`가 그 애플리케이션을 띄워 예산 계약을 세 런타임에서 증명한다. 증명되는 명제는 "**이 필터가 등록되면** 예산이 지켜진다"이고, "플랫폼이 이 필터를 등록한다"는 명제는 어떤 레인도 세우지 않는다 — 그리고 §16.1이 확인했듯 플랫폼은 등록하지 않는다.
같은 구조가 스로틀(§16.1) · 멱등성(§20.1) · SSE(§36.1) · 요청 컨텍스트(§12.1)에 반복된다.
**빠진 검증은 하나다** — 두 자동설정(`WebMvcPlatformAutoConfiguration` · `WebFluxPlatformAutoConfiguration`)이 세운 컨텍스트에 무엇이 있는지 확인하는 테스트. `WebMvcPlatformAutoConfigurationTest`(112줄)와 `WebFluxPlatformAutoConfigurationTest`(112줄)가 존재하지만 자동설정이 **선언한** 빈들을 확인할 뿐, 필터 체인에 예산·스로틀·멱등성이 있는지는 묻지 않는다 — 자동설정이 그것들을 선언하지 않으므로 확인할 것도 없다.
**이 sub-scope에 P1을 두는 이유** — 결함은 픽스처에 있지 않다. 픽스처는 정확하고 계약은 잘 쓰였다. 결함은 **검증 전략의 경계**에 있다: 이 leaf는 "능력이 올바른가"를 다섯 레인으로 묻고 "플랫폼이 능력을 설치하는가"를 묻는 레인을 하나도 갖지 않는다. 그 공백이 §12.1 · §16.1 · §20.1 · §36.1 · §40.1 다섯 개의 P1/P2가 초록색 스위트 아래에서 성립할 수 있게 한 단일 원인이다.
**권고** — 픽스처를 하나 더 만드는 것이 아니라, **아무것도 등록하지 않는** 픽스처를 하나 만든다: `@SpringBootConfiguration` + `@EnableAutoConfiguration`만 있고 `@Bean`이 없는 애플리케이션을 띄워 필터 체인·컨트롤러 조언·인터셉터 목록을 스냅샷으로 고정한다. 그 스냅샷이 §16.1 · §20.1을 즉시 드러내고, 이후 회귀도 막는다. `WebPlatformContractRecording`이 이미 기록·비교 형태를 갖고 있으므로 형식은 있다.
### 48.2 — testkit·레인 자체의 결함: 없음
계약 강제(§47.2), 규칙 팩 배선(§46), 카운트 고정(§47.4), 실제 소켓·실제 프록시 사용이 모두 갖춰져 있다. `nginxProxyTest`가 Docker 부재 시 조용히 통과하지 않고 실패한다는 점(build.gradle: "A lane that quietly passes when the container runtime is missing is a lane that has been certifying nothing since whenever Docker last broke")까지 포함해, 이 소스셋들은 이 저장소가 검증에 대해 아는 것을 가장 잘 보여준다.
## 49. Sub-scope 12 완료 조건
- denominator 94 / 94 FULL_READ
- §8.1~§8.4 수행 — 검증 경계에서 P1 1건
- 소스 미변경
---
# 50. 모듈 종합 — `adapter-inbound-web`
## 50.1 커버리지 원장 정산
| # | sub-scope | main | test | 기타 | 합 | 상태 |
|---|---|---:|---:|---:|---:|---|
| 1 | governance + `config`·`core`·`contract`·`moduleboundary`·`*/autoconfigure` | 30 | 17 | 4 | 51 | COMPLETE |
| 2 | `error` + `validation` + `envelope` | 23 | 10 | | 33 | COMPLETE |
| 3 | `auth` + `authz` + `security` | 27 | 17 | | 44 | COMPLETE |
| 4 | `ratelimit` + `admission` + `budget` + `*/throttle` | 41 | 9 | | 50 | COMPLETE |
| 5 | `idempotency` + `operation` + `operationasync` + `evidence` | 40 | 10 | | 50 | COMPLETE |
| 6 | `pagination` + `cursor` + `conditional` + `cache` + `versioning` | 42 | 12 | | 54 | COMPLETE |
| 7 | `http` + `json` + `advanced/codec` + `openapi` | 34 | 11 | | 45 | COMPLETE |
| 8 | `observability` + `proxy` + `filter` + `mvc/*`·`webflux/*` 잔여 | 38 | 15 | | 53 | COMPLETE |
| 9 | `advanced/**` | 52 | 13 | | 65 | COMPLETE |
| 10 | `fileserver/**` | 51 | 22 | | 73 | COMPLETE |
| 11 | `notification/platform/**` + `admin/**` | 22 | 4 | | 26 | COMPLETE |
| 12 | `testkit` + 대체 소스셋 3종 | 0 | 10 | 84 | 94 | COMPLETE |
| | **합계** | **400** | **150** | **88** | **638** | **12 / 12** |
`FULL_READ 638 · STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`. 각 sub-scope의 분모는 evidence `189``201``OWNED FILES` 블록과 `file_count` 라인이 확인한다. 분할은 `188-...`의 패키지 트리에서 기계적으로 계산했고 중복 0 · 미할당 0이다.
## 50.2 발견 종합 — P1 6건 · P2 8건 · P3 9건 · 기록 9건
| 심각도 | § | 발견 |
|---|---|---|
| **P1** | 8.1 | **RFC 9457 계약 23개 파일이 출하 애플리케이션에 등록되지 않는다** — 컴포지션 루트가 `mvc.error`·`webflux.error`·`mvc.budget`·`mvc.operation`·`webflux.operation` 다섯 패키지를 컴포넌트 스캔에서 제외하고("Ownership by auto-configuration is what ties a control's presence to its dependency's"), 그것을 넘겨받는 자동설정이 없다. README의 D5(`ProblemDetail` 거부)만 출하된다 |
| **P1** | 12.1 | 플랫폼 요청 컨텍스트가 서블릿에는 생산자가 없고(`store()` 호출자 0 · 리졸버는 항상 throw), 리액티브에는 `ActorContext.anonymous()`로 고정 |
| **P1** | 16.1 | 용량 보호 계층 41개 main 파일(예산 · 승인 · 스로틀)이 픽스처에서만 등록된다 — 프로덕션에 요청 본문 상한도 데드라인도 동시성 상한도 없다 |
| **P1** | 20.1 | 멱등 실행 계층 38개 파일이 픽스처에서만 조립된다 — `Idempotency-Key`가 읽히지 않고 거부되지도 않는다 |
| **P1** | 40.1 | `@ConditionalOnWebApplication(REACTIVE)`가 걸린 29개 main 파일이 어떤 출하 배포에서도 활성화될 수 없다(클래스패스가 SERVLET을 고정). §12.1과 합치면 **어떤 배포에도 `WebRequestContext` 생산자가 없다** |
| **P1** | 48.1 | 다섯 레인·세 런타임 패리티가 검증하는 것은 픽스처의 조립이고, "플랫폼이 능력을 설치하는가"를 묻는 레인이 없다 — 위 다섯 발견의 단일 원인 |
| P2 | 12.2 | 프레임워크 자유 신원 모델 11파일과 교차 테넌트 가드 `rejectTenantInput`이 프로덕션에서 참조 0 |
| P2 | 16.2 | 리액티브 전송에 속도 제한 경로가 하나도 없다 |
| P2 | 24.1 | 배선된 `CacheControlFilter``no-store`가 배선된 조건부 읽기(ETag/304)를 무력화하고, 조정용 `cache` 패키지 310 LOC은 참조 0 |
| P2 | 28.1 | `maxArrayElements`가 선언만 되고 강제되지 않으며 바이트 예산 백스톱(§16.1)도 없다 |
| P2 | 32.1 | 요청 식별자를 클라이언트가 고를 수 없다는 정책이, 뒤에 도는 다른 배선 필터에 의해 뒤집힌다 |
| P2 | 32.2 | forwarded 헤더 신뢰 판정이 Nginx 설정에만 있고 Java 정책 421 LOC은 미배선 |
| P2 | 36.1 | 선언된 Advanced 능력 11개 중 9개는 켜는 방법이 없다 |
| P3 ×9 | 8.2 · 20.3 · 24.3 · 25.3 · 25.4 · 36.2 · 44.1 · 44.2 · 28.3 외 | 죽은 메서드 · 구분자 기반 지문 · 미강제 한도 · 이름 불일치 · 참조 0인 138줄 · 실행되지 않는 시작 검증 등 |
## 50.3 이 모듈의 성격 — 하나의 원인, 여섯 개의 결과
이 leaf는 코드 품질이 저장소 최고 수준이다. 자기고발 주석의 밀도는 notification보다 높고, 모듈 경계는 스캐너로 강제되며(§3.4), 검증 장치는 여섯 소스셋·다섯 레인·세 런타임 패리티·실제 Nginx 컨테이너·실제 소켓 고장 주입까지 갖췄다.
그런데 **main 397개 파일 중 프로덕션 컨텍스트에 실제로 들어가는 것은 소수다.** 두 자동설정이 23개 빈을 등록하고, 컴포넌트 스캔이 `@RestController` 18 · `@RestControllerAdvice` 6 · `@Component` 7 · `@Configuration` 23을 잡고, app-bootstrap이 19개 타입을 명시적으로 배선한다 — 그리고 그 19개 중 18개가 fileserver다.
원인은 하나다. **이 leaf는 "능력을 구현하는 코드"와 "능력을 검증하는 픽스처"를 둘 다 갖고, "능력을 조립하는 자동설정"을 갖지 않는다.**
`fileserver`가 반례이자 증거다. 그것만 app-bootstrap에 전용 자동설정(`FileserverPlatformAutoConfiguration` · `FileserverStartupConfiguration`)을 갖고, 그래서 그것만 완전히 조립된다 — 시작 시 검증(`attestMapping()`)까지 호출되고(§39.1), 자기 보안 브리지를 배선했으며(§38), 신뢰 프록시 결정을 명시적으로 내린다. 같은 판단이 필요한 플랫폼 쪽(`WebSecurityContextBridge` · `TrustedProxyPolicy` · `WebPlatformStartupValidator`)은 전부 미배선이다.
`notification/platform`이 두 번째 반례다 — 자기 `@Configuration`에서 의존을 `@ConditionalOnMissingBean`으로 공급하고, javadoc이 그렇게 하는 이유를 "...reference got a deployment that would not boot"로 적는다. 이 leaf는 그 실패를 한 번 겪고 한 곳에서 고쳤다. `budgets`(§16.3)와 `durable-operations`(§20.2)에는 같은 수정이 적용되지 않았다.
**검증 장치가 이 상태를 가리는 방식이 이 모듈의 핵심 관찰이다.** 레인은 다음을 증명한다:
> "`WebMvcBudgetFilter`가 등록되면 예산이 Tomcat·Jetty·Reactor Netty에서 동일하게 지켜진다."
그리고 다음을 묻지 않는다:
> "플랫폼이 `WebMvcBudgetFilter`를 등록하는가."
첫 문장이 참이고 둘째 질문의 답이 "아니오"인 상태는, 다섯 레인이 전부 초록인 채로 성립한다. §48.1의 권고 — 빈을 하나도 선언하지 않는 픽스처로 필터 체인 스냅샷을 고정하는 것 — 하나가 §12.1 · §16.1 · §20.1 · §36.1을 동시에 드러낸다.
## 50.4 다른 모듈과의 대조
| 형태 | notification (모듈 13) | inbound-web (모듈 14) |
|---|---|---|
| "장치는 있고 회로가 닫히지 않음" | P2 7건 중 5건 | P1 6건 중 5건 |
| 계약 강제 | 상속(8종 중 3종 적용) | 소스셋 + 태스크 그래프(3 런타임 전부) |
| 자기고발 주석 | 높음 | 더 높음 |
| 자기 패턴을 스스로 고친 사례 | 3건(배경 작업자 · webhook SSRF · VAPID 역사 키) | 3건(Jackson 2→3 · `@ConditionalOnBean` 회피 · 프레임워크 탐지 정규식에 Jackson 2·3 병기) |
두 모듈이 같은 병을 앓지만 규모가 다르다. notification에서는 개별 장치가 연결되지 않았고, web에서는 **계층 전체**가 연결되지 않았다. 그리고 web의 검증 장치가 더 정교하기 때문에, 그 공백이 더 잘 숨는다.
httpclient(모듈 11)의 P1 — TLS 실패가 CONNECT로 오분류 — 은 성격이 다르다. 그것은 로직 오류였고 **테스트가 잡았다**. 이 모듈의 P1 여섯 건은 전부 로직이 아니라 조립에 관한 것이고, 전부 테스트가 통과하는 상태에서 나왔다.
## 50.5 완료 게이트
- [x] denominator 638 / 638 FULL_READ, `STRUCTURAL_ONLY 0 · EXCLUDED 0 · UNCLASSIFIED 0`
- [x] 12개 sub-scope 각각 §8.1~§8.4 negative-space probe 수행
- [x] evidence `188``201` 생성 (패키지 도달성 지도 `192` 포함), 각 파일에 `OWNED FILES` + revision + 실행 probe
- [x] 실행 검증: `:adapter:inbound:web:test` + `:webSecurityBoundaryTest` (§50.6)
- [x] 거짓 양성 후보 검증 후 기각: `CorsSettings``contains("*")`(→ 소비처가 `setAllowedOrigins`라 정확), `SafeProblemDetailExtensions` 호출자 0(→ `WebProblem`이 고정 컴포넌트 record라 우회 경로 없음), `TusChecksumVerifier`의 비상수시간 비교(→ 비교 대상이 클라이언트가 이미 아는 값), `WebMvcBudgetExceptionHandler``@ConditionalOnBean` 의심(→ 실제로는 회피했고 주석이 그 이유를 적음), `WebRequestId`/`WebTraceId`의 문법 부재(→ 두 필터가 anchored 정규식으로 강제)
- [x] 소스 미변경 (`git status --short` = 0줄)
- [x] **분석 중 수정**: §8.1은 처음에 "두 advice가 함께 등록되어 다섯 예외를 나눠 갖는다"로 기록했으나, 이어진 `adapter-inbound-grpc` 분석에서 `CaSkeletonApplication`의 컴포넌트 스캔 제외 정규식을 전수 확인하며 `mvc.error`·`webflux.error`·`mvc.budget`·`mvc.operation`·`webflux.operation` 다섯 패키지가 스캔에서 빠져 있고 그것을 등록하는 자동설정이 없다는 사실이 드러났다. §7.1·§7.2·§8.1·§50.2를 그 사실로 교체했다. 근거 `evidence/raw/203-composition-root-scan-boundary.txt`. §16.3·§20.2의 "게이트를 켜면 부팅 실패"는 정확히는 "스캔이 잡지 않아 조건 평가에도 이르지 못한다"이며 결과(능력이 켜지지 않음)는 같다.
## 50.6 실행 검증
```
$ ./gradlew :adapter:inbound:web:test :adapter:inbound:web:webSecurityBoundaryTest
> Task :adapter:inbound:web:test
> Task :adapter:inbound:web:webSecurityBoundaryTest
BUILD SUCCESSFUL in 24s
GRADLE_EXIT=0
test-results 집계: classes=176 tests=1221 failures=0 errors=0 **skipped=0**
```
두 레인 모두 통과. **이 모듈의 P1 6건과 P2 8건 중 테스트가 검출한 것은 하나도 없다** — §50.3이 서술한 이유 때문이다.
`webCrossStackParityTest` · `webJettyCompatTest` · `webFluxContractTest` · `webNginxProxyTest` · `webAdvancedTest`는 실행하지 않았다. 앞의 셋은 `webCrossStackParityTest` 하나로 묶여 있으나 Jetty·Reactor Netty 두 임베디드 서버를 내려받아야 하고, `webNginxProxyTest`는 Docker 컨테이너 런타임을 요구한다(§2). 이 분석은 컨테이너를 띄우지 않는다. 다만 §40.1이 확인했듯 그 레인들이 인증하는 것은 픽스처가 세운 컨텍스트이며, 이 분석의 발견은 그 레인들의 통과와 양립한다.
---
## 51. 분석 후 정정 (2026-08-31, 교차 스코프 분석 중)
교차 스코프 분석에서 저장소 전체의 `AutoConfiguration.imports` 7개 파일과 `app-bootstrap``spring.factories`를 전수로 다시 읽고 **§8.1의 서술을 정정했다.**
**정정 전(잘못된 표현):** "그것을 넘겨받을 자동설정은 작성되지 않았다."
**정정 후(확인된 사실):** 자동설정 두 개(`WebMvcPlatformAutoConfiguration` · `WebFluxPlatformAutoConfiguration`)는 존재하고, 이 leaf의 `.imports`에 등록돼 있으며, `app-bootstrap`이 이 leaf를 `implementation`으로 물고 있고 저장소의 유일한 `AutoConfigurationImportFilter`가 JPA 전용이므로 **출하 컨텍스트에 실제로 import된다.** 두 자동설정은 `@Bean` 13개·10개를 등록한다.
**결함 자체는 그대로이고 오히려 더 좁고 선명해진다** — 등록되는 13+10개는 전부 **협력자**(`ProblemCatalog`·`WebProblemFactory`·`WebBudgetCatalog`·`InMemoryWebOperationCatalog`·정책·매퍼·필터)이고, 스캔에서 제외된 다섯 패키지의 **여섯 컴포넌트**(problem advice 2 · budget advice 1 · budget filter 1 · operation controller 2)는 어느 자동설정도 `@Bean`으로도 `@Import`로도 소유하지 않는다. 여섯 전부 main 참조 0이다.
따라서 실패 시나리오·권고·§16.3·§20.2·§48.1과의 연결은 모두 유효하다. 바뀐 것은 **원인의 위치**다 — "자동설정이 없다"가 아니라 "자동설정이 의존만 소유하고 통제를 소유하지 않는다"이며, 권고(두 자동설정이 두 핸들러를 등록하도록)는 정정 후에 **더 작은 변경**이 된다: 새 자동설정을 만드는 것이 아니라 기존 자동설정에 `@Bean` 여섯 개를 추가하는 일이다.
빈 개수도 정정한다 — 본문의 "12개·11개"는 실측 **13개(MVC)·10개(WebFlux)**다.
증거: `evidence/raw/264-cross-scope-autoconfiguration-roots.txt`
## Source anchors
이 문서가 backtick으로 인용한 타입·경로를 저장소 트리에 대고 해석한 결과다. 해석된 것만 싣는다 — 총 **189개** (main 152 · test 26 · 기타 11).
```
src/adapter/inbound/web/build.gradle
src/config/architecture/modules.json (adapter-inbound-web 항목)
main:
src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformSnapshot.java
src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformStartupValidator.java
src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/SpringMvcRouteInventoryCollector.java
src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteContract.java
src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteInventory.java
src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionDecision.java
src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionPermit.java
src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionProfile.java
src/main/java/dev/caskeleton/adapter/inbound/web/admission/SemaphoreAdmissionController.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeature.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeatureFlags.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/RepresentationNegotiationPolicy.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/SecureXmlInputFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCborMapperFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebRepresentation.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebXmlMapperFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/WebFunctionalHandlerAdapter.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamingExecutorConfiguration.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadMvcConfiguration.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadSettings.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPointerAuthorization.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchFieldAuthorization.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/GapAndDuplicateGuard.java
src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadProfile.java
src/main/java/dev/caskeleton/adapter/inbound/web/auth/AuthenticatedPrincipal.java
src/main/java/dev/caskeleton/adapter/inbound/web/auth/JwtToAuthenticatedPrincipalConverter.java
src/main/java/dev/caskeleton/adapter/inbound/web/auth/RestrictedPathRule.java
src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java
src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetCatalog.java
src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetMeter.java
src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebRequestBudget.java
src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicy.java
src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicyCatalog.java
src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebVaryPolicy.java
src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadEvaluator.java
src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ETags.java
src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTag.java
src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTagCodec.java
src/main/java/dev/caskeleton/adapter/inbound/web/conditional/MutationPreconditionEvaluator.java
src/main/java/dev/caskeleton/adapter/inbound/web/conditional/WebPreconditionFailedException.java
src/main/java/dev/caskeleton/adapter/inbound/web/core/ActorContext.java
src/main/java/dev/caskeleton/adapter/inbound/web/core/ExternalRequestContext.java
src/main/java/dev/caskeleton/adapter/inbound/web/core/TenantContext.java
src/main/java/dev/caskeleton/adapter/inbound/web/core/WebOperationName.java
src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestContext.java
src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestId.java
src/main/java/dev/caskeleton/adapter/inbound/web/core/WebTraceId.java
src/main/java/dev/caskeleton/adapter/inbound/web/cursor/CursorException.java
src/main/java/dev/caskeleton/adapter/inbound/web/envelope/EnvelopeBodyAdvice.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/BudgetProblemMapper.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/ClientSafeErrorMessages.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/ErrorResponseFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/GlobalExceptionHandler.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalog.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCode.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/SafeProblemDetailExtensions.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/ThrottleProblemWriter.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/ValidationIssue.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblem.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemSanitizer.java
src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidenceTracker.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/config/MvcTransferExecutorConfiguration.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/http/ZeroCopyEligibility.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/nginx/DefaultNginxInternalUriMapper.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/nginx/NginxDownloadStrategy.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/nginx/NginxInternalUriMapper.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/problem/FileserverExceptionHandler.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/problem/FileserverProblem.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/problem/FileserverProblemFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/reactive/FileserverReactiveConfiguration.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/security/FileserverRequestContextFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/fileserver/tus/TusChecksumVerifier.java
src/main/java/dev/caskeleton/adapter/inbound/web/filter/CacheControlFilter.java
src/main/java/dev/caskeleton/adapter/inbound/web/filter/RequestLoggingFilter.java
src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalOrigin.java
src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalPrefix.java
src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriBuilder.java
src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriPolicy.java
src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/FingerprintHeaderPolicy.java
src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKeySupport.java
src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/SemanticRequestFingerprintFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/WebIdempotencyGate.java
src/main/java/dev/caskeleton/adapter/inbound/web/json/BoundedJsonFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/json/WebJsonProfile.java
src/main/java/dev/caskeleton/adapter/inbound/web/json/WebObjectMapperFactory.java
src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebStableModule.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformSettings.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletRequest.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletResponse.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetExceptionHandler.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetFilter.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextArgumentResolver.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextHolder.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/error/WebMvcProblemExceptionHandler.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcEvidenceFilter.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcRequestIdFilter.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/operation/OperationHttpController.java
src/main/java/dev/caskeleton/adapter/inbound/web/mvc/throttle/WebMvcThrottleFilter.java
src/main/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/ExternalRequestUrlResolver.java
src/main/java/dev/caskeleton/adapter/inbound/web/observability/HeaderSanitizer.java
src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAccessLogger.java
src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditAction.java
src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditEvent.java
src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditPublisher.java
src/main/java/dev/caskeleton/adapter/inbound/web/openapi/CursorSchemaContributor.java
src/main/java/dev/caskeleton/adapter/inbound/web/openapi/ProblemSchemaContributor.java
src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiBreakingPolicy.java
src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiDiffResult.java
src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiProfile.java
src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGate.java
src/main/java/dev/caskeleton/adapter/inbound/web/operation/InMemoryWebOperationCatalog.java
src/main/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationCatalog.java
src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationAccessPolicy.java
src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationQueryService.java
src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterFingerprint.java
src/main/java/dev/caskeleton/adapter/inbound/web/pagination/HmacWebCursorCodec.java
src/main/java/dev/caskeleton/adapter/inbound/web/pagination/PageValidationException.java
src/main/java/dev/caskeleton/adapter/inbound/web/pagination/UnsupportedQueryVocabularyException.java
src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorCodec.java
src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorException.java
src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorKeyRing.java
src/main/java/dev/caskeleton/adapter/inbound/web/proxy/ForwardedHeaderSanitizer.java
src/main/java/dev/caskeleton/adapter/inbound/web/proxy/NormalizedForwardedHeaders.java
src/main/java/dev/caskeleton/adapter/inbound/web/proxy/TrustedProxyPolicy.java
src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/EdgeRateLimitTransportBridge.java
src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitInterceptor.java
src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitWebConfig.java
src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/VersionedEdgeSubjectPseudonymizer.java
src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/WebRateLimiter.java
src/main/java/dev/caskeleton/adapter/inbound/web/security/AuthenticationView.java
src/main/java/dev/caskeleton/adapter/inbound/web/security/SecurityIdentity.java
src/main/java/dev/caskeleton/adapter/inbound/web/security/UntrustedTenantInputException.java
src/main/java/dev/caskeleton/adapter/inbound/web/security/WebActorContextResolver.java
src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCorsPolicyValidator.java
src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCsrfPolicyResolver.java
src/main/java/dev/caskeleton/adapter/inbound/web/security/WebSecurityContextBridge.java
src/main/java/dev/caskeleton/adapter/inbound/web/security/WebTenantContextResolver.java
src/main/java/dev/caskeleton/adapter/inbound/web/settings/CorsSettings.java
src/main/java/dev/caskeleton/adapter/inbound/web/settings/SecuritySettings.java
src/main/java/dev/caskeleton/adapter/inbound/web/versioning/PathApiVersionResolver.java
src/main/java/dev/caskeleton/adapter/inbound/web/versioning/SunsetViolationException.java
src/main/java/dev/caskeleton/adapter/inbound/web/versioning/UnsupportedApiVersionException.java
src/main/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java
src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/BoundedServerWebExchange.java
src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/WebFluxBudgetFilter.java
src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextAccessor.java
src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextFilter.java
src/main/java/dev/caskeleton/adapter/inbound/web/webflux/error/WebFluxProblemExceptionHandler.java
src/main/java/dev/caskeleton/adapter/inbound/web/webflux/operation/ReactiveOperationHttpController.java
src/main/java/dev/caskeleton/adapter/inbound/web/webflux/throttle/WebFluxThrottleFilter.java
test:
src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedRollbackIT.java
src/test/java/dev/caskeleton/adapter/inbound/web/error/NoResourceFoundErrorHandlingTest.java
src/test/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalogTest.java
src/test/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactoryTest.java
src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModuleBoundaryTest.java
src/test/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfigurationTest.java
src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGateTest.java
src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiSnapshotTest.java
src/test/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationExceptionMapperTest.java
src/test/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfigurationTest.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebArchitectureRules.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/WebBudgetContract.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractRecording.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractSuite.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireOutcome.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireProbe.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/HttpResponseLossFixture.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/IdempotencyResponseLossContract.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ResponseLossFixture.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/WebFaultInjector.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationHttpContract.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineOrderContract.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadAndShutdownContract.java
src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/WebThrottleHttpContract.java
src/testkit/java/dev/caskeleton/webtestkit/BudgetFixtureApplication.java
src/testkit/java/dev/caskeleton/webtestkit/ThrottleFixtureApplication.java
기타:
CLAUDE.md
README.md
src/app-bootstrap/build.gradle
src/build.gradle
src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/JettyWebBudgetIT.java
src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/JettyWebThrottleIT.java
src/sample-portfolio/build.gradle
src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveBudgetFixtureApplication.java
src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveWebBudgetIT.java
src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveThrottleFixtureApplication.java
src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveWebThrottleIT.java
해석되지 않은 인용 (12종) — 외부 타입·문서상 약칭 등:
application.yml
evidence/raw/203-composition-root-scan-boundary.txt
evidence/raw/188-inbound-web-module-inventory.txt
evidence/raw/189-inbound-web-governance-probes.txt
evidence/raw/190-inbound-web-error-probes.txt
evidence/raw/191-inbound-web-security-probes.txt
evidence/raw/193-inbound-web-capacity-probes.txt
evidence/raw/192-inbound-web-package-reachability.txt
evidence/raw/194-inbound-web-idempotency-probes.txt
evidence/raw/195-inbound-web-representation-probes.txt
evidence/raw/196-inbound-web-codec-probes.txt
evidence/raw/197-inbound-web-observability-probes.txt
```