- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다 - 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5 (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를 techviz 로 만들었다 - 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs 돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다. Concept 이 인용한 코드가 SSOT 에 없어 뺐다 - candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a11-f006-boundeddatabufferflux | 부르는 쪽이 이미 걸어 둔 것을 한 번 더 건다 | http-client-and-resilience | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a11-f006-boundeddatabufferflux | 2026-09-02 | case-a11-f006-boundeddatabufferflux.body.md |
|
|
|
부르는 쪽이 이미 걸어 둔 것을 한 번 더 건다
클래스 자바독이 취소와 오류를 실제로 새는 경로로 지목한다. 그 두 이름을 딴 연산자는 신호에 대해 항등이다. 남은 폐기 연산자도 이 사슬을 부르는 유일한 지점이 바로 뒤에서 한 번 더 걸어 둔 것이라, 호출 지점에서는 셋 다 빼도 결과가 같다.
관계
- 타입이 문서화한 불변식은 타입이 강제한다 자바독이 지목한 경로를 그 이름의 연산자가 지키지 않는다.
- 누수 하나는 회수되고 하나는 회수되지 않는다 같은 리프의 자원 해제 사례다.
- 그 속성을 보일 수 없는 대역 위에서 통과한 테스트는 증인이 아니다 취소 경로 test 본문이 확인하는 것은 버퍼 해제가 아니다.
문제
반응형 본문을 제한하고 넘기지 않은 버퍼를 해제하는 클래스가 있다.
클래스 자바독이 목적을 적는다. 취소와 오류가 실제로 새는 경로라는 것이다. 구독자가 요구를 멈추고, 상류가 이미 만든 것을 버리고, 그 버퍼는 아무도 반환하지 않는 직접 메모리라는 것이다.
그 두 경로에 붙은 연산자가 실제로 무엇을 하는지, 그리고 이 사슬이 불리는 자리에서는 어떻게 되는지 확인했다.
결론
취소 연산자의 본문은 비어 있고, 오류 대체 연산자는 받은 오류를 그대로 다시 방출한다. 둘 다 신호에 대해 항등이다. 사슬의 모양은 바꾼다. 두 연산자가 붙으면 반환 타입이 달라지고 융합이 끊긴다.
버퍼 다섯 개 중 하나만 받고 취소하면 있는 그대로에서 미회수가 0 이고, 두 연산자를 빼도 0 이다. 폐기 연산자까지 빼면 넷이 남는다. 이 사슬만 구독했을 때는 폐기 연산자가 일한다.
그런데 이 사슬을 부르는 곳은 하나이고, 그 자리가 바로 뒤에서 같은 폐기 연산자를 한 번 더 건다. 같은 조건에서 사슬 자신의 폐기 연산자를 빼도 미회수가 0 이다. 폐기 처리기는 문맥으로 상류에 전파되므로 바깥의 것이 안쪽까지 덮는다.
오류 경로는 다르다. 상류가 셋을 쥔 채 던지면 있는 그대로에서 셋이 남고, 폐기 연산자를 빼도 셋이다. 자바독이 지목한 두 경로 중 오류 쪽에서 이 사슬이 하는 일은 없다.
오류 대체 연산자 안의 주석은 흐르던 것이 위의 폐기 연산자로 처리된다고 적는데, 오류 신호에서 그 폐기는 일어나지 않는다.
폐기 연산자가 도는 조건도 좁다. 상류가 폐기 규약을 지킬 때만이다. 규약을 지키지 않는 상류로 바꾸면 폐기 연산자가 있으나 없으나 넷이 남는다.
실제 누수가 있다는 주장은 아니다. 취소 시 미방출 버퍼는 드라이버의 바이트 흐름이 스스로 돌려준다.
문제는 코드가 하지 않는 일을 하는 것처럼 읽힌다는 것이다. 자바독이 지목한 두 누수 경로의 이름을 딴 연산자가 나란히 있고 둘 다 비어 있으므로, 이 클래스를 읽는 사람은 취소와 오류 해제가 여기서 명시적으로 처리된다고 결론짓게 된다.
취소 경로 test 본문이 단언하는 것은 다음 호출이 성공한다는 것, 즉 연결 반환이다. 다만 그 test 클래스에는 누수 탐지기가 최고 수준으로 살아 있다는 것과 누수 보고가 하나도 없다는 것을 단언하는 확장이 붙어 있다.
판정은 P3 다.
수정은 둘 중 하나다. 세 연산자를 지우고 자바독이 부르는 쪽의 폐기 연산자와 드라이버의 역할을 정확히 적게 하거나, 취소와 오류 경로에서 실제로 해제해야 할 것이 있다면 그것을 구현하는 것이다.
검증 환경
OpenJDK : 21.0.12 확인 방식 : 연산자 본문 확인과 자바독 대조, 호출 지점 추적, 실행 탐침 소스 수정 : x
재현 조건
- 클래스 자바독의 누수 경로 서술을 읽는다.
- 조립된 연산자 사슬을 순서대로 읽는다.
- 이 사슬을 부르는 곳을 세고, 그 호출 뒤에 무엇이 이어지는지 읽는다.
- 취소 경로 test 본문과 그 클래스에 붙은 확장이 무엇을 단언하는지 확인한다.
- 참조 계수를 볼 수 있는 버퍼 다섯 개를 만들어, 사슬만 구독하고 하나만 받은 뒤 취소한다.
- 같은 조건을 호출 지점의 조립 모양으로 바꿔 반복한다.
- 상류가 셋을 쥔 채 던지는 오류 경로로 반복한다.
- 폐기 규약을 지키지 않는 상류로 반복한다.
본문
클래스 자바독이 두 경로를 이름으로 지목한다.
지목된 두 경로
:::evidence key="a11-f006-boundeddatabufferflux" alt="클래스 자바독이 지목한 두 누수 경로, 조립되는 연산자 사슬 전체를 줄 번호와 함께, 이 사슬을 부르는 유일한 지점과 그 호출을 감싼 표현식과 바로 뒤에 이어지는 연산자, 취소 경로 test 두 요청 전체와 그 클래스에 붙은 확장, 그리고 그 확장이 단언하는 두 가지를 출력한 터미널 기록." caption="자바독은 취소와 오류를 실제로 새는 경로로 지목한다 · 사슬은 doOnNext · doOnDiscard · 비어 있는 doOnCancel · 오류를 그대로 재방출하는 onErrorResume 순 · 부르는 곳은 한 군데이고 그 자리가 105 에서 같은 doOnDiscard 를 한 번 더 건다 · test 클래스에는 누수 탐지기를 단언하는 확장이 붙어 있다 — 104줄 · exit 0" zoom="true" :::
* <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.
사슬에 그 두 이름을 딴 연산자가 나란히 있다.
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" :::
[취소] bound 만 구독한다 — 다섯 중 하나만 받고 취소
있는 그대로 전달 1, 상류가 쥐고 있던 것 중 미회수 0 / 4
두 연산자 제거 전달 1, 상류가 쥐고 있던 것 중 미회수 0 / 4
doOnDiscard 까지 제거 전달 1, 상류가 쥐고 있던 것 중 미회수 4 / 4
31 과 32~37 을 빼도 결과가 같고, 30 을 빼면 달라진다. 여기까지는 30 이 일한다.
부르는 자리에서는 다르다
이 사슬을 부르는 곳은 하나다.
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 안쪽까지 덮는다.
[취소] 부르는 쪽이 105 에서 같은 폐기 연산자를 한 번 더 건다
있는 그대로 전달 1, 상류가 쥐고 있던 것 중 미회수 0 / 4
doOnDiscard 까지 제거 전달 1, 상류가 쥐고 있던 것 중 미회수 0 / 4
30 을 빼도 0 이다. 실제로 불리는 모양에서는 doOnNext 뒤의 세 연산자가 모두 잉여다.
오류 경로에서는 아무것도 하지 않는다
[오류] 상류가 셋을 쥔 채 던진다
있는 그대로 전달 2, 상류가 쥐고 있던 것 중 미회수 3 / 3
doOnDiscard 까지 제거 전달 2, 상류가 쥐고 있던 것 중 미회수 3 / 3
34~35 의 주석은 흐르던 것이 위의 폐기 연산자로 처리된다고 적는다. 오류 신호에서는 그 폐기가 일어나지 않는다.
폐기 연산자가 도는 조건
[취소] 상류가 폐기 규약을 지키지 않는다
있는 그대로 전달 1, 상류가 쥐고 있던 것 중 미회수 4 / 4
doOnDiscard 까지 제거 전달 1, 상류가 쥐고 있던 것 중 미회수 4 / 4
취소에서 상류가 쥔 것을 돌려주는 것은 상류가 폐기 규약을 지킬 때뿐이다.
취소 경로 test 가 보는 것
StepVerifier.create(
gateway
.download(
profile.name(),
HttpOperation.get(new OperationName("download"), "/small", Map.of()))
.doOnNext(DataBufferUtils::release))
.expectNextCount(1)
.verifyComplete();
앞의 요청이 하나만 받고 취소한 뒤, 두 번째 요청이 성공하는지 본다. 단일 연결 풀이라 그 성공이 연결 반환의 증거다. 버퍼는 test 가 직접 해제한다.
버퍼 해제를 이 본문이 단언하지는 않는다. 대신 클래스에 확장이 붙어 있다.
assertThat(ResourceLeakDetector.getLevel())
그 확장이 탐지기가 최고 수준으로 살아 있다는 것과, 실행 뒤 누수 보고가 하나도 없다는 것을 단언한다.
남는 것
누수가 실재한다고 주장하지 않는다. 취소 시 미방출 버퍼는 드라이버의 바이트 흐름이 스스로 돌려주고, 누수 탐지기가 모든 레인에서 확인한다.
문제는 읽는 방식이다. 자바독이 두 누수 경로를 이름으로 지목하고, 그 두 이름을 딴 연산자가 사슬에 나란히 있고, 둘 다 비어 있다. 세 번째는 부르는 쪽이 이미 걸어 둔 것과 같다.
확인하지 못한 것
실제 드라이버의 바이트 흐름 위에서 같은 대조를 하지 않았다. 탐침은 참조 계수를 볼 수 있는 힙 버퍼를 직접 만들어 흘렸고, 자바독이 말하는 직접 메모리는 탐침 범위 밖이다.