리액터, 백프레셔와 여러가지 전략
리액터, 백프레셔와 여러가지 전략을 어떤 기준으로 선택해야 할까요?
\n\n> 리액터, 백프레셔와 여러가지 전략을 어떤 기준으로 선택해야 할까요?\n# 백프레셔(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개 순차 처리 (채팅 메시지) |
주의할 점
1. onBackpressureBuffer()의 기본 크기는 무제한이다
버퍼 크기를 지정하지 않으면 메모리가 허용하는 한 계속 쌓인다. 프로덕션에서는 반드시 maxSize를 지정해야 한다.
2. onBackpressureDrop()은 데이터 유실을 허용하는 전략이다
드롭된 데이터는 복구할 수 없다. 로그나 메트릭처럼 유실 가능한 데이터에만 사용해야 한다.
3. request(n)을 호출하지 않으면 데이터가 아예 흐르지 않는다
Reactive Streams 스펙상 Subscriber가 request()를 호출해야 Publisher가 데이터를 보낸다. 커스텀 Subscriber를 만들 때 onSubscribe에서 request()를 빠뜨리면 아무 일도 안 일어난다.