자바 아키텍처·Spring 학습 노트 목차

리액티브(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를 포기하는 값까지 받아들여야 한다.

비동기·스케줄링·배치 — @Async·@Scheduled·Batch실무 사고 사례 — Spring·JPA·MSA