리액티브 스트림과 백프레셔 제어 (Reactive Streams & Backpressure)

개발자 팀·2026년 6월 2일

self-study-series

목록 보기
14/16

"WebFlux를 도입했는데 오히려 장애가 늘었다"는 이야기를 종종 듣습니다. 그 원인의 상당수는 백프레셔(Backpressure)를 제대로 이해하지 못한 데서 옵니다.

리액티브 모델은 스레드 부족 문제를 풀어주지만, 그 대신 생산과 소비의 속도 차이라는 새로운 문제를 안깁니다. 빠른 생산자가 느린 소비자에게 데이터를 계속 밀어 넣으면 메모리가 폭증하고, 그 흐름을 제어하지 못하면 OutOfMemoryError로 이어집니다.

이 글에서는 Reactive Streams 명세의 내부 동작 원리부터 백프레셔의 본질인 수요 신호, 스케줄러, 그리고 실무에서 자주 마주치는 함정(무한 버퍼, 이벤트 루프 블로킹, 컨텍스트 유실)과 해결책까지 정리합니다.


목차

  1. 문제 인식 -- 블로킹 I/O와 스레드 풀의 한계
  2. 리액티브 스트림 명세 -- 네 가지 인터페이스
  3. 백프레셔의 본질 -- 수요 신호
  4. 푸시 vs 풀 -- 왜 백프레셔가 필요한가
  5. 콜드 퍼블리셔와 핫 퍼블리셔
  6. 백프레셔 전략 -- 네 가지 선택지
  7. 무한 버퍼의 함정
  8. 스케줄러 -- subscribeOn과 publishOn
  9. 블로킹 호출이 이벤트 루프를 막는다
  10. WebFlux의 백프레셔 전파
  11. flatMap 동시성 제어와 limitRate
  12. 리액티브 컨텍스트 전파
  13. 성능 비교와 실전 적용
  14. 핵심 요약과 실전 체크리스트

1. 문제 인식 -- 블로킹 I/O와 스레드 풀의 한계

전통적 동기 모델은 요청 하나당 스레드 하나를 점유합니다. 문제는 외부 DB나 API 응답을 기다리는 동안에도 그 스레드가 아무 일도 하지 못한 채 묶여 있다는 점입니다. 동시 요청이 폭증하면 스레드 수가 함께 늘어 컨텍스트 스위칭 비용과 메모리가 한계에 부딪힙니다.

구분블로킹 모델리액티브 모델
동시성 단위요청당 스레드 1개소수 스레드로 다수 요청
I/O 대기스레드 점유한 채 대기스레드 반납, 콜백으로 재개
한계동시성 증가 시 스레드 폭증흐름 제어 없으면 메모리 폭증

리액티브는 스레드 문제를 풀지만, 대신 생산-소비 속도 불일치라는 새 문제를 만듭니다. 그 해법이 바로 백프레셔입니다.

2. 리액티브 스트림 명세 -- 네 가지 인터페이스

Reactive Streams는 특정 라이브러리가 아니라 표준 명세이며, 네 개의 인터페이스로 구성됩니다.

인터페이스역할
Publisher데이터를 방출하며 subscribe로 구독을 받음
SubscriberonSubscribe, onNext, onError, onComplete 콜백 수신
Subscriptionrequest(n)으로 수요 요청, cancel로 취소
ProcessorPublisher이자 Subscriber로 중간 변환 담당

핵심은 데이터가 아니라 수요(demand)가 구독자에서 생산자로 거꾸로 흐른다는 점입니다. 이 수요 신호 메커니즘이 곧 백프레셔의 토대입니다.

3. 백프레셔의 본질 -- 수요 신호

구독자가 먼저 자신이 처리할 수 있는 만큼만 request(n)으로 수요를 요청합니다. 생산자는 요청받은 n개만 onNext로 방출하고 그 이상은 보내지 않습니다. 구독자가 다 소화하면 다시 수요를 요청하는 식으로, 소비 속도가 생산 속도를 직접 제어합니다.

생산자가 일방적으로 밀어내는 것이 아니라 소비자가 당겨오는 "풀(pull)" 방식이라는 점이 핵심입니다.

4. 푸시 vs 풀 -- 왜 백프레셔가 필요한가

  • 푸시 방식(백프레셔 없음): 생산자가 소비 속도와 무관하게 밀어내, 느린 소비자 앞에 큐가 무한정 쌓이고 결국 OutOfMemoryError로 이어집니다.
  • 풀 기반(백프레셔): 소비자가 요청한 만큼만 받으므로 메모리가 안정적으로 유지됩니다.

생산자와 소비자의 속도 차이를 메모리로 흡수하지 않고, 수요 신호로 조절하는 것이 백프레셔의 본질입니다.

5. 콜드 퍼블리셔와 핫 퍼블리셔

같은 퍼블리셔라도 구독 시점과의 관계에 따라 동작이 완전히 달라집니다.

구분콜드(Cold) 퍼블리셔핫(Hot) 퍼블리셔
방출 시작구독할 때마다 처음부터 새로구독과 무관하게 이미 진행 중
구독자별 데이터각자 전체를 처음부터구독 이후 방출분만
예시Flux.range, DB 조회Sinks, 이벤트 브로드캐스트

콜드는 share()publish().refCount()로 핫으로 변환할 수 있습니다. 핫 퍼블리셔는 늦게 구독한 쪽이 앞부분을 놓치므로 백프레셔 설계가 더 까다롭습니다.

6. 백프레셔 전략 -- 네 가지 선택지

전략동작위험 또는 적합한 상황
onBackpressureBuffer초과분을 버퍼에 저장상한이 없으면 OOM 위험
onBackpressureDrop초과분을 버림유실 허용 가능한 지표성 데이터
onBackpressureLatest최신 값만 유지최신 상태만 중요한 경우
onBackpressureError초과 시 에러 방출빠른 실패가 필요한 경우

연산자마다 기본 동작이 다르므로 전략을 명시적으로 지정하는 편이 안전합니다. 유실을 감수할지, 지연을 감수할지, 실패를 택할지가 선택의 핵심 기준입니다.

7. 무한 버퍼의 함정

가장 흔한 함정은 상한 없는 버퍼입니다. 다음은 빠른 생산자와 느린 소비자를 연결한 코드입니다.

// 빠른 생산자 + 느린 소비자 + 상한 없는 버퍼
Flux.interval(Duration.ofMillis(1))   // 1ms마다 방출
    .onBackpressureBuffer()           // 크기 제한 없음 -> 위험
    .publishOn(Schedulers.boundedElastic())
    .subscribe(this::slowProcess);    // 처리에 100ms 소요

생산이 소비보다 100배 빠른 셈입니다. onBackpressureBuffer()를 인자 없이 호출하면 버퍼 크기에 제한이 없어, 처리되지 못한 데이터가 끝없이 쌓입니다. 힙을 서서히 잠식하다가 결국 OutOfMemoryError로 죽습니다. 부하 테스트에서는 멀쩡하다가 운영에서 몇 시간 뒤 터지는 진단하기 까다로운 장애입니다.

개선의 핵심은 버퍼에 상한을 두고 오버플로 전략을 명시하는 것입니다.

// 버퍼 상한과 오버플로 전략을 명시
Flux.interval(Duration.ofMillis(1))
    .onBackpressureBuffer(
        1000,                               // 최대 버퍼 크기
        dropped -> log.warn("유실 발생: {}", dropped),
        BufferOverflowStrategy.DROP_OLDEST) // 초과 시 오래된 것 폐기
    .publishOn(Schedulers.boundedElastic())
    .subscribe(this::slowProcess);

버퍼는 일정 크기 이상 커지지 않으므로 메모리가 보호됩니다. 다만 데이터 일부 유실을 허용하는 대신 시스템 안정성을 확보하는 트레이드오프가 있습니다. 어떤 데이터를 버려도 되는지는 도메인에 따라 신중하게 판단해야 합니다.

8. 스케줄러 -- subscribeOn과 publishOn

  • subscribeOn: 소스 구독이 일어나는 스레드를 결정합니다. 체인 어디에 두든 상류 전체에 영향을 줍니다.
  • publishOn: 그 지점을 기준으로 아래쪽 하류의 실행 스레드를 전환합니다.

둘을 혼동하면 의도한 스레드에서 코드가 돌지 않아 블로킹 격리가 깨집니다. 특히 블로킹 코드를 격리하려고 스케줄러를 지정했는데 엉뚱한 스레드에서 돌면, 다음 절의 장애로 이어집니다.

9. 블로킹 호출이 이벤트 루프를 막는다

상황: WebFlux 앱인데 부하가 조금만 늘어도 전체 응답이 멈추듯 느려집니다. 스레드 덤프를 보면 소수의 이벤트 루프 스레드가 블로킹 상태로 묶여 있습니다.

원인: 이벤트 루프 스레드에서 JPA 같은 블로킹 호출을 직접 실행한 것입니다. 이벤트 루프는 의도적으로 소수만 두는데, 하나가 막히면 그 위에 얹힌 모든 요청이 함께 대기합니다. 논블로킹 모델의 전제가 무너집니다.

해결:

  • 불가피한 블로킹 호출은 subscribeOn(Schedulers.boundedElastic())으로 별도 스레드에 격리
  • 가능하면 R2DBC 같은 리액티브 드라이버로 블로킹 자체를 제거

10. WebFlux의 백프레셔 전파

데이터는 클라이언트에서 DB 방향으로 흐르지만, 수요 신호는 반대로 전파됩니다. DB 드라이버가 느려 수요를 적게 요청하면, 그 수요 부족이 비즈니스 로직 → WebFlux 핸들러 → Netty 이벤트 루프로 거슬러 올라갑니다.

결국 Netty가 TCP 윈도우를 조절해 클라이언트의 전송 속도까지 늦춥니다. 즉 애플리케이션 내부의 백프레셔가 네트워크 끝단까지 전달됩니다. 이것이 블로킹 모델로는 얻기 어려운 진정한 end-to-end 흐름 제어입니다.

11. flatMap 동시성 제어와 limitRate

// flatMap 기본 동시성은 256 -> 하위 시스템 과부하 위험
Flux.fromIterable(userIds)
    .flatMap(id -> userClient.fetch(id), 16) // 동시 구독을 16개로 제한
    .limitRate(50)                            // 상류에 50개씩 수요 요청
    .subscribe(this::handle);

flatMap은 내부 퍼블리셔를 동시에 구독하는데, 기본값이 256이라 외부 호출이 폭증할 수 있습니다. 두 번째 인자로 동시성을 제한하고, limitRate로 한 번에 당겨오는 양(prefetch)을 조절해 하위 시스템 부하를 다스립니다.

12. 리액티브 컨텍스트 전파

상황: 로그에서 traceId가 사라지고, 보안 정보나 사용자 정보가 군데군데 비어 있습니다.

원인: 리액티브 체인은 여러 스레드를 넘나들며 실행됩니다. ThreadLocal 기반의 MDC나 SecurityContext는 스레드가 바뀌면 따라가지 못해 값이 유실됩니다.

해결:

  • Reactor의 Context 사용 -- contextWrite로 값을 싣고 체인 내에서 읽음
  • Micrometer Context Propagation으로 MDC와 추적 정보를 자동 전파

13. 성능 비교와 실전 적용

모델동시성 처리메모리적합한 상황
스레드 풀 (Spring MVC)요청당 스레드 1개스레드 스택만큼 큼CPU 바운드, 중간 동시성
이벤트 루프 (WebFlux)소수 스레드로 다수 요청작음I/O 바운드, 고동시성

핵심은 처리량 수치가 아니라 스레드 모델의 차이입니다. 이벤트 루프 모델은 I/O 대기에 강하지만, 블로킹 코드가 하나라도 섞이면 그 이점이 통째로 사라집니다.

실전 적용은 다음 기준으로 판단합니다.

상황권장 선택
외부 I/O 다수 호출 조합, 고동시성WebFlux + 리액티브 드라이버
단순 CRUD, 블로킹 라이브러리 의존전통적 Spring MVC 유지
점진 전환WebClient 등 핵심 경로만 리액티브로

리액티브는 만능이 아닙니다. 블로킹이 끼어들면 복잡도만 늘고 이점은 사라집니다. 스택 트레이스 단절, 디버깅 난이도 같은 운영 비용도 도입 전에 반드시 고려해야 합니다.

14. 핵심 요약과 실전 체크리스트

항목핵심 내용
Reactive StreamsPublisher, Subscriber, Subscription, Processor 표준 명세
백프레셔수요 신호가 구독자에서 생산자로 역류
Cold vs Hot구독 시점마다 새로 vs 진행 중인 스트림 공유
백프레셔 전략Buffer, Drop, Latest, Error 중 명시적 선택
스케줄러subscribeOn은 상류, publishOn은 하류 스레드
블로킹 함정이벤트 루프에서 블로킹하면 전체 마비
컨텍스트 전파ThreadLocal 대신 Reactor Context 사용

실무 적용 팁

  • 백프레셔 전략: onBackpressure 계열로 버퍼 상한과 오버플로 전략을 반드시 명시
  • 블로킹 격리: 블로킹 호출은 boundedElastic으로 격리하거나 리액티브 드라이버로 대체
  • 동시성 제한: flatMap 동시성 인자와 limitRate로 하위 시스템 부하 통제
  • 컨텍스트: traceId, 보안 정보는 Reactor Context와 Context Propagation으로 전파
  • 도입 판단: I/O 바운드 고동시성에만 적용, 블로킹이 섞이면 MVC가 더 낫습니다

영상으로 보기

이 글의 내용을 영상으로도 정리해 두었습니다.

유튜브 영상 보기

profile
공부하고 기록하고 공유하는 개발자 팀(Tim) 입니다. 늘끄적입니다.

0개의 댓글