ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Tomcat에서 Mono를 반환하면 정말 논블로킹일까? — Tomcat 스레드, Servlet Async, WebClient를 처음부터 끝까지 정리
    카테고리 없음 2026. 1. 1. 16:46
    728x90
    반응형

    📘 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을 실행하는 서버다.

    역할을 단순화하면:

    1. 클라이언트의 TCP 연결을 받는다
    2. HTTP 요청을 해석한다
    3. Java 코드(Controller)를 실행한다
    4. 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
    반응형
Designed by Tistory.