Virtual Thread 너 좋다던데?

최근 보안 이슈가 많이 발생하다보니 회사에서는 외부의 공격으로 부터 페이로드 변조를 검증하는 기능을 추가하게 되었다.

 

페이로드 검증 기능을 추가하고 보니 피크 타임 기준으로 504 에러가 급증하는게 확인됐다.

 

이 문제를 해결하기 위해서 Read Write 서비스 분리, WebFlux 적용, 페이로드 검증 서비스 분리 등 여러 가지 방법들을 적용하면서 문제를 해결했었다.

 

지금 와서 다시 생각을 해본다면 Virtual Thread를 사용 해 볼 수 있지 않을까? 라는 생각이 든다.

 

이 글의 포스팅 목적은 생각 난 김에 해보자. 그리고 정리해보자! 의 목적이 크다.

 

Virtual Thread 그게 뭐야?

Virtual Thread는 JVM이 직접 스케줄링하는 경량 스레드로 기존의 "요청당 스레드" 동기 프로그래밍 모델을 유지하면서도 I/O 대기 중 OS Thread를 점유하지 않도록 설계된 실행 단위를 말한다고 한다.

 

음.. 어렵지만 아주 좋아보이는구만? 좋은 말은 다 갖다 붙인거 같긴한데 확 다가오지 않는다. 

하나씩 뜯어보기 전에 Virtual Thread와 기존 자바 Thread(Platform Thread)를 비교해보자.

 

Platform Thread

자바의 전통적인 Thread를 Platform Thread라고 일컽는다. Platform Thread는 new Thread()로 생성하면 OS에게 '커널 스레드 하나 만들어줘' 요청 하는 구조이다. 따라서 Platform Thread 1개는 곧 OS Thread 1개와 맵핑된다.

Platform Thread

 

전통적인 Spring MVC + Platform Thread 환경에서 요청 하나는 다음과 같은 순서로 처리된다.

  1. 클라이언트 요청이 유입된다.
  2. 요청마다 하나의 Platform Thread가 할당된다.
  3. 각 Platform Thread는 JVM 내부에서 OS 커널 스레드(OS Thread) 와 1:1로 매핑된다.
  4. 할당된 Thread가 요청 처리를 수행한다.
  5. 처리 과정에서 blocking I/O가 발생할 수 있다.
  6. I/O 대기 동안 해당 OS Thread는 block 상태로 유지되며 점유된다.
  7. 새로운 요청을 처리하기 위해서는 추가적인 Thread 할당이 필요해진다.

이 구조에서는 Thread가 곧 요청 처리 단위이자 자원 제한 장치로 동작한다.


최악의 케이스1 : CPU Bound + 과도한 Thread 수

요청 처리 로직이 CPU 연산 위주이거나, Thread 수가 CPU 처리 능력을 크게 초과하는 경우를 가정해보자.

  1. 1,000개의 요청이 동시에 유입된다.
  2. 요청당 하나의 Platform Thread가 할당되어, 총 1,000개의 Platform Thread가 생성된다.
  3. 각 Platform Thread는 OS Thread 1,000개와 1:1로 매핑된다.
  4. CPU가 동시에 실행할 수 있는 Thread 수는 Logical Core 개수에 불과하다.
  5. 초과된 OS Thread들은 커널 스케줄러에 의해 time-slicing 방식으로 번갈아 실행된다.
  6. 이 과정에서 빈번한 Context Switch가 발생한다.
  7. 결과적으로 CPU는 실제 비즈니스 로직보다스레드 전환과 스케줄링 오버헤드에 더 많은 자원을 소모하게 된다.

최악의 케이스2 : I/O Bound + Thread Pool 고갈

이번에는 요청 대부분이 blocking I/O를 포함하는 경우를 살펴보자.

  1. 1,000개의 요청이 동시에 유입된다.
  2. 요청당 하나의 Platform Thread가 할당되어 1,000개의 Platform Thread가 생성된다.
  3. 각 Platform Thread는 OS Thread 1,000개와 1:1로 매핑된다.
  4. 각 요청은 DB 호출이나 외부 API 호출과 같은 blocking I/O 작업을 수행한다.
  5. I/O 대기 동안 해당 OS Thread는 block 상태로 전환되지만, 커널 스레드는 계속 점유된 상태로 유지된다.
  6. I/O 응답이 반환되기 전까지 Thread는 반환되지 않는다.
  7. 그 결과, Thread Pool의 모든 스레드가 장시간 점유되어 포화 상태로 유지된다.
  8. 이후 유입되는 요청은:
    • Thread 할당을 기다리며 큐에 쌓이거나
    • 타임아웃 또는 요청 거절로 이어진다.

이 경우 Thread Pool은 더 이상 처리량을 늘려주지 못하고, 시스템 전체의 병목 지점으로 작용한다.

 

Virtual Thread

Virtual Thread는 Java 21에서 정식 도입된 새로운 스레드 모델이다. OS Thread 와 1:1이 아닌 N:M 구조로 JVM이 알아서 스케줄링 한다. 밭일에 비교하자면, Platform Thread는 정규직이라면, Virtual Thread는 일용직 일꾼 정도로 비교할 수 있을것 같다.

 

Virtual Thread

 

Virtual Thread 환경에서는 요청 처리 방식이 Platform Thread와 근본적으로 달라진다.

  1. 클라이언트 요청이 유입된다.
  2. 요청마다 하나의 Virtual Thread가 생성된다.
  3. Virtual Thread는 실행을 위해 Carrier Thread(OS Thread) 에 mount 된다.
  4. Carrier Thread 위에서 요청 처리가 수행된다.
  5. 처리 과정에서 blocking I/O가 발생할 수 있다.
  6. JVM(Project Loom)은 해당 I/O 대기를 감지한다.
  7. I/O 대기 동안 Virtual Thread는 Carrier Thread에서 unmount 되어 분리된다.
  8. 분리된 Virtual Thread는 대기 상태로 전환된다.
  9. 비워진 Carrier Thread는 즉시 다른 Virtual Thread를 mount 하여 실행을 계속한다.
  10. I/O가 완료되면, 대기 중이던 Virtual Thread는 다시 Carrier Thread에 mount 되어중단된 지점부터 실행을 재개한다.

 

Virtual Thread의 핵심 특징

앞서 살펴본 Virtual Thread의 동작 방식을 바탕으로 핵심 특징을 정리해보았다.

 

1. I/O 대기 중 OS Thread를 점유하지 않는다.

Platform Thread 환경에서는 I/O 대기 중에도 비싼 OS Thread가 점유된 채 아무 일도 하지 못했다.

Virtual Thread는 I/O 대기가 감지되면 Carrier Thread에서 unmount되어 분리되고, 해당 Carrier Thread는 즉시 다른 Virtual Thread를 실행한다.

 

2. 수십만 개의 동시 생성이 가능하다.

Platform Thread는 생성 시 스택 메모리, 레지스터 상태, 컨텍스트 등의 정보를 갖기 때문에 수 천 개만 생성해도 메모리 압박이 발생한다.

반면 Virtual Thread는 JVM 힙에 경량 객체로 존재하며, 필요할 때만 Carrier Thread에 mount되어 실행된다.

 

3. 기존 동기 프로그래밍 모델을 유지한다.

WebFlux 같은 리액티브 프로그래밍은 코드 스타일 자체가 완전히 달라진다. 

그러나, Virtual Thread는 기존 MVC 스타일의 동기 코드를 그대로 사용하면서도 내부적으로는 비동기처럼 효율적으로 동작한다. 

 

4. Thread Pool이 필요 없다.

Platform Thread 시절에는 OS Thread 폭증을 방지하기 위해서 Thread Pool을 사용했다. 하지만 Virtual Thread는 생성/파괴 비용이 매우 저렴하기 때문에 풀링할 이유가 없다. 오히려 풀링하면 동시성을 풀 크기로 제한하게 되어 Virtual Thread의 이점을 깎아먹는다.

 

5. 하위 자원에 대한 보호는 여전히 필요하다.

Virtual Thread가 Thread Pool의 필요성을 제거 했다고 해서 모든 자원에 대한 제한이 사라진 것은 아니다. DB 커넥션 풀, 외부 API Rate Limit 같은 하위 자원에 대한 보호는 여전히 필요하다.

오히려 Virtual Thread 환경에서는 병목 지점이 이동한다. Platform Thread 시절에는 Thread Pool의 크기가 자연스럽게 동시 요청을 제한했다. 하지만, Virtual Thread는 입구가 거의 무한하기 때문에 DB 커넥션 풀 앞에서 대기가 폭증할 수 있다.

 

Virtual Thread  VS Platform Thread  성능 테스트 

지금까지 공부한 내용으로만 본다면 Virtual Thread는 굉~장히 좋은 기술로만 보인다.

그렇다면 진짜로 좋을까? 성능 차이가 얼마나 날까? 분명 좋은게 있으면 안좋은 부분도 있을텐데 뭐가 있을까? 확인해보려고 한다.

ChatGPT에게 Virtual Thread를 테스트하기 위한 코드 예시를 보여달라고 하면 아래와 같은 코드들을 작성을 많이 해준다.

    public Map<String, Object> simulateLightIo() {
        long startTime = System.currentTimeMillis();

        try {
            Thread.sleep(100); // 100ms delay
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Interrupted during I/O simulation", e);
        }

        long duration = System.currentTimeMillis() - startTime;

        return Map.of(
            "operation", "light-io",
            "targetDelayMs", 100,
            "actualDelayMs", duration,
            "thread", getThreadInfo()
        );
    }

 

Thread.sleep()을 이용해 I/O 대기 상황을 만들면 Virtual Thread는 I/O 대기가 발생한 상황을 인지해서 umount/mount를 수행하므로 Platform Thread와 Virtual Thread를 성능 차이를 확인해볼 수 있다.

 

그럼 실제로 성능 차이가 발생하는지 확인해보려고 한다.

 

성능 테스트 및 모니터링 도구

  • k6 
  • grafana, prometheus

테스트 시나리오

  • I/O-bound 작업 환경에서의  Virtual Thread와 Platform Thread 간 TPS 차이
  • Virtual Thread와 Platform Thread 간 메모리 사용량 비교

테스트 환경

Thread 설정

spring.threads.virtual.enabled false true
server.tomcat.threads.max 50 - (무의미)
server.tomcat.threads.min-spare 10 -
server.tomcat.accept-count 1000 1000
  • Platform Thread 환경에서는 Tomcat Worker Thread 수를 제한하여 일반적인 Thread Pool 구조 사용
  • Virtual Thread 환경에서는 요청마다 Virtual Thread가 생성되므로 Tomcat Thread 설정 무의미
  • Accept-count는 동일하게 유지하여 요청 큐 조건은 동일

K6 부하 시나리오

Ramp-up 1 30s 0 → 50
Ramp-up 2 30s 50 → 100
Ramp-up 3 30s 100 → 200
Steady 30s 200 유지
Ramp-down 30s 200 → 0

I/O-bound 작업 환경

 

Virtual Thread TPS & Virtual Users

 

Platform Thread & Virtual Users

 

 

Virtual Thread VS Platform Thread 결과 비교

지표 Virtual Thread Platform Thread  
TPS ~697 req/s ~394 req/s 1.77x
평균 응답시간 ~105.9 ms ~228.8 ms 2.16x 개선
P95 응답시간 ~112.8 ms ~364.8 ms 3.23x 개선

 

메모리 (RSS-JVM) 사용량 비교

Plaform Thread VS Virtual Thread

  • Platform Thread를 사용한 테스트에서는 약 1.2GB의 OS Stack Overhead(RSS-JVM) 발생
  • Virtual Thread를 사용한 테스트에서는 약830MB의 OS Stack Overhead(RSS-JVM) 발생 

Virtual Thread VS Platform Thread 결과 비교

지표 Virtual Thread Platform Thread 결과 비교
Live Threads 29개 510개 -481개
RSS 증가량 +554MB +961MB Platform이 407MB 더 많음
RSS-JVM (OS Stack Overhead) 증가량 +209MB +290MB Platform이 81MB 더 많음
RSS-JVM 최종값 540MB 605MB -

테스트 결과 정리

IO-bound와 메모리 사용량 비교 결과를 정리하기전에(이미 결과는 나왔지만..) 예상 결과를 정리해보자.

I/O-bound 환경 예상

  • Virtual Thread는 blocking I/O 대기 중 Carrier Thread(OS Thread)를 점유하지 않고 unmount 되기 때문에 Platform Thread  대비 더 많은 동시 요청을 처리할 수 있으며, 높은 처리량을 기대할 수 있다.
    • OS Thread 수 제한 떄문에 발생하던 병목이 감소한다.
    • 동시 처리량 증가 

메모리 사용률 예상

  • Platform Thread는 OS native stack을 포함하기 떄문에 스레드 수 증가 시 메모리 사용량이 빠르게 증가한다.
  • Virtual Thread는 JVM이 관리하는 경량 객체이며 stack frame이 heap에 저장되는 구조를 사용하기 때문에 상대적으로 적은 메모리를 사용한다.

과연 실제 테스트 결과는 어떤가?

I/O-bound 환경 결과

  • Platform Thread 대비 Virtual Thread 사용 시 1.77배 TPS 개선
  • Platform Thread 대비 Virtual Thread 사용 시 P95 기준 3.23배 개선
  • 예상 결과와 동일한 결과

메모리 사용률 결과

  • Platform Thread 대비 Virtual Thread 사용 시 약 80MB 더 적은 메모리 사용
  • 예상 결과와 동일한 결과

Virtual Thread의 한계

지금까지 살펴 본 결과로만 보면 "Virtual Thread는 무조건 쓰는게 정답"처럼 보인다.

하지만 정말 그럴까? 

 

실제로 Java 공식 문서에서도 Virtual Thread 사용 시 주의해야 할 점들을 분명히 언급한다.

(참고: Java Virtual Thread 공식 문서)

 

Core Libraries

Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging high-throughput concurrent applications.

docs.oracle.com

 

각 내용을 하나씩 정리해보자.

Represent Every Concurrent Task as a Virtual Thread; Never Pool Virtual Threads

이 내용은 사실 앞서  Thread Pool이 필요 없다. 라는 내용으로 이미 살펴본 내용과 동일하다.

왜 Thread Pool이 필요없을까?  Virtual Thread 생성/파괴 비용이 매우 저렴하기 때문에 풀링할 이유가 없다. 오히려 풀링하면 동시성을 풀 크기로 제한하게 되어 Virtual Thread의 이점을 깎아먹는다.

  • Thread Pool(풀링)의 목적은 "만들기 비싼 자원(Platform Thread)"을 재사용해서 생성/파괴 비용을 줄이려는 목적이다.
  • 하지만, Virtual Thread는 생성/파괴가 매우 저렴하게 설계 됐기 때문에 풀링을 사용하면 오히려 이점이 사라진다

Use Semaphores for Limited Resources

앞서 살펴본 것처럼 Virtual Thread는 매우 많은 수를 생성할 수 있다.
이는 기존 Platform Thread 환경에서 자연스럽게 존재하던 “Thread 수 제한”이 사라졌음을 의미한다.

즉, Thread Pool을 통해 간접적으로 이루어지던 하위 자원 보호가 더 이상 자동으로 보장되지 않는다.

Platform Thread 환경

Platform Thread에서는 Thread Pool 크기가 곧 동시 실행 가능한 작업 수를 제한하는 역할을 한다.

예를 들어:

  • Thread Pool = 200

이라면, 

  • 동시에 최대 200개의 요청만 실행
  • 결과적으로 DB 호출,외부 API 호출도 약 200개 수준으로 자연스럽게 제한된다.
  • Thread Pool 자체가 일종의 Backpressure 역할을 수행하게 된다.

Virtual Thread 환경

Virtual Thread는 매우 가볍기 때문에 요청 수 만큼 Thread를 생성하는 것이 가능하다.

예:

  • 요청 50,000개 -> Virtual Thread 50,000개 생성

이때 별도의 제한이 없다면 :

  • DB Connection Pool = 50
  • 나머지 49,950개의 Thread는 커넥션을 기다리며 대기

결과 :

  • 불필요한 메모리 점유 증가
  • 대기 Queue 증가
  • Timeout 증가
  • 외부 API의 경우 Rate Limit 초과
  • Retry Storm / Circuit Breaker 발생 가능

즉, Thread 수 제한이 사라진 대시 하위 자원 보호를 개발자가 직접 설계해야한다.

해결방법 : Semaphore 기반 동시성 제한

따라서 DB, 외부 API와 같은 제한된 자원 앞단에는 명시적인 동시성 제어가 필요하다.

Semaphore를 사용하면:

  • Permit N개만 자원 접근 허용
  • 나머지는 대기하거나 빠르게 실패

예:

  • DB 접근 Semaphore= 50

동시에 최대 50개의 요청만 DB 호출 수행

Don't Cache Expensive Reusable Objects in Thread-Local Variables

Virtual Thread에서도 Thread-Local 자체는 문제 없이 사용할 수 있따.

특히 트랜잭션 정보, 사용자 ID 등 실행 컨텍스트를 저장하는 용도로는 여전히 유효하다.

하지만 Thread-Local을 expensive object를 캐싱하는 용도로 사용하는 패턴은 Virtual Thread 환경에서 문제가 될 수 있다.

Platform Thread 환경

기존 Thread Pool 기반 구조에서는 :

  • Thread 수가 제한 됨
  • Thread가 여러 작업에서 재사용됨

따라서 Thread-Local에 expensive object를 저장하면

  • 객체 생성 횟수 감소
  • 메모리 절약
  • 성능 향상

Virtual Thread 환경

Virtual Thread의 설계 철학은

  • 작업(task) 마다 새롭게 생성
  • task 간 재사용 비권장

결과적으로 

  • Thread-Local 캐시 재사용 불가
  • task 마다 객체 생성
  • 많은 Virtual Thread -> 객체 대량 생성
  • 메모리 사용량 증가

즉, Thread-Local 캐싱이 의도와는 반대로 동작하게 된다.

공식 문서에서는 Thread-Local 캐싱을 해야하는 경우 아래와 같이 대안으로 제안하고 있다.

  • immutable 객체 사용 ( 예 : DateTimeFormatter)
  • static/shared instance
  • ScopedValue( Java 21+ )

Avoid Lengthy and Frequent Pinning

Pinning이란?

Virtual Thread가 특정 OS Thread(Carrier Thread)에 고정되어 umount 되지 못하는 상태를 의미한다.

 

Virtual Thread의 기본 동작 방식은 다음과 같다.

  • Virtual Thread가 blocking 상태(I/O 대기 등)에 들어가면
  • JVM Scheduler가 감지
  • Virtual Thread를 OS Thread에서 umount
  • OS Thread 다른 Virtual Thread 실행에 재사용

즉, I/O 대기 중 OS Thread를 점유하지 않는 것이 Virtual Thread의 핵심 설계 철학이다.


Pinning이 발생한다면?

  • Virtual Thread가 blocking 상태에 들어간다.
  • OS Thread에서 umount 되지 못함
  • OS Thread가 계속 해당 Virtual Thread에 묶이게 된다.

결과적으로 

  • OS Thread 재사용 불가
  • 동시성 처리 능력 감소
  • Virtual Thread가 Platform Thread 처럼 동작

Pinning은 언제 발생하는가?

Java 공식 문서에서는 두 가지 경우를 명시하고 있다.

  • synchronized 블록/메서드 내부에서 blcoking 연산을 수행할 때
  • Native Method 또는 Foreign Function을 실행할 때

하지만 중요한점은 synchronized 자체가 Pinning을 유발하는것은 아니다.


왜 synchronized 내부에서 Pinning이 발생할는건가?

synchronized는 바이트코드 레벨에서 monitorenter / montiorexit 으로 변환되며, Monitor Lock을 사용한다.

Monitor Lock의 특징:

  • 락 소유권이 OS Thread 기준으로 lock 관리
  • 락을 가진 thread 상태가 유지되어야 함

따라서, Virtual Thread가 Monitor Lock을 보유한 상태에서 blocking I/O 수행 시 JVM은 Monitor Lock의 일관성을 유지하기 위해 Virtual Thread를 Carrier Thread에서 분리하지 않는다.


Pinning 자체가 문제는 아니다.?

Java 공식 문서 상에서도 다음과 같이 언급되고 있다.

performing a blocking operation while inside a synchronized block or method causes the JDK's virtual thread scheduler to block a precious OS thread, may adversely affect server throughput if it is both long-lived and frequent.

 

위 문장에서의 핵심은 long-lived이다.

상황 Pinning 발생  문제 여부
synchronized 내부에서 짧은 인메모리 연산 O X - 금방 끝나므로 Carrier Thread가 바로 반환됨
synchronized 내부에서 드문 blocking I/O O X - 빈도가 낮아 전체 처리량에 영향 미미
synchronized 내부에서 잦고 긴 blocking I/O O O - Carrier Thread가 지속적으로 고갈됨

 

쉽게 생각해보면 짧은 Pinning이 간헐적으로 발생하는 정도는 나머지 Carrier Thread 들이 충분히 커버할 수 있다. 하지만 길고 잦은 Pinning이 반복되면 사용 가능한 Carrier Thread가 고갈되고, 다른 Virtual Thread 들이 스케줄링 되지 못하며, 결국 Platform Thread를 쓰는 것과 다를 바 없는 상황이 만들어진다.


Pinning 해결 방법

Pinning이 문제가 된다면 synchronized를 ReentrantLock으로 교체하면 된다.

ReetrantLock은 synchronized와 달리 Monitor Lock이 아닌 AbstractQueuedSynchronizer(AQS)기반으로 구현되어 있으며, 내부적으로 LockSupport.park()/unpark()를 사용한다. 이 API는 Virtual Thread 스케줄러가 인식할 수 있기 때문에 umount/mount가 정상적으로 동작한다.

 

 

Before - Pinning 발생 가능

synchronized(obj) {
    // 잦고 긴 blocking I/O
    frequentIO();
}

 

After - Pinning 해결

ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    frequentIO();
} finally {
    lock.unlock();
}

 

참고 : JDK 24에서 개선

JDK 24(2025년 3월)에서는 JEP 491: Synchronize Virtual Threads without Pinning이 반영되었다. Monitor Lock의 구현 자체가 Virtual Thread를 인식하도록 변경되어, synchronized 내부에서 blocking 연산을 수행해도 더 이상 Pinning이 발생하지 않는다.

 

나에게 질의응답 하며 정리하기

Q1. Virtual Thread를 사용하면 OS Thread에 1:1 맵핑이 안된다?

Platform Thread는 OS Thread와 1:1 맵핑되어 사용된다.

OS Thread는 메모리 스택, 컨텍스트 스위치, 스케줄링 등 매우 비싼 자원이다. 따라서, Virtual Thread가 나오기 전까지는 아래와 같이 대응했다.

  • Thread Pool을 만들어서 사용한다 
  • OS Thread가 I/O 작업에 의해 Blocking 상태로 할당되어 있다. -> 논블로킹/이벤트루프 모델인 WebFlux 사용

그치만 WebFlux는 러닝 커브가 매우 높다. Virtual Thread 등장 이후에는 아래와 같이 선택지가 좀 더 늘어났다고 생각됐다.

  • Thread Pool을 만들어서 사용한다.
  • Virtual Thread를 사용한다.
    • synchronized 안에서 blocking, 네이티브호출, 일부 드라이버/라이브러리 내부적으로 Pinning 유발
    • 따라서 사용시 주의가 필요하다.
  • I/O 때문에 OS Thread가 묶이는 문제를 피하기 위해 non-blocking/event-loop 모델(WebFlux)이 사용되기도 한다.

(질문에 대한 답변을 하다가 다른 이야기로 새버렸다..)

따라서, Platform Thread는 OS Thread를 1:1로 점유하니 blocking I/O가 많으면 확장성이 Thread Pool에 묶이게 된다. WebFlux를 사용하면 이 문제를 해결할 수 있지만 복잡도가 높다.

Virtual Thread는 OS Thread와 1:1로 고정되지 않고 대기 시 umount 되기 때문에 동기 코드 스타일을 유질하면서도 동시성을 확장할 수 있는 선택지로 추가되었다.

Q2. WebFlux VS Virtual Thread

WebFlux

WebFlux는 Netty 기반의 Non-blocking/ Event-loop 모델로 요청 처리 전체를 비동기/논 블로킹 파이프라인으로 만들 수 있도록 지원해준다.

  • 목표 : 요청 처리 전체를 비동기/논블로킹 파이프라인으로 만들기
  • 핵심 : 소수의 이벤트 루프 thread가 콜백/리액티브 체인을 돌리면서 I/O 완료 신호에 반응
  • 장점 : 스트리밍/backpressure
  • 전제 : 컨트롤러부터 DB/외부호출 까지 가능한 한 논블로킹 API로 진행되어야 효과가 크다.

Virtual Thread

WebFlux는 요청 처리 전체를 비동기/논블로킹 파이프라인으로 만든다면 Virtual Thread는 프로그래밍 모델은 동기 방식을 그대로 사용하되 실행 방식에서만 Virtual Thread를 사용한다.

  • 목표 : blocking 코드를 그대로 쓰면서 비동기 처럼 동작하도록 한다.
  • 핵심 : 요청 당 Thread 모델을 유지 하되, 그 Thread가 OS Thread가 아니라 JVM이 스케줄링하는 Virtual Thread가 진행하도록 한다.
  • 장점 : 코드, 디버깅, 트랜잭션 등 기존 MVC 스타일을 그대로 사용할 수 있다.
  • 전제 : I/O에서 Block되면 JVM이 Carrier Thread를 다른 작업에게 mount/unmount 하기 때문에 효율적이다.

정리하면 WebFlux는 프로그래밍/실행 모델 자체가 이벤트 기반이고 Virtual Thread는 프로그래밍 모델을 동기 방식 그대로 사용 하되, 실행 방식만 Virtual Thread를 사용한다.

  • 일반적인 REST API + DB/JDBC + 외부 HTTP 호출 중심이라면 MVC + Virtual Thread가 코드 단순성 대비 효과가 좋다.
    • MVC + Virtual Thread를 사용할 때 DB 커넥션 풀이 그대로 병목이 될 가능성이 높다.
    • Virtual Thread를 사용하면 더 적은 OS Thread를 이용해 더 많은 요청을 수용할 수 있다.
    • 다만, DB에서 처리가 병목된다면 마찬가지로 병목이 발생한다. 
    • 따라서, Virtual Thread를 사용하면 기존 Thread Pool 크기로 인해 요청 처리량이 먼저 제한되지 않고 DB 및 외부 시스템이 허용하는 처리량 한도 내에서 훨씬 많은 동시 요청을 수용할 수 있다.
  • 대규모 스트리밍(SSE/WebSocket/대용량 스트림 처리) + BackPressure가 중요한 경우 WebFlux가 강점이다.

 

MVC + Platform Thread : Thread Pool 한계 -> 입구컷 발생

 

MVC + Platform Thread 입구컷

 

MVC + Virtual Thread : 입구 제한 없음 -> DB앞에서 폭증

MVC + Virtual Thread DB 커넥션 폭발

 

WebFlux

WebFlux

Q3. How To Virtual Thread In Spring MVC

Spring MVC 환경에서 HTTP 요청을 기본 Thread가 아닌 Virtual Thread를 사용하도록 설정이 존재한다.

Spring Boot 3.2+ 에서 application.properties에 아래 한 줄만 추가하면 된다.

spring.threads.virtual.enabled=true

 

위 옵션을 활성화하면 Tomcat(또는 Jetty)의 요청 처리 스레드풀이 Virtual Thread 기반 Executor로 교체된다.

동작 원리

서블릿 기반 서버(Tomcat/Jetty)는 기본적으로 요청 1개 = Thread 1개를 할당해서 처리한다. 위 설정을 키면 이 Thread Pool이 Executor.newVirtualThreadPerTaskExecutor()로 대체되어, 요청 마다 새로운 Virtual Thread가 생성된다.

기존: 고정 크기 Platform Thread Pool (기본 200개)
변경 후: 요청마다 Virtual Thread 생성 (사실상 무제한)

 

 

Q4. Virtual Thread를 OS Thread에 Mount/Unmount는 누가해주는걸까?

JVM(Project Loom) 내부의 Virtual Thread Scheduler가 Virtual Thread를 Carrier(OS) Thread 위에 mount하고, I/O 대기 시 unmount 하는 작업을 자동으로 수행한다.

Virtual Thread Scheduler의 실체는 사실 ForkJoinPool이다.

  • 기본 Carrier Thread 개수 = CPU 논리 코어 개수
  • JVM 옵션으로 조정 가능
  • Work Strealing 알고리즘으로 Carrier Thread 간 작업 부하를 균등하게 분산한다.

그렇다면 Virtual Thread Scheduler 안에서 어떤 로직이 있길래 Mount / Unmount가 진행될까?

이를 가능하게 해주는 메커니즘의 핵심은 Continuation이다.

동작 내부 로직
Mount Continuation의 저장된 스택 프레임을 Carrier Thread에 복원하고 실행을 재개한다
Unmount 현재 실행 상태(스택 프레임, 지역 변수 등)를 Continuation에 저장하고 Carrier Thread에서 분리한다

 

Virtual Thread = Runnable 작업 + Continuation(실행 상태 스냅샷)

 

즉, Continuation은 Virtual Thread의 실행 상태를 저장하고 복원하는 체크 포인트 역할을 한다.

 

JVM 블로킹 여부 판단

블로킹 포인트 예시
네트워크 I/O Socket.read(), Socket.write(), HttpClient 호출
파일 I/O FileInputStream.read(), FileChannel 작업
JDBC 네트워크 대기 DB 쿼리 실행 후 응답 대기
스레드 대기 Thread.sleep(), LockSupport.park()
동기화 대기 Object.wait(), ReentrantLock.lock() 대기

* 주의: synchronized 블록 내부에서 블로킹이 발생하면 Unmount가 불가능하다 (Pinning 현상)

Q5. Virtual Thread는 Thread Pool이 필요할까?

Virtual Thread는 기본 설계 철학에서 Thread Pool이 필요하지 않다. 오히려 Thread Pool은 Virtual Thread의 설계를 위반하는 행위이다. 하지만 DB/ 외부 시스템 보호를 위한 자원 풀의 동시성 제한은 여전히 필요하다.

 

Q.6 Proejct Loom이란?

Project Loom은 Java에서 Virtual Thread와 구조적 동시성(Structured Concurrency)을 도입한 OpenJDK 공식 프로젝트를 말한다.

Loom은 "직조기"라는 뜻으로, 여러 가닥의 실(수많은 Virtual Thread)을 소수의 날실(Carrier Thread) 위에 짜 올리는 것에 비유한 이름이다.

 

Loom이 도입한 핵심 내용은 아래와 같다.

1) Virtual Thread

  • JVM이 스케줄링하는 경량 스레드
  • 수십만 개 생성 가능
  • I/O 대기 시 OS Thread를 점유하지 않음
  • Java 21에서 정식 도입

2) Continuation

  • Virtual Thread의 실행 상태를 저장/복원하는 내부 메커니즘
  • mount / unmount를 가능하게 하는 핵심 기술
  • 스택 프레임을 힙 메모리에 저장했다가 복원하는 방식

3) Carrier Thread Scheduler

  • Virtual Thread를 OS Thread(Carrier Thread)에 실어 실행하는 내부 스케줄러
  • 실체는 ForkJoinPool( Work Stealing방식) 

4) Structured Concurrency(Preview)

  • 여러 비동기 작업을 하나의 논리적 단위로 묶어 관리하는 새 동시성 모델
  • StructuredTaskScope를 통해 부모-자식 태스크 관계 명확히 정의
  • 하나가 실패하면 나머지를 자동 취소하는 등에러 전파가 구조적으로 제어 됨
  • Java 21 에서 Preview로 도입