-
Tomcat에서 Mono를 반환하면 정말 논블로킹일까? — Tomcat 스레드, Servlet Async, WebClient를 처음부터 끝까지 정리카테고리 없음 2026. 1. 1. 16:46728x90반응형
📘 Tomcat + MVC + WebClient(Mono 반환) 완전 정리
Spring MVC 프로젝트에서 다음과 같은 코드를 보면 많은 개발자들이 혼란을 느낀다.
@GetMapping("/async") public Mono<String> async() { return webClient.get().uri("http://external/api") .retrieve() .bodyToMono(String.class); }- block()도 없고
- Mono를 반환하는데
- 왜 “Tomcat에서는 여전히 블로킹 비동기다”라는 말이 나올까?
이 글에서는 Tomcat을 전혀 모르는 사람도 이해할 수 있도록,
요청이 들어온 순간부터 응답이 네트워크로 나가기까지
모든 스레드의 이동과 역할을 하나씩 풀어본다.1️⃣ Tomcat이란 무엇인가? (아주 기초부터)
Tomcat은 Java Servlet을 실행하는 서버다.
역할을 단순화하면:
- 클라이언트의 TCP 연결을 받는다
- HTTP 요청을 해석한다
- Java 코드(Controller)를 실행한다
- HTTP 응답을 네트워크로 직접 써서 돌려준다
이 모든 과정은 스레드(Thread) 를 사용해 처리된다.
2️⃣ Tomcat의 핵심 스레드 구조
Tomcat에는 여러 종류의 스레드가 있지만,
요청 처리 관점에서 중요한 것은 아래 3가지다.🔹 1. Acceptor Thread (연결 담당)
- TCP 연결을 받기만 하는 스레드
- “손님이 왔다”를 감지하는 역할
- 실제 로직은 처리하지 않음
Client ──▶ Acceptor Thread👉 전화 교환원 같은 역할
🔹 2. Worker Thread = Tomcat 요청 스레드 (가장 중요)
이 스레드가 흔히 말하는 **“Tomcat 요청 스레드”**다.
Tomcat의 Thread Pool에서 가져와 HTTP 요청 하나를 끝까지 책임지는 실행 주체다.이름 예시:
http-nio-8080-exec-1
📌 수행 역할 (요청 라이프사이클 기준)
- Filter 체인 실행
- EncodingFilter
- Spring Security FilterChain
- 기타 서블릿 필터
- DispatcherServlet 진입
- HandlerMapping
- HandlerAdapter
- Controller 메서드 실행
- @GetMapping, @PostMapping 등
- 파라미터 바인딩
- Validation
- 비즈니스 로직 수행
- Service 호출
- Transaction 시작/종료
- DAO / Repository 호출
- DB I/O (blocking)
- 부가 컨텍스트 관리
- SecurityContext (ThreadLocal)
- RequestContextHolder
- MDC (traceId, logging)
- 응답 생성
- JSON 직렬화 (Jackson)
- View 렌더링 (JSP / Thymeleaf)
- HTTP Status / Header 설정
- HTTP 응답 write & 커밋
- Socket write
- 응답 완료 후 스레드 반환
📌 중요한 성질
- 요청 1개 = 스레드 1개
- 요청 처리 동안 스레드는 점유됨
- 블로킹 작업(DB, 외부 API, sleep)이 있으면
→ 해당 스레드는 대기 상태로 묶임 - 요청 종료 시 ThreadLocal 컨텍스트 정리 후 풀로 반환
👉
Worker Thread = Tomcat 요청 스레드 = HTTP 요청 전체 라이프사이클을 책임지는 핵심 실행 스레드🔹 4. Netty EventLoop Thread (WebClient 전용)
- WebClient 내부에서만 사용
- Tomcat과 완전히 별개
- 논블로킹 네트워크 처리
#요청 처리 (webClient가 아닌 일반 비즈니스 로직)
🟢 1단계: 요청 수락
[Client] │ ▼ [Acceptor Thread] │ TCP 연결 수락 ▼💡 설명:
연결만 받고
바로 다음 단계로 넘긴다🟡 2단계: Controller 실행 (동기 구간) — 여기가 길어짐(핵심)
[Worker Thread #1] ← Tomcat 요청 스레드 ├─ HTTP 요청 파싱 ├─ DispatcherServlet ├─ @GetMapping 메서드 실행 ├─ userService.findByIdBlocking(id) 실행 (블로킹/CPU) 🔥 ├─ Mono.just(result) 반환 (이미 완료된 값 포장) ├─ request.startAsync() (형식상 비동기 전환) └─ 스레드 반환 (풀로 복귀)위험: 비즈니스 로직을 먼저 수행하고 Mono로 포장만 함
@RestController @RequiredArgsConstructor public class UserController { private final UserService userService; @GetMapping("/users/{id}") public Mono<UserDto> getUser(@PathVariable long id) { // 🔥 여기서 이미 동기/블로킹 작업이 실행됨 (Tomcat Worker Thread가 잡힘) User user = userService.findByIdBlocking(id); // 예: JPA/Hibernate, JDBC, sleep, 외부 SDK 등 // Mono를 반환하긴 하지만, "이미 다 끝난 결과"를 포장한 것뿐 return Mono.just(UserDto.from(user)); } }@Service public class UserService { public User findByIdBlocking(long id) { // 예시 1) DB 블로킹 // return userRepository.findById(id).orElseThrow(); // 예시 2) 외부 SDK 블로킹 // return legacyClient.fetchUser(id); // 예시 3) 명시적 블로킹 try { Thread.sleep(500); } catch (InterruptedException ignored) {} return new User(id, "name"); } }💡 포인트
- 컨트롤러가 Mono를 반환하더라도, 그 전에 userService를 호출해버리면
“비동기 전환”은 이미 늦음 - 결과적으로 Worker Thread #1이 오래 잡힌다
🟣 3단계: (WebClient 같은 비동기 I/O) — 이 케이스에서는 없음/의미 없음
[Netty EventLoop Thread] ├─ 외부 API HTTP 요청 전송 ├─ 응답 수신 └─ Mono 완료 시그널 발생💡 설명:
- 위 “위험 코드”에는 WebClient 같은 비동기 I/O가 없으므로
3단계는 사실상 발생하지 않는다 - Mono는 그냥 “이미 계산 끝난 값”을 들고 있는 형태
🔴 4단계: HTTP 응답 write (핵심)
[Worker Thread #2] ← Tomcat 요청 스레드 (재투입) ├─ AsyncContext 재개 ├─ response.getOutputStream() ├─ write(bytes) ├─ flush() └─ socket write (여기서 블로킹 가능 ❌)💥 핵심 포인트:
응답을 실제로 네트워크에 쓰는 순간
반드시 Tomcat Worker Thread가 필요OS Send Buffer가 가득 차면
→ 이 스레드는 대기(block)👉 이 때문에 Tomcat은 서버 I/O가 블로킹이라고 한다.
🔵 5단계: 요청 종료
[Worker Thread #2] └─ 스레드 풀로 반환결론
(컨트롤러에서 블로킹 비즈니스 로직 먼저 실행 → Mono.just(result)로 포장)는 사실상 WebFlux/Reactive를 “쓴 척”만 하는 구조라서, 기대했던 이점이 거의 없어.
- 요청 스레드(Worker Thread #1) 가 블로킹 로직 때문에 끝까지 잡혀 있음
- 그 다음에야 Mono가 “이미 완료된 값”으로 만들어짐
- Spring이 startAsync()로 전환하더라도 이미 병목이 다 지나간 뒤라 의미가 거의 없음
즉 동기 MVC + 포장에 가깝고, WebFlux의 핵심 가치(적은 스레드로 대량 동시성, I/O 대기 시간 흡수) 를 못 가져가.
그래도 “Mono를 쓰려면” 최소한 이렇게는 해야 함 (현실적 타협)
블로킹 로직을 완전 없애기 어렵다면, 블로킹을 별도 스레드풀로 명시적으로 격리해야 그나마 의미가 생겨.
✅ 최소 타협 패턴 (블로킹 오프로딩)
@GetMapping("/users/{id}") public Mono<UserDto> getUser(@PathVariable long id) { return Mono.fromCallable(() -> userService.findByIdBlocking(id)) .subscribeOn(Schedulers.boundedElastic()) .map(UserDto::from); }이렇게 하면:
- Worker Thread #1은 startAsync()까지 가서 빨리 반환
- 블로킹은 boundedElastic-*에서 처리
- 완료되면 Worker Thread #2가 응답 write
👉 이게 “WebFlux 흉내”가 아니라 실제로 동시성 이점을 가져오는 최소 조건이야.
#요청 처리 Webclient
예제 코드 다시 보기
@GetMapping("/async") public Mono<String> async() { return webClient.get() .uri("http://external/api") .retrieve() .bodyToMono(String.class); }이제 이 코드가 어떤 스레드를 거쳐서 실행되는지
시간 순서대로 하나씩 보자.요청 처리 전체 흐름 (스레드 타임라인)
🟢 1단계: 요청 수락
[Client] │ ▼ [Acceptor Thread] │ TCP 연결 수락 ▼- 연결만 받고
- 바로 다음 단계로 넘긴다
🟡 2단계: Controller 실행 (Async 전환 트리거 구간 - 요청 처리 흐름이 비동기로 전환되는 지점)
[Worker Thread #1] ← Tomcat 요청 스레드 ├─ HTTP 요청 파싱 ├─ DispatcherServlet ├─ @GetMapping 메서드 실행 ├─ Mono 반환 ├─ request.startAsync() └─ 스레드 반환 (풀로 복귀)설명:
- 여기까지는 일반 MVC와 동일
- Mono 반환 → Spring이 비동기 요청으로 인식
- request.startAsync() 호출
- 이 시점에서 Worker Thread #1은 더 이상 기다리지 않는다
🟣 3단계: 외부 API 호출 (WebClient)
[Netty EventLoop Thread] ├─ 외부 API HTTP 요청 전송 ├─ 응답 수신 └─ Mono 완료 시그널 발생💡 설명:
- Tomcat 스레드 ❌
- Worker Thread ❌
- 완전 논블로킹
- 소수의 스레드로 대량 요청 처리
🔴 4단계: HTTP 응답 write (핵심)
[Worker Thread #2] ← Tomcat 요청 스레드 (재투입) ├─ AsyncContext 재개 ├─ response.getOutputStream() ├─ write(bytes) ├─ flush() └─ socket write (여기서 블로킹 가능 ❌)💥 핵심 포인트:
- 응답을 실제로 네트워크에 쓰는 순간
- 반드시 Tomcat Worker Thread가 필요
- OS Send Buffer가 가득 차면
→ 이 스레드는 대기(block)
👉 이 때문에 Tomcat은 서버 I/O가 블로킹이라고 한다.
🔵 5단계: 요청 종료
[Worker Thread #2] └─ 스레드 풀로 반환5️⃣ 전체 흐름 한 눈에 요약[요청 수락] Acceptor Thread ↓ [컨트롤러 실행] Worker Thread #1 (Tomcat 요청 스레드) ├─ Controller 실행 ├─ Mono 반환 └─ Async 시작 후 반환 [외부 API 호출] Netty EventLoop Thread ├─ 논블로킹 HTTP 호출 └─ 응답 수신 [응답 write] Worker Thread #2 (Tomcat 요청 스레드) ├─ OutputStream.write() └─ write 동안 블로킹 가능 ❌ [요청 종료] Worker Thread 반환6️⃣ 그래서 “Tomcat + WebFlux는 블로킹 비동기”라는 말의 의미
- 요청 처리 흐름은 비동기
- 외부 호출은 논블로킹
- 응답 write는 블로킹
따라서 정확한 분류는:
비동기(Async) + 블로킹 I/O 서버
줄여서 흔히 말하는 표현이:
블로킹 비동기
728x90반응형