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>
227 lines
13 KiB
Markdown
227 lines
13 KiB
Markdown
---
|
|
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 -->
|