Spring WebClient Non-Cache 스트리밍
1. 왜 스트리밍이 필요한가
파일을 외부 저장소(S3, 다른 서버 등)에서 받아 클라이언트에 내려줘야 하는 상황을 먼저 살펴보겠습니다.
전통적인 방식 (Spring MVC + RestTemplate)
클라이언트 ──요청──▶ 우리 서버 ──요청──▶ 외부 서버
│
[파일 전체 수신]
[메모리 / 디스크에 적재]
│
클라이언트 ◀──응답── 우리 서버
이 방식에는 세 가지 문제가 있습니다.
- 메모리 폭발 —
RestTemplate.getForObject(url, ByteArray::class.java)한 줄로 파일 전체가 JVM 힙에 올라옵니다. 1GB 파일 × 동시 100 요청이면 단순 계산으로 100GB가 필요합니다. - TTFB(Time To First Byte) 지연 — 서버가 파일을 완전히 받기 전까지 클라이언트는 아무것도 받지 못합니다.
- 디스크 잔재 — 임시 파일로 저장하면 디스크 I/O가 추가되고 정리 코드도 별도로 필요합니다.
이전 프로젝트에서 GB 단위의 문서 파일을 업로드/다운로드하는 경우도 있어서 1, 2의 이슈가 발생하였고, 이를 해결하기 위해 스트리밍 구조를 도입하게 되었습니다.
목표 상태
- 외부 서버에서 도착하는 데이터를 청크 단위로 즉시 클라이언트에 전달합니다.
- 서버는 청크 하나(수십 KB) 이상 메모리를 점유하지 않습니다.
- 서버 로컬 디스크에 파일이 남지 않습니다.
이를 가능하게 하는 것이 Spring WebClient + WebFlux의 스트리밍 파이프라인입니다.
2. 배경 개념: NIO → Netty → WebFlux → WebClient
스트리밍이 어떻게 동작하는지 이해하려면 아래 레이어를 순서대로 살펴볼 필요가 있습니다.
2-1. Java NIO (Non-Blocking I/O)
전통적인 java.io는 blocking 모델입니다. InputStream.read()를 호출하면 데이터가 올 때까지 스레드가 멈춥니다. 커넥션 1,000개를 동시에 처리하려면 스레드도 1,000개가 필요합니다.
java.nio는 이 구조를 근본적으로 바꿉니다.
Channel— 읽기/쓰기 방향이 분리된 I/O 통로입니다.Selector— 여러 Channel을 등록해 두고, “준비된 채널”만 통지받는 이벤트 감시자입니다.ByteBuffer— 채널과 데이터를 주고받는 버퍼입니다.
스레드 하나가 Selector를 통해 수천 개의 Channel을 감시하다가 “읽을 데이터가 있다”는 이벤트가 오면 그때만 처리합니다. 스레드 수와 커넥션 수가 분리되는 것이 핵심입니다.
2-2. Netty
NIO는 강력하지만 저수준 API를 직접 다루기에는 번거롭습니다. Netty는 NIO 위에 올라간 비동기 네트워크 프레임워크로, 이 복잡성을 추상화해 줍니다.
Spring WebFlux가 기본 내장 서버로 Netty를 선택한 이유가 여기에 있습니다.
EventLoop 모델
EventLoop Thread 1 ─── Channel A (커넥션 수천 개 감시) EventLoop Thread 2 ─── Channel B ... (보통 CPU 코어 수 × 2개)
요청당 스레드를 생성하지 않습니다. 소수의 EventLoop 스레드가 수많은 커넥션의 이벤트(읽기 준비, 쓰기 완료 등)를 콜백으로 처리합니다.
ByteBuf
Netty의 버퍼 타입입니다. JVM 힙 메모리뿐 아니라 오프-힙(Direct Memory)도 사용할 수 있어 GC 부담 없이 네트워크 I/O를 처리할 수 있습니다. 레퍼런스 카운팅으로 수명을 관리하므로, 사용 후 반드시 release()가 호출되어야 메모리가 반환됩니다.
2-3. Project Reactor & Spring WebFlux
Reactor는 JVM 위의 리액티브 스트림 구현체입니다.
| 타입 | 의미 |
|---|---|
Mono<T> |
0 또는 1개의 결과를 비동기로 표현합니다. |
Flux<T> |
0 ~ N개의 결과를 비동기 스트림으로 표현합니다. |
파일 다운로드에서 Flux<DataBuffer>는 “네트워크에서 도착하는 청크들의 스트림”이 됩니다.
백프레셔(Backpressure)가 이 구조의 핵심입니다. 구독자(Subscriber)가 request(n)으로 자신이 처리할 수 있는 수량을 업스트림에 알립니다. 클라이언트 TCP 버퍼가 차면 이 시그널이 0이 되어 소스에서 읽는 속도 자체를 늦춥니다. 메모리가 무한정 쌓이지 않는 근본적인 이유입니다.
Spring WebFlux는 이 Reactor 스트림을 Netty 위에서 HTTP 요청/응답으로 연결해 줍니다. 컨트롤러가 Flux를 반환하면 WebFlux가 각 원소를 HTTP 청크로 클라이언트에 write합니다.
2-4. Spring WebClient
RestTemplate의 리액티브 대체제입니다. 내부적으로 Netty HttpClient를 사용하며, non-blocking 커넥션 풀을 관리합니다.
응답 바디를 꺼내는 방법은 주로 두 가지입니다.
// 1. retrieve() — 응답 바디만 꺼낼 때 (4xx/5xx 에러 처리 포함) webClient.get().uri(url).retrieve().bodyToFlux<DataBuffer>() // 2. awaitExchange() — 응답 헤더까지 직접 다뤄야 할 때 val clientResponse: ClientResponse = webClient.get().uri(url).awaitExchange()
스트리밍에서는 대부분 bodyToFlux<DataBuffer>()로 충분하며, 외부 서버의 헤더를 클라이언트에 그대로 전달해야 할 경우에 awaitExchange()를 활용합니다.
3. 스트리밍 파이프라인이 동작하는 원리
외부 서버 │ HTTP chunked transfer / 일반 응답 ▼ Netty HttpClient (WebClient 내부) │ 네트워크 청크가 도착할 때마다 Flux<DataBuffer> emit ▼ Spring WebFlux 핸들러 (우리 코드) │ DataBuffer를 조립하지 않고 바로 하류로 전달 ▼ Netty (서버 사이드) │ 클라이언트 소켓에 청크 write ▼ 클라이언트 브라우저 / 앱
DataBuffer란
Netty ByteBuf를 Spring이 추상화한 인터페이스(org.springframework.core.io.buffer.DataBuffer)입니다. 레퍼런스 카운팅으로 수명을 관리하므로, 파이프라인 밖으로 새어 나가면 메모리 누수가 발생할 수 있습니다.
서버에 파일이 남지 않는 이유
Flux<DataBuffer> 파이프라인은 청크를 수신하는 즉시 하류로 emit하고, write가 완료되면 release()합니다. 파이프라인 어느 시점에도 파일 전체가 메모리에 조립되지 않습니다.
백프레셔가 스트리밍을 제어하는 방식
- 클라이언트 TCP 버퍼가 가득 찹니다.
- Netty가 소켓 write를 일시 중단합니다.
- Reactor가 업스트림으로 demand = 0 신호를 전파합니다.
- WebClient가 외부 서버에서 읽는 것을 일시 중단합니다.
클라이언트가 느리게 받더라도 서버 메모리가 무한정 커지지 않는 이유가 바로 이 흐름 덕분입니다.
4. 구현 예제
4-1. WebClient 빈 등록
@Configuration
class WebClientConfig {
@Bean
fun webClient(): WebClient =
WebClient.builder()
// 스트리밍 시 단일 응답을 버퍼에 모으지 않으므로 제한을 해제합니다.
.codecs { it.defaultCodecs().maxInMemorySize(-1) }
.build()
}
maxInMemorySize는 bodyToMono<ByteArray>()처럼 응답 전체를 하나로 모을 때 적용되는 상한입니다. bodyToFlux<DataBuffer>()는 이 제한과 무관하지만, 같은 WebClient로 다른 API도 호출하는 경우라면 명시적으로 설정해 두는 것이 안전합니다.
4-2. 잘못된 방법 — 파일 전체를 메모리에 적재
// BAD: 파일 전체가 ByteArray로 JVM 힙에 올라옵니다.
val bytes: ByteArray = webClient.get()
.uri(fileUrl)
.retrieve()
.bodyToMono<ByteArray>()
.awaitSingle()
response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))).awaitFirstOrNull()
4-3. 올바른 방법 — Flux<DataBuffer> 스트리밍
@GetMapping("/download")
suspend fun download(
@RequestParam url: String,
response: ServerHttpResponse,
): Void? {
response.headers.set(
HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"file\""
)
response.headers.contentType = MediaType.APPLICATION_OCTET_STREAM
val body: Flux<DataBuffer> = webClient.get()
.uri(url)
.retrieve()
.bodyToFlux<DataBuffer>()
return response.writeWith(body).awaitFirstOrNull()
}
response.writeWith(body)는 Flux<DataBuffer>를 그대로 클라이언트에 write하는 WebFlux API입니다. 청크가 도착하는 즉시 클라이언트로 흘러가며, 서버 메모리는 청크 하나 크기만 점유합니다.
4-4. 외부 서버의 헤더를 클라이언트에 그대로 전달 (패스스루)
파일명이나 Content-Type을 외부 서버에서 그대로 클라이언트에 전달해야 할 때는 awaitExchange()로 헤더를 먼저 받아 복사합니다.
4-5. 에러 핸들링과 DataBuffer 누수 방지
외부 서버가 4xx/5xx를 반환하거나 스트림 도중 에러가 발생하면, DataBuffer가 release되지 않아서 오프-힙 메모리 누수로 이어질 수 있습니다.
5. 주의사항 & 흔한 실수
| 실수 | 왜 문제인가 | 올바른 방법 |
|---|---|---|
bodyToMono<ByteArray>() 사용 |
파일 전체가 힙에 올라갑니다. | bodyToFlux<DataBuffer>() |
maxInMemorySize 기본값 방치 |
256KB 초과 응답에서 DataBufferLimitException이 발생합니다. |
-1 또는 충분한 크기로 설정합니다. |
| DataBuffer release 누락 | 오프-힙 메모리 누수로 OOM이 발생할 수 있습니다. | doOnDiscard + DataBufferUtils::release |
exchangeToMono { it.bodyToMono<ByteArray>() } |
동일한 문제가 발생합니다. | exchangeToFlux { it.bodyToFlux<DataBuffer>() } |
| EventLoop 스레드에서 블로킹 코드 실행 | Netty EventLoop가 점유되어 서비스 전체에 지연이 발생합니다. | withContext(Dispatchers.IO)로 컨텍스트를 전환합니다. |
6. WebClient 메트릭 관찰
6-1. 스트리밍에서 메트릭이 중요한 이유
일반 API 요청은 수십~수백 ms 만에 완료되지만, 파일 스트리밍은 요청 하나가 수십 초씩 살아 있습니다. 이 특성 때문에 다음과 같은 문제가 생길 수 있습니다.
- 커넥션 풀 고갈이 느리게, 눈에 안 띄게 누적됩니다.
- 전통적인 “응답시간 히스토그램”은 스트림이 끝난 뒤 기록되므로, 현재 진행 중인 요청 수를 실시간으로 파악할 수 없습니다.
따라서 커넥션 풀 상태와 진행 중 요청 수를 별도로 관찰하는 것이 중요합니다.
6-2. Micrometer 자동 계측 — ObservationRegistry
Spring Boot Actuator + Micrometer 의존성이 있으면 WebClient는 ObservationRegistry를 통해 자동으로 계측됩니다.
@Bean
fun webClient(observationRegistry: ObservationRegistry): WebClient =
WebClient.builder()
.observationRegistry(observationRegistry) // 자동 계측을 활성화합니다.
.codecs { it.defaultCodecs().maxInMemorySize(-1) }
.build()
자동으로 생성되는 주요 메트릭은 다음과 같습니다.
| 메트릭 이름 | 타입 | 설명 |
|---|---|---|
http.client.requests |
Timer | 외부 요청 응답시간 (uri, method, status 태그 포함) |
reactor.netty.connection.provider.* |
Gauge/Counter | 커넥션 풀 상태 (active, pending, idle, max) |
jvm.buffer.memory.used |
Gauge | 오프-힙 ByteBuffer 사용량 (DataBuffer 누수 탐지용) |
6-3. Netty 커넥션 풀 메트릭 활성화
reactor.netty.connection.provider.* 메트릭은 ConnectionProvider에 이름이 부여되어야 정상적으로 노출됩니다.
@Bean
fun webClient(observationRegistry: ObservationRegistry): WebClient {
val provider = ConnectionProvider.builder("file-download-pool")
.maxConnections(200)
.pendingAcquireMaxCount(500)
.metrics(true) // reactor.netty 메트릭을 활성화합니다.
.build()
val connector = ReactorClientHttpConnector(HttpClient.create(provider))
return WebClient.builder()
.clientConnector(connector)
.observationRegistry(observationRegistry)
.codecs { it.defaultCodecs().maxInMemorySize(-1) }
.build()
}
6-4. 진행 중 스트리밍 요청 수 직접 측정
http.client.requests Timer는 스트림이 완료된 뒤 기록됩니다. 현재 열려 있는 스트림 수는 별도 Gauge로 측정하는 것을 권장드립니다.
@RestController
class DownloadController(
private val webClient: WebClient,
meterRegistry: MeterRegistry,
) {
private val activeStreams = AtomicInteger(0)
init {
Gauge.builder("webclient.streaming.active", activeStreams, AtomicInteger::get)
.description("현재 진행 중인 파일 스트리밍 요청 수")
.register(meterRegistry)
}
@GetMapping("/download")
suspend fun download(
@RequestParam url: String,
response: ServerHttpResponse,
): Void? {
response.headers.contentType = MediaType.APPLICATION_OCTET_STREAM
val body = webClient.get()
.uri(url)
.retrieve()
.bodyToFlux<DataBuffer>()
.doOnDiscard(DataBuffer::class.java, DataBufferUtils::release)
activeStreams.incrementAndGet()
return response.writeWith(body)
.doFinally { activeStreams.decrementAndGet() } // 성공·실패·취소 모두 감소시킵니다.
.awaitFirstOrNull()
}
}
6-5. Actuator 엔드포인트 & Prometheus 연동
# application.yml
management:
endpoints:
web:
exposure:
include: health, metrics, prometheus
metrics:
tags:
application: ${spring.application.name}
주요 확인 경로는 다음과 같습니다.
/actuator/metrics/http.client.requests— 외부 요청 타이밍 확인/actuator/metrics/reactor.netty.connection.provider.file-download-pool.active.connections— 커넥션 풀 상태 확인/actuator/prometheus— Prometheus scrape 엔드포인트
6-6. Grafana 대시보드 핵심 패널
| 패널 | PromQL | 해석 |
|---|---|---|
| 진행 중 스트리밍 수 | webclient_streaming_active |
동시 파일 전송 부하 |
| 커넥션 풀 사용률 | reactor_netty_connection_provider_active_connections / reactor_netty_connection_provider_max_connections |
풀 고갈 위험도 |
| 업스트림 응답시간 P99 | histogram_quantile(0.99, rate(http_client_requests_seconds_bucket[1m])) |
외부 서버 지연 |
| DataBuffer 오프-힙 사용량 | jvm_buffer_memory_used_bytes{id="direct"} |
메모리 누수 여부 |
7. 정리
이 글에서 다룬 내용을 핵심만 요약하면 다음과 같습니다.
- NIO → Netty → Reactor → WebFlux → WebClient는 하나의 non-blocking 스택입니다. 각 레이어가 아래 레이어의 비동기 특성을 그대로 물려받습니다.
Flux<DataBuffer>는 이 스택에서 “파일 청크의 스트림”을 표현하는 적절한 타입입니다.- 백프레셔가 클라이언트 속도에 맞춰 업스트림 읽기를 자동으로 조절하므로, 서버 메모리 사용량에 자연스러운 상한이 생깁니다.
- DataBuffer는 레퍼런스 카운팅으로 관리됩니다.
doOnDiscard를 통해 에러·취소 시의 누수를 반드시 방지해 주시기 바랍니다. - 스트리밍은 요청이 오래 살아 있는 특성상, 커넥션 풀 사용률과 진행 중 요청 수 메트릭을 응답시간 히스토그램과 함께 관찰하시길 권장드립니다.
핵심 한 줄: 받은 청크를 즉시 흘려보내고 release — 어느 시점에도 파일 전체를 조립하지 않는다.