"WebFlux를 도입했는데 오히려 장애가 늘었다"는 이야기를 종종 듣습니다. 그 원인의 상당수는 백프레셔(Backpressure)를 제대로 이해하지 못한 데서 옵니다.
리액티브 모델은 스레드 부족 문제를 풀어주지만, 그 대신 생산과 소비의 속도 차이라는 새로운 문제를 안깁니다. 빠른 생산자가 느린 소비자에게 데이터를 계속 밀어 넣으면 메모리가 폭증하고, 그 흐름을 제어하지 못하면 OutOfMemoryError로 이어집니다.
이 글에서는 Reactive Streams 명세의 내부 동작 원리부터 백프레셔의 본질인 수요 신호, 스케줄러, 그리고 실무에서 자주 마주치는 함정(무한 버퍼, 이벤트 루프 블로킹, 컨텍스트 유실)과 해결책까지 정리합니다.
전통적 동기 모델은 요청 하나당 스레드 하나를 점유합니다. 문제는 외부 DB나 API 응답을 기다리는 동안에도 그 스레드가 아무 일도 하지 못한 채 묶여 있다는 점입니다. 동시 요청이 폭증하면 스레드 수가 함께 늘어 컨텍스트 스위칭 비용과 메모리가 한계에 부딪힙니다.
| 구분 | 블로킹 모델 | 리액티브 모델 |
|---|---|---|
| 동시성 단위 | 요청당 스레드 1개 | 소수 스레드로 다수 요청 |
| I/O 대기 | 스레드 점유한 채 대기 | 스레드 반납, 콜백으로 재개 |
| 한계 | 동시성 증가 시 스레드 폭증 | 흐름 제어 없으면 메모리 폭증 |
리액티브는 스레드 문제를 풀지만, 대신 생산-소비 속도 불일치라는 새 문제를 만듭니다. 그 해법이 바로 백프레셔입니다.
Reactive Streams는 특정 라이브러리가 아니라 표준 명세이며, 네 개의 인터페이스로 구성됩니다.
| 인터페이스 | 역할 |
|---|---|
Publisher | 데이터를 방출하며 subscribe로 구독을 받음 |
Subscriber | onSubscribe, onNext, onError, onComplete 콜백 수신 |
Subscription | request(n)으로 수요 요청, cancel로 취소 |
Processor | Publisher이자 Subscriber로 중간 변환 담당 |
핵심은 데이터가 아니라 수요(demand)가 구독자에서 생산자로 거꾸로 흐른다는 점입니다. 이 수요 신호 메커니즘이 곧 백프레셔의 토대입니다.

구독자가 먼저 자신이 처리할 수 있는 만큼만 request(n)으로 수요를 요청합니다. 생산자는 요청받은 n개만 onNext로 방출하고 그 이상은 보내지 않습니다. 구독자가 다 소화하면 다시 수요를 요청하는 식으로, 소비 속도가 생산 속도를 직접 제어합니다.
생산자가 일방적으로 밀어내는 것이 아니라 소비자가 당겨오는 "풀(pull)" 방식이라는 점이 핵심입니다.

OutOfMemoryError로 이어집니다.생산자와 소비자의 속도 차이를 메모리로 흡수하지 않고, 수요 신호로 조절하는 것이 백프레셔의 본질입니다.
같은 퍼블리셔라도 구독 시점과의 관계에 따라 동작이 완전히 달라집니다.
| 구분 | 콜드(Cold) 퍼블리셔 | 핫(Hot) 퍼블리셔 |
|---|---|---|
| 방출 시작 | 구독할 때마다 처음부터 새로 | 구독과 무관하게 이미 진행 중 |
| 구독자별 데이터 | 각자 전체를 처음부터 | 구독 이후 방출분만 |
| 예시 | Flux.range, DB 조회 | Sinks, 이벤트 브로드캐스트 |
콜드는 share()나 publish().refCount()로 핫으로 변환할 수 있습니다. 핫 퍼블리셔는 늦게 구독한 쪽이 앞부분을 놓치므로 백프레셔 설계가 더 까다롭습니다.
| 전략 | 동작 | 위험 또는 적합한 상황 |
|---|---|---|
onBackpressureBuffer | 초과분을 버퍼에 저장 | 상한이 없으면 OOM 위험 |
onBackpressureDrop | 초과분을 버림 | 유실 허용 가능한 지표성 데이터 |
onBackpressureLatest | 최신 값만 유지 | 최신 상태만 중요한 경우 |
onBackpressureError | 초과 시 에러 방출 | 빠른 실패가 필요한 경우 |
연산자마다 기본 동작이 다르므로 전략을 명시적으로 지정하는 편이 안전합니다. 유실을 감수할지, 지연을 감수할지, 실패를 택할지가 선택의 핵심 기준입니다.
가장 흔한 함정은 상한 없는 버퍼입니다. 다음은 빠른 생산자와 느린 소비자를 연결한 코드입니다.
// 빠른 생산자 + 느린 소비자 + 상한 없는 버퍼
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);
버퍼는 일정 크기 이상 커지지 않으므로 메모리가 보호됩니다. 다만 데이터 일부 유실을 허용하는 대신 시스템 안정성을 확보하는 트레이드오프가 있습니다. 어떤 데이터를 버려도 되는지는 도메인에 따라 신중하게 판단해야 합니다.

subscribeOn: 소스 구독이 일어나는 스레드를 결정합니다. 체인 어디에 두든 상류 전체에 영향을 줍니다.publishOn: 그 지점을 기준으로 아래쪽 하류의 실행 스레드를 전환합니다.둘을 혼동하면 의도한 스레드에서 코드가 돌지 않아 블로킹 격리가 깨집니다. 특히 블로킹 코드를 격리하려고 스케줄러를 지정했는데 엉뚱한 스레드에서 돌면, 다음 절의 장애로 이어집니다.
상황: WebFlux 앱인데 부하가 조금만 늘어도 전체 응답이 멈추듯 느려집니다. 스레드 덤프를 보면 소수의 이벤트 루프 스레드가 블로킹 상태로 묶여 있습니다.
원인: 이벤트 루프 스레드에서 JPA 같은 블로킹 호출을 직접 실행한 것입니다. 이벤트 루프는 의도적으로 소수만 두는데, 하나가 막히면 그 위에 얹힌 모든 요청이 함께 대기합니다. 논블로킹 모델의 전제가 무너집니다.
해결:
subscribeOn(Schedulers.boundedElastic())으로 별도 스레드에 격리
데이터는 클라이언트에서 DB 방향으로 흐르지만, 수요 신호는 반대로 전파됩니다. DB 드라이버가 느려 수요를 적게 요청하면, 그 수요 부족이 비즈니스 로직 → WebFlux 핸들러 → Netty 이벤트 루프로 거슬러 올라갑니다.
결국 Netty가 TCP 윈도우를 조절해 클라이언트의 전송 속도까지 늦춥니다. 즉 애플리케이션 내부의 백프레셔가 네트워크 끝단까지 전달됩니다. 이것이 블로킹 모델로는 얻기 어려운 진정한 end-to-end 흐름 제어입니다.
// flatMap 기본 동시성은 256 -> 하위 시스템 과부하 위험
Flux.fromIterable(userIds)
.flatMap(id -> userClient.fetch(id), 16) // 동시 구독을 16개로 제한
.limitRate(50) // 상류에 50개씩 수요 요청
.subscribe(this::handle);
flatMap은 내부 퍼블리셔를 동시에 구독하는데, 기본값이 256이라 외부 호출이 폭증할 수 있습니다. 두 번째 인자로 동시성을 제한하고, limitRate로 한 번에 당겨오는 양(prefetch)을 조절해 하위 시스템 부하를 다스립니다.
상황: 로그에서 traceId가 사라지고, 보안 정보나 사용자 정보가 군데군데 비어 있습니다.
원인: 리액티브 체인은 여러 스레드를 넘나들며 실행됩니다. ThreadLocal 기반의 MDC나 SecurityContext는 스레드가 바뀌면 따라가지 못해 값이 유실됩니다.
해결:
Context 사용 -- contextWrite로 값을 싣고 체인 내에서 읽음| 모델 | 동시성 처리 | 메모리 | 적합한 상황 |
|---|---|---|---|
| 스레드 풀 (Spring MVC) | 요청당 스레드 1개 | 스레드 스택만큼 큼 | CPU 바운드, 중간 동시성 |
| 이벤트 루프 (WebFlux) | 소수 스레드로 다수 요청 | 작음 | I/O 바운드, 고동시성 |
핵심은 처리량 수치가 아니라 스레드 모델의 차이입니다. 이벤트 루프 모델은 I/O 대기에 강하지만, 블로킹 코드가 하나라도 섞이면 그 이점이 통째로 사라집니다.
실전 적용은 다음 기준으로 판단합니다.
| 상황 | 권장 선택 |
|---|---|
| 외부 I/O 다수 호출 조합, 고동시성 | WebFlux + 리액티브 드라이버 |
| 단순 CRUD, 블로킹 라이브러리 의존 | 전통적 Spring MVC 유지 |
| 점진 전환 | WebClient 등 핵심 경로만 리액티브로 |
리액티브는 만능이 아닙니다. 블로킹이 끼어들면 복잡도만 늘고 이점은 사라집니다. 스택 트레이스 단절, 디버깅 난이도 같은 운영 비용도 도입 전에 반드시 고려해야 합니다.
| 항목 | 핵심 내용 |
|---|---|
| Reactive Streams | Publisher, 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으로 전파이 글의 내용을 영상으로도 정리해 두었습니다.