Files
document-haness/docs/clean-architecture-backend-template/analysis/11-adapter-outbound-httpclient.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

838 lines
75 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.
# 11 · adapter-outbound-httpclient 완전 해부
> 상태: COMPLETE — 2026-08-31 재검증
> 기준 revision(최초 분석): `a24ece9cf797f7ea647e33bf846b115208ed1ba5`
> 재검증 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` · 리프 소스 변경 **0** (`EVD-333`)
> 분석 범위: `src/adapter/outbound/httpclient` · Gradle `:adapter:outbound:httpclient`
> SSOT owner: `adapter-outbound-httpclient`
> integration/family document: `analysis/00-project-overview.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity · denominator · coverage ledger
- registered leaf id: `adapter-outbound-httpclient`
- canonical state `analysisFile`: `analysis/11-adapter-outbound-httpclient.md` (이 문서)
- source path: `src/adapter/outbound/httpclient`
- registry `allowed_dependencies`: `["domain-core", "application-core", "shared-contract", "adapter-outbound-support"]`
- registry `runtime_memberships`: `["app-bootstrap"]`
- 재검증 증거: `EVD-332`(§51 진단 교정), `EVD-333`(소스 드리프트 0)
**고정 섹션 골격과의 대응.** 이 문서는 leaf 안에 7개 하위 범위가 있어 하위 범위별로 번호를 이어 쓴다.
고정 골격의 각 역할은 다음 절이 맡는다 — 재검증은 번호를 바꾸는 대신 대응을 명시한다.
| 고정 골격 | 이 문서 |
|---|---|
| §0 SSOT identity / denominator / coverage ledger | 이 절 + §52 |
| 하위 범위 denominator | §1, §10, §19, §27, §34, §41, §45 |
| §12 negative-space probes 4종 | §7, §16, §24, §31, §38, §48 (하위 범위별) |
| §14 evidence table | §55 + `EVD-332`, `EVD-333`, evidence `167`~`175` |
| §16 확인한 것 / 확인하지 못한 것 | §55 |
| §17 손볼 것 (P1/P2/P3) + 확인된 설계 | §53 |
tracked file **370개** — main 260 (15,004 LOC), test 62 (6,049), testkit source set 35 (2,754), `httpClientPerformanceTest` 7 (495), `jmh` 2 (130), governance 4. 총 약 24.4k LOC.
```json
{ "id": "adapter-outbound-httpclient",
"gradle_path": ":adapter:outbound:httpclient",
"allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"],
"runtime_memberships": ["app-bootstrap"] }
```
cache-redis와 같은 형태다 — 설계 문서는 이 플랫폼을 **19개 Gradle 모듈**로 나누지만 "This repository's fail-closed module registry outranks that layout, so the module boundaries are packages under `dev.caskeleton.adapter.outbound.httpclient` and `HttpClientModuleBoundaryTest` enforces the design's module dependency table."
main 패키지 배치(260):
| 계층 | 패키지 | 파일 |
|---|---|---|
| 복원력 | `resilience` | 39 |
| 프로파일/조립 | `profile` | 29 |
| 공개 API | `api/error` 23 · `api/result` 9 · `api/body` 7 · `api/operation` 6 · `api` 5 | 50 |
| 보안 | `security` 21 · `auth` 18 | 39 |
| 게이트웨이 | `restclient` 19 · `webclient` 17 | 36 |
| 서비스/동적 | `service` 14 · `dynamic` 11 · `observation` 6 · `migration` 4 | 35 |
| 전송 | `transport` 10 · `reactor` 6 · `apache` 5 · `jdk` 4 · `http3` 4 · `spring7` 3 | 32 |
`build.gradle`이 이 저장소에서 가장 긴 근거 주석을 갖는다. 몇 가지가 특히 이 leaf의 성격을 보여 준다.
- **HTTP/3가 `compileOnly`인 이유** — 이전에는 `implementation`이어서 "the whole QUIC/HTTP-3/QPACK stack on every deployment's runtime classpath — megabytes and an attack surface — to serve a feature the Stable starter never auto-configures."
- **Jackson 3가 optional이 아닌 이유** — `RestClientResponseReader.defaultConverters()`가 Spring 7 컨버터를 만드는데 생성자 서명에 타입이 없어 javac가 못 잡았고, Jackson 3가 test/jmh 클래스패스에만 있어서 "the reader worked in every test and would have thrown `NoClassDefFoundError` in any deployment that did not happen to have Jackson 3 from somewhere else."
- **Resilience4j는 실행 원시연산만** — "HTTP retry *eligibility* is owned by this module (design D-09) and **never delegated to a generic retry library**."
- **testkit이 별도 source set인 이유** — 세 lane이 소비하는데 그중 하나만 test lane이고, `jmh``sourceSets.test.output`을 참조하면 IDE가 모델링하지 못해 "every testkit reference in the benchmarks was an unresolved type in the editor while the build was green."
- **lane 5개 중 둘이 `failOnNoDiscoveredTests`를 잃었던 기록** — "the cross-transport contract suite and the SSRF/credential-leak suite would each have reported success on discovering nothing. Declaring the lanes removes the opportunity: the convention has no opt-out."
- **`check`가 무엇을 게이트하는지** — 특수 lane들이 "existed but hung off nothing: `check` ran only `test`". 지금은 hermetic한 넷(contract·security·BlockHound·spring62 surface)이 `check`에 붙어 있고, Docker가 필요한 fault lane과 머신 의존적인 performance lane은 의도적으로 빠져 있다.
### 하위 범위 ledger
| # | 범위 | main | test | 기타 | 합 | 상태 |
|---|---|---|---|---|---|---|
| 1 | governance + `profile/**` — 프로파일 검증 · 런타임 레지스트리 · 조립 | 29 | 2 | 4 | 35 | **COMPLETE** |
| 2 | `api/**` — error · result · body · operation · 루트 | 50 | 5 | | 55 | **COMPLETE** |
| 3 | `resilience/**` — 재시도 자격 · 회로 · 격벽 · 파이프라인 | 39 | 8 | | 47 | **COMPLETE** |
| 4 | `restclient/**` + `webclient/**` — 블로킹/리액티브 게이트웨이 | 36 | 10 | | 46 | **COMPLETE** |
| 5 | `security/**` + `auth/**` — SSRF 방어 · TLS 재료 · 자격증명 | 39 | 7 | | 46 | **COMPLETE** |
| 6 | `service` + `dynamic` + `observation` + `migration` + contract/architecture test | 35 | 21 | | 56 | **COMPLETE** |
| 7 | 전송 6종 + testkit / performance / jmh source set | 32 | 9 | 44 | 85 | **COMPLETE** |
| | **TOTAL** | **260** | **62** | **48** | **370** | **7 / 7** |
manifest: `evidence/raw/167-httpclient-module-inventory.txt`.
---
## 1. Sub-scope 01 범위와 denominator
> 내부 상태: COMPLETE — **35 / 35 FULL_READ**
> 범위: governance 4 + `profile/**` main 29 (1,271 LOC) + 전용 test 2
> 역할: Named Client Profile의 fail-closed startup 검증과 런타임 세대(generation) 레지스트리
manifest와 probe: `evidence/raw/168-httpclient-profile-probes.txt`.
## 2. `ClientProfileValidator` — 34개 위반 코드가 각각 과거 사고를 적는다
이 저장소에서 본 가장 조밀한 설정 검증기다. `validate(profile, environment)`가 11개 검사 그룹을 돌리고 결과를 정렬해 "a configuration error reports deterministically across runs and machines"를 보장한다.
특히 이 leaf에서만 보이는 태도가 하나 있다 — **바인딩은 되지만 어떤 전송에도 닿지 않는 설정을 무시하지 않고 거부한다.**
> "Three of them had no consumer anywhere: `timeout.dns`, `proxy.credential-provider` and `proxy.import-ambient-no-proxy`. An operator who set a DNS timeout believed resolution was bounded and it was not; one who named a proxy credential provider believed the proxy was authenticated and it was not… **the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing.**"
기본값은 통과시키므로 "only a deliberate, unmet request fails"다. 앞선 열 개 모듈에서 반복해 발견한 "선언되었으나 아무것도 하지 않는 설정" 패턴을, 이 모듈은 **명시적 거부로 처리한다.**
같은 논리가 관측 설정에도 적용된다 — `full-url-recording`은 아무도 읽지 않았고 `body-logging`은 actuator 보고에만 닿았다. "Leaving them that way is the worse of the two failure modes — an operator who set them believed the platform was recording full URLs or bodies, and an operator who left them false had no assurance that it was not." 지금은 production에서 둘 다 거부된다.
나머지 검사도 각각 구체적인 다운그레이드를 막는다.
- **`REACTIVE_REDIRECT_UNSUPPORTED`** — 엔진 리다이렉트는 모든 전송에서 꺼져 있고 hop별 재검증을 하는 coordinator는 블로킹 스택에만 있다. 리액티브 프로파일이 redirect를 켜면 "the caller received the 302 as an ordinary response and read its empty body as the answer." 거부가 정직한 결과다 — "a configured guarantee that silently does nothing is worse than one the platform declines to offer."
- **`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`** — `ProtocolIntent`가 "H2를 선호"와 "H2를 요구"를 구분한다. JDK 클라이언트는 `HTTP_2`를 선호로 다뤄 조용히 HTTP/1.1로 협상하고 Apache classic은 HTTP/1.1 전용이라, `HTTP_2`만 선언한 프로파일이 "ran happily over HTTP/1.1, and nothing anywhere said so."
- **`TLS_PROTOCOL_SET_REQUIRED`** — 빈 집합이 통과하면 JVM 기본값이 선택되어, "a profile that meant to pin a TLS floor got whatever the platform default happened to be — including TLS 1.2 on a profile whose operator had deliberately emptied the list to 'tighten' it."
- **`DYNAMIC_TARGET_PROXY_UNSUPPORTED`** — 포워드 프록시는 호스트명을 자기 쪽에서 다시 해석하므로 "The SSRF defence would be present, correct, and bypassed."
- **`RETRY_POLICY_CONTRADICTS_ATTEMPTS`** — `policy`를 실행 경로에서 아무도 읽지 않아 "the actuator could report `retryPolicy: none` for a profile that was retrying three times."
**배선 확인.** `app-bootstrap``HttpClientStartupValidator:37`이 이 검증기를 생성한다(`168-...` §8.1). 이 leaf는 앞선 cache-redis와 달리 **실제로 조립돼 있다** — app-bootstrap에 이 leaf를 위한 auto-configuration 12개가 있다.
## 3. `ClientRuntimeRegistry` — 세대 교체가 틈으로 관측되지 않는다
"A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is never observable as a gap." `acquire`는 관측한 세대가 예약 직전에 draining으로 넘어가면 **새로 발행된 세대에 대해 재시도**한다.
과거 누수 두 건이 코드와 주석에 남아 있다.
- 교체된 세대가 `runtimes`에서 빠지고 스케줄된 drain 작업만 소유하게 되어, 그 작업이 발화하기 전에 레지스트리가 닫히면 "leaked the whole generation — and the resource-bound suite could not see it, because nothing enumerated it." 지금은 `retired` 집합이 추적한다.
- `close()``forEach`로 닫다가 첫 예외에서 멈춰 "a single misbehaving pool left every remaining connection, thread and socket open — **shutdown leaked more the worse the failure was.**" 지금은 전부 닫고 실패를 suppressed로 모은다.
## 4. P3 — `close()`가 실패하면 drain 스케줄러 스레드가 남는다
§3의 두 번째 수정이 절반만 적용돼 있다.
```java
retired.clear();
runtimes.clear();
if (firstFailure != null) {
throw firstFailure; // ← 여기서 던진다
}
ScheduledExecutorService scheduler = drainScheduler.getAndSet(null); // ← 도달하지 않는다
if (scheduler != null) {
// Await termination: a registry that returns while its drain thread is still alive would
// leak a thread per rotation cycle, which the resource-bound suite exists to catch.
scheduler.shutdownNow();
}
```
`forceClose()` 중 하나라도 던지면 `throw firstFailure`가 먼저 실행되어 **스케줄러 종료 블록에 도달하지 않는다.** `drainScheduler`도 비워지지 않으므로 스레드가 살아 있고, 클래스 javadoc이 "The single scheduled executor… is shut down with the registry so **no thread outlives it**"이라고 적은 성질이 그 경로에서 성립하지 않는다.
바로 위 루프는 "Every runtime is closed even when one refuses"를 위해 예외를 모으도록 고쳐졌는데, 같은 논리가 스케줄러에는 적용되지 않았다. test `closingTheRegistryReleasesEveryGenerationAndLeavesNoThread`는 실패 없는 경로만 검증한다.
**판정: P3.** 스레드가 데몬이라 JVM 종료를 막지는 않고, 레지스트리당 하나이며, 닫기 실패라는 조건이 필요하다. 그러나 주석이 "a thread per rotation cycle"을 명시적 위험으로 적고 resource-bound suite가 그것을 잡으려 존재하는데, 정확히 그 누수가 실패 경로에 남아 있다. 수정은 스케줄러 종료를 `finally`로 옮기는 한 줄이다.
## 5. P3 — `POOL_ROUTE_EXCEEDS_TOTAL` 위반 코드는 발화할 수 없다
```java
// PoolSettings 정규 생성자
if (maxConnectionsPerRoute > maxTotalConnections) {
throw new IllegalArgumentException("per-route pool must not exceed the total pool");
}
// ClientProfileValidator:192
if (profile.pool().maxConnectionsPerRoute() > profile.pool().maxTotalConnections()) {
out.add(violation("POOL_ROUTE_EXCEEDS_TOTAL", profile, "pool.max-connections-per-route"));
}
```
`ClientProfile`은 이미 구성된 `PoolSettings`를 들고 있고, 그 record는 route > total인 상태로 **존재할 수 없다**. production의 유일한 생성 지점(`HttpClientProfileFactory:83`)도 같은 생성자를 지난다. 따라서 검증기의 이 분기는 도달 불가이고, 그 코드의 test 참조가 0인 것도 그래서다(§6).
sub-scope 03(redis)의 `requireIdentifier` 죽은 분기와 같은 모양이다 — **선행 검증이 후행 검증을 가린다.** 다만 결과가 다르다: record 생성자는 `IllegalArgumentException`을 던져 startup을 즉시 실패시키므로, 검증기가 수집해 정렬된 목록으로 보고하는 **결정적 진단 형식을 이 한 조합만 받지 못한다.** 주석이 설명하는 실제 위험("on Reactor — where the per-route knob is the only one that exists — it silently becomes the effective limit")은 여전히 막혀 있다. **P3.**
## 6. P3 — 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않는다
`ClientProfileValidator`가 내는 코드는 **34종**이다. 저장소 전체의 `test`/`testkit` source set에서 그 문자열을 참조하는 파일 수를 세면(`168-...` §8.4b):
| test 참조 | 코드 수 | 예 |
|---|---|---|
| 1건 이상 | **12** | `TRUST_ALL_FORBIDDEN`(3) · `HOSTNAME_VERIFICATION_REQUIRED`(2) · `PLAINTEXT_*`(2) · `HTTP3_STABLE_FORBIDDEN`(2) … |
| **0건** | **22** | `DYNAMIC_TARGET_PROXY_UNSUPPORTED` · `FULL_URL_RECORDING_FORBIDDEN` · `BODY_LOGGING_FORBIDDEN` · `REACTIVE_REDIRECT_UNSUPPORTED` · `HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED` · `TLS_PROTOCOL_SET_REQUIRED` · `DNS_TIMEOUT_UNSUPPORTED` · `PROXY_CREDENTIAL_UNSUPPORTED` · `RETRY_POLICY_CONTRADICTS_ATTEMPTS` · `MISSING_PRODUCTION_SETTING` · `ALLOWED_HOST_MISMATCH` · `ALLOWED_PORT_MISMATCH` … |
문제는 개수가 아니라 **어느 쪽이 비어 있는가**다. 확인되지 않는 22종에는 §2가 인용한 사고 유래 가드가 거의 전부 들어 있다 — SSRF 우회(`DYNAMIC_TARGET_PROXY_UNSUPPORTED`), 로그의 PII(`FULL_URL_RECORDING_FORBIDDEN`·`BODY_LOGGING_FORBIDDEN`), 조용한 프로토콜 다운그레이드(`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`·`TLS_PROTOCOL_SET_REQUIRED`), 아무 일도 하지 않는 설정(`DNS_TIMEOUT_UNSUPPORTED`·`PROXY_CREDENTIAL_UNSUPPORTED`), 그리고 리다이렉트를 조용히 무시하는 경우(`REACTIVE_REDIRECT_UNSUPPORTED`).
test 두 개(`ClientProfileValidatorTest` 106줄)가 그룹으로 몇 개를 묶어 확인하지만(`rejectsSimpleFactoryAndUnacknowledgedHttp3AndJdkRoutePool`), 나머지 22종은 분기를 지워도 초록으로 남는다. 코드 자체는 현재 옳다 — 위험은 회귀다. **P3.** 수정은 `@ParameterizedTest`로 코드별 최소 케이스를 한 벌 놓는 것이고, 34종이 모두 결정적으로 정렬된 목록을 내므로 그 형태가 자연스럽다.
## 7. Negative-space probes — sub-scope 01
- **8.1 reachability**: `ClientProfileValidator`가 app-bootstrap `HttpClientStartupValidator`에 배선됨 확인. `ClientRuntimeRegistry`는 leaf 내부 10곳 + app-bootstrap 5곳에서 소비.
- **8.2 계약 ↔ 구현**: 위반 코드 34종 전수 열거와 각 코드가 막는 다운그레이드를 주석에서 추적(§2).
- **8.3 중복 mechanism**: `PoolSettings` 생성자와 validator가 같은 규칙을 두 번 검사하고 후자가 도달 불가(§5).
- **8.4 test 대비 표면**: 코드 34 vs test 참조 12(§6). `close()` 실패 경로의 스케줄러 도달성(§4).
## 8. Sub-scope 01 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P3** | `ClientRuntimeRegistry.close()``throw firstFailure`를 스케줄러 종료보다 먼저 실행해, 닫기 실패 시 drain 스레드가 남는다 — 클래스 javadoc의 "no thread outlives it"과 어긋난다 | `forceClose()`가 던지는 종료 |
| **P3** | `POOL_ROUTE_EXCEEDS_TOTAL` 분기가 `PoolSettings` 생성자에 가려 도달 불가 | 진단 형식 |
| **P3** | 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않고, 그 22종에 사고 유래 보안 가드가 대부분 포함된다 | 회귀 |
## 9. Sub-scope 01 완료 조건
- denominator 35 / 35 FULL_READ (`168-...` OWNED FILES)
- §8.1~§8.4 probe 수행, 중복 mechanism 1건 · 도달성 2건 조사
- 위반 코드 34종을 전수 열거해 test 참조 수를 계수(§6)
- 소스 미변경
---
## 10. Sub-scope 02 범위와 denominator
> 내부 상태: COMPLETE — **55 / 55 FULL_READ**
> 범위: `api/error` 23 + `api/result` 9 + `api/body` 7 + `api/operation` 6 + `api` 루트 5 (main 50, 1,552 LOC) + 전용 test 5
> 역할: 이 플랫폼의 공개 어휘 — 무엇을 요청하고, 무엇을 증거로 삼고, 무엇이 실패했는지
manifest와 probe: `evidence/raw/169-httpclient-api-probes.txt`.
## 11. 증거(evidence) 모델이 이 모듈의 중심이다
세 개의 enum이 재시도 안전성 판단의 전부를 담는다.
**`ExecutionEvidence`** — `NOT_SENT` · `SENT_NO_RESPONSE` · `RESPONSE_RECEIVED` · `PARTIAL_RESPONSE`. 규칙 한 줄이 붙어 있다 — "`NOT_SENT` is only used when a stage failure proves the request never reached the server. **A generic engine I/O failure is never upgraded to `NOT_SENT`.**"
**`AttemptStage`** — 12단계에 명시적 `order``provesNotSent` 플래그가 붙는다. VALIDATION(0)부터 PROXY_CONNECT(6)까지가 `provesNotSent=true`이고 REQUEST_HEADERS(7)부터는 false다. 즉 **요청 헤더를 쓰기 시작한 순간부터는 "보내지 않았다"를 증명할 수 없다**는 규칙이 데이터로 표현된다. `order`가 enum 서수와 분리된 이유도 적혀 있다 — "the progress tracker forbids regression and the evidence classifier reads the rank, so neither depends on enum declaration ordinals."
**`BodyReplayability`** — `REPLAYABLE(3)` · `REOPENABLE(2)` · `ONE_SHOT(1)` · `UNKNOWN(0)``weakest(left, right)` 결합 연산이 있어 복합 본문이 가장 약한 쪽을 따른다. `strength`도 서수와 분리돼 있다.
그리고 `OperationIdempotency`가 네 번째 축을 더한다 — 규칙 한 줄이 설계 결정 D-09다: "**Retry eligibility never derives safety from the HTTP method alone.**" `CONTRACT_IDEMPOTENT`는 "메서드는 표준 멱등이 아니지만 업스트림 계약이 반복 안전을 보장한다"를 표현 가능하게 만든다.
## 12. 저카디널리티·무비밀 원칙이 타입 수준에서 강제된다
`HttpFailureMetadata`의 javadoc이 **제외 목록을 열거**한다 — "Full URL, query values, expanded path variables, request or response bodies, `Authorization` / `Cookie` / API key values, the raw idempotency key, client secrets, and resolved IPs are deliberately absent."
그리고 `toString()` 재정의 세 곳이 각각 과거 유출을 적는다.
- **`HttpOperation.toString()`** — 생성된 record toString이 "every header value, the body object and the expanded URI variables"를 찍었고, "an `Authorization` header, a request payload and a customer identifier were **one stack trace away from the log aggregator**." 지금은 헤더 **키 집합**과 본문 **클래스 이름**만 낸다.
- **`ObjectBody.toString()`** — "the generated `toString` rendered the payload itself… which for an outbound call is **by definition someone else's data**." 지금은 `ObjectBody[Type, media/type, REDACTED]`.
- **`IdempotencyKey.toString()`** — `IdempotencyKey[REDACTED]`, 원값은 명시적 접근자로만.
`api/body`의 나머지 넷(`BodySource`·`EmptyBody`·`IOSupplier`·`OneShotStreamBody`·`ReopenableStreamBody`)은 toString을 재정의하지 않는데, 그 record 성분이 스트림 핸들·공급자·길이·미디어 타입이라 기본 toString이 데이터를 찍지 않는다. 값을 들고 있는 둘(`ByteArrayBody`·`ObjectBody`)만 재정의돼 있다 — 전수 확인했다(`169-...` §8.1).
`HttpOperation`**URI 템플릿만** 들고 다닌다("never an expanded URL: observability tags and failure metadata must stay low-cardinality, and the security layer expands components itself"). `TRACE`는 enum에 없고, test `exposesHttpMethodSemanticsWithoutTrace``HttpMethod.values()``"TRACE"`가 없음을 반사로 확인한다 — 저장소 전체에서 `TRACE` 문자열의 다른 참조는 그 test 한 줄뿐이다.
`FailureCategory` 24종이 전송 중립 어휘이고 "Every transport classifier maps engine-specific exceptions onto exactly one of these values so Apache, JDK, Reactor Netty, and Jetty produce **identical** retry and observation semantics." `permanent()`가 8종을 영구 실패로 분류한다. `api/error`에는 예외 21종 + 메타데이터 1종이 있고 전부 `HttpClientException`을 상속하며 metadata를 노출한다(test `everyStableFailureExposesMetadata`).
## 13. `ObjectBody`의 재생 가능성 판정 — 값의 성질이지 코덱의 성질이 아니다
이 sub-scope에서 가장 신중한 코드다. 과거 동작과 그 결과가 적혀 있다.
> "Every `ObjectBody` used to report `REPLAYABLE` unconditionally, on the strength of a javadoc line asking callers not to mutate the value afterwards. A mutable DTO handed to the platform and then changed by the caller — a builder reused across calls, a collection the caller kept a reference to — produced a retry that sent **different bytes under the same idempotency key**, which is the one thing a replay must never do."
지금은 `deeplyImmutable(value)`가 구조적으로 판정한다 — 문자열·숫자·불리언·문자·enum·UUID·`Temporal`은 통과, 컬렉션과 맵은 **JDK의 불변 뷰인지 이름으로 확인**하고 원소까지 재귀, record는 모든 성분을 반사로 재귀 확인, 그 외는 전부 `ONE_SHOT`. 반사가 실패하면 "A component the platform cannot inspect cannot be certified, and an uncertified body is one-shot rather than optimistically replayable."
컬렉션 판정이 이름 기반인 이유도 적혀 있다 — "`List.of(...)` and `Collections.unmodifiableList(...)` return package-private classes with no shared marker interface. An ordinary `ArrayList` the caller still holds is exactly the case this must not accept."
test 넷이 네 갈래를 고정한다 — `anImmutableRecordReplays`, `aMutableValueIsOneShot`, `aRecordWrappingMutableStateIsOneShot`, `anArbitraryBeanIsOneShot`.
## 14. P3 — `Number`가 허용 목록에 있어 가변 숫자 타입이 REPLAYABLE로 인증된다
`deeplyImmutable`의 첫 분기가 `candidate instanceof Number`를 무조건 통과시킨다.
```java
if (candidate instanceof String
|| candidate instanceof Number // ← java.util.concurrent.atomic.* 가 전부 여기 들어온다
|| candidate instanceof Boolean
) {
return true;
}
```
`java.util.concurrent.atomic``AtomicInteger`·`AtomicLong`·`LongAdder`·`DoubleAdder`·`LongAccumulator`·`DoubleAccumulator`는 모두 `Number`를 상속하는 **가변** 타입이다. 따라서 성분에 `AtomicInteger`를 가진 record(또는 `ObjectBody`의 값 자체가 `AtomicInteger`인 경우)는 `REPLAYABLE`로 인증되고, 호출자가 그 사이에 값을 증가시키면 재시도가 **같은 idempotency key로 다른 바이트를 보낸다** — 이 검사가 존재하는 이유로 인용된 바로 그 결과다.
같은 형태의 좁은 구멍이 `Temporal`에도 있다(java.time 구현체는 불변이지만 사용자 정의 `Temporal` 구현은 그렇지 않을 수 있다). 그러나 `Number`가 훨씬 현실적이다 — DTO에 카운터를 두는 것은 드물지 않다.
**판정: P3.** 도달성이 좁고(원자 카운터를 요청 DTO에 넣어야 한다), 검사 전체의 방향은 보수적이며, `aMutableValueIsOneShot` test가 일반적인 가변 객체는 잡는다. 기록하는 이유는 이 검사가 "records, enums, strings, boxed primitives and immutable collection views replay; **anything else** is treated as one-shot"라고 선언하는데 `Number` 한 줄이 그 선언보다 넓기 때문이다. 수정은 boxed primitive 여덟 종과 `BigInteger`/`BigDecimal`을 명시하거나, `java.util.concurrent.atomic` 패키지를 제외하는 것이다.
## 15. P3/기록 — 재생 가능성 판정이 호출마다 반사로 재계산된다
`replayability()`는 캐시 없이 매번 `deeplyImmutable(value)`를 돌리고, record 성분마다 `accessor.setAccessible(true)` + `invoke`를 수행한다. 재시도 엔진은 물리 시도마다 이 값을 읽으므로, 중첩이 깊은 DTO에서는 시도 수 × 트리 크기만큼 반사 호출이 일어난다.
정확성 문제는 아니다 — 결과가 값에만 의존하므로 캐시해도 같다. 이 모듈에 `httpClientPerformanceTest` source set이 따로 있고 "assert on resource bounds rather than behaviour"를 목적으로 하는데, 그 lane이 이 경로를 재는지는 sub-scope 07에서 확인한다. **P3/기록.**
## 16. Negative-space probes — sub-scope 02
- **8.1 값 유출 표면**: `api/body`의 7종 중 값을 들고 있는 둘만 `toString`을 재정의했고 나머지는 기본 toString이 데이터를 찍지 않음을 전수 확인(§12).
- **8.2 허용 목록의 경계**: `deeplyImmutable`의 7개 `instanceof` 분기와 컬렉션 이름 판정을 전수 검토, `Number`에서 구멍 발견(§14).
- **8.3 어휘 ↔ 예외 대응**: `FailureCategory` 24종과 `api/error` 예외 21종의 대응 확인.
- **8.4 부재 주장**: `TRACE`가 enum·구현·부트스트랩 어디에도 없고 참조는 그 부재를 확인하는 test 한 줄뿐(§12).
## 17. Sub-scope 02 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P3** | `ObjectBody.deeplyImmutable``instanceof Number``AtomicInteger`·`LongAdder` 등 가변 숫자 타입을 `REPLAYABLE`로 인증한다 — 재시도가 같은 idempotency key로 다른 바이트를 보낼 수 있다 | 원자 카운터를 담은 요청 DTO |
| P3/기록 | `replayability()`가 호출마다 반사로 재계산되고 캐시가 없다 | 깊은 DTO · 다중 시도 |
## 18. Sub-scope 02 완료 조건
- denominator 55 / 55 FULL_READ (`169-...` OWNED FILES)
- §8.1~§8.4 probe 수행, 허용 목록 경계 조사 1건
- 증거 3축(`ExecutionEvidence`·`AttemptStage`·`BodyReplayability`)과 멱등성 축의 관계를 코드로 추적
- 소스 미변경
---
## 19. Sub-scope 03 범위와 denominator
> 내부 상태: COMPLETE — **47 / 47 FULL_READ**
> 범위: `resilience/**` main 39 (1,746 LOC) + 전용 test 8 (47 test 메서드)
> 역할: 이 플랫폼이 존재하는 이유 — 증거 기반 재시도 자격 판정과 시도별 가드
manifest와 probe: `evidence/raw/170-httpclient-resilience-probes.txt`.
## 20. 재시도 결정표가 순서로 표현돼 있다
`DefaultRetryEligibilityEngine.decide`의 javadoc이 규칙이다 — "The order is the point. Cheap absolute blockers come first (attempts, budget, replayability, first byte, deadline, draining), then ambiguity, then status- and failure-specific rules. **A later rule can never re-enable something an earlier rule forbade.**"
절대 차단 여섯이 먼저다 — 시도 수 소진 · 예산 소진 · 본문 재생 불가 · **첫 바이트 전달됨** · 런타임 draining · 남은 deadline이 최소 시도 예산 이하. 그다음 영구 실패 범주, 그다음 증거, 그다음 상태/실패별.
상태별 규칙에 수정 이력이 붙어 있다.
> "408, 425 and 429 all mean the request reached the upstream and was answered, so repeating one is only safe under the same rule as every other repeat. These three used to skip that check: **a non-idempotent POST answered 429 was retried, and a rate-limited upstream that had already accepted the work got it a second time.** A 429 is a scheduling signal, never a statement that nothing happened."
그리고 `RetryContext.safelyIdempotent()`가 이 모듈의 D-09를 구현한다 — HTTP 메서드는 `RetryContext`**아예 없다**("so a POST with a registered idempotency key and a GET against a non-idempotent RPC endpoint are both handled correctly instead of by method-name folklore"). 키 기반 멱등성은 **키가 실제로 전송됐는지**까지 요구한다.
> "The two used to be conflated: the platform read `idempotencyKey.isPresent()`, concluded the upstream could deduplicate, and retried — while the header was never sent, so the upstream had nothing to deduplicate against and **processed the request twice.** Possession of a key is the caller's intent; transmission is the upstream's ability to honour it."
test `aKeyThatWasNeverSentDoesNotMakeARepeatSafe`가 그것을 고정한다.
`Retry-After`는 남은 deadline 안에 들어갈 때만 존중된다(`allowWithin`), 그리고 존중된 `Retry-After``maxBackoff`로 잘리지 **않는다** — 잘라 버리면 업스트림이 요청한 대기보다 일찍 다시 두드리게 되기 때문이다.
## 21. 가드 순서와 그 근거
`AttemptResiliencePipeline`이 물리 시도마다 **Circuit Breaker → Rate Limiter → Bulkhead → HTTP 호출**을 고정 순서로 적용하고 역순으로 해제한다.
> "The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead permit is spent, otherwise **a dead upstream keeps consuming the quota and concurrency that healthy upstreams need.**"
> "A local rejection (rate limiter or bulkhead) is deliberately *not* recorded as a circuit error: the upstream never saw the request, and **counting our own back-pressure as upstream failure would open the breaker on a healthy dependency.**"
그리고 브레이커가 무엇을 보는지에 대한 수정 이력이 하나 더 있다 — 이전에는 원시 전송만 파이프라인 안에서 돌고 응답→예외 매핑이 밖에서 일어나서 "a 503 completed the call normally, the breaker recorded a success, and **an upstream that answered nothing but 503 never opened its circuit. The thing the breaker is for was the one thing it could not see.**" 지금은 `remoteFailure` 분류기가 반환값을 보고 브레이커에 알린다.
test 47개가 이 규칙들을 촘촘히 덮는다 — `appliesCircuitThenRateLimiterThenBulkheadPerAttempt`, `openCircuitDoesNotConsumeRateOrBulkheadPermit`, `bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError`, `answeredStatusesDoNotRetryANonIdempotentOperation`, `deniesOneShotBodyEvenForPut`, `honorsRetryAfterOnlyInsideDeadline`, `protocolProofOfNonProcessingWinsOverEverything`, `streamAfterGoAwayLastIdIsPeerNotProcessed` 등.
`Http2ProtocolEvidence`는 프로토콜 수준 증거를 다룬다 — `REFUSED_STREAM`과 GOAWAY의 last-stream-id보다 큰 스트림 id는 **피어가 처리하지 않았음의 증명**이라 `NOT_SENT`로 승격되고, 그 이하 id의 리셋은 여전히 모호하다(`streamAtOrBelowGoAwayLastIdStaysAmbiguous`, `aBareStreamResetProvesNothing`).
## 22. P2 — 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다
`AttemptResiliencePipeline.execute`의 진입부다.
```java
83: if (!circuitBreaker.tryAcquirePermission()) { throw HttpCircuitOpenException }
88: if (!rateLimiter.tryAcquirePermission()) {
89: rejections.rateLimited();
90: throw new HttpRateLimitRejectedException(); // ← 회로 permission 미반환
92: }
93: if (!bulkhead.tryAcquire()) {
94: rateLimiter.onCompleted(); // ← rate 토큰은 반환한다
95: rejections.bulkheadRejected();
96: throw new HttpBulkheadRejectedException(); // ← 회로 permission 미반환
98: }
```
83행에서 회로 permission을 얻은 뒤, 88행과 93행의 **로컬 거부 두 경로는 `onSuccess`·`onError`·`releasePermission` 중 어느 것도 부르지 않고 던진다.** `releasePermission`은 이 leaf와 app-bootstrap 어디에도 등장하지 않고(`170-...` §8.1, exit=1), `AttemptCircuitBreaker` 인터페이스에도 그 연산이 없다(`tryAcquirePermission`·`onSuccess`·`onError`·`state` 넷뿐).
Resilience4j에서 이것이 중요한 상태는 **HALF_OPEN**이다. 그 상태의 `tryAcquirePermission()``permittedNumberOfCallsInHalfOpenState` 중 하나를 소비하고, 그 시험 슬롯은 `onSuccess`/`onError`/`releasePermission` 중 하나로만 돌아온다. 아무것도 부르지 않으면 슬롯은 영구히 소비된다.
**실패 시나리오.** 업스트림 장애로 회로가 OPEN → 대기 후 HALF_OPEN 전이 → 트래픽이 돌아오면서 평상시 부하에 맞춰 사이징된 로컬 rate limiter나 bulkhead가 거부 → 그 거부마다 시험 슬롯 하나가 사라진다. 허용된 시험 호출 수만큼 그런 거부가 나면 브레이커는 성공도 실패도 관측하지 못한 채 HALF_OPEN에 머문다. Resilience4j의 `maxWaitDurationInHalfOpenState` 기본값은 0(무한 대기)이므로, **회복한 업스트림에 대해 회로가 닫히지 않는다.** 그리고 이 조건들은 우연히 겹치는 것이 아니라 **회복 순간에 자연히 함께 일어난다** — 회로가 반쯤 열리는 바로 그때 트래픽이 몰린다.
비대칭이 이 finding을 뒷받침한다. bulkhead 거부 경로는 `rateLimiter.onCompleted()`**rate 토큰을 명시적으로 돌려준다**(94행) — 저자가 permit 반환을 의식하고 있었다는 증거다. 세 가드 중 둘은 반환되고 첫 번째만 반환되지 않는다.
test도 그 공백을 그대로 보여 준다 — `openCircuitDoesNotConsumeRateOrBulkheadPermit`(회로가 거부할 때 뒤의 둘을 소비하지 않음)과 `bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError`(bulkhead 거부가 rate를 돌려줌)는 있지만, **"rate/bulkhead 거부가 회로 permission을 돌려준다"는 test는 없다.**
**판정: P2.** 수정은 `AttemptCircuitBreaker``releasePermission()`을 더하고(Resilience4j `CircuitBreaker.releasePermission()`에 위임, `alwaysClosed()`는 no-op) 두 로컬 거부 경로에서 호출하는 것이다.
## 23. Confirmed — `PARTIAL_RESPONSE` 재시도 분기는 도달 가능하다 (후보 → 결함 아님)
`decide`의 40~46행은 `evidence == PARTIAL_RESPONSE`일 때 안전 멱등이면 재시도를 허용한다. 그런데 28행이 이미 `context.firstByteDelivered()`에서 거부한다. `DefaultExecutionEvidenceClassifier``progress.responseBytesDelivered() > 0 || progress.firstByteDelivered()`일 때 `PARTIAL_RESPONSE`를 내고, `AttemptProgressTracker.recordDeliveredBytes`가 두 필드를 **함께** 세팅한다. 여기까지만 보면 40행은 도달 불가로 보인다.
전수 추적한 결과 **도달 가능하다**. `PARTIAL_RESPONSE`를 만드는 곳이 evidence classifier 하나가 아니다 — 전송 실패 분류기 넷이 엔진 예외로부터 직접 그 값을 만든다(`ApacheFailureClassifier:121,136` · `ReactorFailureClassifier:94,114` · `JdkFailureClassifier:83,103` · `JettyHttp3FailureClassifier:41`). 그 경로는 tracker의 `firstByteDelivered`를 보지 않으므로, "엔진은 응답 일부를 봤지만 **호출자에게는 한 바이트도 전달되지 않은**" 상태가 표현된다. 40행 주석이 말하는 구분("A partial response that never reached the caller may still be retried… once a byte was delivered the earlier guard has already denied it")이 실제로 성립한다.
`FirstByteRetryBoundaryTest`가 그 경계를 양쪽에서 고정한다. **결함 아님으로 판정.**
## 24. Negative-space probes — sub-scope 03
- **8.1 permit 반환 대칭**: 세 가드의 획득/반환 경로 전수 추적 — 회로만 반환 없음(§22). `releasePermission` 저장소 전체 매치 0.
- **8.2 분기 도달성**: `PARTIAL_RESPONSE` 생산 지점 전수 조사로 후보를 오탐 판정(§23).
- **8.3 결정표 순서**: 절대 차단 6 → 영구 실패 → 증거 → 상태/실패별의 단조성 확인(§20).
- **8.4 test 밀도**: main 39 파일에 test 8 파일 / 47 메서드. 상태별·증거별·본문별 갈래가 이름으로 고정됨.
## 25. Sub-scope 03 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P2** | rate limiter·bulkhead 로컬 거부 경로가 회로 브레이커 permission을 반환하지 않는다 — HALF_OPEN 시험 슬롯이 소진되어 회복한 업스트림에 대해 회로가 닫히지 않을 수 있다 | 회로 회복 중 로컬 백프레셔가 걸리는 배포 |
## 26. Sub-scope 03 완료 조건
- denominator 47 / 47 FULL_READ (`170-...` OWNED FILES)
- §8.1~§8.4 probe 수행, permit 반환 대칭 조사 1건
- 후보 finding 1건(`PARTIAL_RESPONSE` 분기 도달 불가 의심)을 생산 지점 전수 조사로 **오탐 판정**(§23)
- 소스 미변경
---
## 27. Sub-scope 04 범위와 denominator
> 내부 상태: COMPLETE — **46 / 46 FULL_READ**
> 범위: `restclient/**` 19 + `webclient/**` 17 (main 36, 3,476 LOC) + 전용 test 10
> 역할: 블로킹/리액티브 두 게이트웨이 — 준비 · 시도 실행 · 응답 매핑 · 스트리밍 · SSE
manifest와 probe: `evidence/raw/171-httpclient-gateway-probes.txt`.
## 28. 두 예산, 두 계층, 그리고 읽는 도중의 강제
`ResponseSizeLimiter`**와이어 바이트와 디코드 바이트를 따로** 센다 — "a compressed payload passes a wire check and then expands, so a single limit either rejects legitimate traffic or lets a **decompression bomb** through." 그리고 `CountingBoundedInputStream`이 상한을 **읽는 도중에** 적용한다 — "a response that is discovered to be too large only once it is fully buffered has already cost the memory the limit exists to protect."
`BoundedErrorBody`는 오류 본문을 RFC 9457 문서를 해독할 만큼만 읽고, "The bytes never reach an exception message or a log." `toString()`은 길이와 truncated 여부만 낸다.
`RemoteProblemDecoder`의 규칙 한 줄이 이 계층의 성격을 요약한다 — "**The wire status wins.** A remote `status` member is read and discarded, because trusting it would let an upstream **relabel a 503 as a 400 and change our retry behaviour from its own body.**" 확장 속성도 allowlist로 걸러 "an upstream cannot inject unbounded attributes into our telemetry." test 넷이 그 갈래를 고정한다(`mapsProblemJsonWithoutTrustingBodyStatus`·`dropsExtensionsThatAreNotAllowlisted`·`treatsANonProblemContentTypeAsAnEmptyProblem`·`survivesAnUnparseableProblemDocument`).
## 29. 리다이렉트는 엔진이 아니라 이 플랫폼이 따라간다
모든 전송에서 엔진 리다이렉트를 끄고 `BlockingRedirectCoordinator`만이 hop을 만든다. 각 hop마다 **대상 정책을 다시 적용**하고 origin이 바뀌면 자격증명을 떨어뜨린다 — "which is exactly what an engine's built-in follower does not do."
수정 이력 둘이 붙어 있다.
- "The redirect policy decides whether a hop is permissible in shape; the profile decides whether its destination is permissible at all. **Only the first check existed, so an upstream could redirect a trusted profile to an origin its allowlist excluded.**" 지금은 `targetGuard.requireAllowed(follow.target(), metadata)`가 매 hop 실행된다.
- 303은 메서드를 GET으로 바꾸면서 **본문도 버린다** — "Changing only the method sent the original payload as a GET body to a destination the upstream chose." 301/302는 메서드를 유지한다("the platform refuses to guess a rewrite the caller did not ask for").
hop 상한은 `RedirectEvaluator:16``context.hop() >= context.policy().maxHops()`로 강제하고 test `stopsAtTheConfiguredHopLimit`이 잡는다 — 조정자의 `for` 루프에 종료 조건이 없어 보이는 것은 평가기가 거부로 끝내기 때문이다(후보 추적 → **결함 아님**).
그리고 hop은 재시도가 아니다 — "A hop is a physical request: it passes through the resilience pipeline via the supplied sender, so it consumes rate and bulkhead capacity. **It is not a retry, because nothing failed.**"
## 30. P3 — `BoundedDataBufferFlux`의 두 연산자가 이름만 있고 아무것도 하지 않는다
클래스 javadoc이 목적을 적는다 — "Bounds a reactive body and releases every buffer it does not hand on… **Cancellation and error are the paths that leak in practice**: the subscriber stops asking, the upstream drops what it already produced, and those buffers are direct memory nobody returns."
그런데 그 두 경로에 붙은 연산자가 둘 다 비어 있다.
```java
return source
.doOnNext(buffer -> { limiter.recordWireBytes(); guard.markDelivered(); })
.doOnDiscard(DataBuffer.class, DataBufferUtils::release) // ← 실제로 일하는 유일한 연산자
.doOnCancel(() -> {}) // ← no-op
.onErrorResume(failure -> Flux.error(failure)); // ← 같은 오류를 그대로 재방출, no-op
```
`doOnCancel(() -> {})`은 정의상 아무 일도 하지 않고, `onErrorResume(f -> Flux.error(f))`는 오류 신호에 대해 항등이다. 따라서 이 클래스의 버퍼 해제는 **전적으로 `doOnDiscard`와 드라이버 자신의 해제 동작에 의존한다.**
누수가 실재한다고 주장하지는 않는다 — Reactor Netty의 `ByteBufFlux`는 취소 시 미방출 버퍼를 스스로 해제하고, `doOnDiscard`는 discard 프로토콜을 지원하는 연산자에 대해 동작한다. 문제는 **코드가 하지 않는 일을 하는 것처럼 읽힌다**는 것이다: 두 누수 경로의 이름을 딴 연산자가 나란히 있고 둘 다 비어 있어서, 이 클래스를 읽는 사람은 취소·오류 해제가 여기서 명시적으로 처리된다고 결론짓게 된다. 취소 경로에 test가 없지는 않다 — `ReactiveStreamingLifecycleTest.cancellationReleasesTheConnectionForTheNextCall`이 있다. 다만 그것이 확인하는 것은 **연결 반환**이고 버퍼 해제가 아니며, 실질적 안전망은 모든 lane에 켜져 있는 Netty leak detector(`paranoid`)다(§0).
**판정: P3.** 수정은 두 연산자를 지우고 javadoc이 `doOnDiscard`와 드라이버의 역할을 정확히 적게 하거나, 취소 경로에서 실제로 해제해야 할 것이 있다면 그것을 구현하는 것이다.
## 31. Negative-space probes — sub-scope 04
- **8.1 죽은 연산자**: `BoundedDataBufferFlux`의 4개 연산자 중 2개가 no-op(§30). 소비자는 `ReactiveStreamingGateway:102` 하나.
- **8.2 편의 생성자의 가짜 신원**: `ResponseSizeLimiter`의 2인자 생성자는 프로파일 이름이 리터럴 `"response-size-limiter"`인 정적 메타데이터를 쓴다. production 호출 지점 셋(`BlockingAttemptExecutor:52`·`BlockingStreamingGateway:60`·`ReactiveStreamingGateway:69`)은 **전부 3인자 생성자로 실제 메타데이터를 넘긴다** — 2인자 형태는 test 전용이다. 결함 아님으로 판정하되, `public`이므로 fork가 쓰면 예외에 가짜 프로파일 이름이 실린다.
- **8.3 hop 상한**: 조정자 루프의 종료 조건 부재를 후보로 추적해 `RedirectEvaluator`의 거부로 확정 — **오탐**(§29).
- **8.4 상태 신뢰 경계**: 원격 problem 문서의 `status`가 폐기되고 와이어 상태가 이긴다는 규칙을 코드와 test로 확인(§28).
## 32. Sub-scope 04 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P3** | `BoundedDataBufferFlux``doOnCancel(() -> {})``onErrorResume(f -> Flux.error(f))`가 no-op인데, 클래스 javadoc은 그 두 경로를 이 클래스가 처리한다고 적는다 | 코드 독해 · 회귀 |
## 33. Sub-scope 04 완료 조건
- denominator 46 / 46 FULL_READ (`171-...` OWNED FILES)
- §8.1~§8.4 probe 수행
- 후보 finding 2건(리다이렉트 hop 무한 루프 의심, `ResponseSizeLimiter` 가짜 메타데이터)을 각각 평가기 거부와 호출 지점 전수로 추적해 **결함 아님으로 판정**(§29, §31)
- 소스 미변경
---
## 34. Sub-scope 05 범위와 denominator
> 내부 상태: COMPLETE — **46 / 46 FULL_READ**
> 범위: `security/**` 21 + `auth/**` 18 (main 39, 2,072 LOC) + 전용 test 7
> 역할: 목적지·헤더·본문 정책, TLS 재료와 회전, 자격증명 해석
manifest와 probe: `evidence/raw/172-httpclient-security-auth-probes.txt`.
## 35. 목적지 정책 — 절대 URI를 정화하지 않고 거부한다
`TrustedTargetPolicy`의 규칙 — "An absolute URI is **rejected here rather than sanitised**: H2 exists to vary method, relative path, query, approved headers, and body — not the destination. Changing the destination is what H3 is for, and H3 has its own policy, credentials, and DNS validation."
`requireRelativeTemplate`가 빈 템플릿, `//` 시작, `://` 포함, `/`로 시작하지 않음을 거부한다. 확장은 문자열 연결이 아니라 Spring `DefaultUriBuilderFactory``TEMPLATE_AND_VALUES` 인코딩이라 "a value containing `/`, `?`, or `#` cannot change the shape of the request." 그리고 확장 **후에** `requireAllowedOrigin`이 host/port allowlist를 다시 본다.
멱등성 키 처리에 수정 이력 둘이 붙어 있다.
> "Before, the key was carried on the operation, checked for presence by the retry engine, and **never written to the wire**: the upstream saw no key, could not deduplicate, and the platform meanwhile treated a repeat as contractually safe. **A duplicated payment is the shape of that bug.**"
> "A caller-supplied value for the same header is **refused rather than merged.** Two keys for one request is a contradiction."
그리고 리다이렉트 hop에 allowlist를 다시 적용하는 `requireAllowedTarget`이 public인 이유도 적혀 있다 — 조정자가 이전에는 리다이렉트 정책만 보고 프로파일 allowlist를 보지 않아 "An upstream could therefore redirect a trusted profile to any origin the redirect policy tolerated, including one the operator had explicitly excluded."
## 36. 헤더 소유권과 자격증명 제거
`HeaderPolicy``PLATFORM_OWNED` 9종(`authorization`·`proxy-authorization`·`host`·`content-length`·`transfer-encoding`·`traceparent`·`tracestate`·`baggage`·`cookie`)은 호출자가 덮을 수 없고, CR/LF는 무조건 거부된다 — "a header value that can contain a newline is a **request-splitting primitive**."
`SensitiveHeaderStripper`에 이 sub-scope에서 가장 미묘한 수정이 있다.
> "It **adds** rather than replaces, which its name always claimed and its behaviour did not. A profile that named a custom API-key header — `X-Client-Key`, say — produced a stripper that dropped only that one and forwarded `X-Api-Key` across an origin boundary, so **configuring a custom header made the default headers *less* protected than leaving it alone.**"
## 37. 자격증명은 값이 아니라 신원만 남긴다
`SingleFlightTokenLoader`가 토큰 갱신을 한 번으로 접고, 그 실행 위치에 대한 두 가지 과거 오류를 적는다 — 공용 `ForkJoinPool`에서 돌아 "That pool is sized for CPU-bound work"였고, 대기가 **무한 `join()`**이라 "A token endpoint that accepted the connection and never answered" 상황에서 전체가 멈췄다.
`UnauthorizedRetryPolicy`의 재시도 상한이 **1**인 이유 — "An expired token produces a 401 that a refresh fixes; a wrong scope produces a 401 that no number of refreshes fixes." 그리고 재생 가능한 본문과 (읽기 전용이거나 명시적 키를 가진) 연산만 허용한다. test 셋이 그 갈래를 고정한다(`denies401ReplayForOneShotPost`·`allowsExactlyOneReplayForASafeReplayableOperation`·`allowsAKeyedWriteOnlyWhenAuthFailedBeforeAnySideEffect`).
`AccessToken``RequestCredentials``toString()`을 REDACTED로 재정의하고, `CredentialRequest`도 마찬가지인데 그 이유가 구체적이다 — "The generated `toString` printed the authenticated principal and the full target URI, including any query string. **A credential-resolution failure is exactly when this record ends up in a log line**, which made the failure path the most likely place for a user identity and a signed URL to escape."
`OAuth2TokenCacheKey`가 6개 성분을 모두 키에 넣는 이유도 적혀 있다 — "sharing a token across principals, scope sets, audiences, tenants, or client certificates is a **privilege-escalation bug, not a cache optimisation.**"
`TlsRuntimeRotationCoordinator`는 회전을 세대 교체로 처리하고("a connection pool holds sockets that were negotiated with the old material"), 어느 프로파일이 참여하는지를 회전된 신원 이름으로 정한다 — 이전에는 "The identity argument used to be required and then **ignored**: every registered profile was" 회전 대상이었다.
## 38. Negative-space probes — sub-scope 05
- **8.1 자격증명 유출 표면**: `auth`의 18 타입 중 record 5종을 전수 확인 — 값을 담는 셋(`AccessToken`·`RequestCredentials`·`CredentialRequest`)은 모두 redacted `toString`을 갖고, `OAuth2TokenCacheKey`·`UnauthorizedRetryContext`는 비밀을 담지 않는다. 나머지 13종은 `final class`/`interface`/`enum`이라 생성된 toString이 없다.
- **8.2 가짜 메타데이터 편의 생성자**: `HeaderPolicy.validate(input)` 1인자 오버로드가 정적 `UNBOUND`(프로파일 이름 리터럴 `"header-policy"`)를 쓰지만, production 호출 지점 둘(`TrustedTargetPolicy:80`·`DefaultDynamicTargetGateway:156`)은 **전부 2인자 형태로 실제 메타데이터를 넘긴다** — sub-scope 04의 `ResponseSizeLimiter`와 같은 형태이며 같은 결론(결함 아님).
- **8.3 SSRF 방어 지점 전수**: `requireAllowedTarget`/`requireAllowedOrigin`의 호출 지점 셋(준비 시 1, 리다이렉트 hop 1, 내부 1)과 동적 대상의 `IpAddressClassifier`/`ValidatedDnsResolver`(sub-scope 06 범위)를 확인.
- **8.4 소유 헤더 목록**: `PLATFORM_OWNED` 9종과 `ALWAYS_STRIPPED` 4종 + 관례적 API 키 2종의 관계 확인.
## 39. Sub-scope 05 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| — | **없음.** 목적지가 정화가 아니라 거부로 다뤄지고, 헤더 소유권과 CR/LF 거부가 타입 수준에 있으며, 자격증명을 담는 모든 값 타입이 redacted `toString`을 갖는다 | — |
## 40. Sub-scope 05 완료 조건
- denominator 46 / 46 FULL_READ (`172-...` OWNED FILES)
- §8.1~§8.4 probe 수행, 자격증명 유출 표면 전수 조사
- 후보 finding 1건(`HeaderPolicy`의 가짜 메타데이터)을 호출 지점 전수로 추적해 결함 아님으로 판정(§38)
- 소스 미변경
---
## 41. Sub-scope 06 범위와 denominator
> 내부 상태: COMPLETE — **56 / 56 FULL_READ**
> 범위: `service` 14 + `dynamic` 11 + `observation` 6 + `migration` 4 (main 35, 2,698 LOC) + test 21 (전용 11 + `contract` 7 + `architecture` 3)
> 역할: 선언적 서비스 클라이언트, 동적 대상(SSRF 방어), 관측 태그, RestTemplate 이관 진단
manifest와 probe: `evidence/raw/173-httpclient-service-dynamic-probes.txt`.
## 42. 동적 대상 — SSRF 방어가 소켓까지 이어진다
이 sub-scope의 중심은 `CallScopedDnsPin`의 javadoc이 적는 과거 결함이다.
> "This is the piece the SSRF defence was missing. `ValidatedDnsResolver` resolved a host, rejected the target if any answer was forbidden, and produced a `PinnedTarget` holding the exact approved addresses — **and then the gateway handed the transport a URL containing the hostname, and the transport resolved it again.** Everything between the two resolutions was unvalidated: a DNS server under an attacker's control answers the first query with a public address and the second with `169.254.169.254`, and the platform connects to the metadata service having 'validated' the target. **The classic rebinding attack, defeated by a check that discarded its own result.**"
지금은 사슬이 닫혀 있다. `TargetCanonicalizer`가 순서대로 파싱·userinfo 거부·Punycode 정규화·allowlist 비교를 하고("Order is the security property"), `ValidatedDnsResolver`**모든 응답**을 검사하며("Validating only the first answer is a common and fatal shortcut"), `DefaultDynamicTargetGateway:105-121`이 hop마다 `CallScopedDnsPin`을 열고 `finally`에서 `resolver.forget(...)`을 부른다. 그리고 전송 쪽은 app-bootstrap `HttpClientTransportAutoConfiguration:81`이 `CallScopedDnsPin::addressesFor`를 `ValidatedAddressResolverGroup`에 주입해 **소켓이 핀에 없는 주소로 나가지 못한다** — 핀이 비면 "The transport must then refuse rather than fall back to a system lookup."
`IpAddressClassifier`도 두 가지를 명시한다 — IPv4-mapped IPv6를 되돌려 정규화하고, 구조가 "an allowlist of globally routable unicast space, then the operator's own" 형태다. 그리고 "Every rejection here used to be an acceptance. `Integer.parseInt` took `-1`…"라는 수정 이력이 있다.
관측도 닫혀 있다 — `HttpClientTagPolicy`**모르는 태그 이름을 거부**한다("a metric backend cannot undo a"高카디널리티 태그), `SensitiveValueRedactor`는 쿼리 값을 키별로 마스킹하지 않고 **통째로 버린다**("an allowlist of 'safe' query" 키를 유지하는 방식은 안전하지 않다는 판단).
계약 lane의 메타 test가 특히 좋다 — `everySelectedBlockingTransportRanEveryContract`, `everySelectedReactiveTransportRanEveryContract`, `everySelectedTransportIsClaimedByExactlyOneContainer`. lane이 "무언가 돌았다"가 아니라 **"선택된 전송 각각이 모든 계약을 돌았다"**를 확인한다. `HttpClientModuleBoundaryTest`는 설계의 19-모듈 의존 표를 패키지 경계로 강제하고, `PublicApiArchitectureTest.coreApiDependsOnNothingInsideThePlatform`이 공개 API의 독립성을 잡는다.
## 43. Confirmed — `ValidatedDnsResolver`의 `approved` 맵은 hop마다 비워진다 (후보 → 결함 아님)
javadoc이 "They are deliberately **not retained here** between calls"라고 적는데 `approved` `ConcurrentHashMap` 필드는 여전히 존재하고 `resolve()`마다 채워진다. 무경계 증가로 보였으나 전수 추적 결과 `DefaultDynamicTargetGateway`**`finally`에서** `resolver.forget(host)`를 부르므로(:118-122) 항목은 hop 종료와 함께 사라진다. 그리고 보안 경로는 이 맵이 아니라 thread-local 핀을 쓴다 — `approvedAddresses(String)`의 production 호출자는 **0**이고 test·testkit 관측용이다. **결함 아님.**
(맵이 host만으로 키를 잡으므로 같은 host에 대한 동시 호출은 항목을 공유하고 먼저 끝난 쪽이 `forget`한다. 보안 결정은 thread-local이 내리므로 결과에 영향이 없다.)
## 44. Sub-scope 06 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| — | **없음.** 정규화→검증→핀→소켓 사슬이 닫혀 있고, 관측 태그가 allowlist로 폐쇄되며, 계약 lane이 전송별 완전 실행을 메타 test로 확인한다 | — |
---
## 45. Sub-scope 07 범위와 denominator
> 내부 상태: COMPLETE — **85 / 85 FULL_READ**
> 범위: `transport` 10 + `reactor` 6 + `apache` 5 + `jdk` 4 + `http3` 4 + `spring7` 3 (main 32, 2,189 LOC) + 전용 test 9 + `testkit` source set 35 (2,754 LOC) + `httpClientPerformanceTest` 7 (495) + `jmh` 2 (130)
> 역할: 전송 제공자 6종과 그 능력 선언, 그리고 세 개의 보조 source set
manifest와 probe: `evidence/raw/174-httpclient-transport-testkit-probes.txt`.
## 46. 전송은 능력을 선언하고, 프로파일보다 약하면 startup이 실패한다
`TransportCapabilityValidator`가 프로파일이 요구하는 것과 전송이 선언한 것을 대조해 부족분을 이름으로 모아 거부한다 — 프로토콜, route pool, 유계 pending 큐, proxy, mutual TLS, 동적 대상 안정성. 메시지는 "profile settings and capability names only — never a URL, address, or secret."
능력 레코드가 그 선언을 데이터로 만든다. `ReactiveTransportCapabilities.reactorNetty()`는 9개 능력을 전부 `true`로, `jettyHttp3Experimental()`은 route pool·유계 큐·DNS 핀·동적 안정성을 `false`로 선언한다. HTTP/3는 `compileOnly` 의존이라 클래스가 없으면 `Http3CapabilityReport`가 전송을 거부한다 — "the failure mode is a startup error rather than a `NoClassDefFoundError` mid-call"(§0).
testkit이 별도 source set인 것도 이 sub-scope의 성격이다 — 계약을 담은 클래스 35개(`BlockingTransportContract`·`ReactiveTransportContract`·`RetrySafetyContract`·`ResourceLifecycleContract`·`ObservabilityContract`·`DynamicTargetSecurityContract`)를 test·performance·jmh 세 lane이 공유한다. `NettyLeakDetectionExtension`은 leak detector 레벨을 **믿지 않고 확인한다** — "asserts the level rather than trusting the flag reached the forked JVM"(§0).
성능 lane 7개는 자원 상한을 검증한다 — `PoolSaturationPerformanceTest`·`RetryStormBudgetTest`·`RuntimeRotationDrainTest`·`OAuthRefreshContentionTest`·`LargeBodyResourceTest`·`Http2StreamSaturationTest`. §15에서 남긴 질문(`ObjectBody.replayability()`의 반사 비용을 재는 lane이 있는가)의 답은 **없다** — 성능 lane은 풀·재시도·회전·토큰 경합·본문 크기·H2 스트림을 재고 본문 재생 가능성 판정 비용은 재지 않는다.
## 47. P3 — 동적 대상 DNS 핀 능력 검사가 블로킹 오버로드에만 있다
`TransportCapabilityValidator`에는 오버로드가 둘이다. 블로킹 쪽에는 이런 검사와 주석이 있다.
```java
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
// `validatedDnsPinning` was declared on every capability record and read by nothing. It is
// the capability that decides whether the SSRF address validation survives to the socket, so
// a transport that does not have it cannot serve a dynamic target no matter what its
// `dynamicTargetStable` flag says — the two were being conflated.
missing.add("call-scoped validated DNS pinning");
}
```
**리액티브 오버로드에는 이 검사가 없다.** `dynamicTargetStable`만 본다 — 즉 주석이 "conflated"라고 지적한 바로 그 상태가 리액티브 경로에 그대로 남아 있다.
지금 노출은 없다. 두 리액티브 전송의 두 플래그가 같은 값이기 때문이다 — `reactorNetty()`는 둘 다 `true`, `jettyHttp3Experimental()`은 둘 다 `false`. 게다가 `ClientProfileValidator``DYNAMIC` + `JETTY` 조합을 이미 거부한다(`DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED`).
**판정: P3.** 위험은 fork가 리액티브 전송을 추가하면서 `dynamicTargetStable=true, validatedDnsPinning=false`로 선언하는 경우 — 주석이 "cannot serve a dynamic target no matter what its `dynamicTargetStable` flag says"라고 못박은 정확히 그 조합이 리액티브 쪽에서는 통과한다. 수정은 같은 세 줄을 리액티브 오버로드에 복사하는 것이다.
## 48. Negative-space probes — sub-scope 06·07
- **8.1 SSRF 사슬의 끝점**: 정규화 → 전체 응답 검증 → thread-local 핀 → 전송 resolver 주입까지 호출 지점으로 전수 추적(§42).
- **8.1b 잔존 캐시**: `ValidatedDnsResolver.approved`의 수명과 production 호출자 0을 확인해 오탐 판정(§43).
- **8.2 조건부 형제**: `TransportCapabilityValidator`의 두 오버로드 비교 — 한쪽에만 적용된 수정(§47).
- **8.3 능력 선언 대 실제**: 두 리액티브 전송의 9개 능력 플래그 전수 확인(§46, §47).
- **8.4 lane 완전성**: 계약 lane의 메타 test 3종과 성능 lane 7종의 대상 범위 확인(§42, §46).
## 49. Sub-scope 06·07 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| **P3** | 동적 대상의 `validatedDnsPinning` 능력 검사가 `TransportCapabilityValidator`의 블로킹 오버로드에만 있고 리액티브 오버로드에는 없다 — 주석이 "conflated"라고 지적한 상태가 한쪽에 남아 있다 | `dynamicTargetStable=true, validatedDnsPinning=false`인 리액티브 전송을 추가하는 fork |
## 50. Sub-scope 06·07 완료 조건
- denominator 56 / 56 및 85 / 85 FULL_READ (`173-...`, `174-...` OWNED FILES)
- §8.1~§8.4 probe 수행, 조건부 형제 비교 1건
- 후보 finding 1건(`approved` 맵 무경계 증가 의심)을 `finally` 배치와 호출자 전수로 추적해 **결함 아님으로 판정**(§43)
- §15에서 남긴 성능 lane 질문을 해소(§46)
- 소스 미변경
---
## 51. 교정 — 영구 TLS 실패의 `CONNECT` 분류는 분류기 결함이 아니라 픽스처의 듀얼스택 호스트명이다
이 절은 이전 사이클이 여기에 적었던 **P1 진단을 철회하고 교체한다**. 관측된 실패는 그대로 재현되지만,
그 원인으로 지목했던 기전은 측정으로 반증되었다. 근거는 `EVD-332`다.
### 51.1 관측은 그대로다
`:adapter:outbound:httpclient:test` 는 HEAD 에서도 **283 중 3건 실패**한다.
```
anUntrustedAuthorityIsAPermanentTlsFailure() → expected: TLS_HANDSHAKE but was: CONNECT
anExpiredCertificateIsAPermanentTlsFailure() → expected: TLS_HANDSHAKE but was: CONNECT
aHostnameMismatchIsAPermanentTlsFailure() → expected: TLS_HANDSHAKE but was: CONNECT
```
세 건 모두 `MutualTlsHandshakeContractTest.java:168``assertThat(classified.stage()).isEqualTo(TLS_HANDSHAKE)` 다.
바로 앞줄인 167행(`evidence == NOT_SENT`)은 통과한다.
### 51.2 철회하는 진단
이전 사이클의 주장은 이랬다.
> Apache HttpClient 5는 TLS 핸드셰이크 실패를 연결 단계 실패로 감싼다 — 사슬이
> `HttpHostConnectException` → `SSLHandshakeException`이다. 바깥 것이 CONNECT 분기에 먼저 걸리므로
> 안쪽 `SSLHandshakeException`은 검사되지 않는다.
**틀렸다.** 그 진단은 `ApacheFailureClassifier.recognize`의 분기 순서(pool → DNS → CONNECT → TLS)를 읽고
사슬의 모양을 추론한 것이지, 사슬을 실제로 떠본 것이 아니다. 잡힌 예외를 그대로 출력하면 이렇다.
```
[0] ResourceAccessException :: ... Connect to https://localhost:56507 failed: Connection refused
[1] HttpHostConnectException :: Connect to https://localhost:56507 failed: Connection refused
```
사슬에 `SSLHandshakeException`**없다**. 그림자에 가려진 것이 아니라 애초에 도착하지 않았다.
분류기는 자기가 받은 것을 정확히 분류했다.
### 51.3 확정된 기전 — 접속 호스트만 바꾼 대조
동일한 서버 객체, 동일한 클라이언트 신뢰재료. `baseUrl`의 호스트 문자열만 바꿨다.
```
=== untrusted-authority @127.0.0.1 ===
[1] javax.net.ssl.SSLHandshakeException :: (bad_certificate) PKIX path validation failed
[4] java.security.SignatureException :: Signature does not match.
-> stage=TLS_HANDSHAKE category=TLS_PERMANENT permanent=true ← test가 기대하는 값
=== untrusted-authority @localhost ===
[1] HttpHostConnectException :: Connect to https://localhost:40485 failed: Connection refused
-> stage=CONNECT category=CONNECT permanent=false ← test가 본 값
```
이 컨테이너의 `/etc/hosts``localhost`를 두 패밀리에 준다.
```
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
InetAddress.getAllByName("localhost") -> [127.0.0.1, 0:0:0:0:0:0:0:1]
```
`MockWebServer`는 IPv4 루프백에만 바인딩하고, `MockHttpServer.uri()`는 호스트명 `localhost`를 돌려준다
(`MockHttpServer.java:70-72`). Apache HttpClient 5의 연결 오퍼레이터는 해석된 주소를 순회하면서
**마지막이 아닌 주소의 실패를 삼킨다**.
```
127.0.0.1 → TCP 성공 → TLS 핸드셰이크 실패(진짜 실패) → 삼켜짐
::1 → TCP 거부(듣는 소켓 없음) → 마지막 주소 → HttpHostConnectException 으로 승격
```
호출자에게 도달하는 유일한 예외는 두 번째 주소의 연결 거부다.
핸드셰이크가 **성공하는** test가 통과하는 이유도 같은 루프다. 127.0.0.1 에서 성공하면 루프가 즉시
반환하므로 `::1`을 시도하지 않는다. 따라서 처음 눈에 띄었던 `startTls(..., true/false)` 차이는
원인이 아니라 상관관계였다 — 실패하는 케이스가 곧 두 번째 주소까지 가는 케이스다.
`startTls(..., false)`에서 서버가 뜨지 않는다는 가설도 함께 기각했다. 두 경우 모두 원시 소켓 접속이
성공한다(`raw 127.0.0.1: OK`, `raw localhost: OK`).
### 51.4 두 개의 판정
**(a) test 실패 자체 — P3, 픽스처 결함.** 프로덕션 코드에 결함이 없다. `localhost`가 IPv4로만 풀리는
환경에서는 세 건 모두 통과한다. 고칠 것은 `MockHttpServer.uri()`가 호스트명을 돌려준다는 점이다 —
루프백 IPv4 주소를 돌려주거나 test가 주소를 고정하면 사라진다. 이 저장소는 `preferIPv4Stack`
어디에도 설정하지 않으므로, 듀얼스택 CI/컨테이너에서 이 세 건은 항상 빨갛다.
**(b) 이 실패가 드러낸 런타임 성질 — P2/기록, 이 모듈에서 고칠 수 없다.** 분류표는 두 범주를
정반대로 다룬다.
| 분류 | `DefaultRetryEligibilityEngine` |
|---|---|
| `TLS_PERMANENT` | `permanent()``RetryDenied.permanentFailure` (37-39행) |
| `CONNECT` | `failureDecision``case CONNECT -> RetryAllowed.of("CONNECT")` — 멱등성과 무관하게 재시도 |
다중 주소 호스트에서 한 패밀리는 TLS를 영구 거절하고 다른 패밀리는 연결을 거부하면, 절대 검증되지 않을
인증서에 대한 호출이 `CONNECT`로 분류되어 예산·데드라인이 소진될 때까지 매 시도 재시도된다.
분류기가 볼 수 있는 정보 안에서 이 강등을 막을 방법은 없다 — Apache의 루프가 앞선 주소의 실패를 이미
버렸기 때문이다. 이 모듈의 설계 전제는 "증거에 기반해 재시도 안전성을 판정한다"인데, 증거를 만드는
계층이 증거의 일부를 버리는 지점이 여기다. 다만 인증서가 신뢰 불가면 보통 두 패밀리 모두 TLS에서
실패하고, 그때는 마지막 주소의 예외도 `SSLHandshakeException`이라 올바르게 `TLS_PERMANENT`가 된다.
강등은 **패밀리별 실패 양상이 다를 때만** 일어난다.
### 51.5 이전 사이클이 남긴 열린 항목의 처리
이전 §51은 "`ReactorFailureClassifier`·`JdkFailureClassifier`도 같은 사슬 순회 형태를 쓰므로 같은
그림자 문제가 있는지 확인이 필요하다"로 끝났다. 그 열린 항목은 **전제가 반증되어 소멸한다**
Apache는 TLS 실패를 연결 예외로 감싸지 않는다(§51.3의 `@127.0.0.1` 측정이 직접 보여 준다).
사슬 순회 순서가 TLS를 가리는 일은 이 경로에서 일어나지 않는다. 다른 두 분류기를 같은 방식으로
개별 실행해 보지는 않았다. 확인하지 못한 것으로 남긴다.
## 52. 모듈 ledger 정합
| # | 범위 | main | test | 기타 | 합 | FULL_READ | probe |
|---|---|---|---|---|---|---|---|
| 1 | governance + `profile/**` | 29 | 2 | 4 | 35 | 35 | `167`, `168` |
| 2 | `api/**` | 50 | 5 | | 55 | 55 | `169` |
| 3 | `resilience/**` | 39 | 8 | | 47 | 47 | `170` |
| 4 | `restclient/**` + `webclient/**` | 36 | 10 | | 46 | 46 | `171` |
| 5 | `security/**` + `auth/**` | 39 | 7 | | 46 | 46 | `172` |
| 6 | `service`+`dynamic`+`observation`+`migration` | 35 | 21 | | 56 | 56 | `173` |
| 7 | 전송 6종 + testkit/perf/jmh | 32 | 9 | 44 | 85 | 85 | `174` |
| | **TOTAL** | **260** | **62** | **48** | **370** | **370** | **7 / 7** |
coverage ledger: `FULL_READ` **370** / `STRUCTURAL_ONLY` **0** / `EXCLUDED` **0** / 미분류 **0**.
## 53. 모듈 findings
| # | 우선순위 | finding | 위치 |
|---|---|---|---|
| 1 | **P3** | `MutualTlsHandshakeContractTest` 3건이 실패하지만 원인은 프로덕션 코드가 아니라 픽스처다 — `MockHttpServer.uri()`가 듀얼스택 `localhost`를 돌려주고 `MockWebServer`는 IPv4에만 바인딩한다 (이전 사이클의 P1 진단은 `EVD-332`로 철회) | §51 |
| 1b | **P2/기록** | 다중 주소 호스트에서 패밀리별 실패 양상이 다르면 영구 TLS 실패가 재시도 가능한 `CONNECT`로 강등된다 — Apache가 앞선 주소의 실패를 버리므로 이 모듈에서 고칠 수 없다 | §51.4 |
| 2 | **P2** | rate limiter·bulkhead 로컬 거부 경로가 회로 브레이커 permission을 반환하지 않는다 — HALF_OPEN 시험 슬롯 소진으로 회복한 업스트림에 회로가 닫히지 않을 수 있다 | §22 |
| 3 | **P3** | `ClientRuntimeRegistry.close()`가 실패 시 drain 스케줄러 종료에 도달하지 않아 스레드가 남는다 | §4 |
| 4 | **P3** | `POOL_ROUTE_EXCEEDS_TOTAL` 위반 코드가 `PoolSettings` 생성자에 가려 도달 불가 | §5 |
| 5 | **P3** | 위반 코드 34종 중 **22종**이 어떤 test에서도 이름으로 확인되지 않고, 그 22종에 사고 유래 보안 가드가 대부분 포함된다 | §6 |
| 6 | **P3** | `ObjectBody.deeplyImmutable``instanceof Number``AtomicInteger`·`LongAdder` 등 가변 타입을 `REPLAYABLE`로 인증한다 | §14 |
| 7 | **P3** | `BoundedDataBufferFlux``doOnCancel`·`onErrorResume`가 no-op인데 javadoc은 그 두 경로를 이 클래스가 처리한다고 적는다 | §30 |
| 8 | **P3** | 동적 대상 `validatedDnsPinning` 능력 검사가 `TransportCapabilityValidator`의 블로킹 오버로드에만 있다 | §47 |
| 9 | P3/기록 | `ObjectBody.replayability()`가 호출마다 반사로 재계산되고 캐시가 없으며, 성능 lane도 이 경로를 재지 않는다 | §15, §46 |
**결함 아님으로 판정한 후보 6건**`PARTIAL_RESPONSE` 분기 도달 불가 의심(§23), 리다이렉트 hop 무한 루프 의심(§29), `ResponseSizeLimiter`·`HeaderPolicy`의 가짜 메타데이터 생성자(§31, §38), `ValidatedDnsResolver.approved` 맵 무경계 증가 의심(§43).
## 54. 이 모듈에서 반복해서 나타난 패턴
- **거부가 무시보다 낫다.** 이 저장소의 다른 열 모듈에서 반복 발견한 "선언되었으나 아무것도 하지 않는 설정"을, 이 모듈은 `validateUnsupportedSettings`로 **명시적 거부**한다 — "the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing."
- **증거가 메서드 이름을 이긴다.** `RetryContext`에 HTTP 메서드가 없고, 멱등성 키는 **전송됐는지**까지 요구하며, 첫 바이트 전달은 래치다.
- **저장소에서 가장 밀도 높은 사고 기록.** 34개 위반 코드, `AttemptResiliencePipeline`의 브레이커 가시성 수정, `SensitiveHeaderStripper`의 "adds rather than replaces", `CallScopedDnsPin`의 "a check that discarded its own result", `TrustedTargetPolicy`의 "A duplicated payment is the shape of that bug" — 각 가드가 자신이 막는 사고를 인용한다.
- **그러나 그 규율이 test로 고정된 비율은 낮다.** 위반 코드 34 중 12만 test가 이름으로 잡고(§6), 사고 유래 가드 대부분이 그 밖에 있다. 그리고 §51은 그 반대편의 함정이다 — test 3건이 빨간 채로 남아 있고, 이전 사이클은 그 빨강을 프로덕션 P1으로 읽었다. 실제로는 픽스처의 호스트명 문제였다(`EVD-332`). 실패하는 test는 결함의 증거가 아니라 조사의 시작점이다.
## 55. 검증
`evidence/raw/175-httpclient-suite-verification.txt`.
```
lane classes tests failures skipped
test 61 283 3 0
httpClientStableContractTest 7 24 0 0
httpClientSecurityTest 2 9 0 0
httpClientBlockHoundTest 1 3 0 0
spring62ApiSurfaceScan 1 6 0 0
git status --short → 0
```
두 가지를 그대로 기록한다.
1. **`:check`는 소스와 무관한 이유로 실패한다.** `src/gradle/archive-hygiene.gradle`의 게이트가 `build/libs`에 남은 이전 리비전 JAR 두 개(`+0137263441f6`, `+e98b56eb03ec`)를 발견하고 빌드를 깬다. 저장소가 `cleanStaleTraceableJars`라는 remedy 태스크를 제공한다. **이 분석은 사용자 워크스페이스의 빌드 산출물을 삭제하지 않았고**, 대신 다섯 lane을 개별 실행해 실제 결과를 얻었다.
2. **`:test`의 3건 실패는 실재하지만 프로덕션 결함이 아니다.** 2026-08-31 HEAD 재실행에서도 283 중 3건이 동일하게 실패한다. 예외 사슬을 직접 뜬 결과 원인은 픽스처가 듀얼스택 `localhost`를 쓰는 것이었고, 접속 호스트를 `127.0.0.1`로 바꾸면 세 건 모두 `TLS_PERMANENT`가 된다(`EVD-332`). §51이 교정된 진단이다.
3. **재검증 시점의 소스 드리프트는 0이다.** 문서 기준 revision `a24ece9c`와 HEAD `21234e38` 사이에서 `src/adapter/outbound/httpclient` 변경 파일 수는 0이며, 함께 바뀐 `src/build.gradle`·`modules.json`도 이 리프에 영향이 없다(`EVD-333`).
작업 트리는 변경 0 — 이 분석은 어떤 애플리케이션 코드도 수정하지 않았다.
## 56. 모듈 완료 조건
- denominator 370 / 370 FULL_READ, `STRUCTURAL_ONLY` 0, `EXCLUDED` 0, 미분류 0 (§52)
- 7개 하위 범위 전부 §8.1~§8.4 negative-space probe 수행, evidence `167`~`175` 9건 생성
- 후보 finding 6건을 코드로 추적해 결함 아님으로 판정
- lane 5종 실행, 실패 3건을 예외 사슬 실측까지 추적해 픽스처 원인으로 확정하고 이전 P1 진단을 철회(§51, `EVD-332`)
- 재검증 revision `21234e38`에서 리프 소스 변경 0 확인(`EVD-333`)
- §0 SSOT identity 블록·고정 골격 대응표·Source anchors 추가
- 소스 미변경
## Source anchors
```
src/adapter/outbound/httpclient/build.gradle
src/config/architecture/modules.json (adapter-outbound-httpclient 항목)
src/build.gradle:477-492 (plain-JUnit 조건 — 이 리프는 else 분기)
main/…/apache/ApacheFailureClassifier.java:31-43,45-96,98-106
main/…/api/operation/FailureCategory.java:9-32,34-44
main/…/api/operation/AttemptStage.java:9-42
main/…/resilience/DefaultRetryEligibilityEngine.java:25-51,53-59
main/…/profile/ClientProfileValidator.java
main/…/profile/ClientRuntimeRegistry.java
main/…/api/body/ObjectBody.java
main/…/resilience/AttemptResiliencePipeline.java
main/…/security/SensitiveHeaderStripper.java
main/…/security/TrustedTargetPolicy.java
main/…/dynamic/ValidatedDnsResolver.java
main/…/transport/TransportCapabilityValidator.java
main/…/reactor/BoundedDataBufferFlux.java
test/…/security/MutualTlsHandshakeContractTest.java:38-67,104-126,128-171
testkit/…/testkit/MockHttpServer.java:32-49,60-72
testkit/…/testkit/TlsFixture.java
testkit/…/testkit/TlsMaterials.java
evidence/raw/167-… ~ 175-httpclient-suite-verification.txt
evidence/raw/332-httpclient-dualstack-localhost-masks-tls-permanent.txt
evidence/raw/333-eighteen-docs-source-drift-zero.txt
```