Spring WebFlux 스트리밍 응답 — NDJSON·SSE·Backpressure 완전 정리

2026-05-03AWS SAA-C03 스터디

Spring WebFlux 스트리밍 응답 핵심 정리 — 일반 Flux vs NDJSON 스트리밍 차이·produces = APPLICATION_NDJSON_VALUE·TEXT_EVENT_STREAM_VALUE·서버 스트리밍·클라이언트 스트리밍·Backpressure·limitRate·StepVerifier 테스트까지. produces 생략 함정, curl --no-buffer 확인, 클라이언트 연결 끊김 시 Flux 자동 취소를 코드와 비유로 풀어 정리.

📚 Spring WebFlux 핵심 정리 · 9편 / 14편 — NDJSON·SSE·Backpressure 완전 정리

이 글은 Spring WebFlux 핵심 정리 시리즈의 아홉 번째 편입니다. 지금까지 WebFlux로 API를 만들고, WebFilter로 공통 처리를 하고, WebClient로 외부 서비스를 호출했다면 이번 9편은 그 모든 것을 실시간으로 흘려보내는 방법 — 스트리밍 응답입니다.

Flux를 반환하면 이미 스트리밍처럼 보이는데, 막상 브라우저나 curl로 확인해 보면 데이터가 한 번에 쏟아집니다. 한 개씩 흘러오지 않아요. 이건 WebFlux 문제가 아니라 produces 설정 한 줄이 빠진 문제예요. 이번 편의 핵심 질문은 세 가지입니다. "produces 없을 때와 있을 때 어떻게 달라지는가, NDJSON과 SSE는 어떻게 다른가, Backpressure는 어떻게 동작하는가" — 이 세 가지만 잡으면 충분합니다.

본문 흐름은 택배 발송 비유 두 가지를 따라 풀어 가요. 일반 응답 = "택배 박스에 다 채워서 한 번에 발송" — 모든 데이터가 모일 때까지 기다렸다가 한 번에 전송. 스트리밍 응답 = "컨베이어 벨트 — 물건이 도착하는 순간순간 바로바로 발송" — 데이터가 준비되는 즉시 클라이언트로 흘려보내는 구조예요.

학습 노트

이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, Reactive Streams 명세, 여러 공개 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

스트리밍 응답은 spring-boot-starter-webflux만 있으면 구현할 수 있어요. curl -N http://localhost:8080/stream으로 터미널에서 실시간으로 데이터가 흘러오는 걸 직접 확인해 보세요.

WebFlux 스트리밍이 처음엔 왜 혼란스럽게 느껴질까요

이유는 네 가지예요.

첫째, Flux를 반환했는데 브라우저에서 한 번에 쏟아집니다. "Flux가 스트리밍 아닌가요?" 하는 의문이 생겨요. Flux는 리액티브 파이프라인이지, HTTP 응답 방식을 결정하지 않아요. 응답 형식은 produces 설정이 결정합니다.

둘째, NDJSON과 SSE가 비슷해 보입니다. 둘 다 데이터를 줄줄 흘려보내는데, 어떻게 다른지, 브라우저 지원은 어떻게 다른지 처음엔 불명확해요.

셋째, curl로 스트리밍 엔드포인트를 확인하면 버퍼링이 걸립니다. 모든 데이터가 올 때까지 터미널에 아무것도 나오지 않아요. curl -N 옵션(또는 --no-buffer)을 붙여야 실시간으로 데이터가 흘러오는 걸 볼 수 있어요.

넷째, 클라이언트가 연결을 끊었을 때 서버에서 무슨 일이 일어나는지 처음엔 잘 모릅니다. 스트리밍 중 클라이언트가 새로고침하면 이전 Flux는 어떻게 되는지, Backpressure는 어떻게 동작하는지가 불명확해요.

해결법은 하나예요. 스트리밍을 "택배 발송 방식 두 가지"로 잡고 풀면 갑자기 명확해집니다. 박스에 다 채워서 발송(일반 JSON) vs 컨베이어 벨트로 하나씩 즉시 발송(NDJSON·SSE) — 이 그림 하나가 모든 후속 개념을 묶어 줘요.

produces가 스트리밍 동작을 결정한다

HTTP 스트리밍의 핵심은 produces 어노테이션 파라미터입니다.

application/json      → 모든 데이터를 메모리에 모아서 한 번에 JSON Array로 전송
application/x-ndjson → 한 줄에 JSON 1개, 줄바꿈으로 구분하여 순차 전송
text/event-stream     → SSE 형식으로 순차 전송 (브라우저 EventSource API 호환)

코드로 보면 차이는 딱 한 줄이에요.

// 일반 응답 — produces 없음 (기본 application/json)
// Flux의 모든 항목을 List로 수집한 뒤 JSON Array로 한 번에 전송
@GetMapping("/products")
public Flux<Product> getAllProducts() {
    return productRepository.findAll();
}

// 스트리밍 응답 — NDJSON
// 각 항목이 준비될 때마다 즉시 한 줄씩 전송
@GetMapping(value = "/products/stream", produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<Product> streamAllProducts() {
    return productRepository.findAll();
}

// SSE 스트리밍 — 브라우저 EventSource API 호환
@GetMapping(value = "/products/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Product> streamProductsAsSSE() {
    return productRepository.findAll();
}

비유로 한 줄 정리 — 일반 응답 = "택배 박스 다 채워서 한 번에 발송". 스트리밍 = "컨베이어 벨트로 도착할 때마다 즉시 전송". 같은 Flux 파이프라인이지만 배송 방식(produces)이 다릅니다.

여기서 시험 함정이 하나 있어요. produces 없이 Flux를 반환하면 스트리밍 효과가 없습니다. Spring WebFlux가 기본적으로 모든 항목을 모아 JSON Array로 만들어 버려요. 데이터가 DB에 100만 건이면 100만 건을 메모리에 올린 뒤 한 번에 전송 — OOM(Out of Memory) 위험이 생깁니다. 스트리밍이 목적이라면 produces = MediaType.APPLICATION_NDJSON_VALUE를 반드시 지정해야 합니다.

NDJSON vs SSE — 무엇이 다른가

두 스트리밍 형식의 차이를 정리합니다.

항목NDJSON (application/x-ndjson)SSE (text/event-stream)
형식줄바꿈으로 구분된 JSON 객체data: {...}\n\n 형식
브라우저 지원직접 지원 없음 (JS fetch 사용)EventSource API 지원
이벤트 타입없음event: typeName 지원
재연결없음자동 재연결 지원
주요 용도마이크로서비스 간 스트리밍, 대용량 데이터브라우저 실시간 업데이트 (알림, 대시보드)

NDJSON은 "줄마다 JSON 객체 하나"예요.

{"id":1,"name":"Product A","price":100}
{"id":2,"name":"Product B","price":200}
{"id":3,"name":"Product C","price":300}

SSE는 브라우저 표준 형식이에요.

data: {"id":1,"name":"Product A","price":100}

data: {"id":2,"name":"Product B","price":200}

data: {"id":3,"name":"Product C","price":300}

서버 스트리밍 구현 패턴

DB에서 데이터를 스트리밍으로 읽어 실시간으로 전달하는 가장 기본적인 패턴이에요.

@RestController
@RequestMapping("/stream")
public class ProductStreamController {

    private final ProductRepository productRepository;

    // 서버 스트리밍 — DB에서 순차적으로 읽어 전송
    @GetMapping(value = "/products", produces = MediaType.APPLICATION_NDJSON_VALUE)
    public Flux<Product> streamProducts() {
        return productRepository.findAll()
                // delayElements는 데모용 — 프로덕션에서 반드시 제거
                .delayElements(Duration.ofMillis(100))
                .doOnNext(p -> log.debug("Streaming: {}", p.getName()));
    }

    // Flux.interval로 주기적 데이터 생성 — 실시간 피드 시뮬레이션
    @GetMapping(value = "/heartbeat", produces = MediaType.APPLICATION_NDJSON_VALUE)
    public Flux<Map<String, Object>> heartbeat() {
        return Flux.interval(Duration.ofSeconds(1))
                .map(i -> Map.of(
                    "seq", i,
                    "time", Instant.now().toString(),
                    "status", "alive"
                ))
                .take(60);  // 최대 60초 (무한 스트림 방지)
    }
}

Flux.interval(Duration)은 지정한 간격으로 0, 1, 2, 3... 숫자를 방출하는 무한 스트림이에요. .take(n)으로 개수를 제한하거나, 클라이언트가 연결을 끊으면 자동으로 취소됩니다.

여기서 시험 함정이 하나 있어요. delayElements()는 데모·테스트 전용입니다. 로컬 환경에서는 DB 속도가 너무 빨라 스트리밍이 눈에 안 보이기 때문에 인위적으로 지연을 추가하는 거예요. 프로덕션 코드에 delayElements()를 그대로 남기면 성능이 심각하게 저하됩니다. 반드시 제거해야 해요.

클라이언트 스트리밍 — Flux 요청 받기

서버가 클라이언트로부터 스트리밍 데이터를 받는 패턴이에요.

@PostMapping(value = "/upload", consumes = MediaType.APPLICATION_NDJSON_VALUE)
public Mono<UploadResponse> uploadProducts(@RequestBody Flux<ProductDto> productFlux) {
    return productFlux
            .map(dto -> Product.builder()
                    .name(dto.getName())
                    .price(dto.getPrice())
                    .build())
            .flatMap(productRepository::save)
            .count()  // 모든 항목 저장 완료 후 개수 반환
            .map(count -> new UploadResponse(count, "Upload successful"));
}

여기서 시험 함정이 하나 있어요. .count()는 Flux가 완료 신호(onComplete)를 받아야 실행됩니다. 클라이언트가 스트림을 닫지 않으면(Flux를 완료하지 않으면) .count()는 영원히 대기해요. WebClient로 클라이언트 스트리밍을 보낼 때도 반드시 contentType(MediaType.APPLICATION_NDJSON)을 지정해야 하고, Flux가 완료되어야 서버 응답이 옵니다.

curl로 스트리밍 확인하는 방법

# 기본 curl — 버퍼링으로 모든 데이터가 올 때까지 대기
curl http://localhost:8080/stream/products

# --no-buffer 또는 -N — 실시간으로 데이터가 흘러오는 것 확인
curl -N http://localhost:8080/stream/products
curl --no-buffer http://localhost:8080/stream/products

# Accept 헤더 지정 (명시적으로 NDJSON 요청)
curl -N -H "Accept: application/x-ndjson" http://localhost:8080/stream/products

# SSE 스트리밍 확인
curl -N -H "Accept: text/event-stream" http://localhost:8080/stream/products/sse

여기서 시험 함정이 하나 있어요. curl은 기본적으로 응답을 버퍼링합니다. 스트리밍 엔드포인트인데 curl로 확인하면 모든 데이터가 올 때까지 아무것도 나오지 않아요. 실제 스트리밍 동작을 확인하려면 반드시 curl -N을 사용해야 합니다.

WebTestClient로 스트리밍 테스트

스트리밍 응답은 StepVerifier로 테스트합니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductStreamTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void testProductStream() {
        webTestClient.get()
                .uri("/stream/products")
                .accept(MediaType.APPLICATION_NDJSON)
                .exchange()
                .expectStatus().isOk()
                .returnResult(Product.class)
                .getResponseBody()
                .as(StepVerifier::create)
                .expectNextCount(10)   // 10개 수신 확인
                .thenCancel()          // 무한 스트림이면 cancel로 종료
                .verify();
    }
}

thenCancel()expectComplete()의 선택:

  • expectComplete() — 유한 스트림 (완료 신호까지 기다림)
  • thenCancel() — 무한 스트림 (원하는 개수 받은 후 구독 취소)

무한 스트림에서 expectComplete()를 쓰면 테스트가 영원히 기다립니다.

Backpressure — 클라이언트와 서버의 속도 균형

스트리밍에서 서버가 초당 1000개를 방출하는데 클라이언트가 100개밖에 처리 못한다면 어떻게 될까요? Backpressure 없이는 클라이언트 메모리가 차고 OOM이 발생합니다.

Project Reactor는 TCP 수준의 Backpressure를 자동으로 지원해요. 클라이언트가 "나 10개 더 받을 수 있어(request(10))"라고 신호를 보내고, 서버는 그에 맞게 방출 속도를 조절합니다.

// limitRate() — 한 번에 처리할 항목 수 명시적 제한
@GetMapping(value = "/products/controlled", produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<Product> controlledStream() {
    return productRepository.findAll()
            .limitRate(10);  // 한 번에 최대 10개씩 요청-처리 사이클
}

// onBackpressureBuffer — 생산자가 소비자보다 빠를 때 버퍼링
Flux<Long> fastProducer = Flux.interval(Duration.ofMillis(1));

fastProducer
    .onBackpressureBuffer(1000)      // 최대 1000개 버퍼, 이후 DROP
    .subscribe(item -> processItem(item));

여기서 시험 함정이 하나 있어요. Backpressure는 HTTP 스트리밍에서 자동으로 동작합니다. TCP 흐름 제어가 이를 처리해요. limitRate()는 DB 쿼리나 상위 파이프라인의 prefetch 수를 조절하고 싶을 때 추가하는 거예요. 항상 수동으로 설정해야 하는 건 아닙니다.

클라이언트 연결 끊김 시 Flux 자동 취소

스트리밍 응답의 중요한 특성 하나 — 클라이언트가 연결을 끊으면 Flux가 자동으로 취소됩니다.

@GetMapping(value = "/products/stream", produces = MediaType.APPLICATION_NDJSON_VALUE)
public Flux<Product> streamProducts() {
    return productRepository.findAll()
            .doOnNext(p -> log.debug("Sending: {}", p.getName()))
            // 클라이언트 연결 끊김 → 이 콜백 실행 후 Flux 종료
            .doOnCancel(() -> log.info("Client disconnected, stream cancelled"));
}

1편에서 짚었던 요청 취소 전파가 여기서 그대로 적용됩니다. 사용자가 브라우저에서 새로고침하면 이전 스트림 구독이 취소되고, R2DBC DB 쿼리도 중단돼요. WebFlux가 자원을 효율적으로 관리하는 핵심 메커니즘입니다.

스트리밍 타입 선택 가이드

어떤 상황에서 어떤 스트리밍 방식을 선택해야 할지 정리합니다.

상황선택
DB에서 대용량 데이터를 순차적으로 전송Flux + APPLICATION_NDJSON_VALUE
브라우저에 실시간 이벤트 푸시 (알림, 대시보드)Flux + TEXT_EVENT_STREAM_VALUE (SSE)
클라이언트가 대용량 데이터를 순차적으로 업로드@RequestBody Flux + consumes = NDJSON
클라이언트·서버 실시간 양방향 (채팅, 게임)WebSocket
마이크로서비스 간 고성능 양방향 스트리밍gRPC bidirectional streaming

자주 만나는 함정 — 시험 직전 압축 노트

여기까지가 WebFlux 9편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.

  • Flux 반환만으로는 스트리밍 안 됨produces 미지정 시 JSON Array로 한 번에 전송
  • produces = APPLICATION_NDJSON_VALUE — 각 항목 방출 시 줄바꿈 구분 JSON으로 즉시 전송
  • produces = TEXT_EVENT_STREAM_VALUE — SSE 형식, 브라우저 EventSource API 호환
  • NDJSON = application/x-ndjson / SSE = text/event-stream MIME 타입
  • curl -N 또는 curl --no-buffer — 기본 curl은 버퍼링으로 스트리밍 확인 불가
  • 일반 응답 = "택배 박스에 다 모아서 한 번에" / 스트리밍 = "컨베이어 벨트로 즉시즉시"
  • delayElements(Duration) 프로덕션 코드에서 반드시 제거 — 성능 심각 저하
  • Flux.interval(Duration.ofSeconds(1)) — 주기적 데이터 생성 무한 스트림
  • .take(n) — 무한 스트림에서 최대 n개로 제한
  • 클라이언트 연결 끊김 → Flux 자동 취소doOnCancel() 콜백 활용
  • Backpressure는 TCP 수준 자동 처리limitRate(n) 추가 제어 가능
  • limitRate(10) — 한 번에 최대 10개씩 요청-처리 사이클 (메모리 효율화)
  • 클라이언트 스트리밍: @RequestBody Flux + consumes = APPLICATION_NDJSON_VALUE
  • .count()는 Flux 완료 신호 후 실행 — 클라이언트 스트림이 완료되어야 서버 응답
  • StepVerifier + .thenCancel() — 무한 스트림 테스트 (expectComplete 대신)
  • StepVerifier + .expectComplete() — 유한 스트림 테스트
  • 대용량 DB 조회 시 스트리밍이 유리 — 일반 응답은 10만 건 메모리 ~200MB, 스트리밍은 ~수 MB
  • 진정한 양방향 스트리밍 → WebSocket 또는 gRPC (HTTP 스트리밍은 단방향 확장)
  • SSE vs WebSocket — SSE는 서버→클라이언트 단방향이지만 브라우저 표준, WebSocket은 양방향
  • onBackpressureBuffer(1000) — 빠른 생산자 대응 버퍼 / onBackpressureDrop() — 처리 못하는 항목 버림

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!