--- 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: - 원본 분석 절은 analysis/11-adapter-outbound-httpclient.md#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. 폐기 규약을 지키지 않는 상류로 반복한다. ## 본문 클래스 자바독이 두 경로를 이름으로 지목한다. ## 지목된 두 경로 :::evidence key="a11-f006-boundeddatabufferflux" alt="클래스 자바독이 지목한 두 누수 경로, 조립되는 연산자 사슬 전체를 줄 번호와 함께, 이 사슬을 부르는 유일한 지점과 그 호출을 감싼 표현식과 바로 뒤에 이어지는 연산자, 취소 경로 test 두 요청 전체와 그 클래스에 붙은 확장, 그리고 그 확장이 단언하는 두 가지를 출력한 터미널 기록." caption="자바독은 취소와 오류를 실제로 새는 경로로 지목한다 · 사슬은 doOnNext · doOnDiscard · 비어 있는 doOnCancel · 오류를 그대로 재방출하는 onErrorResume 순 · 부르는 곳은 한 군데이고 그 자리가 105 에서 같은 doOnDiscard 를 한 번 더 건다 · test 클래스에는 누수 탐지기를 단언하는 확장이 붙어 있다 — 104줄 · exit 0" zoom="true" ::: ```text *
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()) ``` 그 확장이 탐지기가 최고 수준으로 살아 있다는 것과, 실행 뒤 누수 보고가 하나도 없다는 것을 단언한다. ## 남는 것 누수가 실재한다고 주장하지 않는다. 취소 시 미방출 버퍼는 드라이버의 바이트 흐름이 스스로 돌려주고, 누수 탐지기가 모든 레인에서 확인한다. 문제는 읽는 방식이다. 자바독이 두 누수 경로를 이름으로 지목하고, 그 두 이름을 딴 연산자가 사슬에 나란히 있고, 둘 다 비어 있다. 세 번째는 부르는 쪽이 이미 걸어 둔 것과 같다. ## 확인하지 못한 것 실제 드라이버의 바이트 흐름 위에서 같은 대조를 하지 않았다. 탐침은 참조 계수를 볼 수 있는 힙 버퍼를 직접 만들어 흘렸고, 자바독이 말하는 직접 메모리는 탐침 범위 밖이다.