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

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

Follows the import procedure in README.md.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,241 @@
---
kind: CASE
slug: a11-f001-close
title: 누수 하나는 회수되고 하나는 회수되지 않는다
topic: http-client-and-resilience
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a11-f001-close
evidenceCapturedOn: 2026-09-02
body: case-a11-f001-close.body.md
assets:
- key: a11-f001-close
file: ../../../final/evidence/rendered/a11-f001-close.svg
- key: a11-f001-close-leak
file: ../../../final/evidence/rendered/a11-f001-close-leak.svg
evidence:
- ../../../final/evidence/raw/a11-f001-close.txt
- ../../../final/evidence/raw/a11-f001-close-leak.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L121 이다. 등급은 P3 이다. 다시 던지기가 스케줄러 종료 블록보다 앞에 있어 실패 경로에서 그 블록에 닿지 않는다는 관찰과, 그래서 javadoc 이 적은 스레드 수명 성질이 깨진다는 판정이 그 절에 있다. 바로 위 루프에는 예외를 모으는 수정이 적용됐는데 스케줄러에는 오지 않았다는 지적, 대응 test 가 실패 없는 경로만 본다는 사실도 있다. 판정 근거 셋과 그럼에도 기록해야 하는 사유, 그리고 수정이 한 줄이라는 서술까지 그 절이 적는다.
- 이 기록이 더한 것은 넷이다. 그 경로를 실제로 만들어 배수 스레드가 남는 것을 관측했고, 한 번 더 닫으면 회수된다는 것도 확인했다. 강제 닫기가 하나도 던지지 않는데 스레드가 남고 회수되지 않는 두 번째 경로를 찾았다. 닫기를 거부한 런타임이 자원을 쥔 채 닫힘으로 표시된다는 것을 자원 닫기 호출 수로 확인했다. 그리고 주석이 감시자로 지목한 묶음이 그 스레드를 만든 적조차 없다는 것을 같은 순서로 돌려 확인했다.
---
# 누수 하나는 회수되고 하나는 회수되지 않는다
레지스트리 닫기가 첫 실패를 다시 던지는 자리가 스케줄러 종료 블록보다 앞에 있다. 그 순서에서 배수 스레드가 남는데, 한 번 더 닫으면 회수된다. 종료 대기가 실패하는 다른 경로에서는 참조가 이미 비워진 뒤라 회수되지 않는다. 그리고 닫기를 거부한 런타임은 자원이 열린 채 닫힘으로 표시된다.
## 관계
- **회전이 틈으로 관측되지 않게 만든 순서와 두 누수 이력**
이 결함이 그 두 수정 중 뒤엣것의 남은 절반이다.
- **타입이 문서화한 불변식은 타입이 강제한다**
javadoc 이 적은 스레드 수명을 강제하는 장치가 없다.
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
주석이 감시자로 지목한 묶음이 그 스레드를 본 적이 없다.
## 문제
레지스트리의 닫기는 모든 런타임을 닫고 배수 스케줄러를 종료해야 한다.
클래스 자바독이 그 성질을 적는다. 하나뿐인 예약 실행기가 레지스트리와 함께 종료되므로 어떤 스레드도 레지스트리보다 오래 살지 않는다는 것이다.
## 결론
수정이 절반만 적용되어 있다.
닫기의 마지막 부분에서 은퇴 목록과 런타임 목록을 비우고, 첫 실패가 있으면 다시 던진다. 그 던지기 다음에 스케줄러 종료 블록이 온다.
강제 닫기 하나라도 던지면 144 에서 끝나 146 이후에 닿지 않는다. 실행해서 확인했다. 회전 하나를 만들고 닫으면 배수 스레드가 1 로 남고 살아 있다.
이 누수는 회수된다. 두 목록이 이미 비워진 뒤라 두 번째 닫기에는 실패할 것이 없고, 그때 146 에 닿아 스레드가 정리된다.
같은 메서드에 회수되지 않는 경로가 하나 더 있다. 종료 블록 안의 대기가 5 초를 넘기면 153 이 던지는데, 그 시점에는 146 이 이미 참조를 비운 뒤다. 강제 닫기가 하나도 던지지 않아도 일어난다. 마감 작업이 인터럽트를 무시하고 도는 동안 닫으면 그렇게 된다. 실행해서 확인했다. 첫 닫기가 5000 밀리초 뒤에 던지고 스레드가 남으며, 두 번째 닫기는 정상 반환하는데 스레드는 그대로다.
새는 것이 스레드만도 아니다. 런타임 닫기는 자원 닫기를 부르기 전에 상태를 닫힘으로 바꾼다. 그래서 닫기를 거부한 런타임은 자원이 해제되지 않은 채 닫힘으로 표시되고, 이후의 강제 닫기는 상태 검사에 걸려 아무 일도 하지 않는다. 자원 닫기 호출 수가 1 에서 늘지 않는 것으로 확인했다. 바로 위 루프가 막으려던 것이 하나가 거부해도 나머지가 새지 않게 하는 것이었는데, 거부한 그 하나는 되돌릴 수 없다.
종료 블록 안의 주석은 배수 스레드가 살아 있는 채 레지스트리가 반환하면 회전 주기마다 스레드 하나가 샌다고 적고, 자원 경계 묶음을 그 감시자로 지목한다.
그 묶음은 이 스레드를 본 적이 없다. 그 묶음이 보는 회전은 임차를 쥐지 않아 은퇴 세대가 곧바로 닫히고, 스케줄러를 만드는 조건이 거짓이 된다. 회전 49 회 동안 배수 스레드가 0 이다. 마지막 줄의 단언은 성공 경로에서도 빈 검사다.
같은 성질을 보는 단위 test 는 실패 없는 경로만 만든다.
판정은 P3 다. 스레드가 데몬이라 가상 머신 종료를 막지 않고, 레지스트리당 하나이며, 닫기 실패라는 조건이 필요하다. 원본이 든 근거 그대로다.
그럼에도 기록하는 것은 주석이 지목한 감시자가 그 누수를 본 적이 없기 때문이다.
수정은 한 줄이 아니다. 종료 블록을 finally 로 옮기려면 그 앞의 루프까지 감싸는 try 를 먼저 만들어야 하고, 만들어도 그 블록 안에 던지는 줄이 있어 원래 실패가 밀려난다. 던지기를 뒤로 미루는 편이 손이 덜 가지만 두 번째 누수를 못 막는다. 어느 쪽이든 스케줄러 실패를 원래 실패에 억제 예외로 붙이는 처리가 필요하다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 닫기 메서드의 제어 흐름 확인, 실행 탐침
소스 수정 : x
## 재현 조건
1. 레지스트리 닫기 메서드를 끝까지 읽는다.
2. 첫 실패를 다시 던지는 줄과 스케줄러 종료 블록의 앞뒤를 확인한다.
3. 런타임 닫기가 상태와 자원 닫기 중 무엇을 먼저 하는지 읽는다.
4. 배수 스레드를 만드는 조건과 이름과 데몬 여부를 확인한다.
5. 주석이 지목한 묶음의 회전이 임차를 쥐는지 확인한다.
6. 그 묶음과 같은 순서로 49 회 회전시켜 배수 스레드를 센다.
7. 임차를 쥔 채 회전한 뒤 자원 닫기가 던지게 하고 닫는다. 두 번 닫아 스레드와 자원 닫기 호출 수를 본다.
8. 마감 작업이 인터럽트를 무시하고 도는 동안 닫아, 종료 대기가 실패할 때를 본다. 다시 닫아 회수되는지 본다.
## 본문
<!-- body:start -->
레지스트리 클래스의 javadoc 이 스레드 수명을 못박는다.
## 적어 둔 성질
:::evidence key="a11-f001-close" alt="레지스트리 클래스의 javadoc 이 적은 스레드 수명 서술, 닫기 메서드 전체를 줄 번호와 함께, 런타임 닫기가 상태를 먼저 바꾸는 네 줄, 배수 스레드를 만드는 조건과 그 스레드의 이름과 데몬 설정, 주석이 감시자로 지목한 묶음의 회전 루프와 마지막 단언, 그리고 같은 성질을 보는 단위 test 본문을 출력한 터미널 기록." caption="javadoc 은 어떤 스레드도 레지스트리보다 오래 살지 않는다고 적는다 · close() 는 143~145 에서 첫 실패를 다시 던지고 스케줄러 종료는 146 부터이며 그 안에도 던지는 줄이 있다 · 런타임 닫기는 자원 닫기 전에 상태를 CLOSED 로 바꾼다 · 감시자로 지목된 묶음의 회전은 임차를 쥐지 않는다 — 124줄 · exit 0" zoom="true"
:::
```text
* <p>A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is
* never observable as a gap. The single scheduled executor exists only to enforce drain deadlines
* and is created lazily; it is shut down with the registry so no thread outlives it.
```
## 던지기가 종료보다 앞에 있다
```text
141: retired.clear();
142: runtimes.clear();
143: if (firstFailure != null) {
144: throw firstFailure;
145: }
146: ScheduledExecutorService scheduler = drainScheduler.getAndSet(null);
147: if (scheduler != null) {
148: // Await termination: a registry that returns while its drain thread is still alive would
149: // leak a thread per rotation cycle, which the resource-bound suite exists to catch.
150: scheduler.shutdownNow();
```
바로 위 루프는 하나가 거부해도 나머지를 닫도록 예외를 모으게 고쳐진 자리다.
```text
126: // Every runtime is closed even when one refuses. forEach stopped at the first exception, so a
127: // single misbehaving pool left every remaining connection, thread and socket open — shutdown
128: // leaked more the worse the failure was.
```
같은 논리가 스케줄러에는 오지 않았다.
## 회수되는 쪽
:::evidence key="a11-f001-close-leak" alt="주석이 지목한 묶음과 같은 모양으로 49 회 회전시켰을 때의 배수 스레드 최대치, 임차를 쥔 채 회전한 뒤 자원 닫기가 던지게 하고 두 번 닫았을 때의 결과와 거부한 런타임의 상태와 자원 닫기 호출 수, 그리고 마감 작업이 인터럽트를 무시하고 도는 동안 닫았을 때의 결과와 다시 닫아도 회수되지 않는 것을 출력한 터미널 기록. 픽스처는 고정 리비전 소스에서 직접 컴파일한다." caption="감시자와 같은 모양의 회전 49 회 동안 배수 스레드 0 · 강제 닫기가 던지면 스레드 1 이 남고 두 번째 닫기로 회수되지만 거부한 런타임은 CLOSED 로 굳는다 · 종료 대기가 5000 밀리초에 실패하면 두 번째 닫기로도 회수되지 않는다 — 20줄 · exit 0" zoom="true"
:::
```text
[강제 닫기 하나가 던지는 경로]
swap 직후 drain 스레드 : 1
1회차 close() : IllegalStateException: pool refused to close / drain 스레드 1
거부한 런타임 state : CLOSED / 자원 닫기 호출 1회
다시 forceClose 후 자원 닫기 호출 : 1회
2회차 close() : 정상 반환 / drain 스레드 0
```
두 번째 닫기가 스레드를 회수한다. 두 목록이 이미 비워져 있어 실패할 것이 없고, 그래서 146 에 닿는다.
회수되지 않는 것이 그 줄 사이에 있다. 거부한 런타임의 상태가 `CLOSED` 인데 자원 닫기는 한 번뿐이고, 다시 불러도 늘지 않는다.
```text
96: public final void close() {
97: ClientRuntimeState previous = state.getAndSet(ClientRuntimeState.CLOSED);
98: if (previous != ClientRuntimeState.CLOSED) {
99: resourceCloser.run();
100: }
101: }
```
상태를 먼저 바꾼다. 자원 닫기가 던지면 그 런타임은 자원을 쥔 채 닫힘으로 굳고, 이후의 강제 닫기는 98 에서 되돌아간다. 126~128 이 막으려던 것이 하나가 거부해도 나머지가 새지 않는 것이었는데, 거부한 그 하나는 열린 채 남는다.
## 회수되지 않는 쪽
```text
[강제 닫기가 하나도 던지지 않는데 스레드가 남는 경로]
마감 작업이 도는 중 drain 스레드 : 1
강제 닫기가 던진 것 : 없음
1회차 close() : IllegalStateException: http client drain scheduler did not terminate (5000ms)
1회차 후 drain 스레드 : 1
2회차 close() : 정상 반환 / drain 스레드 1
```
종료 블록 자체가 던지는 경로다.
```text
152: if (!scheduler.awaitTermination(SHUTDOWN_AWAIT_MILLIS, TimeUnit.MILLISECONDS)) {
153: throw new IllegalStateException("http client drain scheduler did not terminate");
```
146 이 참조를 이미 비웠으므로 두 번째 닫기는 종료할 대상을 찾지 못한다. 여기 필요한 것은 강제 닫기 실패가 아니라, 마감 작업이 인터럽트를 넘기지 못하는 것뿐이다. `shutdownNow()` 가 보낼 수 있는 것은 인터럽트 하나다.
## 감시자가 그 스레드를 본 적이 없다
148~149 의 주석이 자원 경계 묶음을 감시자로 지목한다. 그 묶음의 회전은 이렇게 생겼다.
```text
31: for (int generation = 2; generation <= 50; generation++) {
32: ClientRuntime replacement =
33: new ClientRuntime(
34: ClientProfiles.builder("rotating").build(),
35: new RuntimeGeneration(generation),
36: closed::incrementAndGet);
37: registry.swap(first.name(), replacement, Duration.ofSeconds(1));
```
임차를 쥐지 않는다. 그러면 `beginDrain` 이 그 자리에서 세대를 닫고, 스케줄러를 만드는 조건이 거짓이 된다.
```text
99: previous.beginDrain(drainTimeout);
100: if (previous.state() != ClientRuntimeState.CLOSED && !drainTimeout.isZero()) {
```
그 묶음과 같은 순서로 돌려 보면 그렇다.
```text
[자원 경계 묶음이 도는 모양] 임차를 쥐지 않고 회전한다
회전 49회 동안 관측된 drain 스레드 최대치 : 0
닫힌 세대 : 50 / close() 후 drain 스레드 : 0
```
마지막 줄의 `noneMatch` 단언은 만들어진 적 없는 스레드를 찾는다. 실패 경로 이전에, 성공 경로에서도 빈 검사다.
같은 성질을 보는 단위 test 도 실패 경로를 만들지 않는다.
```java
ClientRuntimeRegistry registry = new ClientRuntimeRegistry(Map.of(first.name(), first));
ClientRuntimeLease lease = registry.acquire(first.name());
registry.swap(first.name(), second, Duration.ofSeconds(30));
registry.close();
```
이쪽은 임차를 쥐어 스케줄러가 만들어지지만, `running(1)``running(2)` 의 자원 닫기가 둘 다 빈 람다다.
## 스레드는 데몬이다
```text
177: Thread thread = new Thread(runnable, "httpclient-runtime-drain");
178: thread.setDaemon(true);
```
가상 머신 종료를 막지는 않는다. P3 을 유지하는 세 근거 중 하나다.
## 수정의 모양
종료 블록을 `finally` 로 옮기려면 121~145 를 감싸는 `try` 를 먼저 만들어야 한다. 만들어도 그 블록 안에 153 이 있어서, `finally` 에서 나온 예외가 원래 던지던 예외를 밀어낸다. 호출자가 받는 것이 어느 풀이 닫기를 거부했는지에서 종료 대기 실패로 바뀐다.
던지기를 종료 블록 뒤로 내리는 쪽이 더 작은 수정이지만 153 문제는 그대로다. 어느 쪽이든 스케줄러 실패를 원래 실패에 억제 예외로 붙여야 한다.
## 확인하지 못한 것
회전을 여러 번 돌려 스레드가 누적되는지 재지 않았다. 스케줄러는 레지스트리당 하나이므로 한 레지스트리에서는 최대 하나다.
큐에 남은 마감 작업이 은퇴 세대와 레지스트리를 얼마나 오래 붙잡는지 측정하지 않았다. 그 참조는 마감이 지나면 풀린다.
<!-- body:end -->
@@ -0,0 +1,226 @@
---
kind: CASE
slug: a11-f002-pool-route-exceeds-total
title: 설정 참고 문서가 약속한 코드를 운영자는 받지 못한다
topic: http-client-and-resilience
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a11-f002-pool-route-exceeds-total
evidenceCapturedOn: 2026-09-02
body: case-a11-f002-pool-route-exceeds-total.body.md
assets:
- key: a11-f002-pool-route-exceeds-total
file: ../../../final/evidence/rendered/a11-f002-pool-route-exceeds-total.svg
- key: a11-f002-pool-route-exceeds-total-shape
file: ../../../final/evidence/rendered/a11-f002-pool-route-exceeds-total-shape.svg
evidence:
- ../../../final/evidence/raw/a11-f002-pool-route-exceeds-total.txt
- ../../../final/evidence/raw/a11-f002-pool-route-exceeds-total-shape.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L146 이다. 등급은 P3 이다. 레코드 정규 생성자와 검증기 분기가 같은 조건을 본다는 대조, 프로파일이 이미 구성된 풀 설정을 들고 있어 그 상태로 존재할 수 없다는 판정, 프로덕션의 생성 지점이 같은 생성자를 지난다는 확인, 이 코드의 test 참조가 0 이라는 사실이 그 절에 있다. 결정적 진단 형식을 이 한 조합만 받지 못한다는 지적도 원본의 것이다.
- 이 기록이 더한 것은 넷이다. 두 실패 모양을 프로덕션 판정으로 나란히 실행해, 정렬까지 포함한 약속이 지켜지는 쪽과 문장 하나만 오는 쪽을 보였다. 문법과 정책의 분업이 이 계층 전반의 방식이고 이 규칙만 양쪽에 적혀 있다는 것을 확인했다. 같은 목록의 `DUPLICATE_CLIENT_NAME` 이 같은 성질이라는 것을 찾았다. 그리고 검증기의 코드가 서른다섯 종이며 원본의 서른넷은 여러 줄에 걸친 호출 하나를 지나친 수라는 것을 확인했다.
---
# 설정 참고 문서가 약속한 코드를 운영자는 받지 못한다
풀 상한 규칙이 레코드 정규 생성자와 프로파일 검증기 양쪽에 적혀 있다. 앞의 검증에서 먼저 예외가 발생하므로 뒤의 위반 코드는 실행되지 않는다. 설정 참고 문서는 그 코드를 발화하는 코드들과 같은 줄에 적어 둔다. 같은 목록에 같은 성질의 코드가 하나 더 있다.
## 관계
- **지워도 test가 초록인 검사가 셋이다**
두 사례 모두 앞의 검증에서 먼저 예외가 발생해 뒤의 검증 코드에 도달하지 못한다.
- **위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않는다**
이 코드의 test 참조가 0 인 이유가 그 표에 있다. 그 표의 34 는 한 줄 grep 이 만든 수다.
- **문서가 지목하는 조정 레코드를 쓰는 코드가 없다**
두 사례 모두 문서가 약속한 것을 만드는 코드가 없다.
## 문제
풀 설정 레코드의 정규 생성자가 경로당 상한이 전체 상한을 넘으면 던진다.
프로파일 검증기가 같은 조건을 검사하고 위반 코드를 목록에 넣는다.
두 검사가 겹치는지, 겹친다면 운영자가 받는 것이 무엇인지 확인했다.
## 결론
겹치고, 뒤가 도달하지 않는다.
프로파일은 이미 구성된 풀 설정을 들고 있다. 그 레코드는 경로당 상한이 전체를 넘는 상태로 존재할 수 없다. 정규 생성자를 지나지 않고 레코드를 만들 방법이 없으므로 생성 지점 수와 무관하게 그렇다. src/main 의 생성 지점이 부트스트랩 팩토리 한 군데이고 설정에서 읽은 값도 같은 생성자를 지난다는 것은 프로덕션 값도 예외가 아니라는 확인이다.
여기까지는 이 계층의 일반적인 분업이다. 레코드가 막는 것은 문법이 틀린 값이고, 검증기가 잡는 것은 문법은 맞는데 정책이 금하는 값이다. 리다이렉트 홉 수와 재시도 백오프도 같은 식으로 나뉘어 있다.
이 규칙만 양쪽에 적혀 있고, 실제로 걸리는 것은 앞쪽이다.
그래서 운영자가 받는 것이 달라진다. 프로덕션에서 전송을 잘못 잡으면 코드와 프로파일 이름과 설정 경로를 담은 위반이 돌아오고, 여럿이면 정렬된 목록으로 온다. 풀 상한을 잘못 잡으면 코드도 이름도 경로도 없는 인자 예외 하나가 온다. 부트스트랩을 거치면 빈 생성 실패가 그 인자 예외를 감싼다.
같은 목록에 같은 성질의 코드가 하나 더 있다. 이름 중복을 세는 자리는 프로파일 맵을 순회하는데, 맵의 키가 프로파일 이름이라 같은 이름이 두 번 들어갈 수 없다. 이름이 겹치면 설정 레코드가 먼저 그 이름을 두 번 선언했다고 던진다. 여기서도 코드가 비어 나간다.
경로당 상한과 전체 상한이 같은 값이면 양쪽 다 통과한다. 검사가 초과만 보기 때문이다.
주석이 적은 실제 위험은 막혀 있다. 어떤 반응형 전송에서는 경로당 손잡이가 유일하게 존재하는 것이라 그것이 조용히 실효 상한이 된다는 것인데, 그 조합은 만들어지지 않는다.
검증기가 내는 코드는 서른다섯 종이다. 원본이 적은 서른넷은 한 줄 grep 이 만든 수다. 한 코드가 세 줄에 걸쳐 있어 그 검색에 잡히지 않는다.
판정은 P3 다. 잘못된 설정은 어느 쪽이든 기동을 세운다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 레코드 생성자와 검증기 분기 대조, 생성 지점 전수 확인, 실행 탐침
소스 수정 : x
## 재현 조건
1. 풀 설정 레코드의 정규 생성자를 읽는다.
2. 프로파일 검증기의 같은 조건 분기를 읽는다.
3. 검증기 클래스 javadoc 이 약속하는 보고 형태를 읽는다.
4. 검증기가 내는 코드 수를 여러 줄에 걸친 호출까지 세어 확인한다.
5. src/main 전체에서 그 레코드를 만드는 곳과 값의 출처를 본다.
6. 이름 중복을 세는 자리가 무엇을 순회하는지, 그 앞에 무엇이 있는지 읽는다.
7. 두 코드의 이름을 참조하는 곳을 저장소 전체에서 센다.
8. 프로덕션 판정으로 정상 프로파일과 위반 하나짜리와 둘짜리를 검증기에 넣어 결과 모양을 본다.
9. 경로당 상한이 전체를 넘는 풀 설정을 만들어 보고, 같은 이름을 두 번 넣은 맵의 크기를 본다.
## 본문
<!-- body:start -->
풀 상한을 잘못 잡는 실수를 두 곳이 본다.
## 같은 규칙이 두 곳에 있다
:::evidence key="a11-f002-pool-route-exceeds-total" alt="풀 설정 레코드의 정규 생성자가 막는 두 조건, 프로파일 검증기가 같은 조건을 다시 보는 분기와 그 주석, 검증기 클래스 javadoc 이 약속하는 보고 형태, 검증기가 내는 코드 수를 여러 줄에 걸친 호출까지 세어 한 줄 검색과 대조한 결과와 그 걸리지 않는 호출, src/main 전체에서 그 레코드를 만드는 곳, 이름 중복을 세는 자리와 그 앞에서 먼저 던지는 설정 레코드, 그리고 두 코드의 이름을 참조하는 곳을 저장소 전체에서 확장자 제한 없이 센 터미널 기록." caption="레코드 생성자가 경로당 초과를 먼저 막고 검증기가 같은 조건을 다시 본다 · javadoc 은 코드마다 정렬된 위반을 약속한다 · 코드는 35종이고 한 줄 검색은 34종만 본다 · 이름 중복도 맵 앞에서 설정 레코드가 먼저 던진다 · 두 코드를 이름으로 참조하는 곳은 그 분기들과 설정 참고 문서뿐 — 54줄 · exit 0" zoom="true"
:::
```java
if (maxConnectionsPerRoute > maxTotalConnections) {
throw new IllegalArgumentException("per-route pool must not exceed the total pool");
}
```
레코드의 정규 생성자다. 검증기에는 같은 조건이 이렇게 있다.
```java
if (profile.pool().maxConnectionsPerRoute() > profile.pool().maxTotalConnections()) {
// A per-route ceiling above the total is incoherent, and on Reactor — where the per-route
// knob is the only one that exists — it silently becomes the effective limit.
out.add(violation("POOL_ROUTE_EXCEEDS_TOTAL", profile, "pool.max-connections-per-route"));
}
```
프로파일은 이미 만들어진 풀 설정을 들고 있고, 레코드는 정규 생성자를 지나지 않고 만들 수 없다. 이 조건이 참인 프로파일은 존재하지 않는다.
```text
bootstrap/autoconfigure/httpclient/HttpClientProfileFactory.java:83: return new PoolSettings(
```
`src/main` 의 생성 지점은 여기 하나이고, 설정에서 읽은 값을 같은 생성자에 넘긴다. 프로덕션 값도 예외가 아니라는 확인이다.
## 문법과 정책은 원래 나뉘어 있다
문법이 틀린 값은 레코드가 막고, 문법은 맞는데 정책이 금하는 값은 검증기가 잡는다. 음수 홉 수는 리다이렉트 설정 레코드가 던지고, 홉 수 0 에 리다이렉트를 켠 조합은 검증기가 코드로 보고한다. 재시도 백오프도 같은 식이다.
풀 상한 규칙만 양쪽에 적혀 있다. 그리고 걸리는 쪽은 앞이다.
## 그래서 운영자가 무엇을 받는가
:::evidence key="a11-f002-pool-route-exceeds-total-shape" alt="프로덕션 판정으로 정상 프로파일과, 경로당 상한이 전체와 같은 프로파일과, 위반이 하나인 프로파일과 둘인 프로파일을 각각 검증기에 넣어 돌아온 위반 목록. 경로당 상한이 전체를 넘는 풀 설정을 만들어 봤을 때의 결과. 그리고 같은 이름을 두 번 넣은 맵의 크기를 출력한 터미널 기록. 픽스처는 고정 리비전 소스에서 직접 컴파일한다." caption="전송 오설정은 코드와 프로파일 이름과 설정 경로를 담은 위반으로 돌아오고 둘이면 정렬되어 온다 · 경로당과 전체가 같으면 통과 · 경로당이 전체를 넘는 풀 설정은 인자 예외로 끝난다 · 같은 이름을 두 번 넣은 맵의 크기는 1 — 20줄 · exit 0" zoom="true"
:::
검증기의 클래스 javadoc 이 보고 형태를 약속한다.
```text
* <p>Every guard in the design has exactly one stable violation code here. The result is sorted so
* a configuration error reports deterministically across runs and machines.
```
프로덕션 판정에서 전송을 잘못 잡으면 그 약속대로 온다.
```text
전송만 SIMPLE 로 바꾼 프로파일
ClientProfileViolation[code=PRODUCTION_SIMPLE_FACTORY_FORBIDDEN, detail=profile=payment setting=transport]
전송 JDK + 경로 풀 요구 + HTTP/3 미승인
ClientProfileViolation[code=HTTP3_STABLE_FORBIDDEN, detail=profile=payment setting=protocols]
ClientProfileViolation[code=JDK_FINE_GRAINED_POOL_UNSUPPORTED, detail=profile=payment setting=transport]
```
둘이면 코드 순으로 정렬되어 온다. 풀 상한은 다르다.
```text
전체 10 / 경로당 20 -> IllegalArgumentException: per-route pool must not exceed the total pool
```
코드도, 프로파일 이름도, 설정 경로도 없다. 부트스트랩에서는 이것이 빈 생성 실패에 감싸여 나온다.
경계값은 양쪽 다 통과한다.
```text
전체 20 / 경로당 20 (같음)
위반 없음
```
검사가 보는 것이 초과뿐이라 같은 값은 걸리지 않는다.
## 같은 목록의 다른 코드
```text
profiles.forEach(
(name, profile) -> {
if (!seenNames.add(name.value())) {
violations.add("DUPLICATE_CLIENT_NAME profile=" + name.value());
}
```
순회 대상이 프로파일 맵이고 키가 프로파일 이름이다. 같은 이름이 두 번 들어갈 수 없다.
```text
같은 이름을 두 번 넣은 맵의 크기 : 1
```
이름이 겹치는 설정은 그 앞에서 이미 죽는다.
```text
if (seen.contains(name)) {
throw new IllegalStateException(where + " declares '" + name + "' more than once");
}
```
여기도 코드가 붙지 않는다.
## 문서는 두 코드를 다른 코드와 같이 적어 둔다
```text
docs/httpclient/configuration-reference.md:191:`RETRY_BACKOFF_REQUIRED`, `MISSING_PRODUCTION_SETTING`, `DUPLICATE_CLIENT_NAME`,
docs/httpclient/configuration-reference.md:194:`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`, `POOL_ROUTE_EXCEEDS_TOTAL`,
docs/httpclient/configuration-reference.md:209:- `POOL_ROUTE_EXCEEDS_TOTAL` — a per-route ceiling above the total is incoherent, and on Reactor,
```
두 코드가 저장소에 나오는 곳은 만드는 분기 각각 한 줄과 이 세 줄뿐이다.
## 코드는 몇 개인가
```text
violation( 호출 40개, 서로 다른 코드 35개
한 줄 grep 이 보는 코드 34개, 놓치는 것 ['PROXY_AMBIENT_NO_PROXY_UNSUPPORTED']
```
한 코드가 세 줄에 걸쳐 있다.
```text
if (profile.proxy().importAmbientNoProxy()) {
out.add(
violation(
"PROXY_AMBIENT_NO_PROXY_UNSUPPORTED", profile, "proxy.import-ambient-no-proxy"));
```
원본이 적은 서른넷은 이 호출을 지나친 수다.
## 확인하지 못한 것
두 위반 코드가 과거에 도달 가능했던 시점이 있었는지 이력에서 확인하지 않았다.
부트스트랩을 실제로 띄워 잘못된 풀 설정이 어떤 예외로 감싸여 보고되는지 관측하지 않았다. 확인한 것은 레코드 생성자가 던지는 예외 자체까지다.
<!-- body:end -->
@@ -0,0 +1,214 @@
---
kind: CASE
slug: a11-f004-number
title: 재생 가능으로 인증된 본문이 다른 바이트를 낸다
topic: http-client-and-resilience
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a11-f004-number
evidenceCapturedOn: 2026-09-02
body: case-a11-f004-number.body.md
assets:
- key: a11-f004-number
file: ../../../final/evidence/rendered/a11-f004-number.svg
- key: a11-f004-number-bytes
file: ../../../final/evidence/rendered/a11-f004-number-bytes.svg
evidence:
- ../../../final/evidence/raw/a11-f004-number.txt
- ../../../final/evidence/raw/a11-f004-number-bytes.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L249 이다. 등급은 P3 이다. 첫 분기가 `Number` 를 무조건 통과시킨다는 지적, 원자 패키지 여섯 타입이 그것을 상속하는 가변 타입이라는 열거, 그래서 성분에 원자 정수를 가진 레코드가 재생 가능으로 인증되고 호출자가 값을 올리면 같은 멱등 키로 다른 바이트가 나간다는 판정, 시간 상위 타입에도 같은 구멍이 있다는 관찰, 자바독 선언과 코드 범위가 어긋난다는 이유가 그 절에 있다. 수정 방향 둘 — 상자 원시형 여덟 종과 큰 정수·큰 십진수를 명시하거나 원자 패키지를 제외하는 것 — 도 그 절이 제시한 것이다.
- 이 기록이 더한 것은 셋이다. 여섯 타입의 판정과, 요청 경로가 실제로 쓰는 변환기로 인코딩한 결과를 받아, 판정이 재생 가능인 채 바이트가 달라지는 것을 그대로 보였다. 선언에 없는 분기가 셋이고 그중 실제 구멍은 하나라는 것, 그리고 상자 원시형과 원자 계열 사이에 남는 두 타입이 자기를 바꾸지 않는다는 것을 확인했다. 그리고 그 test 넷의 입력을 전수 확인해 숫자는 상자 원시형만 들어간다는 것을 보였다 — 원본은 test 넷의 이름과 일반적 가변 객체를 잡는다는 한계까지만 적는다.
---
# 재생 가능으로 인증된 본문이 다른 바이트를 낸다
깊은 불변성 검사의 첫 분기가 숫자 상위 타입을 무조건 통과시킨다. 원자 계열 여섯 타입이 그 상위 타입을 상속하는 가변 타입이다. 성분에 그런 카운터를 둔 요청을 요청 경로의 실제 변환기로 두 번 인코딩해, 인증은 유지되고 결과만 갈리는 것을 받았다.
## 관계
- **재시도 안전성은 증거에 기반해 판정한다**
이 검사가 존재하는 이유다.
- **지워도 test가 초록인 검사가 셋이다**
저쪽은 실행되지 않는 분기이고, 이쪽은 실행되지만 선언보다 넓은 분기다.
- **타입이 문서화한 불변식은 타입이 강제한다**
자바독이 선언한 범위를 코드가 지키지 않는다.
## 문제
요청 본문이 재시도에도 같은 바이트를 내는지 판정하는 검사가 있다.
그 검사의 자바독이 범위를 선언한다. 레코드와 열거형과 문자열과 상자 원시형과 불변 컬렉션 뷰는 재생 가능하고, 그 밖의 모든 것은 일회성으로 취급한다는 것이다. 값을 확실히 하려는 호출자에게는 바이트 배열 본문으로 굳히는 길을 안내한다.
## 결론
첫 분기가 그 선언보다 넓다.
선언에 없는 것이 셋 들어 있다. 숫자 상위 타입과 UUID 와 시간 상위 타입이다. UUID 는 불변이고 시간 쪽은 표준 구현체가 전부 불변이라, 남는 것은 숫자 상위 타입 한 줄이다.
상자 원시형과 원자 계열 사이에 남는 표준 타입은 큰 정수와 큰 십진수 둘뿐이고, 둘 다 자기 값을 바꾸지 않는다. 더하기를 불러도 원래 객체가 그대로인 것을 확인했다. 그래서 표준 라이브러리 안의 실제 구멍은 원자 계열 여섯이다.
원자 정수와 원자 긴 정수와 긴 덧셈기와 실수 덧셈기와 긴 누산기와 실수 누산기다. 여섯 전부 재생 가능으로 인증되는 것을 실행으로 확인했다.
성분에 원자 정수를 둔 레코드도 마찬가지다. 요청 경로가 실제로 쓰는 변환기로 그 본문을 두 번 인코딩하면 첫 번째는 attempt 가 1 이고 두 번째는 2 인데, 사이에서 판정은 재생 가능 그대로다. 재시도가 같은 멱등 키로 다른 바이트를 보낸다는 뜻이다.
그것이 이 검사가 존재하는 이유로 인용된 결과다. 검사를 고정하는 test 클래스의 자바독이 바로 그 문장을 적어 뒀다.
test 넷이 보는 갈래는 따로 있다. 불변 레코드, 호출자가 쥔 컬렉션, 그것을 감싼 레코드, 들여다볼 수 없는 빈이다. 숫자는 상자 원시형만 들어간다.
같은 형태의 좁은 구멍이 시간 상위 타입에도 있다. 사용자 정의 구현은 불변이 아닐 수 있다. 다만 숫자 쪽이 훨씬 현실적이다. 요청 객체에 카운터를 두는 것은 드물지 않다.
판정은 P3 다. 도달하려면 원자 카운터를 요청 객체에 넣어야 하고, 검사 전체의 방향은 보수적이다. 호출자가 쥔 컬렉션은 그것을 감싼 레코드까지 일회성으로 떨어진다.
자바독은 이 검사의 계약이고, 계약과 구현이 어긋난 쪽은 구현이다.
수정은 둘 중 하나다. 상자 원시형 여덟 종과 큰 정수와 큰 십진수를 명시하거나, 원자 패키지를 제외하는 것이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 검사 분기와 타입 계층 대조, 실행 탐침
소스 수정 : x
## 재현 조건
1. 깊은 불변성 검사의 자바독 선언을 읽는다.
2. 첫 분기의 허용 타입 목록을 확인하고 선언에 없는 것을 고른다.
3. 검사를 고정하는 test 넷이 무엇을 보는지 확인한다.
4. 기본 설정의 REST 클라이언트가 JSON 본문을 어느 변환기로 쓰는지 확인한다.
5. 큰 정수와 큰 십진수가 자기 값을 바꾸는지, 원자 정수는 바꾸는지 대조한다.
6. 원자 계열 여섯 타입을 각각 본문 값으로 넣어 판정을 받는다.
7. 성분에 원자 정수를 둔 레코드의 판정을 받고, 그 변환기로 인코딩한 뒤 카운터를 올려 다시 인코딩해 바이트를 비교한다.
8. 호출자가 쥔 컬렉션과 그것을 감싼 레코드의 판정을 대조한다.
## 본문
<!-- body:start -->
이 모듈은 본문을 인코딩하기 전에, 그 본문이 재시도에도 같은 바이트를 낼지 먼저 판정한다.
## 선언과 분기
:::evidence key="a11-f004-number" alt="본문 재생 가능성 검사의 자바독이 선언하는 범위, 깊은 불변성 판정의 첫 분기 전체, 그 검사를 고정하는 test 클래스의 javadoc, 그리고 그 test 넷의 이름과 표시 이름을 출력한 터미널 기록." caption="자바독은 레코드·열거형·문자열·상자 원시형·불변 컬렉션 뷰만 재생한다고 적고, 확실한 호출자에게는 값을 바이트 배열로 굳히라고 덧붙인다 · 첫 분기는 Number 를 무조건 통과시킨다 · test 클래스 javadoc 이 다른 바이트에 같은 멱등 키를 이 검사의 존재 이유로 적는다 — 41줄 · exit 0" zoom="true"
:::
```text
* <p>The check is structural and conservative: records, enums, strings, boxed primitives and
* immutable collection views replay; anything else is treated as one-shot, so the retry engine
* refuses rather than gambling. A caller who knows better can freeze the value itself — serialize
* it to a {@code byte[]} body — which states the guarantee instead of asserting it.
```
확실한 호출자에게는 값을 바이트 배열 본문으로 굳히라고 적어 뒀다. 아래는 그 권고를 따르지 않은 값에 대한 이야기다.
판정의 첫 분기는 이렇다.
```java
if (candidate instanceof String
|| candidate instanceof Number
|| candidate instanceof Boolean
|| candidate instanceof Character
|| candidate instanceof Enum<?>
|| candidate instanceof java.util.UUID
|| candidate instanceof java.time.temporal.Temporal) {
return true;
}
```
선언에 없는 분기가 셋이다. `Number``UUID``Temporal`. `UUID` 는 불변이고 `Temporal` 은 표준 구현체가 전부 불변이라, 남는 것은 `Number` 한 줄이다. 상자 원시형만이 아니라 `Number` 를 상속하는 모든 타입이 들어온다.
## 넣어 보면
:::evidence key="a11-f004-number-bytes" alt="기본 설정의 REST 클라이언트가 JSON 본문을 쓸 때 고르는 변환기, 원자 계열 여섯 타입을 각각 본문 값으로 넣어 받은 판정, 큰 정수와 큰 십진수가 더하기 뒤에도 자기 값을 유지하는 것과 원자 정수가 바뀌는 것의 대조, 성분에 카운터를 둔 레코드의 판정과 그 변환기로 두 번 인코딩한 결과와 바이트 동일성 비교, 그리고 호출자가 쥔 컬렉션과 그것을 감싼 레코드의 판정을 출력한 터미널 기록." caption="요청 경로의 JSON 변환기는 Spring 의 JacksonJsonHttpMessageConverter · 원자 계열 여섯 타입이 전부 REPLAYABLE · 큰 정수와 큰 십진수는 더하기 뒤에도 자기 값 그대로 · 카운터를 둔 레코드는 판정이 그대로인 채 인코딩이 attempt 1 에서 2 로 바뀐다 — 35줄 · exit 0" zoom="true"
:::
```text
[java.util.concurrent.atomic 의 Number 하위 타입]
AtomicInteger Number 상속 true 판정 REPLAYABLE
AtomicLong Number 상속 true 판정 REPLAYABLE
LongAdder Number 상속 true 판정 REPLAYABLE
DoubleAdder Number 상속 true 판정 REPLAYABLE
LongAccumulator Number 상속 true 판정 REPLAYABLE
DoubleAccumulator Number 상속 true 판정 REPLAYABLE
```
여섯 다 값을 바꿀 수 있는 타입이고, 여섯 다 통과한다. `Number` 를 상속했다는 것 말고 공통점이 없다.
상자 원시형과 이 여섯 사이에 남는 표준 타입은 둘뿐이다.
```text
[상자 원시형과 원자 계열 사이에 남는 둘]
BigInteger add 뒤 자기 값 7 판정 REPLAYABLE
BigDecimal add 뒤 자기 값 7.5 판정 REPLAYABLE
AtomicInteger increment 뒤 자기 값 8 판정 REPLAYABLE
```
둘은 더하기를 불러도 자기 값이 그대로다. 원자 정수는 7 이 8 이 된다. 같은 판정을 받는 세 타입 중 마지막 하나만 자기를 바꾼다.
## 판정은 그대로, 바이트는 다르다
요청 경로가 JSON 본문을 쓸 때 고르는 변환기부터 확인했다.
```text
[요청 본문을 인코딩하는 변환기] RestClient 기본 목록
org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
```
그 변환기로 두 번 인코딩한 결과다.
```text
[성분에 카운터를 둔 record]
판정 : REPLAYABLE
첫 인코딩 : {"id":"A-1","attempt":1}
다시 인코딩 : {"id":"A-1","attempt":2}
판정(그대로) : REPLAYABLE
같은 바이트인가 : false
```
사이에 한 것은 카운터를 올린 것뿐이다. 재시도 엔진이 읽는 값은 두 번 다 재생 가능이고, 같은 값을 다시 인코딩한 결과는 다르다.
## 이 검사가 존재하는 이유
```text
* <p>Every {@code ObjectBody} used to report {@code REPLAYABLE}. A caller who reused a builder or
* kept a reference to a list therefore got a retry that re-encoded the value <em>as it was at retry
* time</em> — different bytes, same idempotency key, which is precisely what a replay must never
* be.
```
이 문장은 test 클래스 javadoc 에 있다.
그 test 넷은 다른 갈래를 본다.
```text
28: @DisplayName("a record of immutable components replays")
29: void anImmutableRecordReplays() {
41: @DisplayName("a value the caller can still mutate does not replay")
42: void aMutableValueIsOneShot() {
51: @DisplayName("a record wrapping a mutable component does not replay")
52: void aRecordWrappingMutableStateIsOneShot() {
66: @DisplayName("an uninspectable value is one-shot")
67: void anArbitraryBeanIsOneShot() {
```
입력에는 컬렉션과 맵과 빈이 들어 있고, 숫자는 상자 원시형뿐이다. `Map.of("k", 1)` 의 1 과 `ImmutableOrder` 의 int 가 그것이다. `Number` 분기 자체는 밟히지만 가변 숫자 타입은 한 번도 들어가지 않는다.
## 검사가 잡는 쪽
```text
[검사가 잡는 쪽]
ArrayList 자체 판정 ONE_SHOT
ArrayList 를 감싼 record 판정 ONE_SHOT
List.of 를 감싼 record 판정 REPLAYABLE
```
컬렉션 쪽은 이름으로 불변 뷰인지 확인하고 감싼 레코드까지 따라간다. 원자 카운터를 요청 객체에 넣지 않는 한 이 구멍에 닿지 않는다.
## 확인하지 못한 것
재시도 엔진을 실제로 돌려 두 번째 시도가 그 바이트를 보내는지 관측하지 않았다. 확인한 것은 판정과 인코딩까지다.
사용자 정의 시간 구현으로 같은 구멍을 재현하지 않았다.
<!-- body:end -->
@@ -0,0 +1,216 @@
---
kind: CASE
slug: a11-f006-boundeddatabufferflux
title: 부르는 쪽이 이미 걸어 둔 것을 한 번 더 건다
topic: http-client-and-resilience
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a11-f006-boundeddatabufferflux
evidenceCapturedOn: 2026-09-02
body: case-a11-f006-boundeddatabufferflux.body.md
assets:
- key: a11-f006-boundeddatabufferflux
file: ../../../final/evidence/rendered/a11-f006-boundeddatabufferflux.svg
- key: a11-f006-boundeddatabufferflux-release
file: ../../../final/evidence/rendered/a11-f006-boundeddatabufferflux-release.svg
evidence:
- ../../../final/evidence/raw/a11-f006-boundeddatabufferflux.txt
- ../../../final/evidence/raw/a11-f006-boundeddatabufferflux-release.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L424 이다. 등급은 P3 이다. 자바독이 취소와 오류를 실제로 새는 경로로 적는다는 인용, 두 연산자가 각각 정의상 항등이라는 판정, 그래서 버퍼 해제가 전적으로 폐기 연산자와 드라이버의 해제 동작에 의존한다는 결론, 누수가 실재한다고 주장하지 않는다는 단서, 취소 경로 test 가 확인하는 것은 연결 반환이라는 지적, 실질적 안전망이 모든 레인에 켜진 누수 탐지기라는 지적, 그리고 수정 방향 둘이 그 절에 있다.
- 이 기록이 더한 것은 셋이다. 이 사슬을 부르는 유일한 지점이 바로 뒤에서 같은 폐기 연산자를 한 번 더 걸고, 폐기 처리기가 상류로 전파되므로 호출 지점에서는 사슬 자신의 것까지 잉여라는 것을 실행으로 보였다. 오류 경로에서는 있는 그대로의 사슬도 상류가 쥔 버퍼를 돌려주지 않는다는 것 — 34~35 주석의 서술과 다르다. 그리고 폐기 연산자가 도는 것은 상류가 규약을 지킬 때뿐이라는 것이다.
---
# 부르는 쪽이 이미 걸어 둔 것을 한 번 더 건다
클래스 자바독이 취소와 오류를 실제로 새는 경로로 지목한다. 그 두 이름을 딴 연산자는 신호에 대해 항등이다. 남은 폐기 연산자도 이 사슬을 부르는 유일한 지점이 바로 뒤에서 한 번 더 걸어 둔 것이라, 호출 지점에서는 셋 다 빼도 결과가 같다.
## 관계
- **타입이 문서화한 불변식은 타입이 강제한다**
자바독이 지목한 경로를 그 이름의 연산자가 지키지 않는다.
- **누수 하나는 회수되고 하나는 회수되지 않는다**
같은 리프의 자원 해제 사례다.
- **그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다**
취소 경로 test 본문이 확인하는 것은 버퍼 해제가 아니다.
## 문제
반응형 본문을 제한하고 넘기지 않은 버퍼를 해제하는 클래스가 있다.
클래스 자바독이 목적을 적는다. 취소와 오류가 실제로 새는 경로라는 것이다. 구독자가 요구를 멈추고, 상류가 이미 만든 것을 버리고, 그 버퍼는 아무도 반환하지 않는 직접 메모리라는 것이다.
그 두 경로에 붙은 연산자가 실제로 무엇을 하는지, 그리고 이 사슬이 불리는 자리에서는 어떻게 되는지 확인했다.
## 결론
취소 연산자의 본문은 비어 있고, 오류 대체 연산자는 받은 오류를 그대로 다시 방출한다. 둘 다 신호에 대해 항등이다. 사슬의 모양은 바꾼다. 두 연산자가 붙으면 반환 타입이 달라지고 융합이 끊긴다.
버퍼 다섯 개 중 하나만 받고 취소하면 있는 그대로에서 미회수가 0 이고, 두 연산자를 빼도 0 이다. 폐기 연산자까지 빼면 넷이 남는다. 이 사슬만 구독했을 때는 폐기 연산자가 일한다.
그런데 이 사슬을 부르는 곳은 하나이고, 그 자리가 바로 뒤에서 같은 폐기 연산자를 한 번 더 건다. 같은 조건에서 사슬 자신의 폐기 연산자를 빼도 미회수가 0 이다. 폐기 처리기는 문맥으로 상류에 전파되므로 바깥의 것이 안쪽까지 덮는다.
오류 경로는 다르다. 상류가 셋을 쥔 채 던지면 있는 그대로에서 셋이 남고, 폐기 연산자를 빼도 셋이다. 자바독이 지목한 두 경로 중 오류 쪽에서 이 사슬이 하는 일은 없다.
오류 대체 연산자 안의 주석은 흐르던 것이 위의 폐기 연산자로 처리된다고 적는데, 오류 신호에서 그 폐기는 일어나지 않는다.
폐기 연산자가 도는 조건도 좁다. 상류가 폐기 규약을 지킬 때만이다. 규약을 지키지 않는 상류로 바꾸면 폐기 연산자가 있으나 없으나 넷이 남는다.
실제 누수가 있다는 주장은 아니다. 취소 시 미방출 버퍼는 드라이버의 바이트 흐름이 스스로 돌려준다.
문제는 코드가 하지 않는 일을 하는 것처럼 읽힌다는 것이다. 자바독이 지목한 두 누수 경로의 이름을 딴 연산자가 나란히 있고 둘 다 비어 있으므로, 이 클래스를 읽는 사람은 취소와 오류 해제가 여기서 명시적으로 처리된다고 결론짓게 된다.
취소 경로 test 본문이 단언하는 것은 다음 호출이 성공한다는 것, 즉 연결 반환이다. 다만 그 test 클래스에는 누수 탐지기가 최고 수준으로 살아 있다는 것과 누수 보고가 하나도 없다는 것을 단언하는 확장이 붙어 있다.
판정은 P3 다.
수정은 둘 중 하나다. 세 연산자를 지우고 자바독이 부르는 쪽의 폐기 연산자와 드라이버의 역할을 정확히 적게 하거나, 취소와 오류 경로에서 실제로 해제해야 할 것이 있다면 그것을 구현하는 것이다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 연산자 본문 확인과 자바독 대조, 호출 지점 추적, 실행 탐침
소스 수정 : x
## 재현 조건
1. 클래스 자바독의 누수 경로 서술을 읽는다.
2. 조립된 연산자 사슬을 순서대로 읽는다.
3. 이 사슬을 부르는 곳을 세고, 그 호출 뒤에 무엇이 이어지는지 읽는다.
4. 취소 경로 test 본문과 그 클래스에 붙은 확장이 무엇을 단언하는지 확인한다.
5. 참조 계수를 볼 수 있는 버퍼 다섯 개를 만들어, 사슬만 구독하고 하나만 받은 뒤 취소한다.
6. 같은 조건을 호출 지점의 조립 모양으로 바꿔 반복한다.
7. 상류가 셋을 쥔 채 던지는 오류 경로로 반복한다.
8. 폐기 규약을 지키지 않는 상류로 반복한다.
## 본문
<!-- body:start -->
클래스 자바독이 두 경로를 이름으로 지목한다.
## 지목된 두 경로
:::evidence key="a11-f006-boundeddatabufferflux" alt="클래스 자바독이 지목한 두 누수 경로, 조립되는 연산자 사슬 전체를 줄 번호와 함께, 이 사슬을 부르는 유일한 지점과 그 호출을 감싼 표현식과 바로 뒤에 이어지는 연산자, 취소 경로 test 두 요청 전체와 그 클래스에 붙은 확장, 그리고 그 확장이 단언하는 두 가지를 출력한 터미널 기록." caption="자바독은 취소와 오류를 실제로 새는 경로로 지목한다 · 사슬은 doOnNext · doOnDiscard · 비어 있는 doOnCancel · 오류를 그대로 재방출하는 onErrorResume 순 · 부르는 곳은 한 군데이고 그 자리가 105 에서 같은 doOnDiscard 를 한 번 더 건다 · test 클래스에는 누수 탐지기를 단언하는 확장이 붙어 있다 — 104줄 · exit 0" zoom="true"
:::
```text
* <p>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
24: return source
25: .doOnNext(
26: buffer -> {
27: limiter.recordWireBytes(buffer.readableByteCount());
28: guard.markDelivered();
29: })
30: .doOnDiscard(DataBuffer.class, DataBufferUtils::release)
31: .doOnCancel(() -> {})
32: .onErrorResume(
33: failure -> {
34: // Buffers already emitted belong to the subscriber; anything still in flight is
35: // discarded through doOnDiscard above.
36: return Flux.error(failure);
37: });
```
31 은 본문이 비어 있어 정의상 항등이다. 32~37 은 받은 오류를 그대로 다시 방출하므로 오류 신호에 대해 항등이다.
## 사슬만 구독하면
:::evidence key="a11-f006-boundeddatabufferflux-release" alt="참조 계수를 볼 수 있는 버퍼 다섯 개를 만들어 네 가지 조건으로 돌린 결과. 사슬만 구독해 하나만 받고 취소했을 때, 호출 지점의 조립 모양으로 같은 것을 했을 때, 상류가 셋을 쥔 채 던졌을 때, 그리고 상류가 폐기 규약을 지키지 않을 때다. 각 조건에서 있는 그대로와 연산자를 뺀 사슬을 나란히 세었다." caption="사슬만 구독하면 두 연산자를 빼도 미회수 0 이고 폐기 연산자까지 빼면 넷 · 호출 지점 모양에서는 폐기 연산자를 빼도 0 · 오류 경로는 있는 그대로에서도 셋이 남고 규약을 안 지키는 상류에서는 넷이 남는다 — 17줄 · exit 0" zoom="true"
:::
```text
[취소] bound 만 구독한다 — 다섯 중 하나만 받고 취소
있는 그대로 전달 1, 상류가 쥐고 있던 것 중 미회수 0 / 4
두 연산자 제거 전달 1, 상류가 쥐고 있던 것 중 미회수 0 / 4
doOnDiscard 까지 제거 전달 1, 상류가 쥐고 있던 것 중 미회수 4 / 4
```
31 과 32~37 을 빼도 결과가 같고, 30 을 빼면 달라진다. 여기까지는 30 이 일한다.
## 부르는 자리에서는 다르다
이 사슬을 부르는 곳은 하나다.
```java
87: return request
88: .exchangeToFlux(
89: response -> {
...
102: return BoundedDataBufferFlux.bound(
103: response.bodyToFlux(DataBuffer.class), limiter, guard);
104: })
105: .doOnDiscard(DataBuffer.class, DataBufferUtils::release);
```
105 가 같은 폐기 연산자를 한 번 더 건다. 폐기 처리기는 구독자 문맥에 쓰여 상류로 전파되므로, 바깥의 것이 `bound` 안쪽까지 덮는다.
```text
[취소] 부르는 쪽이 105 에서 같은 폐기 연산자를 한 번 더 건다
있는 그대로 전달 1, 상류가 쥐고 있던 것 중 미회수 0 / 4
doOnDiscard 까지 제거 전달 1, 상류가 쥐고 있던 것 중 미회수 0 / 4
```
30 을 빼도 0 이다. 실제로 불리는 모양에서는 `doOnNext` 뒤의 세 연산자가 모두 잉여다.
## 오류 경로에서는 아무것도 하지 않는다
```text
[오류] 상류가 셋을 쥔 채 던진다
있는 그대로 전달 2, 상류가 쥐고 있던 것 중 미회수 3 / 3
doOnDiscard 까지 제거 전달 2, 상류가 쥐고 있던 것 중 미회수 3 / 3
```
34~35 의 주석은 흐르던 것이 위의 폐기 연산자로 처리된다고 적는다. 오류 신호에서는 그 폐기가 일어나지 않는다.
## 폐기 연산자가 도는 조건
```text
[취소] 상류가 폐기 규약을 지키지 않는다
있는 그대로 전달 1, 상류가 쥐고 있던 것 중 미회수 4 / 4
doOnDiscard 까지 제거 전달 1, 상류가 쥐고 있던 것 중 미회수 4 / 4
```
취소에서 상류가 쥔 것을 돌려주는 것은 상류가 폐기 규약을 지킬 때뿐이다.
## 취소 경로 test 가 보는 것
```java
StepVerifier.create(
gateway
.download(
profile.name(),
HttpOperation.get(new OperationName("download"), "/small", Map.of()))
.doOnNext(DataBufferUtils::release))
.expectNextCount(1)
.verifyComplete();
```
앞의 요청이 하나만 받고 취소한 뒤, 두 번째 요청이 성공하는지 본다. 단일 연결 풀이라 그 성공이 연결 반환의 증거다. 버퍼는 test 가 직접 해제한다.
버퍼 해제를 이 본문이 단언하지는 않는다. 대신 클래스에 확장이 붙어 있다.
```text
assertThat(ResourceLeakDetector.getLevel())
```
그 확장이 탐지기가 최고 수준으로 살아 있다는 것과, 실행 뒤 누수 보고가 하나도 없다는 것을 단언한다.
## 남는 것
누수가 실재한다고 주장하지 않는다. 취소 시 미방출 버퍼는 드라이버의 바이트 흐름이 스스로 돌려주고, 누수 탐지기가 모든 레인에서 확인한다.
문제는 읽는 방식이다. 자바독이 두 누수 경로를 이름으로 지목하고, 그 두 이름을 딴 연산자가 사슬에 나란히 있고, 둘 다 비어 있다. 세 번째는 부르는 쪽이 이미 걸어 둔 것과 같다.
## 확인하지 못한 것
실제 드라이버의 바이트 흐름 위에서 같은 대조를 하지 않았다. 탐침은 참조 계수를 볼 수 있는 힙 버퍼를 직접 만들어 흘렸고, 자바독이 말하는 직접 메모리는 탐침 범위 밖이다.
<!-- body:end -->
@@ -0,0 +1,223 @@
---
kind: CASE
slug: a11-f007-dns
title: 혼동이라 적어 둔 이름이 아직 검사 문구에 있다
topic: http-client-and-resilience
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a11-f007-dns
evidenceCapturedOn: 2026-09-02
body: case-a11-f007-dns.body.md
assets:
- key: a11-f007-dns
file: ../../../final/evidence/rendered/a11-f007-dns.svg
- key: a11-f007-dns-overloads
file: ../../../final/evidence/rendered/a11-f007-dns-overloads.svg
evidence:
- ../../../final/evidence/raw/a11-f007-dns.txt
- ../../../final/evidence/raw/a11-f007-dns-overloads.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L583 이다. 등급은 P3 이다. 블로킹 오버로드에만 그 검사와 주석이 있다는 대조, 반응형 오버로드는 동적 대상 안정만 본다는 지적, 그래서 주석이 혼동이라 적은 상태가 반응형 경로에 남아 있다는 판정, 출하된 두 반응형 전송의 두 플래그가 같은 값이라 지금 노출이 없다는 확인, 프로파일 검증기가 동적 모드와 실험적 전송 조합을 이미 거부한다는 사실, 위험이 열리는 조합과 수정 방향이 그 절에 있다.
- 이 기록이 더한 것은 넷이다. 36 의 문구가 자기가 읽지 않는 플래그의 이름을 쓴다는 것 — 혼동은 과거형이 아니다. 그래서 이 두 검사에 걸린 유일한 test 가 36 의 문구만으로 통과하므로 43 을 지워도 빨개지지 않는다는 것. 두 플래그 조합 넷을 실제로 넣어, 갈리는 것이 한 조합뿐이고 나머지 거부에서 반응형이 내놓는 문구가 검사하지 않은 능력의 이름이라는 것. 그리고 오늘 막고 있는 것이 프로파일 검증기가 아니라 반응형 제공자 맵에 전송이 하나뿐이라는 사실이고, 그 맵은 빈 부재 조건이라 배포가 갈아 끼울 수 있다는 것이다.
---
# 혼동이라 적어 둔 이름이 아직 검사 문구에 있다
전송 능력 검증기의 블로킹 오버로드에 동적 모드 검사가 둘 있다. 앞엣것은 안정 플래그를 읽으면서 뒤엣것이 지키는 능력의 이름을 문구에 쓴다. 반응형 오버로드에는 앞엣것만 있고, 그래서 그 문구가 반응형 거부에도 그대로 나온다.
## 관계
- **두 오버로드가 있었고 호출되는 쪽이 틀린 쪽이었다**
두 사례 모두 오버로드가 둘인데 검사가 한쪽에만 있다.
- **재생 가능으로 인증된 본문이 다른 바이트를 낸다**
같은 리프의 다른 검사 사례다.
- **가드의 존재가 곧 테넌트 격리 보장은 아니다**
지금 노출이 없는 것과 검사가 있는 것은 다르다.
## 문제
능력 검증기는 블로킹용과 반응형용 오버로드를 따로 갖는다.
두 오버로드가 같은 검사를 하는지 대조했다.
## 결론
블로킹 쪽에만 있는 검사가 하나 있다.
동적 모드인데 호출 범위 검증된 DNS 고정 능력이 없으면 누락 목록에 넣는 검사다. 그 앞에는 같은 조건에서 동적 대상 안정 능력을 보는 검사가 따로 있다.
뒤엣것의 주석이 이유를 적는다. 그 능력 플래그가 모든 능력 레코드에 선언되어 있었지만 아무도 읽지 않았다는 것이다. 서버 측 요청 위조 방어의 주소 검증이 소켓까지 살아남는지를 결정하는 것이 그 능력이므로, 그것이 없는 전송은 동적 대상 안정 플래그가 무엇을 말하든 동적 대상을 서비스할 수 없다는 것이다. 둘이 혼동되고 있었다는 것이다.
그런데 앞엣것의 문구가 자기가 읽지 않는 플래그의 이름을 쓴다. 그 검사는 안정 플래그를 보면서 검증된 DNS 고정이 없다고 적고, 고정 플래그를 실제로 읽는 것은 뒤엣것이다. 주석이 지나간 일로 적은 혼동이 이 문구에는 아직 남아 있다.
그래서 하나뿐인 그 test 가 둘째 줄을 놓친다. 그 test 는 두 플래그가 모두 거짓인 능력을 넣고 메시지에 검증된 DNS 고정이 들어 있는지만 본다. 앞엣것의 문구만으로 맞는 조건이다. 뒤엣것을 통째로 지워도 초록이다.
반응형 오버로드에는 앞엣것만 있다.
실행으로 확인했다. 동적 프로파일 하나에 두 플래그 조합 넷을 넣었다. 둘 다 참이면 양쪽 통과다. 안정이 거짓인 두 조합은 양쪽 다 거부하는데, 그때 반응형이 내놓는 문구가 검증된 DNS 고정이 없다는 말이다. 고정이 거짓이고 안정이 참인 조합에서만 갈린다. 블로킹은 호출 범위 항목으로 거부하고 반응형은 통과한다.
지금 그 조합은 만들어지지 않는다. 다섯 능력 모두 두 플래그에 같은 값을 넣어 둔다. 셋은 둘 다 참이고 둘은 둘 다 거짓이다.
막고 있는 것은 프로파일 검증기가 아니다. 프로파일 검증기는 동적 모드와 두 전송 상수의 조합을 이름으로 막는데, 전송이 하나 늘면 그 목록에 없다. 오늘 이 오버로드에 닿는 능력이 하나뿐인 이유는 기본 자동 구성이 반응형 제공자 맵에 전송 하나만 넣기 때문이다.
그 맵은 빈 부재 조건이 붙어 있다. 자기 맵을 올리는 배포는 소스를 건드리지 않고 그 자리의 능력을 갈아 끼울 수 있다.
판정은 P3 다.
위험은 그렇게 올린 능력이 동적 대상 안정을 참으로, 호출 범위 검증된 DNS 고정을 거짓으로 선언하는 경우다. 주석이 못박은 정확히 그 조합이 반응형 쪽에서는 통과한다.
수정은 38~44 를 반응형 오버로드에 복사하는 것이다. 다만 출하된 다섯이 두 플래그를 같은 값으로 두고 있으므로 오늘 바뀌는 판정은 없다.
반응형 레코드에는 어느 검사도 읽지 않는 플래그가 둘 더 있다. 주석이 적은 상태가 그 둘에는 그대로다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 두 오버로드 대조, 플래그 참조 전수 확인, 실행 탐침
소스 수정 : x
## 재현 조건
1. 전송 능력 검증기의 두 오버로드를 나란히 읽는다.
2. 블로킹 쪽 마지막 두 검사가 각각 어떤 플래그를 읽고 어떤 문구를 넣는지 대조한다.
3. 그 두 검사에 걸린 test 를 찾아 무엇을 단언하는지 읽는다.
4. 세 능력 플래그를 읽는 곳을 저장소 전체에서 센다.
5. 출하된 능력 다섯이 두 플래그에 넣는 값을 뽑는다.
6. 반응형 제공자 맵에 어떤 전송이 들어가는지, 그 빈에 어떤 조건이 붙어 있는지 읽는다.
7. 프로파일 검증기가 동적 모드에 대해 이름으로 막는 전송을 확인한다.
8. 동적 프로파일 하나에 두 플래그 조합 넷을 넣어 양쪽 오버로드에 통과시킨다.
## 본문
<!-- body:start -->
전송 능력 검증기에 오버로드가 둘 있다.
## 한쪽에만 있는 검사
:::evidence key="a11-f007-dns" alt="전송 능력 검증기의 블로킹 오버로드에 있는 마지막 두 검사와 그 사이의 주석, 반응형 오버로드의 마지막 검사, 그 두 검사에 걸린 유일한 test 가 단언하는 것, 세 능력 플래그를 읽는 곳 전부, 출하된 능력 다섯이 두 플래그에 넣는 값, 반응형 제공자 맵에 들어가는 전송과 그 빈에 붙은 조건, 그리고 프로파일 검증기가 동적 모드에 대해 이름으로 막는 전송을 출력한 터미널 기록." caption="36 은 안정 플래그를 읽으면서 검증된 DNS 고정이라는 문구를 넣고, 고정 플래그를 읽는 것은 43 이다 · test 는 검증된 DNS 고정이라는 문자열만 확인한다 · 출하된 능력 다섯이 두 플래그를 같은 값으로 선언한다 · 반응형 제공자 맵에는 REACTOR_NETTY 하나가 들어가고 그 빈은 빈 부재 조건이다 — 68줄 · exit 0" zoom="true"
:::
블로킹 쪽의 마지막 두 검사다.
```java
35: if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
36: missing.add("validated DNS pinning for dynamic targets");
37: }
38: if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
39: // `validatedDnsPinning` was declared on every capability record and read by nothing. It is
40: // the capability that decides whether the SSRF address validation survives to the socket, so
41: // a transport that does not have it cannot serve a dynamic target no matter what its
42: // `dynamicTargetStable` flag says — the two were being conflated.
43: missing.add("call-scoped validated DNS pinning");
44: }
```
반응형 쪽은 여기서 끝난다.
```java
64: if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
65: missing.add("validated DNS pinning for dynamic targets");
66: }
67: reject(profile, missing);
68: }
```
## 36 의 문구가 가리키는 플래그
36 은 자기가 읽는 플래그를 가리키지 않는다. 그 검사가 보는 것은 `dynamicTargetStable` 인데 적히는 말은 검증된 DNS 고정이고, `validatedDnsPinning` 을 실제로 읽는 검사는 43 이다. 주석이 과거형으로 적은 혼동이 이 문구에 남아 있다.
그래서 이 두 검사에 걸린 유일한 test 가 43 을 붙들지 못한다.
```java
39: void rejectsDynamicModeWithoutValidatedPinning() {
40- ClientProfile dynamic = ClientProfiles.builder("webhook").mode(ClientMode.DYNAMIC).build();
41- assertThatThrownBy(
42- () ->
43- validator.validate(
44- dynamic, BlockingTransportCapabilities.lightweightHttp11AndHttp2()))
45- .isInstanceOf(HttpConfigurationException.class)
46- .hasMessageContaining("validated DNS pinning");
```
넣는 능력은 두 플래그가 모두 거짓이고, 단언은 메시지에 그 문자열이 있는지다. 36 의 문구만으로 맞는다. 38~44 를 통째로 지워도 이 test 는 초록이다.
## 넣어 보면
:::evidence key="a11-f007-dns-overloads" alt="동적 대상 프로파일 하나에 두 플래그 조합 넷을 넣어, 블로킹 오버로드와 반응형 오버로드가 각각 통과시키는지 거부하는지와 거부할 때 적히는 누락 항목을 출력한 터미널 기록. 출하된 두 반응형 전송의 두 플래그 값도 함께 적는다. 픽스처는 고정 리비전 소스에서 직접 컴파일한다." caption="안정이 거짓인 두 조합은 양쪽 다 거부하고 반응형 문구는 검증된 DNS 고정이 없다는 말이다 · 고정이 거짓이고 안정이 참인 조합에서만 블로킹이 거부하고 반응형이 통과한다 — 20줄 · exit 0" zoom="true"
:::
```text
pinning=true stable=true
블로킹 : 통과
반응형 : 통과
pinning=false stable=false
블로킹 : 거부 — validated DNS pinning for dynamic targets, call-scoped validated DNS pinning
반응형 : 거부 — validated DNS pinning for dynamic targets
pinning=true stable=false
블로킹 : 거부 — validated DNS pinning for dynamic targets
반응형 : 거부 — validated DNS pinning for dynamic targets
pinning=false stable=true
블로킹 : 거부 — call-scoped validated DNS pinning
반응형 : 통과
```
안정이 거짓인 두 조합은 양쪽 다 거부한다. 그때 반응형이 운영자에게 내놓는 말이 검증된 DNS 고정이 없다는 문구다. 검사한 것은 다른 플래그다.
갈리는 것은 마지막 줄뿐이다.
## 지금 그 조합은 만들어지지 않는다
```text
apacheClassic validatedDnsPinning=true dynamicTargetStable=true
http11AndHttp2 validatedDnsPinning=true dynamicTargetStable=true
lightweightHttp11AndHttp2 validatedDnsPinning=false dynamicTargetStable=false
reactorNetty validatedDnsPinning=true dynamicTargetStable=true
jettyHttp3Experimental validatedDnsPinning=false dynamicTargetStable=false
```
출하된 능력 다섯이 전부 두 플래그를 같은 값으로 선언한다. 38~44 를 복사해도 오늘 바뀌는 판정은 없다.
## 막고 있는 것은 프로파일 검증기가 아니다
```text
173- if (profile.mode() == ClientMode.DYNAMIC
174- && (profile.transport() == TransportType.JDK
175- || profile.transport() == TransportType.JETTY)) {
176: out.add(violation("DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED", profile, "transport"));
```
이 검사가 이름으로 막는 것은 두 상수다. 전송이 하나 늘면 여기에는 걸리지 않는다.
오늘 이 오버로드에 닿는 능력이 하나뿐인 이유는 다른 데 있다.
```java
@Bean
@ConditionalOnMissingBean(name = "httpClientReactiveTransportProviders")
Map<TransportType, ReactiveTransportProvider> httpClientReactiveTransportProviders(
ObjectProvider<MeterRegistry> meterRegistry, TlsMaterialProvider tlsMaterialProvider) {
Map<TransportType, ReactiveTransportProvider> providers = new EnumMap<>(TransportType.class);
providers.put(
TransportType.REACTOR_NETTY,
```
맵에 들어가는 전송이 하나다. 그리고 그 빈에는 빈 부재 조건이 붙어 있다. 배포가 자기 맵을 올리면 소스를 갈라내지 않고 다른 능력을 그 자리에 넣을 수 있다.
## 아직 아무도 읽지 않는 플래그
```text
TransportCapabilityValidator.java:38: if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
ReactiveTransportCapabilities.java:14: boolean validatedDnsPinning,
ReactiveTransportCapabilities.java:16: boolean serverSentEvents,
ReactiveTransportCapabilities.java:17: boolean cancellationReleasesConnection) {
BlockingTransportCapabilities.java:19: boolean validatedDnsPinning,
```
`serverSentEvents``cancellationReleasesConnection` 은 선언 줄 말고 나오는 곳이 없다. 39 의 주석이 `validatedDnsPinning` 에 대해 적은 상태가 이 둘에는 손대지 않은 채로 있다.
## 확인하지 못한 것
배포가 제공자 맵을 실제로 갈아 끼워 그 조합을 기동시키지 않았다. 확인한 것은 기동 검증기의 판정까지다.
<!-- body:end -->
@@ -0,0 +1,103 @@
---
kind: CASE
slug: analysis-finding-a11-f003
title: 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않는다
topic: http-client-and-resilience
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:analysis-finding-a11-f003
evidenceCapturedOn: 2026-09-01
body: case-analysis-finding-a11-f003.body.md
assets:
- key: analysis-finding-a11-f003
file: ../../../final/evidence/rendered/analysis-finding-a11-f003.svg
evidence:
- ../../../final/evidence/raw/analysis-finding-a11-f003.txt
source:
- 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#L164 이다.
---
# 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않는다
프로파일 검증기가 내는 위반 코드는 서른네 종이다. 그중 스물두 종은 어느 테스트에서도 이름으로 참조되지 않는다. 그리고 확인되지 않는 쪽에 사고에서 유래한 가드가 거의 전부 들어 있다.
## 관계
- **풀 상한 위반 코드는 발화할 수 없다**
이 계수에서 참조 0 으로 나온 코드 중 하나다.
- **코드 자체가 옳아도 회귀를 막는 것은 test다**
이 사례가 그 규칙의 형태다.
- **커버리지 gate 둘이 나란히 있고 하나는 발화할 수 없다**
같은 계열의 검증 지형 사례다.
## 문제
프로파일 검증기가 위반 코드를 문자열로 낸다. 종류가 서른네 개다.
각 코드가 테스트에서 이름으로 확인되는지 셌다.
## 결론
열두 종은 한 건 이상 참조된다. 모두 신뢰나 이름 검증이나 평문이나 실험 프로토콜 계열이다.
스물두 종은 0 건이다.
문제는 개수가 아니라 어느 쪽이 비어 있는가다.
확인되지 않는 스물두 종에 사고에서 유래한 가드가 거의 전부 들어 있다.
서버 주소 위조 우회와 기록의 개인정보와 조용한 프로토콜 강등과 아무 일도 하지 않는 설정과 재지향을 조용히 무시하는 경우다.
테스트 두 개가 그룹으로 몇 개를 묶어 확인한다. 하나의 메서드가 세 위반을 한 번에 본다.
나머지 스물두 종은 분기를 지워도 초록으로 남는다.
코드 자체는 현재 옳다. 위험은 회귀다.
판정은 P3 다.
수정은 코드별 최소 경우를 매개변수 테스트 한 벌로 놓는 것이다. 서른네 종이 모두 결정적으로 정렬된 목록을 내므로 그 형태가 자연스럽다.
## 검증 환경
확인 방식 : 위반 코드 문자열의 테스트 소스 집합 참조 계수
소스 수정 : x
## 재현 조건
원문은 final/evidence/raw/168 계열에 있다.
1. 검증기가 내는 위반 코드 목록을 만든다.
2. 저장소의 테스트와 테스트킷 소스 집합에서 각 문자열을 검색한다.
3. 참조 건수로 두 무리를 나눈다.
4. 참조 0 인 무리에 어떤 성격의 가드가 들어 있는지 본다.
5. 그룹으로 확인하는 테스트가 몇 개를 덮는지 확인한다.
## 본문
<!-- body:start -->
`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` … |
## ClientProfileValidator 참조 위치
:::evidence key="analysis-finding-a11-f003" alt="코드베이스에서 ClientProfileValidator 를 검색한 출력 6줄. 이 기록이 세는 참조가 그 출력에 그대로 보인다." caption="ClientProfileValidator 코드베이스 검색 — 6줄 · exit 0" zoom="true"
:::
## 문제는 개수가 아니라 어느 쪽이 비어 있는가다
확인되지 않는 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종이 모두 결정적으로 정렬된 목록을 내므로 그 형태가 자연스럽다.
<!-- body:end -->