[Spring] WebFlux 동작 원리: 이벤트 루프와 스레드 모델
개요
Spring MVC가 요청마다 스레드를 점유하는 서블릿(Servlet) 모델이라면, WebFlux는 소수의 이벤트 루프(Event Loop) 스레드가 다수의 커넥션을 번갈아 처리하는 모델이다.
WebFlux는 기본 서버로 Netty를 쓰지만 톰캣이나 언더토우(Undertow)같은 서블릿 컨테이너 위에서도 논블로킹(Non-blocking) 방식으로 돌 수 있고, 컨트롤러를 어노테이션 방식과 함수형 방식 두 가지로 작성할 수 있다. 외부 API를 호출할 때 쓰는 WebClient도 스레드 모델과 맞물려 있어서 설정을 잘못하면 이벤트 루프를 그대로 막아버릴 수 있다.
| 구분 | Spring MVC | Spring WebFlux |
|---|---|---|
| 기반 규약 | 서블릿 API(블로킹) | 리액티브 스트림(논블로킹) |
| 기본 서버 | 톰캣(Tomcat) | Netty |
| 스레드 모델 | 요청당 스레드 점유 | 이벤트 루프 + 소수 스레드 |
| 반환 타입 | 객체, ResponseEntity | Mono, Flux |
| 데이터 접근 | JDBC, JPA(블로킹) | R2DBC, 리액티브 드라이버 |
| 동시성 한계 | 스레드 풀 크기 | 커넥션 수와 이벤트 루프 처리량 |
서블릿 모델의 스레드 점유와 한계
Spring MVC는 서블릿 컨테이너 위에서 돈다. 톰캣은 요청을 받으면 스레드 풀(Thread Pool)에서 워커 스레드(Worker Thread) 하나를 꺼내 그 요청에 배정한다. 이 스레드는 요청 처리가 끝날 때까지 요청을 처리한다.
문제는 처리 중간에 입출력(I/O) 대기가 끼어 있을 때다. 데이터베이스 조회나 외부 API 호출이 일어나면 워커 스레드는 응답이 올 때까지 멈춰서 기다린다. 이 시간 동안 스레드는 중앙처리장치(CPU)를 쓰지 않지만 풀에서 빠져나와 다른 요청을 처리하지도 못한다.
스프링 부트가 내장 톰캣에 설정하는 기본값(server.tomcat.threads.max)은 200이다. 모든 요청이 평균 200밀리초의 I/O 대기를 포함한다면, 200개 스레드가 전부 대기에 묶이는 순간 201번째 요청부터는 큐에서 차례를 기다려야 한다.
// MVC: 이 메서드가 도는 동안 톰캣 워커 스레드 1개가 통째로 점유된다
@GetMapping("/products/{id}")
public ProductResponse getProduct(@PathVariable Long id) {
Product product = productRepository.findById(id); // 스레드 대기
Price price = priceApiClient.fetch(id); // 스레드 대기
return ProductResponse.of(product, price);
}그렇다고 스레드를 늘리게 되면, 스레드 하나당 스택 메모리로 보통 1메가바이트 안팎이 잡히고, 스레드가 많아질수록 운영체제(OS)가 스레드를 번갈아 실행하며 레지스터와 캐시를 교체하는 컨텍스트 스위칭(Context Switching) 비용도 커진다.
이벤트 루프와 I/O 다중화(epoll/kqueue)
WebFlux의 기본 서버인 Netty는 이벤트 루프 모델로 동작한다. 이벤트 루프는 무한히 반복하면서 발생한 I/O 이벤트를 꺼내 처리하는 스레드다. Reactor Netty는 이벤트 루프 스레드 수를 기본적으로 CPU 코어 수(최소 4개)로 설정하며, reactor.netty.ioWorkerCount 시스템 프로퍼티로 조정할 수 있다. 코어가 8개라면 이벤트 루프도 8개 안팎이다. 이 적은 수의 스레드가 수천, 수만 개의 커넥션을 감당한다.
이벤트 루프 스레드는 기다리지 않는다. 요청이 데이터베이스 응답을 기다려야 하면, 그 작업을 논블로킹 방식으로 등록해 두고 즉시 다음 이벤트로 넘어간다. 데이터베이스 응답이 도착하면 그 사건이 이벤트 루프의 큐에 들어오고, 루프가 이를 꺼내 멈췄던 지점의 후속 작업을 이어간다. 한 스레드가 여러 요청의 조각을 시분할로 번갈아 처리하는 셈이다.
// WebFlux: 이 메서드는 파이프라인을 조립해 반환할 뿐, 스레드를 점유하지 않는다
@GetMapping("/products/{id}")
public Mono<ProductResponse> getProduct(@PathVariable Long id) {
return productRepository.findById(id) // 논블로킹 조회
.flatMap(product -> priceClient.fetch(id) // 논블로킹 호출
.map(price -> ProductResponse.of(product, price)));
}이 메서드가 반환되는 순간 이벤트 루프 스레드는 자유로워진다. 데이터베이스 응답이 오기 전까지 그 스레드는 다른 요청을 처리한다. 응답이 오면 이벤트 루프가 flatMap 이후 단계를 이어받는다.
운영체제 수준에서 이를 가능하게 하는 것이 epoll(리눅스), kqueue(BSD 계열, macOS) 같은 I/O 다중화(I/O Multiplexing) 기법이다. select와 poll은 감시 대상 소켓 수가 늘어날수록 매번 전체 목록을 훑어야 해서 비용이 소켓 수에 비례해 커진다. epoll과 kqueue는 커널이 준비된 소켓만 별도로 추려 돌려주므로 감시 대상이 수천 개로 늘어나도 비용이 훨씬 덜 늘어난다.
Reactor Netty의 스레드 그룹과 블로킹 격리
| 스레드 그룹 | 이름 패턴 | 기본 스레드 수 | 용도 |
|---|---|---|---|
| 이벤트 루프 | reactor-http-nio-* | CPU 코어 수(최소 4개) | HTTP I/O 처리. 절대 블로킹하면 안 됨 |
| boundedElastic | boundedElastic-* | CPU 코어 수 x 10(상한) | 블로킹 작업 격리. 유휴 60초 후 스레드 회수 |
| parallel | parallel-* | CPU 코어 수 | CPU 바운드 병렬 연산 |
| single | single-* | 1 | 순차 실행이 필요한 단일 스레드 작업 |
이벤트 루프 스레드는 I/O 전용이다. 이 스레드 위에서 무거운 연산이나 블로킹 호출을 하면 그 시간만큼 다른 모든 커넥션의 처리가 멈춘다. 앞서 서블릿 모델에서 워커 스레드 하나가 I/O 대기로 묶이면 그 스레드만 손해였지만, 이벤트 루프에서는 소수의 스레드가 전체 커넥션을 담당하므로 블로킹 한 번의 피해 범위가 훨씬 크다. 블로킹이 불가피하면 Schedulers.boundedElastic()으로 작업을 옮겨 격리한다. boundedElastic은 기본적으로 CPU 코어 수의 10배를 스레드 상한으로 두고, 스레드 하나당 최대 10만 건까지 작업을 큐에 쌓을 수 있으며, 60초 동안 유휴 상태인 스레드는 회수한다.
// 블로킹 라이브러리 호출을 boundedElastic 스레드로 격리
public Mono<Report> generateReport(Long id) {
return Mono.fromCallable(() -> legacyReportEngine.render(id)) // 블로킹
.subscribeOn(Schedulers.boundedElastic()); // 별도 스레드 풀에서 실행
}같은 이유로 데이터 접근 계층도 논블로킹이어야 한다. 컨트롤러만 Mono를 반환하고 내부에서 블로킹 JPA를 호출하면 이점이 사라진다. 그 호출을 그대로 두면 이벤트 루프가 막히고, boundedElastic으로 격리해도 격리 스레드를 거치는 비용이 추가된다. 진정한 효과를 보려면 데이터베이스 접근도 R2DBC 같은 논블로킹 드라이버로 맞춰야 한다. 스택 전체가 논블로킹일 때 비로소 이벤트 루프 모델이 의미를 가진다.
블로킹 작업이 이벤트 루프와 boundedElastic 사이를 어떻게 오가는지는 다음과 같은 순서로 일어난다.
sequenceDiagram autonumber participant Client participant EventLoop as "이벤트 루프(reactor-http-nio)" participant Elastic as "boundedElastic 스레드 풀" participant Legacy as "블로킹 리소스" Client->>EventLoop: HTTP 요청 수신 EventLoop->>EventLoop: 논블로킹 단계 실행 EventLoop->>Elastic: subscribeOn(boundedElastic) Elastic->>Legacy: 블로킹 호출 실행 Legacy-->>Elastic: 결과 반환 Elastic-->>EventLoop: 결과 전달 EventLoop-->>Client: 응답 작성
컨트롤러 작성 방식: 어노테이션과 함수형 라우팅
어노테이션 기반 컨트롤러
첫 번째는 MVC와 거의 같은 어노테이션 방식이다. @RestController, @GetMapping 같은 익숙한 어노테이션을 그대로 쓰되 반환 타입만 Mono나 Flux로 바꾼다.
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public Mono<UserResponse> getUser(@PathVariable Long id) {
return userService.findById(id)
.map(UserResponse::from);
}
@GetMapping
public Flux<UserResponse> getUsers() {
return userService.findAll()
.map(UserResponse::from);
}
@PostMapping
public Mono<UserResponse> create(@RequestBody Mono<CreateUserRequest> request) {
return request
.flatMap(userService::create)
.map(UserResponse::from);
}
}MVC에서 WebFlux로 옮길 때 학습 곡선이 낮다는 것이 장점이다. 구조가 거의 그대로이고 반환 타입과 데이터 접근 계층만 리액티브로 바꾸면 된다.
함수형 엔드포인트
두 번째는 함수형 엔드포인트(Functional Endpoint) 방식이다. 어노테이션 대신 라우터 함수(RouterFunction)와 핸들러 함수(HandlerFunction)로 라우팅을 코드로 직접 구성한다. 라우팅 규칙을 한곳에 모아 명시적으로 보고 싶거나, 라우팅을 동적으로 조립하고 싶을 때 쓴다.
@Configuration
public class UserRouter {
@Bean
public RouterFunction<ServerResponse> userRoutes(UserHandler handler) {
return RouterFunctions.route()
.GET("/users/{id}", handler::getUser)
.GET("/users", handler::getUsers)
.POST("/users", handler::create)
.build();
}
}
@Component
@RequiredArgsConstructor
public class UserHandler {
private final UserService userService;
public Mono<ServerResponse> getUser(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return userService.findById(id)
.flatMap(user -> ServerResponse.ok().bodyValue(UserResponse.from(user)))
.switchIfEmpty(ServerResponse.notFound().build()); // 값이 없으면 404
}
public Mono<ServerResponse> getUsers(ServerRequest request) {
return ServerResponse.ok().body(userService.findAll(), User.class);
}
public Mono<ServerResponse> create(ServerRequest request) {
return request.bodyToMono(CreateUserRequest.class)
.flatMap(userService::create)
.flatMap(user -> ServerResponse.status(HttpStatus.CREATED)
.bodyValue(UserResponse.from(user)));
}
}두 방식의 실행 성능은 동일하다. 같은 리액티브 엔진 위에서 돌기 때문이다. 차이는 표현 방식이다.
| 구분 | 어노테이션 방식 | 함수형 방식 |
|---|---|---|
| 라우팅 정의 | 어노테이션으로 선언 | 코드로 직접 구성 |
| 학습 곡선 | MVC 경험자에게 낮음 | 리액티브 스타일에 가까움 |
| 라우팅 가시성 | 클래스에 분산 | RouterFunction에 집중 |
| 동적 라우팅 | 어려움 | 코드로 유연하게 조립 |
WebClient로 외부 API 논블로킹 호출하기
WebFlux 환경에서 외부 API를 호출할 때는 블로킹 클라이언트인 RestTemplate을 쓰면 안 된다. RestTemplate은 응답이 올 때까지 이벤트 루프 스레드를 멈춰 세운다. 대신 논블로킹 클라이언트인 WebClient를 쓴다. WebClient는 호출 결과를 Mono나 Flux로 반환하므로 리액티브 체인에 그대로 합쳐진다.
// 잘못된 패턴: WebFlux에서 블로킹 RestTemplate 사용
public ProductResponse wrong(Long id) {
return restTemplate.getForObject("/products/" + id, ProductResponse.class); // 이벤트 루프 블로킹
}
// 올바른 패턴: 논블로킹 WebClient 사용
public Mono<ProductResponse> right(Long id) {
return webClient.get()
.uri("/products/{id}", id)
.retrieve()
.bodyToMono(ProductResponse.class); // 논블로킹, Mono 반환
}WebClient를 빈으로 등록할 때는 타임아웃을 명시적으로 설정해야 한다. 아무 설정도 없으면 커넥션이 영원히 응답을 기다릴 수 있다.
@Configuration
public class WebClientConfig {
@Bean
public WebClient productWebClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000) // 커넥션을 맺는 데 걸리는 시간
.responseTimeout(Duration.ofSeconds(5)); // 응답을 받기까지의 시간
return WebClient.builder()
.baseUrl("https://product-service")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}여기서 HttpClient에 설정한 responseTimeout과, 호출 지점에서 붙이는 .timeout() 연산자는 서로 다른 시점을 가리킨다는 점을 구분해야 한다. .timeout()은 반환된 Mono 전체가 완료되기까지 기다리는 시간이고, 커넥션 자체를 맺는 데 걸리는 시간(Connect Timeout)은 HttpClient 설정이 따로 관리한다. 둘 중 하나만 설정하면 나머지 구간은 무제한으로 대기할 수 있다.
public Mono<ProductResponse> fetchProduct(Long id) {
return webClient.get()
.uri("/products/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
response -> Mono.error(new ProductNotFoundException(id)))
.bodyToMono(ProductResponse.class)
.timeout(Duration.ofSeconds(3)) // Mono 전체 대기 시간
.retryWhen(Retry.backoff(2, Duration.ofMillis(200))
.filter(error -> !(error instanceof ProductNotFoundException))); // 404는 재시도 대상에서 제외
}요청 컨텍스트 전달: ThreadLocal 대신 Reactor Context
리액티브 체인은 단계마다 다른 스레드에서 실행될 수 있다. 앞서 본 것처럼 이벤트 루프에서 시작한 요청이 boundedElastic으로 넘어가고 다시 이벤트 루프로 돌아오는 일이 흔하다. 따라서 요청 단위 상태를 스레드 로컬(ThreadLocal)에 저장하면, 다음 단계가 다른 스레드에서 실행되는 순간 그 값을 읽을 수 없다. 보안 정보나 추적 식별자는 스레드 로컬 대신 Reactor의 Context를 통해 전달한다.
// 잘못된 패턴: 평가 시점의 스레드가 값을 저장한 스레드와 다르면 조회에 실패한다
public Mono<String> wrong() {
return Mono.fromSupplier(() -> USER_ID_HOLDER.get()) // 구독 시점에 실행되는 스레드에서 평가됨
.subscribeOn(Schedulers.boundedElastic()); // boundedElastic으로 옮겨간 뒤 평가되므로 원래 스레드의 값이 아님
}
// 올바른 패턴: Reactor Context로 전달
public Mono<String> right() {
return Mono.deferContextual(ctx -> Mono.just(ctx.get("userId")))
.contextWrite(Context.of("userId", "kim")); // 구독 시점부터 체인을 따라 전파됨
}Reactor의 Context는 스레드에 묶이지 않고 구독(Subscribe) 신호와 함께 체인을 따라 전달된다. 그래서 이벤트 루프에서 boundedElastic으로, 다시 이벤트 루프로 스레드가 바뀌어도 Context에 담긴 값은 그대로 살아 있다. contextWrite는 그 값을 읽는 연산자보다 구독 쪽(체인의 더 아래쪽)에 둬야 한다. Context는 구독 시점에 아래에서 위로 전파되므로, contextWrite가 이후에 와야 그 앞의 모든 단계가 값을 읽을 수 있다.