Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/http-client-and-resilience/case/case-a11-f006-boundeddatabufferflux.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- 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>
2026-09-07 12:39:20 +09:00

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 -->