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>
242 lines
15 KiB
Markdown
242 lines
15 KiB
Markdown
---
|
|
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 -->
|