자바 아키텍처·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)*면 리액티브가 제 몫을 한다. "고동시성 = 무조건 리액티브"는 옛말이다.

단 하나의 금기 — 블로킹 호출

마지막으로, 리액티브를 택했다면 반드시 지킬 규칙이 하나 있다 — 이벤트 루프 스레드에서 절대 블로킹 호출을 하지 마라. JDBC 한 줄, Thread.sleep 한 번, 동기 HTTP 한 번이면 충분하다 — 소수뿐인 루프 스레드 중 하나가 거기서 멈춰 버리고, 그게 몇 개 겹치면 전체 처리량이 폭락한다. 리액티브의 모든 이점은 "끝까지 논블로킹"이라는 전제 위에 서 있어서, 단 한 군데라도 블로킹이면 그 전제가 무너진다. 그래서 DB는 R2DBC, HTTP는 WebClient처럼 논블로킹 도구로만 파이프라인을 채워야 한다(꼭 블로킹해야 하면 위의 boundedElastic으로 격리).

정리하면, 리액티브는 "기다리는 스레드는 낭비"라는 관찰에서 출발해, 값 대신 값이 올 파이프라인을 조립하고(연산자는 flatMap 병렬/concatMap 순서를 가려 쓴다) 구독 시점에 흐르게 하며, 소수의 이벤트 루프 + 스케줄러로 동시성을 스레드 수에서 떼어 내고 백프레셔(buffer/drop/latest)로 흐름을 제어한 모델이다. 가상 스레드가 나온 지금은 순수 I/O 동시성이면 더 단순한 길이 생겼지만, 스트리밍·백프레셔·합성에서는 여전히 리액티브가 — 대신 끝까지 논블로킹이라는 규율을 받아들여야 한다.

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