리액터, 백프레셔와 여러가지 전략
백프레셔(Backpressure)란 ?
리액티브 스트림즈에서 백프레셔(Backpressure) 는 발행자(Publisher)와 소비자(Subscriber) 간 처리 속도 차이로 인한 문제를 해결하기 위하여 발행자가 일방적으로 데이터를 푸시하는 것이 아닌, 소비자가 Subscription 을 통해 요청(request)하는 만큼만 데이터를 풀(Pull) 방식으로 처리하는 메커니즘을 의미합니다.
백프레셔가 왜 필요할까?
리액티브 스트림 환경에서는 일반적으로 업스트림(Upstream)에서 데이터를 발행하고 이를 다운스트림(Downstream)에서 소비하는 파이프라인 구조를 사용합니다. 일반적으로 단일 스레드에서 map(), filter(), concatMap() 같은 연산자만으로 이루어진 단순 파이프라인이라면, 구독자가 Subscription.request(n) 으로 요청한 개수에 맞춰 한 요소씩 빠르게 처리됩니다. 그러나 스레드가 달라지거나 상대적으로 느린 연산 — 외부 API 호출, DB 조회, 복잡한 비즈니스 로직 — 이 들어오는 구간에서는 업스트림과 다운스트림의 처리 속도 차이가 커질 수 있습니다.
이러한 속도 차이가 발생하는 구간에서, 리액터는 업스트림과 다운스트림 사이의 속도 차이를 완충하기 위해 내부 버퍼를 사용합니다. 하지만 다운스트림이 충분히 따라오지 못하면 이 버퍼에 데이터가 계속 쌓이면서 처리 지연과 메모리 사용량이 증가하고, 심한 경우 시스템 과부하나 OOM(Out Of Memory)까지 이어질 수 있습니다.
내부 버퍼만으로 문제 해결 가능한가 ?
과연 내부 버퍼만으로 업스트림과 다운스트림 간 속도 차이로 발생하는 문제를 해결할 수 있을까요?
업스트림이 다운스트림의 처리 속도를 고려하지 않고 계속 데이터를 발행한다면, 내부 버퍼에 데이터가 점점 쌓이면서 처리 지연과 메모리 사용량이 증가하고, 심한 경우 시스템 과부하나 OOM(Out Of Memory)까지 이어질 수 있습니다. 다시 말해, 버퍼는 단지 속도 차이를 잠시 숨겨줄 뿐 근본적인 해결책은 되지 못합니다.
이런 한계를 완화하기 위해 리액티브 스트림즈/리액터는 업스트림이 일방적으로 데이터를 밀어 넣는 푸시(Push) 방식 대신, 다운스트림이 Subscription.request(n) 으로 필요한 만큼만 요청하고 업스트림은 그만큼만 보내는 풀 기반 백프레셔 방식을 사용합니다.
이러한 방식은 소비자가 자신의 처리 능력에 맞춰 요청량을 조절하기 때문에, 버퍼가 통제 없이 커지는 상황을 줄이고 시스템을 보다 안정적으로 운용할 수 있습니다.
내부 버퍼는 어떻게 생겼을까 ?
리액터 내부 구현을 보면, 발행자(Publisher)와 구독자(Subscriber) 사이에서 데이터를 보관하고 전달하는 역할을 하는 핵심 추상화 중 하나가 QueueSubscription<T> 인터페이스입니다.
- reactor.core.Fuseable.QueueSubscription
인터페이스 JAVA interface QueueSubscription<T> extends Queue<T>, Subscription { T poll(); boolean isEmpty(); // fusion, size() 등 }
QueueSubscription 인터페이스는 리액티브 스트림즈의 Subscription 과 큐(Queue) 역할을 함께 수행하는 인터페이스입니다. 위쪽으로는 request(long n), cancel() 같은 메서드를 통해 “얼마나까지 데이터를 쌓아도 되는지”를 발행자에게 알려주고, 아래쪽으로는 poll(), isEmpty() 등을 통해 내부 버퍼에 쌓인 데이터를 하나씩 꺼내 다운스트림으로 넘기는 큐 API를 제공합니다.
다만 모든 연산자가 항상 QueueSubscription 필드를 직접 갖는 것은 아닙니다. 리액터는 성능 최적화를 위해 operator fusion이라는 기법을 사용하는데, 이는 여러 연산자를 하나로 합쳐서 불필요한 중간 버퍼와 신호 전파를 줄이는 방식입니다. 업스트림이 fusion을 지원하면 그 큐를 재사용하고, 지원하지 않으면 별도 내부 큐를 만드는 식으로 유연하게 동작합니다.
정리하면, 리액터는 발행자와 구독자 사이에서 QueueSubscription 같은 구현체를 사용해 “얼마나까지 쌓아둘 수 있는지(request(n))”와 “버퍼에 쌓인 데이터를 어떻게 꺼내 전달할지”를 한 번에 다루는 구조를 취합니다. 이 덕분에 단순 동기 파이프라인에서는 버퍼가 눈에 띄게 커지지 않지만, 스케줄러 전환이나 느린 외부 연산이 끼어 업스트림이 더 빠르게 데이터를 만들어낼 때는, 같은 메커니즘이 곧바로 “문제가 될 수 있는 버퍼 구간” 으로 드러나게 됩니다.
백프레셔 이미지로 이해하기
아래 이미지는 Gemini 로 생성하였습니다.

푸시 방식(왼쪽)의 문제:
- 발행자가 소비자 상태와 무관하게 계속 데이터를 밀어 넣어, 버퍼가 무한정 커지고 결국
OOM(Out Of Memory)으로 이어질 수 있습니다.
풀 방식(오른쪽)의 이점:
- 소비자가
request(n)으로 필요한 만큼만 요청하기 때문에, 버퍼 크기(Capacity: 256 items)를 미리 정해두고 그 안에서만 데이터가 흐르게 할 수 있습니다. - 생산자는 소비자가 준비될 때까지 일시 정지(Paused) 상태가 되어, 시스템이 안정적으로 동작합니다.
지금까지 살펴본 것처럼, 리액티브 스트림즈의 기본 백프레셔 메커니즘은 소비자가 request(n)으로 필요한 만큼만 요청하는 풀 방식입니다. 하지만 실제 애플리케이션에서는 다운스트림이 요청한 속도보다 업스트림이 더 빠르게 데이터를 생산하는 상황이 발생할 수 있습니다.
리액터는 이런 상황에서 "넘치는 데이터를 어떻게 처리할지" 를 결정하는 여러 가지 백프레셔 전략을 제공합니다.
여러가지 백프레셔 전략
리액터는 기본 Subscription.request(n) 풀 방식 외에 버퍼가 가득 찼을 때 넘치는 데이터를 어떻게 처리할지 결정하는 여러가지 백프레셔 전략들을 제공합니다.
백프레셔 전략: IGNORE
IGNORE 전략은 별도 백프레셔 전략을 지정하지 않고 subscribe()만 호출했을 때 기본적으로 적용되는 전략으로, Subscriber의 request(n) 신호를 완전히 무시하는 전략입니다. Publisher는 요청량과 관계없이 자신이 가진 모든 데이터를 무한 발행 시도하며, 내부 RingBufferQueue(기본 256개)가 가득 차면 예외(IllegalStateException)을 발생시키며, 이 예외는 onError 신호로 변환되어 다운스트림 전체로 전파됩니다. 스트림을 종료합니다.
IGNORE전략 예제JAVA Flux.range(1, 1000) .subscribe(System.out::println);
백프레셔 전략: ERROR
ERROR 전략은 리액터에서 제공하는 백프레셔 전략 중 가장 “안전하게 실패”하는 전략입니다. Subscriber가 request(n)으로 허용한 데이터 수만큼만 Publisher가 발행하도록 엄격히 제어합니다. 만일 내부 버퍼 한도를 초과해 데이터를 발행하려는 순간 즉시 예외(IllegalStateException)을 발생시키며, 이 예외는 onError 신호로 변환되어 다운스트림 전체로 전파됩니다.
ERROR전략 예제JAVA Flux.range(1, 1000) .onBackpressureError() .subscribe(i -> { Thread.sleep(10); // 소비 지연 System.out.println(i); });
백프레셔 전략: DROP
DROP 전략은 내부 버퍼(256개)의 한도를 초과하여 발행된 데이터를 즉시 버리고 넘어가는 전략입니다. 만일 내부 버퍼 한도보다 더 많은 데이터가 발행되면 추가 데이터는 버퍼에 쌓지 않고 바로 버려집니다. 다만 IGNORE, ERROR 전략과는 달리 스트림은 정상적으로 계속 진행되며 예외나 중단은 발생하지 않습니다.
DROP전략 예제JAVA Flux.range(1, 1000) .onBackpressureDrop() .subscribe(System.out::println);
백프레셔 전략: LATEST
LATEST 전략은 내부 버퍼(기본 256개)가 가득 찼을 때 최신 데이터 1개만 유지하고 기존 대기 데이터는 모두 버리는 전략입니다. 새 데이터가 들어오면 Subscriber가 버퍼를 비워주기 전까지 기존 데이터 싹 버리고 최신 1개로 교체합니다. DROP(추가 데이터 버림)과 달리 “마지막 상태”만 보존하여 실시간 데이터에서 Subscriber가 준비되면 가장 최근 값을 받을 수 있으며, 스트림은 정상적으로 계속 진행됩니다.
LATEST전략 예제JAVA Flux.range(1, 1000) .onBackpressureLatest() .subscribe(System.out::println);
백프레셔 전략: BUFFER
BUFFER 전략은 Publisher가 빠르게 데이터를 발행해도 모든 데이터를 메모리에 버퍼링하는 기본 전략입니다. 내부 버퍼(기본 256개)가 가득 차면 자동으로 버퍼 크기를 늘려서 무한정 저장합니다. 이 전략은 내부 버퍼 사이즈를 변경하기 때문에 OOM(Out Of Memory)에 주의해야합니다.
BUFFER전략 예제JAVA Flux.range(1, 10000) .onBackpressureBuffer() // 모든 데이터 버퍼링 .subscribe(System.out::println);
BUFFER + DROP LASTEST
BUFFER + DROP LATEST 전략은 고정 버퍼 크기에서 버퍼가 가득 찼을 때 새로 들어오는 최신 데이터만 드롭하는 전략입니다. 버퍼가 꽉 차면 기존에 대기 중인 데이터들은 그대로 보존하고, 새 데이터만 버리고 넘어갑니다. DROP 전략과 비슷하지만 버퍼 크기를 직접 지정할 수 있다는 점이 다릅니다.
BUFFER + DROP LATEST전략 예제JAVA Flux.range(1, 10000) .onBackpressureBuffer(3, OverflowStrategy.DROP_LATEST) .subscribe(System.out::println);
BUFFER + DROP OLDEST
BUFFER + DROP OLDEST 전략은 고정 버퍼 크기에서 버퍼가 가득 찼을 때 가장 오래된 데이터(큐 맨 앞)를 드롭하고 새 데이터를 추가하는 전략입니다. 버퍼가 꽉 차면 FIFO(First In First Out) 방식으로 가장 먼저 들어온 오래된 데이터를 버리고, 새 데이터를 큐 뒤에 추가합니다. “줄 서서 기다리다 제일 오래된 사람 먼저 내보내기” 와 같습니다.
BUFFER + DROP OLDEST전략 예제JAVA Flux.range(1, 10000) .onBackpressureBuffer(3, OverflowStrategy.DROP_OLDEST) .subscribe(System.out::println);
언제, 어떤 전략을 사용해야할까 ?
백프레셔 전략은 현재 처한 상황과 데이터 처리 정책에 따라 신중히 선택해야 합니다. 마지막으로 각 전략의 특징과 적합한 사용 상황을 간단히 정리하겠습니다.
| 전략 | 특징 | 사용 상황 |
|---|---|---|
| IGNORE | Subscriber의 request 신호 무시하고 무제한 발행 | Publisher가 Subscriber 속도 무시하고 싶은 경우 |
| ERROR | 버퍼 초과 시 예외 발생 | 데이터 손실 절대 불가한 상황 |
| DROP | 초과 새 데이터 즉시 버림 | 실시간 스트림, 과거 데이터 무관심 |
| LATEST | 최신 1개만 유지 | 실시간 최신 상태 (주식 시세, 센서) |
| BUFFER + DROP_LATEST | 버퍼 꽉 차면 새 데이터 드롭 | 과거 데이터 완전성 우선 (감사 로그) |
| BUFFER + DROP_OLDEST | 버퍼 꽉 차면 오래된 데이터 드롭 | 최근 N개 순차 처리 (채팅 메시지) |