본문 바로가기
Java/가상 스레드

Virtual Thread (1) - JEP444

by 정재익 2026. 5. 3.

1. 가상스레드의 목표

2. Thread-per-request의 한계

3. Thread-per-request의 한계를 극복하기 위해서 했던 기존 방법

4. 가상 스레드의 탄생배경

5. 가상 스레드와 플랫폼 스레드

6. 가상 스레드 vs 플랫폼 스레드 테스트

7. 가상 스레드를 사용해야할 때

 

https://openjdk.org/jeps/444

 

JEP444를 보고 요약했습니다. 3분의 1정도 한거 같네요

 

가상스레드는 JDK21부터 지원되었다.

모든 가상 스레드는 스레드 로컬을 가진다. 프리뷰 단계에서는 스레드 로컬이 없는 가상 스레드의 생성이 가능했지만 스레드 로컬 자원이 보장됨으로써 훨씬 더 많은 기존 라이브러리를 가상 스레드와 함께 변경없이 사용할 수 있다.

가상 스레드도 생애 전반에 걸쳐 모니터링 되며 새로운 스레드 덤프를 통해 관찰이 가능하다.

 

가상 스레드의 목표

간결한 thread-per-request 스타일로 작성된 서버 애플리케이션이 하드웨어 활용률을 거의 최적에 가깝게 끌어올리며 확장될 수 있게 한다.

 

목표가 아닌 것 :

Java의 기본 동시성 모델을 변경하거나 새로운 데이터 병렬성 구성을 만드는 것은 목표가 아니다.

대용량 데이터를 병렬 처리하는 데에는 여전히 Stream API를 권장

 

Thread-per-request의 한계

서버 애플리케이션은 일반적으로 서로 독립적인 동시 사용자 요청을 처리하므로, 한 요청을 하나의 스레드에 전적으로 맡겨 그 요청이 끝날 때까지 처리한다. 이러한 thread-per-request 스타일은 이해하기 쉽고, 프로그래밍하기 쉽고, 디버rld, 프로파일링하기도 쉽다. 플랫폼의 동시성 단위(스레드)를 애플리케이션의 동시성 단위(요청)로 그대로 사용하기 때문이다.

 

그러나 가용한 스레드 수에는 한계가 있다. JDK가 스레드를 OS 스레드의 래퍼로 구현하기 때문이다. OS 스레드는 비싼 자원이라 너무 많이 가질 수 없고, 따라서 thread-per-request 스타일과 잘 맞지 않는다.

 

한 요청이 그 기간 내내 하나의 스레드(곧 하나의 OS 스레드)를 점유한다면, CPU나 네트워크 연결 같은 다른 자원이 고갈되기 훨씬 전에 스레드 수가 한계 요인이 되곤 한다. 현재 JDK의 스레드 구현은 하드웨어가 감당할 수 있는 수준을 최대한 활용하지 못하고 있다.

 

스레드를 풀로 묶어 쓰더라도 이 점은 마찬가지다 왜냐하면 풀은 새 스레드 시작 비용을 줄여줄 뿐, 전체 스레드 수를 늘려주지는 않기 때문

 

Thread-per-request의 한계를 극복하기 위해서 했던 기존 방법

한 요청을 시작부터 끝까지 한 스레드가 처리하게 두는 대신, 요청 처리 코드는 또 다른 I/O 작업을 기다리는 동안 스레드를 풀에 반환해 다른 요청을 처리할 수 있게 한다. 계산하는 동안에만 스레드를 붙들고 있고 I/O를 기다릴 때는 놓는 방식은 많은 수의 동시 작업을 적은 수의 스레드로 처리할 수 있게 한다

 

OS 스레드의 희소성에 대한 제약은 사라지지만 개발이 불편해진다. 바로 비동기 프로그래밍의 스타일이다.

I/O 작업의 완료를 기다리지 않고 나중에 콜백으로 완료를 알려주는 별도의 메서드를 사용하거나 전용 메서드가 없으니 요청 처리 로직을 잘게 쪼개서 CompletableFuture 같은 것들로 조립해야만한다,

 

이는 프로그램 동작을 이해하는 데 영향을 준다. 스택 트레이스는 쓸 만한 컨텍스트를 제공하지 못하고, 디버거는 요청 처리 로직을 단계별로 따라갈 수 없으며, 프로파일러는 어떤 작업의 비용을 그 호출자와 연관 지을 수 없다.  이 프로그래밍 스타일은 Java 플랫폼과 어긋난다.

 

 

가상 스레드의 탄생배경

애플리케이션이 플랫폼과 조화를 유지하면서 확장될 수 있도록, thread-per-request 스타일을 보존해야 한다. 이를 위해서는 스레드를 더 효율적으로 구현해 더 풍부하게 가질 수 있게 해야 한다.

 

Java 런타임은 Java 스레드와 OS 스레드의 1:1 대응 관계를 끊는 방식으로 Java 스레드를 구현할 수 있다. OS가 큰 가상 주소 공간을 제한된 물리 RAM에 매핑함으로써 가상 메모리를 주듯, Java 런타임도 다수의 가상 스레드를 소수의 OS 스레드에 매핑할 수 있다.

 

가상 스레드와 플랫폼 스레드

JDK의 모든 java.lang.Thread 인스턴스는 플랫폼 스레드다. 플랫폼 스레드는 기반 OS 스레드 위에서 Java 코드를 실행하며, 코드 전체 수명 동안 그 OS 스레드를 점유한다. 플랫폼 스레드의 수는 OS 스레드 수에 의해 제한된다.

 

가상 스레드도 java.lang.Thread의 인스턴스로, 기반 OS 스레드 위에서 Java 코드를 실행하지만 코드의 전체 수명 동안 OS 스레드를 점유하지 않는다. 즉, 다수의 가상 스레드가 같은 OS 스레드 위에서 Java 코드를 실행하며 사실상 그 OS 스레드를 공유할 수 있다. 플랫폼 스레드는 OS 스레드를 독점하는 반면, 가상 스레드는 그렇지 않다. 가상 스레드의 수는 OS 스레드 수보다 훨씬 클 수 있다.

 

한마디로 가상 스레드는 특정 OS 스레드에 묶이지 않은 java.lang.Thread의 인스턴스며, 플랫폼 스레드는 OS 스레드의 얇은 래퍼로 구현된, 전통적 방식의 java.lang.Thread 인스턴스다.

 

thread-per-request 스타일의 애플리케이션 코드는 한 요청 동안 하나의 가상 스레드 위에서 실행될 수 있지만, 그 가상 스레드는 CPU에서 실제 계산을 수행하는 동안에만 OS 스레드를 소비한다

 

가상 스레드는 싸고 풍부하므로 절대 풀링하면 안된다. 모든 애플리케이션 작업마다 가상스레드를 만들어야한다. 대부분의 가상 스레드는 수명이 짧고 호출 스택이 얕으며, 단일 HTTP 클라이언트 호출이나 단일 JDBC 쿼리 정도의 적은 일을 수행한다.

 

즉, 가상 스레드는 Java 플랫폼의 설계와 조화를 이루는 신뢰할 수 있는 thread-per-request 스타일을 보존하면서 가용 하드웨어를 최적으로 활용한다.

 

가상 스레드는 OS가 아니라 JDK가 제공하는 경량 스레드 구현이다. 사용자 모드 스레드의 한 형태이며, Go의 goroutine이나 Erlang의 process처럼 다른 멀티스레드 언어에서 성공한 방식이다.

 

초기 자바에는 그린 스레드라는 사용자 모드 스레드가 있었다.  그러나 Java의 그린 스레드는 모두 하나의 OS 스레드를 공유했고(M:1 스케줄링), 결국 OS 스레드의 래퍼로 구현된 플랫폼 스레드(1:1 스케줄링)에 밀려났다. 가상 스레드는 M:N 스케줄링을 사용한다. 즉 다수(M)의 가상 스레드가 더 적은 수(N)의 OS 스레드 위에 스케줄된다.

 

 

가상 스레드 vs 플랫폼 스레드 테스트

다음은 다수의 가상 스레드를 만드는 예제 프로그램이다. 먼저 제출된 작업마다 새 가상 스레드를 만드는 ExecutorService를 얻은 뒤, 10,000개의 작업을 제출하고 모두 완료되기를 기다린다.

 

public class dd {
    public static void main(String[] args) throws InterruptedException {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var latch = new CountDownLatch(10_000);
            long start = System.nanoTime();

            IntStream.range(0, 10_000).forEach(i ->
                    executor.submit(() -> {
                        Thread.sleep(Duration.ofSeconds(1));
                        latch.countDown();
                        return i;
                    })
            );
            latch.await();

            long end = System.nanoTime();
            System.out.println((end - start) / 1_000_000 + "ms");
        }
    }
}

 

현대 하드웨어는 이런 코드를 동시에 실행하는 10,000개의 가상 스레드를 쉽게 감당한다. 무대 뒤에서 JDK는 이 코드를 적은 수, 어쩌면 단 한 개의 OS 스레드 위에서 돌린다.

 

Executors.newCachedThreadPool()처럼 작업마다 새 플랫폼 스레드를 만들면 부담이 크다.

이 ExecutorService는 10,000개의 플랫폼 스레드(곧 10,000개의 OS 스레드)를 만들려 시도한다. 

public class dd2 {
    public static void main(String[] args) throws InterruptedException {
        int taskCount = 10_000;
        var latch = new CountDownLatch(taskCount);

        try (var executor = Executors.newCachedThreadPool()) {

            long start = System.nanoTime();

            IntStream.range(0, taskCount).forEach(i ->
                    executor.submit(() -> {
                        try {
                            Thread.sleep(Duration.ofSeconds(1));
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        } finally {
                            latch.countDown();
                        }
                    })
            );

            latch.await();

            long end = System.nanoTime();
            System.out.println((end - start) / 1_000_000 + "ms");
        }
    }
}

 

스레드 풀에서 플랫폼 스레드를 가져와도 크게 나아지지는 않는다. 그 ExecutorService는 200개의 플랫폼 스레드를 만들어 10,000개의 작업이 공유하게 하므로, 많은 작업이 동시 실행되지 못하고 순차 실행되며 프로그램 완료에 오랜 시간이 걸린다.

public class dd2 {
    public static void main(String[] args) throws InterruptedException {
        int taskCount = 10_000;
        var latch = new CountDownLatch(taskCount);

        try (var executor = Executors.newFixedThreadPool(200)) {

            long start = System.nanoTime();

            IntStream.range(0, taskCount).forEach(i ->
                    executor.submit(() -> {
                        try {
                            Thread.sleep(Duration.ofSeconds(1));
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        } finally {
                            latch.countDown();
                        }
                    })
            );

            latch.await();

            long end = System.nanoTime();
            System.out.println((end - start) / 1_000_000 + "ms");
        }
    }
}

 

JEP444에서는 스레드 풀을 사용한 경우가 매번 스레드를 생성하는 것 보다 성능이 좀 더 좋다는 느낌으로 말했지만,

실제 테스트에서는 스레드 풀을 만든 경우에 제일 성능이 좋지 않은 결과가 나왔는데 이것은 로컬에서 돌려서 그런것이고 실제로 newCachedThreadPool은 OS 스레드를 무한정 만들기 때문에 오래걸리는 것을 떠나서 OOM으로 서버가 터질 것이다. 아마 이런 의미에서 말하지 않았나 싶다

 

본론으로 돌아와 이 프로그램에서 200개의 플랫폼 스레드 풀은 초당 200 작업의 처리량밖에 내지 못하지만, 가상 스레드는 초당 약 10,000 작업의 처리량을 낸다.

더 나아가 예제의 10000을 1000000으로 바꾸면, 프로그램은 1,000,000개의 작업을 제출하고 1,000,000개의 가상 스레드를 동시에 만들어 초당 약 1,000,000 작업의 처리량을 낼 수 있다

 

그러나 만약 작업이 단순 I/O가 아니라 CPU바운드라면 스레드 수를 코어 수 이상으로 늘리는 것은 가상 스레드든 플랫폼 스레드든 소용이 없다.

 

가상 스레드는 더 빠른 스레드가 아니다 속도가 아니라 규모 즉 높은 처리량을 위해 존재한다.

가상 스레드는 플랫폼 스레드보다 더 많이 가질 수 있기 때문에 리틀의 법칙에 따라 더 높은 처리량에 필요한 더 높은 동시성을 가지게 해준다.

 

가상 스레드를 사용해야할 때

아래의 두 조건을 만족할 때 사용하면 좋다.
1. 동시 작업의 수가 많다.

2. CPU 바운드가 아니다.

 

애플리케이션은 대부분 시간을 대기에 쓰는 다수의 동시 작업 즉 I/O 바운드이기 때문에, 가상 스레드는 일반적인 서버 애플리케이션의 처리량을 향상시킨다.