Java 21 가상 스레드(Virtual Threads) 완벽 가이드

가상 스레드란?

Java 21에서 정식 출시된 Virtual Thread(JEP 444)는 JVM이 관리하는 경량 스레드입니다. 기존 Platform Thread가 OS 스레드와 1:1로 매핑되는 것과 달리, 수백만 개의 Virtual Thread가 소수의 OS 스레드(Carrier Thread) 위에서 실행됩니다.

가상 메모리를 떠올리면 이해하기 쉽습니다. OS가 제한된 물리 RAM을 큰 가상 주소 공간으로 매핑하듯, JVM이 소수의 OS 스레드를 대량의 Virtual Thread로 매핑합니다.

항목Platform ThreadVirtual Thread
매핑OS 스레드 1:1M:N (다수 → 소수 OS 스레드)
메모리스레드당 ~1MB스레드당 ~1-2KB
최대 동시 수수천 개수백만 개
스케줄링OS 스케줄러JVM ForkJoinPool
풀링스레드 풀 필수풀링 불필요
적합한 작업CPU 집약I/O 바운드

핵심을 짚으면: Virtual Thread는 코드를 더 “빠르게” 실행하지 않습니다. 목적은 처리량(throughput) 확장입니다. I/O 대기 시간에 Carrier Thread를 다른 작업에 양보하여 동시 처리 수를 극적으로 늘립니다.

기본 사용법

Thread.ofVirtual()

Virtual Thread를 생성하는 가장 기본적인 방법입니다.

public class VirtualThreadBasic {
    public static void main(String[] args) throws InterruptedException {
        // Virtual Thread 생성 및 실행
        Thread vThread = Thread.ofVirtual()
            .name("my-vthread")
            .start(() -> {
                System.out.println("스레드: " + Thread.currentThread());
                System.out.println("가상 스레드인가? " + Thread.currentThread().isVirtual());
            });
        vThread.join();
        // 출력:
        // 스레드: VirtualThread[#21,my-vthread]/runnable@ForkJoinPool-1-worker-1
        // 가상 스레드인가? true
    }
}

ExecutorService (권장)

실무에서는 Executors.newVirtualThreadPerTaskExecutor()를 더 자주 사용합니다. 태스크마다 새 Virtual Thread를 생성하며, try-with-resources와 함께 쓰면 모든 태스크 완료를 자동으로 기다립니다.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class VirtualThreadExecutor {
    public static void main(String[] args) throws Exception {
        // 10,000개 동시 작업 처리
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = new ArrayList<>();

            for (int i = 0; i < 10_000; i++) {
                final int taskId = i;
                futures.add(executor.submit(() -> {
                    // I/O 작업 시뮬레이션 (실제로는 HTTP 호출, DB 쿼리 등)
                    Thread.sleep(100);
                    return "결과-" + taskId;
                }));
            }

            // 처음 5개 결과만 출력
            for (int i = 0; i < 5; i++) {
                System.out.println(futures.get(i).get());
            }
            System.out.println("... 총 " + futures.size() + "개 태스크 완료");
        }
        // try 블록 종료 시 모든 Virtual Thread 완료 대기
        // 출력:
        // 결과-0
        // 결과-1
        // 결과-2
        // 결과-3
        // 결과-4
        // ... 총 10000개 태스크 완료
    }
}

Platform Thread 풀에서는 200개씩 처리하던 것을 10,000개 동시 처리로 확장할 수 있습니다. 코드 변경은 newFixedThreadPool(200)newVirtualThreadPerTaskExecutor()로 바꾸는 것뿐입니다.

성능 비교

Spring Boot 환경(Tomcat, 블로킹 I/O)에서의 실측 데이터입니다.

지표Platform Thread (200 풀)Virtual Thread개선
동시 1,000 요청큐잉 시작전부 처리~2x
동시 10,000+ 요청타임아웃 발생원활 처리~3x
I/O 응답 지연448ms319ms28.8% 감소
메모리 사용량기준선-43%43% 절감

CPU 바운드 작업에서는 성능 차이가 거의 없습니다. Virtual Thread는 I/O 바운드 서비스에서 빛납니다.

주의사항 3가지

1. Pinning — synchronized 블록의 함정

Virtual Thread가 synchronized 블록 내에서 블로킹 I/O를 수행하면, Carrier Thread에서 언마운트되지 못하고 고정(Pinned) 됩니다. 경량 스레드의 이점이 사라집니다.

import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class PinningExample {
    // 문제: synchronized 안에서 I/O → Pinning 발생
    private static final Object syncLock = new Object();

    static void badExample() throws Exception {
        synchronized (syncLock) {
            Thread.sleep(1000);  // 블로킹 I/O 시뮬레이션 → Carrier Thread 고정!
        }
    }

    // 해결: ReentrantLock으로 교체
    private static final ReentrantLock reentrantLock = new ReentrantLock();

    static void goodExample() throws Exception {
        reentrantLock.lock();
        try {
            Thread.sleep(1000);  // 블로킹 I/O 시뮬레이션 → Pinning 없음
        } finally {
            reentrantLock.unlock();
        }
    }

    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            // ReentrantLock 방식으로 10개 동시 실행
            for (int i = 0; i < 10; i++) {
                executor.submit(() -> {
                    goodExample();
                    System.out.println(Thread.currentThread() + " 완료");
                    return null;
                });
            }
        }
        System.out.println("모든 작업 완료 (Pinning 없이)");
    }
}

실제로 Netflix에서 synchronized Pinning으로 인한 전체 애플리케이션 데드락 사고가 발생한 적이 있습니다. JFR(Java Flight Recorder)로 Pinning을 감지할 수 있습니다.

# Pinning 이벤트 감지
java -Djdk.tracePinnedThreads=full -jar your-app.jar

참고로 Java 24(JEP 491)부터는 이 문제가 근본적으로 해결되어 synchronized에서도 Pinning이 발생하지 않습니다.

2. ThreadLocal 메모리 폭발

Platform Thread 풀(200개)에서 ThreadLocal 캐시는 최대 200개 인스턴스입니다. 하지만 Virtual Thread는 풀링되지 않으므로 50,000개 동시 스레드 = 50,000개 인스턴스가 생성됩니다.

import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.Executors;

public class ThreadLocalDanger {
    // 위험: Virtual Thread에서 ThreadLocal 캐싱
    // 50,000개 Virtual Thread → 50,000개 인스턴스 생성!
    static final ThreadLocal<SimpleDateFormat> BAD_FORMATTER =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

    // 해결: 스레드 안전한 불변 객체 공유 → 하나만 생성
    static final DateTimeFormatter GOOD_FORMATTER =
        DateTimeFormatter.ofPattern("yyyy-MM-dd");

    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 5; i++) {
                executor.submit(() -> {
                    // 불변 DateTimeFormatter 사용 (권장)
                    String date = LocalDate.now().format(GOOD_FORMATTER);
                    System.out.println(Thread.currentThread().threadId() + " → " + date);
                    return null;
                });
            }
        }
        // 출력:
        // 21 → 2026-04-07
        // 22 → 2026-04-07
        // ...
    }
}

3. 하위 시스템 보호

Virtual Thread는 저렴하지만, DB 커넥션 풀이나 외부 API는 그렇지 않습니다. Semaphore로 동시성을 제한하세요.

import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    // DB 커넥션 풀 크기에 맞춰 동시 접근 제한
    static final Semaphore dbSemaphore = new Semaphore(3); // 예시: 최대 3개 동시

    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 10; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    dbSemaphore.acquire();
                    try {
                        // DB 쿼리 시뮬레이션
                        System.out.println("작업 " + taskId + " 실행 중 (동시 허용: "
                            + (3 - dbSemaphore.availablePermits()) + "/3)");
                        Thread.sleep(500);
                    } finally {
                        dbSemaphore.release();
                    }
                    return null;
                });
            }
        }
        System.out.println("모든 작업 완료");
    }
}

Spring Boot에서 활성화

Spring Boot 3.2 이상 + Java 21 환경이라면 한 줄로 활성화됩니다.

# application.properties
spring.threads.virtual.enabled=true

이 설정으로 Tomcat HTTP 요청 처리, @Async 메서드, 메시지 리스너(Kafka, RabbitMQ)가 모두 Virtual Thread에서 실행됩니다.

단, WebFlux를 이미 사용 중이라면 Virtual Thread가 불필요합니다. WebFlux는 이미 논블로킹이므로 혼용하면 오히려 문제가 생깁니다. Spring MVC(블로킹 I/O) 기반 서비스에서 Virtual Thread가 가장 큰 효과를 발휘합니다.

정리

상황권장
I/O 바운드 서비스 (DB, API 호출)Virtual Thread 적극 활용
CPU 집약 작업 (이미지 처리, 암호화)Platform Thread 풀 유지
Java 21 사용 시synchronized Pinning 주의
Java 24+ 사용 시Pinning 해결됨, 안심하고 사용
ThreadLocal 캐싱불변 객체 공유로 교체
동시성 제한 필요 시Semaphore 사용

Virtual Thread를 풀링하지 마세요. 태스크당 하나씩 만들고 버리는 것이 설계 의도입니다. “스레드는 비싸다”는 기존 상식을 버리고, “Virtual Thread는 거의 무료”라는 새로운 모델로 전환하면 됩니다.

이 글이 도움이 되었나요?