본문 바로가기
백엔드

Spring 비동기 처리: MVC 비동기 → WebFlux → Virtual Threads

by 조민서 2026. 3. 26.
  • 동기 블로킹 모델의 한계점
  • Spring MVC의 비동기 확장
  • Reactive 패러다임
  • Java 21 Virtual Threads

각 접근 방식의 동작 원리와 트레이드오프를 정리했다.


1. 도입: 동시성과 I/O 블로킹 문제

Thread-per-request 모델

전통적인 Java 웹 서버(Tomcat, Jetty 등)는 Thread-per-request 모델을 기반으로 동작한다.

  1. 클라이언트 요청이 들어오면 스레드 풀에서 하나의 OS 스레드를 할당하고,
  2. 해당 스레드가 요청의 전체 라이프사이클(수신 → 비즈니스 로직 → DB/외부 API 호출 → 응답 전송)을 담당한다.

이 모델은 구조가 단순하고 디버깅이 용이하지만, I/O 바운드 작업이 많은 환경에서 한계를 드러낸다.

Blocking I/O 대기

핵심 문제는 I/O 대기 시간 동안 스레드가 아무 작업도 수행하지 않으면서도 점유 상태를 유지한다는 것이다.

DB 쿼리 응답을 300ms 동안 기다리는 스레드는, 그 300ms 동안 다른 요청을 처리할 수 없다.
Tomcat의 기본 스레드 풀 크기가 200이라면, 동시에 200개의 요청이 I/O 대기 상태에 빠지는 순간 후속 요청은 큐에서 대기하거나 거부(reject)된다. 이 문제가 특히 심각해지는 시나리오는 다음과 같다.

  • 대용량 파일 스트리밍: 수 GB 파일을 클라이언트에 전송하는 동안 스레드가 수 초 이상 점유된다.
  • 외부 API 체이닝: 하나의 요청이 3~4개의 외부 서비스를 순차 호출하면, 각 호출의 레이턴시가 누적되어 스레드 점유 시간이 급격히 증가한다.
  • SSE(Server-Sent Events): 클라이언트와의 연결을 수 분~수 시간 유지해야 하는 실시간 푸시 시나리오에서는 연결 수만큼의 스레드가 영구적으로 소진된다.

결국 Thread-per-request 모델에서 처리량(Throughput)의 상한은 스레드 풀 크기에 의해 결정되며, 이는 OS 스레드의 메모리 비용(스레드당 약 1MB 스택 메모리)과 컨텍스트 스위칭 오버헤드로 인해 무한히 늘릴 수 없다. 이러한 구조적 제약이 비동기 처리 기술이 요구되는 근본적인 배경이다.


2. Spring MVC 환경에서의 비동기 및 스트리밍 처리

Spring MVC는 기본적으로 Thread-per-request 동기 블로킹 모델이지만, Servlet 3.0+ 비동기 지원을 기반으로 몇 가지 비동기/스트리밍 메커니즘을 제공한다. 이들의 핵심 목적은 Servlet 스레드(Tomcat 워커 스레드)를 조기에 반환하여 스레드 풀 고갈을 완화하는 것이다.

2.1 StreamingResponseBody: 대용량 데이터의 메모리 효율적 전송

StreamingResponseBody는 컨트롤러가 응답 본문을 OutputStream에 직접 쓸 수 있게 해주는 인터페이스이다. 파일 전체를 byte[]로 메모리에 적재한 뒤 응답하는 기존 방식과 달리, 데이터를 청크(chunk) 단위로 읽어 즉시 출력 스트림에 기록하는 스트리밍 방식을 취한다.

@GetMapping("/download/{fileId}")
public ResponseEntity<StreamingResponseBody> downloadFile(@PathVariable String fileId) {

    StreamingResponseBody stream = outputStream -> {
        try (InputStream is = storageService.getFileStream(fileId)) {
            byte[] buffer = new byte[32 * 1024]; // 32KB 버퍼
            int bytesRead;
            while ((bytesRead = is.read(buffer)) != -1) {
                outputStream.write(buffer, 0, bytesRead);
                outputStream.flush();
            }
        }
    };

    return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(stream);
}

 
이 방식의 이점은 두 가지이다.

  1. 파일 크기에 관계없이 JVM 힙 메모리 사용량이 버퍼 크기(위 예시에서 32KB) 수준으로 일정하게 유지되어 OOM(Out of Memory) 위험이 사라진다.
  2. 첫 번째 청크가 준비되는 즉시 클라이언트로 전송이 시작되므로 TTFB(Time To First Byte)가 단축된다.

단, StreamingResponseBody의 쓰기 작업은 별도의 TaskExecutor 스레드에서 실행된다. Servlet 스레드는 반환되지만 TaskExecutor 풀의 스레드가 I/O 완료 시까지 점유되므로, 스레드 소비 자체가 사라지는 것은 아니다.


2.2 ResponseBodyEmitter / SseEmitter: 서버 푸시(Push) 구조

ResponseBodyEmitter는 하나의 HTTP 응답에 데이터를 여러 번에 걸쳐 비동기적으로 전송할 수 있게 해준다.
SseEmitter는 이를 SSE(Server-Sent Events) 프로토콜에 맞게 확장한 구현체이다.

@GetMapping("/notifications")
public SseEmitter subscribe() {
    SseEmitter emitter = new SseEmitter(TimeUnit.MINUTES.toMillis(30));

    notificationService.register(emitter);

    emitter.onCompletion(() -> notificationService.unregister(emitter));
    emitter.onTimeout(() -> notificationService.unregister(emitter));
    emitter.onError(e -> {
        notificationService.unregister(emitter);
        emitter.completeWithError(e);
    });

    return emitter;
}

// 이벤트 발생 시 — 별도 스레드에서 호출
public void pushNotification(String userId, Object payload) {
    SseEmitter emitter = findEmitter(userId);
    try {
        emitter.send(SseEmitter.event()
                .name("notification")
                .data(payload, MediaType.APPLICATION_JSON));
    } catch (IOException e) {
        emitter.completeWithError(e);
    }
}

ResponseBodyEmitter의 핵심 메커니즘은 Servlet 비동기 모드(AsyncContext)를 활용하는 것이다. 컨트롤러가 Emitter 객체를 반환하는 즉시 Servlet 스레드는 풀로 반환되며, 이후 emitter.send()는 임의의 스레드에서 호출할 수 있다. 실시간 알림, 진행률 표시, 그리고 외부 스토리지로부터의 비동기 파일 스트리밍 등에 활용된다.

실무 적용 사례: 오브젝트 스토리지 타일 스트리밍

실무에서 외부 오브젝트 스토리지(SeaweedFS)로부터 대용량 3D 타일 데이터를 서빙하는 과정에 StreamingResponseBody를 적용한 경험이 있다. 기존에는 타일 파일 전체를 byte[]로 메모리에 적재한 뒤 응답하는 방식이었는데, 파일 크기가 최대 20MB에 달하는 환경에서 동시 요청이 몰리면 JVM 힙 사용량이 급증하고 GC 부하가 심각해지는 문제가 있었다.
 
StreamingResponseBody로 전환하면서 스토리지로부터 청크 단위로 읽어 즉시 OutputStream에 기록하는 Pass-through 구조를 채택했고, I/O 버퍼 크기를 8KB에서 32KB로 확장하여 시스템 콜 횟수를 줄였다. 결과적으로 TTFB가 단축되었고 힙 메모리 사용량이 버퍼 크기 수준으로 안정화되었다.
 
다만 이 과정에서 겪은 한 가지 이슈가 있다. 클라이언트의 잦은 지도 조작으로 인해 다운로드 취소가 빈번했는데, 이때 발생하는 ClientAbortException(Broken Pipe) ERROR 레벨로 대량 적재되며 디스크 I/O 병목을 유발했다. 해당 예외를 정상 흐름(DEBUG 레벨 강등)으로 처리하고, 예외 발생 즉시 스레드를 풀로 반환하도록 개선하여 문제를 해소했다.

ResponseBodyEmitter + Reactive Subscriber 결합

StreamingResponseBody의 한계는 쓰기 작업이 TaskExecutor 스레드에서 동기 블로킹으로 실행된다는 점이다.
스토리지로부터 데이터를 읽는 InputStream.read() 호출이 블로킹이므로, 해당 스레드는 데이터가 도착할 때까지 점유된다.
 
이를 개선하는 방법으로 ResponseBodyEmitter와 Reactive StreamsSubscriber<ByteBuffer>를 결합하는 구조가 있다.
S3 SDK 등이 제공하는 비동기 응답을 Subscriber로 수신하고, 청크가 도착하는 즉시(onNext) emitter.send()로 브라우저에 전달하는 파이프라인이다. 이 구조에서는 PREFETCH_COUNT 기반의 배압 제어를 통해 WAS 메모리에 데이터가 무한정 유입되는 것을 억제할 수 있다.

public class S3ToEmitterSubscriber implements Subscriber<ByteBuffer> {

    private static final int PREFETCH_COUNT = 32;
    private final ResponseBodyEmitter emitter;
    private Subscription subscription;

    @Override
    public void onSubscribe(Subscription s) {
        this.subscription = s;
        s.request(PREFETCH_COUNT); // 초기 요청량 제한
    }

    @Override
    public void onNext(ByteBuffer byteBuffer) {
        try {
            emitter.send(byteBuffer, MediaType.APPLICATION_OCTET_STREAM);
            subscription.request(1); // 전송 성공 후에만 다음 청크 요청
        } catch (IOException e) {
            onError(e); // 클라이언트 연결 단절 시 즉시 에러 전파
        }
    }

    @Override
    public void onError(Throwable t) {
        emitter.completeWithError(t); // 소켓 FD + NIO 스레드 즉시 반환
    }

    @Override
    public void onComplete() {
        emitter.complete(); // 정상 종료: 라이프사이클 안전하게 닫기
    }
}

여기서 emitter.completeWithError(t) 호출이 중요하다. 이 호출이 누락되면, 클라이언트가 이미 연결을 끊었음에도 서버 측에서는 응답 스트림이 살아있다고 착각하여 소켓 파일 디스크립터(FD)NIO 워커 스레드가 반환되지 않는다.
이는 고트래픽 환경에서 리소스 좀비화와 메모리 누수를 유발하는 직접적인 원인이 된다.


2.3 배압(Backpressure)과 유량 제어(Flow Control)의 차이

비동기 스트리밍을 다룰 때 배압(Backpressure)과 유량 제어(Flow Control)는 혼용되기 쉬우나, 적용 계층과 메커니즘이 다르다.
 
배압(Backpressure) 은 소비자가 생산자에게 데이터 생산 속도를 늦추도록 역방향으로 신호를 전달하는 애플리케이션 레벨의 메커니즘이다. 위의 S3ToEmitterSubscriber 코드에서 subscription.request(1)이 이 역할을 한다. 브라우저로의 전송(send)이 성공한 후에만 다음 청크를 요청하므로, 소비 속도가 곧 생산 속도의 상한이 된다. 한편 StreamingResponseBody 방식에서는 InputStream.read()가 블로킹이기 때문에, 스토리지의 응답 속도 자체가 생산 속도의 상한이 되어 암묵적인 배압이 형성된다. 다만 이 경우 소비자가 명시적으로 생산량을 제어하는 것이 아니므로, 네트워크 속도 차이가 클 때 WAS 측 TCP 송신 버퍼에 부하가 쌓일 수 있다.
 
유량 제어(Flow Control) 는 TCP 윈도우 사이즈나 HTTP/2 프레임 레벨에서 동작하는 전송 계층의 메커니즘이다. 수신 측 버퍼가 가득 차면 송신 측에 윈도우 업데이트를 보내지 않음으로써 전송을 중단시킨다. 이는 OS 커널과 프로토콜 스택이 자동으로 처리하며, 애플리케이션 코드가 직접 개입하지 않는다.
 
요약하면, 배압은 "얼마나 줄 것인지를 소비자가 결정"하는 Pull 모델이고,
유량 제어는 "수신 버퍼 초과 시 전송을 자동 중단"하는 프로토콜 레벨 안전장치이다.
이 두 메커니즘이 서로 다른 계층에서 상호 보완적으로 동작하며, 양쪽 모두 고려해야 안정적인 스트리밍 파이프라인을 구축할 수 있다.


2.4 Spring MVC 비동기 방식의 구조적 한계

위의 방식들은 Servlet 스레드를 조기에 반환한다는 점에서 개선이지만, 근본적인 한계가 존재한다.
 
첫째, 스레드 소비가 사라지지 않는다. StreamingResponseBody는 TaskExecutor 풀의 스레드를, SseEmitter는 이벤트 전송 시 호출 스레드를 각각 사용한다. 스레드 점유의 주체가 Servlet 풀에서 별도 풀로 이동했을 뿐, I/O 블로킹으로 인한 스레드 낭비 문제 자체는 해소되지 않는다.
 
둘째, 블로킹 I/O 라이브러리에 의존한다. JDBC, HttpURLConnection, 대부분의 파일 I/O API는 여전히 블로킹 방식이다. ResponseEmitter.send() 내부에서 블로킹 I/O가 발생하면, 해당 스레드는 I/O 완료까지 점유된다.
 
셋째, 대규모 동시 연결에서 스레드 풀이 고갈된다. 수천 명이 동시에 SSE로 접속하는 시나리오에서, 각 연결이 간헐적으로라도 스레드를 사용하면 결국 풀이 포화 상태에 도달한다.
 
이러한 한계는 Spring MVC의 비동기 기능이 "동기 모델 위에 비동기를 덧씌운 구조"라는 태생적 제약에서 비롯된다. 근본적으로 다른 접근이 필요했고, 그 답이 Reactive 패러다임이다.


3. Reactive 패러다임: Spring WebFlux

3.1 Event Loop 기반의 Non-blocking I/O

Spring WebFlux는 Reactor Netty를 기본 서버로 사용하며, Event Loop 기반의 완전한 Non-blocking I/O 모델을 채택한다. Thread-per-request와 근본적으로 다른 구조이다.

Event Loop 기반의 Non-blocking I/O 모델

핵심 원리는 I/O 작업이 필요할 때 스레드가 그 결과를 기다리지 않는다.
대신 OS의 I/O 멀티플렉서(epoll, kqueue)에 관심 이벤트를 등록하고, 스레드는 즉시 다른 이벤트를 처리하러 간다. I/O가 완료되면 커널이 Event Loop에 통지하고, 등록된 콜백이 실행된다. 하나의 스레드가 수천 개의 동시 연결을 번갈아 가며 처리할 수 있는 이유이다.


3.2 적은 스레드로 높은 처리량을 달성하는 메커니즘

WebFlux 환경에서 Reactor Netty의 기본 Event Loop 스레드 수는 CPU 코어 수(일반적으로 4~16개)이다. 그럼에도 수만 개의 동시 요청을 처리할 수 있는 이유는, I/O 대기 시간 동안 스레드가 유휴 상태(Idle)로 머물지 않기 때문이다.

@GetMapping("/users/{id}")
public Mono<UserResponse> getUser(@PathVariable Long id) {
    return userRepository.findById(id)                 // 1. 비동기 DB 쿼리
            .flatMap(user ->
                externalApiClient.fetchCredit(user)    // 2. 비동기 외부 API 호출
            )
            .map(UserResponse::from)                   // 3. 변환
            .timeout(Duration.ofSeconds(5))            // 4. 타임아웃
            .onErrorReturn(UserResponse.fallback());   // 5. 폴백
}

위 코드에서 findById()가 DB에 쿼리를 보내는 순간, 스레드는 DB 응답을 기다리지 않고 반환된다.
DB 응답이 도착하면 flatMap 이후의 파이프라인이 (어떤 Event Loop 스레드에서든) 이어서 실행된다.
이 과정에서 스레드는 단 한 번도 블로킹되지 않는다.
 
이 구조의 이점은 메모리 효율성에서도 나타난다. OS 스레드 200개가 각각 1MB의 스택 메모리를 점유하던 것이, Event Loop 4~16 개 + 수만 개의 경량 Subscription 객체로 대체된다.


3.3 실무 도입 시의 트레이드오프

WebFlux는 처리량 측면에서 이론적 우위가 명확하지만, 실무 도입에는 간과할 수 없는 비용이 수반된다.

    • 학습 곡선
      Reactive Streams의 Publisher/Subscriber 개념, Mono/Flux의 다양한 연산자(flatMap, switchIfEmpty, zip, retryWhen 등), 그리고 Cold/Hot Publisher의 차이를 이해해야 한다. 팀 전체가 이 패러다임에 익숙해지기까지 상당한 시간이 필요하다
    • 디버깅의 어려움
      스택 트레이스가 비동기 경계를 넘어갈 때 끊기므로, 예외 발생 지점을 추적하기 어렵다. Reactor의 .checkpoint() 연산자를 삽입하거나, Hooks.onOperatorDebug()를 활성화해야 의미 있는 트레이스를 얻을 수 있지만, 이는 성능 오버헤드를 수반한다.
// 디버깅을 위한 checkpoint 삽입 — 프로덕션에서는 성능 비용 고려 필요
return userRepository.findById(id)
        .checkpoint("[traceId123] after-findById")
        .flatMap(user -> externalApiClient.fetchCredit(user))
        .checkpoint("[traceId123] after-fetchCredit");
    • 생태계 제약
      전체 호출 체인이 Non-blocking이어야 WebFlux의 이점이 발현된다. 체인 중간에 블로킹 호출이 하나라도 끼면 Event Loop 스레드가 점유되어 전체 시스템의 처리량이 급격히 저하된다. 그런데 2026년 현재에도 JDBC(R2DBC가 존재하지만 드라이버 성숙도와 기능 완성도 면에서 제약이 있다), 일부 SDK, 레거시 라이브러리 등 블로킹 I/O에 의존하는 의존성은 여전히 많다.
    • 코드 복잡도
      조건 분기, 반복, 예외 처리 같은 일상적인 로직도 연산자 체인으로 표현해야 한다. 단순한 if-else 분기가 switchIfEmpty + flatMap 조합으로 바뀌면서 가독성이 저하된다.

결국 WebFlux는 "높은 동시 연결 + I/O 바운드 + 팀의 Reactive 역량" 이 세 가지가 모두 갖춰진 환경에서 빛을 발한다. 그렇지 않은 환경에서는 도입 비용이 이점을 상쇄할 수 있다.


4. 새로운 대안: Java 21 Virtual Threads

4.1 Virtual Thread의 동작 원리

Virtual Trhead

Java 21(JEP 444)에서 정식 도입된 Virtual Thread는 JVM이 관리하는 경량 스레드이다. 기존 OS 스레드(이하 Platform Thread)와 1:1로 매핑되던 구조에서 벗어나, 다수의 Virtual Thread가 소수의 Carrier Thread(Platform Thread) 위에서 다중화된다.
이 구조를 M:N 스케줄링이라 한다. M개의 Virtual Thread가 N개의 Carrier Thread 위에 다중화(Multiplexing)되는 방식이다.
스케줄러는 JDK 내부의 ForkJoinPool이 담당하며, OS가 아닌 JVM이 스레드의 마운트/언마운트를 직접 제어한다.
 
동작 방식은 다음과 같다.

  1. Virtual Thread가 네트워크 I/O, Thread.sleep(), BlockingQueue.take() 등의 블로킹 작업을 만나면, JVM은 해당 Virtual Thread를 Carrier Thread에서 언마운트(unmount) 한다.
  2. 언마운트된 Virtual Thread의 스택 프레임은 힙 메모리에 저장되고, Carrier Thread는 즉시 다른 Virtual Thread를 마운트하여 실행을 계속한다.
  3. I/O가 완료되면 해당 Virtual Thread는 (원래의 것이 아닐 수도 있는) 사용 가능한 Carrier Thread에 다시 마운트(Event Loop와 같음)되어 실행을 재개한다. 이 전체 과정은 OS의 관여 없이 JVM 사용자 공간에서 이루어지므로, OS 컨텍스트 스위칭 대비 월등히 가볍다.

OS 스레드와의 결정적 차이는 생성 비용메모리 사용량이다. Platform Thread는 생성 시 약 1MB의 고정 스택 메모리를 할당하며 OS 커널의 스레드 관리 오버헤드가 따르지만, Virtual Thread의 스택은 힙에 할당되며 실제 사용량에 따라 동적으로 증감한다(초기 수백 바이트~수 KB 수준). 덕분에 수십만 개의 Virtual Thread를 동시에 생성해도 메모리 부담이 미미하다.


4.2 동기 코드로 Non-blocking의 이점을 취하는 원리

Virtual Thread의 가장 큰 가치는, 기존 동기식 코드를 그대로 유지하면서도 I/O 대기 시간 동안 Carrier Thread를 낭비하지 않는다는 점이다.

// 기존 동기 코드 — 변경 없음
@GetMapping("/users/{id}")
public UserResponse getUser(@PathVariable Long id) {
    User user = userRepository.findById(id)        // 블로킹 JDBC 호출
            .orElseThrow();
    CreditInfo credit = externalApiClient.fetchCredit(user);  // 블로킹 HTTP 호출
    return UserResponse.from(user, credit);
}

이것이 가능한 이유는 JDK 내부에서 java.net.Socket, java.io.InputStream 등의 블로킹 I/O 메서드가 Virtual Thread 환경을 감지하면 내부적으로 Non-blocking 모드로 전환되도록 재구현되었기 때문이다. 애플리케이션 코드의 변경 없이 JVM 레벨에서 투명하게 최적화가 적용된다.
 
Spring Boot 3.2부터 공식 지원하며, 적용은 설정 한 줄이다.

# application.yml
spring:
  threads:
    virtual:
      enabled: true

이 설정으로 Tomcat의 워커 스레드 풀이 Virtual Thread 기반 Executor로 교체된다. 스레드 풀 크기라는 제약이 사라지고,
동시 요청 수의 상한이 사실상 다운스트림 리소스(DB 커넥션 풀 등)에 의해서만 결정된다.


4.3 Virtual Thread 사용 시 주의해야 할 부분

Virtual Thread는 "설정 한 줄로 성능 개선"이라는 매력이 있지만, 도입 시 반드시 인지해야 할 주의사항이 있다.
 

1. Pinning(고정) 이슈

Java 21~23에서 synchronized 블록 내부에 블로킹 I/O가 있으면, Virtual Thread가 Carrier Thread에서 언마운트되지 못하고 고정된다. 고정된 동안 해당 Carrier Thread는 다른 Virtual Thread를 실행할 수 없으므로, Carrier Thread 전부가 고정되면 나머지 수만 개의 Virtual Thread가 실행 기회를 얻지 못하는 Starvation이 발생한다.

Java 21~23에서는 synchronized를 ReentrantLock으로 교체하여 회피할 수 있으며, Java 24(JEP 491)부터는 JVM 레벨에서 이 문제가 해소되었다. 다만 Native 코드 호출(JNI)로 인한 Pinning은 여전히 남아 있다.

// ❌ Java 21~23에서 Pinning 유발
public synchronized String fetchData() {
    return httpClient.send(request, BodyHandlers.ofString()).body();
}

// ✅ ReentrantLock으로 Pinning 회피
private final ReentrantLock lock = new ReentrantLock();
public String fetchData() {
    lock.lock();
    try {
        return httpClient.send(request, BodyHandlers.ofString()).body();
    } finally {
        lock.unlock();
    }
}

 
2. DB 커넥션 풀 고갈

가장 흔히 간과되는 문제이다. 기존에는 Tomcat 스레드 풀 크기(200)가 동시 요청 수의 암묵적 상한이었고, HikariCP 커넥션 풀(50)과의 비율이 자연스럽게 유지되었다. 그러나 Virtual Thread를 도입하면 이 암묵적 상한이 사라지므로, 수천 개의 Virtual Thread가 동시에 DB 커넥션을 요구할 수 있다. 풀 크기를 초과하는 요청은 커넥션 대기 큐에 쌓이고, connectionTimeout 초과 시 대량 실패로 이어진다. Semaphore 등으로 DB 접근 동시성을 명시적으로 제한하는 설계가 필요하다.

// DB 커넥션 동시 접근 제한 패턴
private static final Semaphore DB_LIMITER = new Semaphore(50);

public UserResponse getUser(Long id) {
    DB_LIMITER.acquire();
    try {
        return userRepository.findById(id).map(UserResponse::from).orElseThrow();
    } finally {
        DB_LIMITER.release();
    }
}

 
3. ThreadLocal의 메모리 폭증

Platform Thread 환경에서는 풀 크기가 고정(예: 200)되어 있어 ThreadLocal에 무거운 객체(DB 커넥션, 버퍼 등)를 캐싱하는 패턴이 통용되었다. 그러나 Virtual Thread 환경에서는 동시에 수십만 개의 스레드가 존재할 수 있으므로, 각 스레드마다  ThreadLocal에 무거운 객체가 할당되면 메모리 사용량이 급격히 증가한다.
 
향후 이 문제의 대안으로 설계된 것이 Scoped Values(Java 25)이다. Scoped Values는 불변(immutable) 값을 스레드 간에 효율적으로 공유할 수 있게 해주며, Virtual Thread 환경에 최적화된 경량 메커니즘이다.
 

4. Virtual Thread를 풀링(Pooling)하지 않는다.

Platform Thread는 생성 비용이 높아 ThreadPoolExecutor로 재사용하는 것이 표준 관행이었다. 그러나 Virtual Thread의 생성 비용은 일반 객체 할당과 비슷한 수준이므로, 풀에 넣어 재사용하면 오히려 풀 관리 오버헤드만 추가된다. Executors.newVirtualThreadPerTaskExecutor()가 작업마다 새 Virtual Thread를 생성하는 Executor를 반환하는 이유이다.

// ❌ 잘못된 사용 — Virtual Thread를 풀링
ExecutorService pool = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());

// ✅ 올바른 사용 — 작업마다 새 Virtual Thread 생성
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

5. 결론 및 기술적 의사결정 고려사항

5.1 아키텍처 관점의 선택 기준

  Spring MVC + Virtual Threads Spring WebFlux
적합한 시나리오 범용 웹 서비스, CRUD 중심, JDBC 기반 높은 동시 연결, 스트리밍, Gateway/Proxy
코드 패러다임 동기/순차(Imperative) 비동기/선언(Reactive)
학습 비용 낮음 (기존 Java 코드 그대로) 높음 (Reactive Streams, 연산자 학습)
디버깅 기존 도구 호환 (스택 트레이스 정상) 비동기 경계에서 트레이스 단절
생태계 호환 JDBC, JPA, 대부분의 라이브러리 R2DBC, WebClient 등 Non-blocking 필수
배압(Backpressure) 수동 구현 또는 불필요 Reactor 내장 (연산자 레벨)

 
WebFlux가 적합한 경우는 명확하다.

  • 수만 동시 연결을 유지하는 SSE/WebSocket 서버
  • 네트워크 I/O만 중개하는 API Gateway
  • Reactor의 선언적 배압 제어가 필요한 고속 데이터 파이프라인
  • ...

그 외의 일반적인 웹 애플리케이션에서는 Virtual Thread가 코드 변경 없이 동시성을 끌어올리는 현실적인 선택지이다.


6. 참고 자료