- 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>
217 lines
13 KiB
Markdown
217 lines
13 KiB
Markdown
---
|
|
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:
|
|
- 원본 분석 절은 final/document.md#a11#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 -->
|