리액티브(WebFlux) — 적은 스레드로 많은 동시성
전통적인 Spring MVC는 요청 하나가 들어오면 스레드 하나를 붙여 준다. 그 스레드는 컨트롤러를 지나 DB를 부르고, 응답이 올 때까지 거기서 멈춰 기다린다. 요청이 한가할 땐 아무 문제가 없다. 그런데 동시 요청이 1만 개로 늘면, 스레드도 1만 개가 필요해지고 — 정작 그 스레드들은 대부분 DB나 외부 API의 응답을 기다리며 놀고 있다. 리액티브는 바로 이 낭비를 다른 방식으로 푸는 패러다임이다. 그리고 뒤에서 보겠지만, *가상 스레드*가 등장한 지금은 "언제 리액티브가 필요한가"의 답이 꽤 달라졌다.
기다리는 스레드는 왜 낭비인가
먼저 왜 thread-per-request가 문제인지 짚고 가자. 스레드는 공짜가 아니다(jcode 05). 스레드 하나마다 OS가 스택 메모리를 수백 KB에서 1MB까지 떼어 주고, 스레드 수가 많아지면 CPU가 이들을 번갈아 실행하느라 컨텍스트 스위칭 비용이 불어난다. 문제는, 웹 요청의 대부분이 연산이 아니라 대기라는 점이다. 그동안 그 스레드는 CPU를 한 톨도 쓰지 않으면서 메모리만 붙잡은 채 블록돼 있다.
그러니 발상을 뒤집어 보자. 기다리는 동안 스레드를 붙잡고 있지 말고, 그 스레드를 풀어서 다른 요청을 처리하게 하면 어떨까? 그러려면 코드를 "결과를 기다렸다가 다음 줄을 실행"하는 식이 아니라, "결과가 도착하면 그때 이걸 해라"라고 미리 적어 두는 식으로 바꿔야 한다. 그 "미리 적어 둔 할 일의 연결"이 바로 Reactor의 Mono와 Flux다.
Mono와 Flux — 값이 아니라 "값이 올 파이프라인"
Reactor에는 두 가지 핵심 타입이 있다. Mono<T>는 0개나 1개의 결과를 비동기로 흘려보내고 끝나는 단건용이고(단건 조회, 저장 결과, 한 번의 HTTP 응답), Flux<T>는 0개부터 N개까지(유한이든 무한이든) 흘려보내는 스트림용이다(목록, 이벤트 스트림, SSE).
Mono<Order> findOrder(Long id) { // 0개나 1개 — 단건
return repo.findById(id)
.flatMap(order -> enrich(order)) // "주문이 오면 보강해라"를 연결
.timeout(Duration.ofSeconds(2)); // "2초 넘으면 끊어라"도 연결
}
여기서 중요한 건, 위 코드가 실행돼도 아직 아무 일도 일어나지 않는다는 것이다. findById, flatMap, timeout은 값을 가져오는 게 아니라 "값이 도착하면 거칠 단계들"을 조립할 뿐이다 — 마치 컨베이어 벨트를 까는 것과 같다. 벨트 위로 물건이 실제로 흐르기 시작하는 건 누군가 **구독(subscribe)**하는 순간이다(WebFlux에서는 프레임워크가 대신 구독해 준다). 이렇게 "지금 당장 실행"이 아니라 "구독하면 실행"이라 lazy하다고 부른다. 각 단계는 스레드를 붙잡고 기다리는 게 아니라, 결과가 오면 다음 단계를 콜백으로 잇는다.
연산자에 숨은 함정 — flatMap이냐 concatMap이냐
파이프라인을 조립하는 연산자 중 비동기 단계를 잇는 flatMap·concatMap은 비슷해 보이지만 동작이 다르다. flatMap은 안쪽 비동기들을 동시에 구독해 병렬로 처리하므로 빠르지만 — 출력 순서가 입력 순서와 달라질 수 있다. concatMap은 하나씩 순서대로 처리해 순서를 보존하지만 그만큼 느리다. (출처: flatMap vs concatMap.) 그래서 순서가 중요하면 concatMap(또는 flatMapSequential), 순서 무관하고 처리량이 중요하면 flatMap을 고른다 — 이 선택을 헷갈리면 "가끔 결과 순서가 뒤바뀐다"는 재현 어려운 버그가 된다.
그 적은 스레드는 어떻게 다 감당하나 — 이벤트 루프와 스케줄러
스레드를 붙잡지 않는다면, 실제 일은 소수의 이벤트 루프 스레드(대략 CPU 코어 수만큼)가 한다. 이들은 논블로킹 I/O 위에서 — DB나 API에 요청을 던져 놓고 기다리지 않은 채 다음 요청을 집어 들고, 응답이 도착하면 그제서야 콜백을 마저 실행한다.
그래서 동시성이 스레드 수에 비례하지 않는다. 이건 새로운 마법이 아니라 OS의 epoll·kqueue(net·OS) 위에 선 것으로, Node.js 이벤트 루프와 같은 모델이다. 어느 스레드에서 일을 돌릴지는 Scheduler가 정한다 — CPU 바운드면 Schedulers.parallel()(코어 수만큼 고정 풀), 어쩔 수 없이 블로킹하는 레거시 호출은 Schedulers.boundedElastic()(별도 탄력 풀로 격리)에 subscribeOn/publishOn으로 옮긴다 — 이벤트 루프를 블로킹으로 더럽히지 않기 위해서다.
흐름이 생기면 따라오는 문제 — 백프레셔
값이 "흐른다"는 모델을 택하면 새로운 고민이 생긴다 — 생산자가 소비자보다 빠르면 어쩌지? 안 처리된 것들이 버퍼에 쌓이다 메모리가 터진다. 그래서 리액티브 스트림에는 **백프레셔(backpressure)**가 들어 있다 — 소비자가 "나는 지금 N개만 더 받을 수 있어"(request(n))라고 흐름을 거꾸로 제어한다. 생산자가 백프레셔를 따를 수 없을 때(예: 외부 이벤트)는 *전략*을 고른다 — onBackpressureBuffer(버퍼링, 한계·오버플로 전략 지정 가능), onBackpressureDrop(넘치면 버림), onBackpressureLatest(가장 최신 것만 유지). (출처: Reactor backpressure.) 그래서 리액티브의 진짜 강점은 "동시성이 높다"보다 대용량 스트림을 흐름 제어와 함께 안전하게 다룬다는 데 있다 — 잠시 뒤 가상 스레드와 비교할 때 이 점이 결정적이다.
그런데 가상 스레드가 계산을 바꿨다
지금까지 본 리액티브의 주된 매력은 "적은 스레드로 I/O 동시성"이었다. 그런데 Java 21의 가상 스레드(jcode)가 이 전제를 흔들었다. 가상 스레드는 블로킹하는 코드를 그대로 써도 스레드 자체가 너무 싸져서, 수만 개를 띄워도 부담이 없다 — 읽기 쉬운 평범한 MVC 스타일 코드로도 높은 I/O 동시성을 얻는다.
벤치마크가 이 변화를 뒷받침한다 — DB·HTTP 바운드 워크로드에서 가상 스레드와 WebFlux는 처리량이 통계적으로 거의 같고(5~8% 이내), 가상 스레드가 코드 단순함·디버깅에서 이긴다. 다만 WebFlux가 분명히 앞서는 곳도 있다 — *스택 전체가 논블로킹(R2DBC)*이고 동시 요청이 1만을 넘는 극단적 동시성에서, 그리고 무엇보다 스트리밍과 백프레셔가 본질인 경우다. (출처: Virtual Threads vs WebFlux 벤치마크.) 그래서 실무 기준은 이렇게 정리된다 — 순수 I/O 동시성만 필요하면 이제는 가상 스레드가 더 단순한 답인 경우가 많고, *스트리밍·백프레셔·복잡한 비동기 합성·이미 리액티브 생태계(R2DBC·리액티브 Kafka)*면 리액티브가 제 몫을 한다. "고동시성 = 무조건 리액티브"는 옛말이다.
그리고 Java 24의 JEP 491이 남은 반론 하나를 마저 지웠다. Java 21~23에서는 가상 스레드가 synchronized 블록 안에서 캐리어 스레드에 고정(pinning)됐고, JDBC 드라이버·커넥션 풀 내부가 synchronized를 쓰다 보니 "가상 스레드로 DB를 때리면 결국 막힌다"가 리액티브를 고수할 실질적 근거였다. JEP 491이 모니터 소유권을 캐리어가 아닌 가상 스레드 자신으로 추적하게 바꾸면서 그 근거가 사라졌다(jcode 동시성 편).
리액티브를 택하면 DB는 R2DBC가 된다 — 그 값을 먼저 계산하라
"끝까지 논블로킹"이라는 전제는 DB에서 가장 비싸게 청구된다. JDBC는 태생이 블로킹 API라 리액티브 파이프라인에 넣을 수 없다. 그래서 나온 게 R2DBC(Reactive Relational Database Connectivity) — 관계형 DB를 논블로킹으로 접근하는 별도 스펙이고, Publisher로 행을 흘려보낸다.
문제는 R2DBC가 JDBC의 리액티브 버전이 아니라 완전히 다른 생태계라는 점이다.
| 잃는 것 | 구체적으로 |
|---|---|
| JPA/Hibernate 전체 | R2DBC에는 JPA 구현이 없다. 영속성 컨텍스트·더티 체킹·지연 로딩·연관관계 매핑이 전부 사라지고, Spring Data R2DBC의 얕은 매핑을 쓰거나 손으로 매핑한다 |
| 마이그레이션 도구의 매끄러움 | Flyway·Liquibase가 JDBC 기반이라 별도 설정이나 우회가 필요하다 |
| 혼용의 편의 | 한 앱에서 JPA와 R2DBC를 같이 쓰면 자동설정이 서로를 비활성화해, 명시적 설정으로 풀어야 한다 |
여기에 트랜잭션이 스레드가 아니라 리액터 컨텍스트를 타고 흐른다는 점이 더해진다. @Transactional이 동작하는 방식 자체가 달라서, 트랜잭션 편에서 익힌 감각이 그대로 옮겨오지 않는다.
실무에서는 — 이게 WebFlux 채택률이 꺾인 진짜 이유다. 흔히 "Mono/Flux 체인이 어려워서"라고 말하지만, 팀을 실제로 되돌린 건 대개 DB 계층이었다. 가상 스레드 이전에는 "리액티브의 복잡도를 감수하면 대신 스레드 효율을 얻는다"는 교환이 성립했다. 지금은 가상 스레드가 그 효율을 복잡도 없이 주므로, 일반 CRUD 서비스에서 R2DBC로 JPA를 통째로 포기할 근거가 남지 않는다. 반대로 스트리밍·백프레셔가 본질이거나 이미 리액티브로 짜인 시스템이라면 R2DBC는 여전히 제 값을 한다 — 요는 "WebFlux를 택한다 = ORM을 포기한다"는 것을 먼저 계산에 넣으라는 것이다.
단 하나의 금기 — 블로킹 호출
마지막으로, 리액티브를 택했다면 반드시 지킬 규칙이 하나 있다 — 이벤트 루프 스레드에서 절대 블로킹 호출을 하지 마라. JDBC 한 줄, Thread.sleep 한 번, 동기 HTTP 한 번이면 충분하다 — 소수뿐인 루프 스레드 중 하나가 거기서 멈춰 버리고, 그게 몇 개 겹치면 전체 처리량이 폭락한다. 리액티브의 모든 이점은 "끝까지 논블로킹"이라는 전제 위에 서 있어서, 단 한 군데라도 블로킹이면 그 전제가 무너진다. 그래서 DB는 R2DBC, HTTP는 WebClient처럼 논블로킹 도구로만 파이프라인을 채워야 한다(꼭 블로킹해야 하면 위의 boundedElastic으로 격리).
정리하면, 리액티브는 "기다리는 스레드는 낭비"라는 관찰에서 출발해, 값 대신 값이 올 파이프라인을 조립하고(연산자는 flatMap 병렬/concatMap 순서를 가려 쓴다) 구독 시점에 흐르게 하며, 소수의 이벤트 루프 + 스케줄러로 동시성을 스레드 수에서 떼어 내고 백프레셔(buffer/drop/latest)로 흐름을 제어한 모델이다. 가상 스레드가 나온 지금은 순수 I/O 동시성이면 더 단순한 길이 생겼고 Java 24의 JEP 491이 그 길의 마지막 걸림돌(pinning)까지 치웠다. 그래도 스트리밍·백프레셔·합성에서는 여전히 리액티브가 — 대신 끝까지 논블로킹이라는 규율과, DB를 R2DBC로 바꾸면서 JPA를 포기하는 값까지 받아들여야 한다.