전통적인 Spring MVC 같은 서블릿 기반 프레임워크는 "Thread-pre-Request"모델을 사용합니다. 즉, 클라이언트의 요청이 들어올 때마다 새로운 스레드가 생성되거나 스레드 풀에서 하나를 가져와서 그 요청을 처리합니다.
요청마다 스레드가 할당되므로, I/O 작업(데이터베이스 호출, 외부 API 호출 등) 중 스레드가 block이 되면 해당 스레드는 대기 상태가 됩니다.
동시 요청이 많아지면 스레드 풀이 고갈될 수 있고, 스레드 생성/관리 비용이 커저 성능 저하가 발생할 수 있어요.
반면, Spring WebFlux는 non-blocking event loop 모델을 기반으로 동작합니다. 이 모델에서는 스레드가 요청을 기다리며 block 되지 않고, 작업이 완료될 때까지 다른 요청을 처리할 수 있습니다. WebFlux는 기본적으로 Reactor Netty라는 비동기 서버를 사용하며, 이 서버는 소수의 스레드로 많은 요청을 효율적으로 처리합니다.
WebFlux의 스레드 모델은 리액티브 스트림 구현체와 밀접하게 연관되어 있습니다. 주요 특징은 다음과 같습니다.
WebFlux는 기본적으로 소수의 이벤트 루프 스레드(예: reactor-http-nio-X)를 사용합니다. 이 스레드 수는 보통 CPU 코어 수에 맞춰 설정되며, 요청을 받아 비동기적으로 처리합니다.
이 스레드는 요청을 직접 처리하기보다는, 작업을 스케줄링하고 콜백을 등록하는 역할을 합니다.
I/O 작업(네트워크 호출, 파일 읽기 등)이 발생하면 스레드가 블록되지 않고, 해당 작업이 완료되면 이벤트 루프가 이를 감지해 후속 처리를 진행합니다. 이 과정에서 스레드는 계속해서 다른 요청을 처리할 수 있어요.
Reactor는 스레드 사용을 제어하기 위해 Scheduler라는 추상화를 제공합니다.
WebFlux에서 자주 사용되는 스케줄러는
Schedulers.parallel(): CPU 코어 수에 맞춘 스레드 풀, 주로 CPU 집약적인 작업에 사용.Schedulers.boundedElastic(): I/O 작업처럼 블록될 가능성이 있는 작업을 별도의 스레드 풀에서 처리.Schedulers.single(): 단일 스레드에서 작업 실행.WebFlux에서 스레드는 전통적인 방식처럼 요청마다 1:1로 매핑되지 않습니다.
Mono나Flux)으로 변환합니다. 이 작업은 필요에 따라 다른 스케줄러로 오프로드(offload)될 수 있어요.Schedulers.boundedElastic()으로 옮겨 실행되며, 이벤트 루프 스레드는 계속 다른 요청을 처리합니다.subscribeOn()이나 publishOn() 같은 연산자를 통해 특정 작업을 다른 스레드 풀에서 실행하도록 제어할 수 있습니다.subscribeOn(): 전체 파이프라인을 특정 스케줄러에서 실행.publishOn(): 이후 연산을 다른 스케줄러로 전환.1) subscribeOn(): 전체 파이프라인을 특정 스케줄러에서 실행
subscribeOn()은 리액티브 스트림(Mono나 Flux)이 구독(subscribe)되는 시점부터 전체 파이프라인의 실행을 지정한 스케줄러에서 처리하도록 합니다. Mono<String> blockingOperation() {
return Mono.fromCallable(() -> {
Thread.sleep(1000); // 블록킹 작업 시뮬레이션
return "Result";
})
.subscribeOn(Schedulers.boundedElastic()) // 블록킹 작업을 별도 스레드 풀에서 실행
.map(result -> result + " Processed");
}
@GetMapping("/test")
public Mono<String> test() {
return blockingOperation();
}
Thread.sleep()은 블록킹 작업이라 이벤트 루프 스레드(reactor-http-nio-X)에서 실행하면 안됩니다. 이벤트 루프가 멈추면 다른 요청을 처리 못 하기 때문이에요.subscribeOn(Schedulers.boundedElastic())을 사용하면 이 블록킹 작업이 boundedElastic-X 스레드에서 실행돼요. 전체 파이프라인(fromCallable과 map)이 boundedElastic 스레드 풀에서 실행됩니다. 이벤트 루프 스레드는 자유롭게 남아서 다른 요청을 처리할 수 있습니다.2) publishOn(): 이후 연산을 다른 스케줄러로 전환
publishOn()은 이 연산자가 호출된 지점 이후의 연산을 지정한 스케줄러에서 실행하도록 합니다. 그 전 단계는 영향을 받지 않아요. 즉, 파이프라인 중간에 스레드를 "전환"하는 역할을 합니다.Mono<String> asyncOperation() {
return Mono.just("Start")
.map(s -> {
System.out.println("Step 1: " + Thread.currentThread().getName());
return s + " Step1";
})
.publishOn(Schedulers.parallel()) // 여기서부터 스레드 전환
.map(s -> {
System.out.println("Step 2: " + Thread.currentThread().getName());
return s + " Step2";
});
}
@GetMapping("/test2")
public Mono<String> test2() {
return asyncOperation();
}
map 연산을 포함합니다. 첫 번째 map은 기본 스레드(예: reactor-http-nio-1)에서 실행되고, publishOn(Schedulers.parallel()) 이후 두 번째 map은 parallel-1 스레드에서 실행됩니다.map이 CPU 집약적인 작업이라면, 이벤트 루프 스레드를 막지 않고 Schedulers.parallel()(CPU 작업에 최적화된 스레드 풀)로 옮겨서 처리할 수 있습니다.@RestController
public class ExampleController {
@GetMapping("/test")
public Mono<String> getData() {
return Mono.just("Hello")
.map(s -> {
System.out.println("Mapping on: " + Thread.currentThread().getName());
return s + " World";
})
.subscribeOn(Schedulers.boundedElastic());
}
}
1) 요청이 들어오면 이벤트 루프 스레드(예: reactor-http-nio-1)가 이를 받아 Mono를 생성합니다.
2) subscribeOn(Schedulers.boundedElastic()) 때문에 실제 작업은 boundedElastic-X 스레드에서 실행됩니다.
3) 이벤트 루프 스레드는 블록되지 않고 다른 요청을 계속 처리합니다.
Thread.sleep())을 이벤트 루프 스레드에서 실행하면 성능이 급격히 저하됩니다. 이런 경우 별도의 스케줄러로 분리해야 해요.