Spring MVC는 요청이 들어오면 스레드를 하나 할당한다. 그 스레드는 DB 쿼리 결과가 돌아올 때까지, 외부 API 응답이 올 때까지 대기한다. I/O 바운드 워크로드에서는 스레드 대부분이 대기 상태에 놓인다. WebFlux는 이벤트 루프 기반 논블로킹 모델로 이 구조를 바꾼다.
Spring MVC의 스레드 모델
Spring MVC는 thread-per-request 모델이다. Tomcat이 요청을 받으면 스레드 풀에서 스레드 하나를 꺼내 할당한다. 해당 스레드가 컨트롤러 → 서비스 → DB 접근 → 응답 반환까지 전 과정을 처리한다.
문제는 I/O 대기 구간이다. JDBC로 DB 쿼리를 실행하면 결과가 올 때까지 스레드가 블로킹된다. RestTemplate으로 외부 API를 호출해도 마찬가지다. 스레드는 아무 일도 하지 않으면서 자원을 점유한다.
동시 요청 수는 스레드 풀 크기에 제한된다. Tomcat의 기본 스레드 풀은 200개다. 201번째 요청은 앞선 요청이 끝날 때까지 대기열에서 기다린다. I/O 대기가 길어지면 처리량이 급격히 떨어진다.
WebFlux의 이벤트 루프 모델
WebFlux는 Netty 기반 이벤트 루프 위에서 동작한다. 스레드가 요청을 받으면 I/O 작업을 OS에 위임하고 즉시 다음 요청을 처리한다. I/O가 완료되면 콜백으로 결과를 받아 나머지 로직을 실행한다.
스레드가 대기하지 않으므로 소수의 스레드로 많은 동시 요청을 처리할 수 있다. CPU 코어 수만큼의 이벤트 루프 스레드가 수천 개의 동시 연결을 감당한다.
I/O 바운드 워크로드에서 처리량이 올라간다. 외부 API 호출이 많은 API Gateway, OAuth2 인증 서버, 마이크로서비스 간 통신 같은 워크로드가 대표적이다.
CPU 바운드 작업에서는 이점이 없다. 이벤트 루프 스레드에서 무거운 연산을 실행하면 다른 요청 처리가 밀린다. 이미지 처리, 암호화 같은 작업은 별도 스레드 풀로 분리해야 한다.
Reactor
이벤트 루프 모델은 콜백 기반이다. 콜백이 중첩되면 코드 복잡도가 올라간다. Project Reactor는 이를 선언적 파이프라인으로 해결한다. 핵심 타입은 두 가지다.
Mono: 0 또는 1개의 결과를 비동기로 반환한다. DB에서 사용자 한 명을 조회하거나, 외부 API에서 토큰 하나를 받아올 때 사용한다.
Flux: 0~N개의 결과를 비동기 스트림으로 반환한다. DB에서 목록을 조회하거나, 실시간 이벤트를 구독할 때 사용한다.
@GetMapping("/{id}")
public Mono<User> getUser(@PathVariable String id) {
return userRepository.findById(id);
}
@GetMapping
public Flux<User> getAllUsers() {
return userRepository.findAll();
}
Reactor는 연산자 체이닝으로 비동기 파이프라인을 구성한다.
public Mono<AuthToken> login(String code) {
return oauth2Client.getToken(code) // Mono<TokenDto>
.map(tokenMapper::toDomain) // Mono<Token>
.flatMap(userRepository::upsertUser) // Mono<User>
.map(authService::generateToken); // Mono<AuthToken>
}
map은 동기 변환, flatMap은 비동기 변환이다. flatMap은 내부에서 다시 Mono나 Flux를 반환하는 I/O 작업에 사용한다.
중요한 특성이 하나 있다. Reactor는 구독 시점에 실행된다. Mono나 Flux를 생성하는 것만으로는 아무 일도 일어나지 않는다. .subscribe()가 호출되거나 WebFlux가 응답으로 반환할 때 비로소 파이프라인이 실행된다.
WebClient
RestTemplate은 블로킹이다. 이벤트 루프 스레드에서 블로킹 호출을 실행하면 해당 스레드가 점유되어 전체 처리량이 떨어진다. WebFlux 환경에서는 논블로킹 HTTP 클라이언트인 WebClient를 사용한다.
public Mono<UserDto> getResource(String accessToken) {
return webClient
.get()
.uri(uri -> uri.queryParam("access_token", accessToken).build())
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, this::handleError)
.bodyToMono(UserDto.class);
}
retrieve()로 응답을 받고, bodyToMono()로 역직렬화한다. 전체 과정이 논블로킹이다. onStatus()로 HTTP 상태별 에러 처리를 선언적으로 정의할 수 있다.
Spring MVC에서도 WebClient를 사용할 수 있다. RestTemplate은 Spring 5부터 유지보수 모드에 들어갔고, Spring은 WebClient를 권장한다.
리액티브 데이터 접근
리액티브 스택의 이점은 전 구간이 논블로킹일 때 온전히 발휘된다. 컨트롤러와 서비스가 논블로킹이어도 DB 접근이 블로킹이면 이벤트 루프 스레드가 묶인다.
MongoDB: Spring Data는 ReactiveMongoRepository를 제공한다. 모든 CRUD 메서드가 Mono나 Flux를 반환한다.
interface UserDao extends ReactiveMongoRepository<UserEntity, String> {}
관계형 DB: R2DBC(Reactive Relational Database Connectivity)를 사용한다. JDBC의 리액티브 대안이다. MySQL, PostgreSQL 등의 드라이버를 지원한다.
JPA는 블로킹이다. Hibernate와 JDBC가 스레드를 블로킹하므로 WebFlux와 함께 사용하면 논블로킹의 이점이 사라진다. 관계형 DB가 필요하면 R2DBC를, 문서형 DB가 적합하면 Reactive MongoDB를 선택한다.
선택 기준
WebFlux와 Spring MVC는 상호 배타적이지 않다. Spring은 같은 프로젝트에서 두 가지를 함께 사용할 수 있도록 설계했다. 하지만 실질적으로는 스택 전체를 하나로 통일하는 것이 유리하다.
WebFlux가 적합한 경우:
- I/O 바운드 워크로드가 지배적일 때
- 높은 동시성이 필요할 때 (API Gateway, 인증 서버)
- 마이크로서비스 간 통신이 많을 때
- MongoDB, Redis 같은 리액티브 드라이버가 존재하는 데이터 저장소를 사용할 때
Spring MVC가 적합한 경우:
- JDBC/JPA 기반 관계형 DB가 핵심일 때
- CPU 바운드 작업이 많을 때
- 팀이 리액티브 프로그래밍에 익숙하지 않을 때
Spring MVC는 요청당 스레드를 할당한다. I/O가 많아지면 스레드가 묶인다. WebFlux는 이벤트 루프로 이 대기 시간을 제거한다. 어떤 모델이 맞는지는 워크로드가 I/O 바운드인지 CPU 바운드인지, 동시성 요구가 얼마나 큰지에 따라 갈린다.