리액티브와 백프레셔 - WebFlux가 해결하는 것과 옮기는 것
WebFlux를 “더 빠른 스프링”으로 소개하면 도입 후 실망한다. 처리량이 그대로거나 오히려 떨어지고, 디버깅은 훨씬 어려워진다. 리액티브가 실제로 해결하는 문제는 속도가 아니라 자원 사용 방식이고, 그 대가를 알고 골라야 한다.
서블릿 모델의 한계
서블릿 스택은 요청 하나가 스레드 하나를 점유한다. 외부 호출을 기다리는 동안에도 쥐고 있다. 스레드는 스택 메모리를 차지하므로 수천 개를 만들 수 없고, 그래서 풀 크기가 동시성의 상한이 된다.
느린 업스트림 하나가 그 풀을 다 먹으면 무관한 엔드포인트까지 멈춘다. Tomcat 스레드 고갈 실험에서 잰 것이 정확히 이 현상이었다. 요청 스레드 200개 전부가 소켓 읽기에 묶였고, DB만 읽는 엔드포인트의 p50이 클라이언트 타임아웃 30초가 됐다.
리액티브가 바꾸는 것
리액티브 스택은 소수의 이벤트 루프 스레드가 논블로킹 IO로 많은 연결을 다룬다(epoll). 대기 중인 요청은 스레드를 쥐지 않고 콜백으로 등록만 되어 있다. 그래서 연결이 수만 개여도 스레드는 코어 수 정도면 된다.
얻는 것은 높은 동시 연결 수에서의 메모리 효율이다. 처리 속도가 아니다. CPU 바운드 작업이 빨라지지 않고, DB 쿼리도 같은 시간이 걸린다.
백프레셔: 리액티브 스트림의 핵심
리액티브의 진짜 기여는 백프레셔다. 생산자가 소비자보다 빠를 때 무엇을 할 것인가.
- 버퍼에 쌓는다 → 메모리가 터진다
- 버린다 → 데이터를 잃는다
- 소비자가 받을 수 있는 만큼만 요청한다 → 백프레셔
Reactive Streams 명세는 소비자가 request(n)으로 수요를 표현하고 생산자가 그만큼만 보내게 한다. 이것이 Publisher, Subscriber, Subscription 인터페이스의 존재 이유다.
백프레셔가 필요한 경우는 생각보다 좁다. 요청-응답 HTTP API는 요청 하나에 응답 하나라 압력이 쌓이지 않는다. 백프레셔가 값을 하는 곳은 스트리밍이다. 대용량 파일 처리, 서버-센트 이벤트, 메시지 스트림 처리, DB 커서로 수백만 행을 읽어 다른 곳으로 보내는 파이프라인.
대가
디버깅이 어렵다. 스택 트레이스가 실행 흐름을 반영하지 않는다. 연산자 체인 어디서 났는지 알려면 checkpoint()나 Hooks.onOperatorDebug()가 필요하고, 후자는 비용이 크다.
전체 경로가 논블로킹이어야 한다. 중간에 블로킹 호출이 하나라도 있으면 이벤트 루프가 묶이고, 그 루프가 담당하는 모든 연결이 함께 멈춘다. JDBC가 블로킹이므로 R2DBC로 바꿔야 하고, 그러면 JPA를 못 쓴다. 쓰는 라이브러리 전체가 리액티브를 지원해야 한다.
ThreadLocal이 동작하지 않는다. 스레드가 계속 바뀌므로 MDC, SecurityContext, 트랜잭션 컨텍스트가 따라가지 않는다. Reactor Context로 대체해야 하고, 라이브러리들이 그것을 지원해야 한다.
팀이 배워야 한다. 연산자 수십 개의 의미, 콜드와 핫 퍼블리셔의 차이, 구독 시점, 스케줄러 선택. 리뷰에서 잡기 어려운 실수가 많다.
가상 스레드가 바꾼 계산
Java 21의 가상 스레드는 리액티브가 풀던 문제의 상당 부분을 동기 코드로 푼다. 블로킹 호출에서 캐리어 스레드를 놓아 주므로, 스레드당 메모리 문제 없이 높은 동시성을 얻는다. 스택 트레이스와 디버거가 그대로 동작한다(가상 스레드의 내부).
그래서 선택 기준이 좁아졌다.
| 상황 | 선택 |
|---|---|
| 요청-응답 API, 높은 동시성이 필요 | 가상 스레드 + 서블릿 스택 |
| 진짜 스트리밍(SSE, 대용량 파이프라인) | 리액티브 |
| 백프레셔가 실제로 필요 | 리액티브 |
| 이미 리액티브 생태계 위에 있다 | 유지 |
“동시성 때문에 WebFlux”는 가상 스레드가 나온 뒤 근거가 약해졌다. 남는 이유는 백프레셔와 스트리밍, 그리고 이미 그 스택을 쓰고 있다는 사실이다.
이 설명이 깨지는 곳
- 가상 스레드가 상한을 없애 주지는 않는다. 스레드 고갈 대신 진행 중 호출이 무한히 쌓인다. 벌크헤드 같은 명시적 상한이 여전히 필요하다.
- 리액티브가 자원을 덜 쓴다는 것은 조건부다. 연결 수가 적으면 차이가 없고, 연산자 체인의 오버헤드가 더 클 수 있다.
- WebFlux를 쓴다고 전부 논블로킹인 것은 아니다.
block()을 부르는 코드가 하나 섞이면 그 지점에서 모델이 깨진다. - R2DBC는 JDBC의 대체재가 아니다. 기능 차이가 있고, 트랜잭션과 잠금 다루기가 다르다.
무엇을 재면 확인되는가
- 동시 연결 수를 100 → 10,000으로 늘려 가며 서블릿+가상 스레드와 리액티브의 메모리·처리량·p99를 비교한다. 연결 수가 적으면 차이가 없다는 것부터 확인된다.
- 이벤트 루프 스레드에서 100ms를 블로킹하고 같은 루프의 다른 연결 지연을 본다.
- 느린 소비자를 만들어 백프레셔 없는 파이프라인과 있는 파이프라인의 메모리를 비교한다.
spring-ops-lab은 서블릿 스택으로 고정했으므로 1번과 2번은 그 범위 밖이다. 설계 문서의 “하지 않는 것”에 WebFlux를 명시한 이유는 변수를 줄이기 위해서였고, 비교가 필요하면 별도 하니스를 만들어야 한다.
실무와의 접점
monticker에서 Netty 이벤트 루프 모델로 다수 연결에 시세를 뿌렸다. 그 경우는 리액티브가 맞는 자리였다. 연결이 많고, 서버가 계속 밀어내는 스트림이며, 소비자가 느릴 때 어떻게 할지를 정해야 했다. 반대로 요청-응답 API에 같은 모델을 쓸 이유는 없었다. 모델은 트래픽의 모양이 정하는 것이지 최신 여부가 정하는 것이 아니다.
정리
- 리액티브가 해결하는 것은 속도가 아니라 높은 동시 연결에서의 자원 사용이다.
- 진짜 기여는 백프레셔이고, 그것이 필요한 곳은 요청-응답이 아니라 스트리밍이다.
- 대가는 디버깅 난이도, 전체 스택의 논블로킹 요구, ThreadLocal 부재, 학습 비용이다.
- 이벤트 루프에서 블로킹하면 그 루프의 모든 연결이 멈춘다.
- 가상 스레드가 “동시성 때문에 리액티브”의 근거를 약화시켰다. 남는 이유는 백프레셔와 스트리밍이다.
- 모델은 트래픽의 모양이 정한다.
댓글
아직 댓글이 없습니다