프로그래밍 PROGRAMMING/자바 JAVA AND FRAMEWORKS

[JAVA/SPRING] Java Virtual Threads로 고성능 서버 구축하기 스레드 전환 비용 줄이는 방법

매운할라피뇨 2026. 3. 6. 08:30
반응형
Java Virtual Threads로 고성능 서버 구축


개요

Java 21에서 정식 기능(GA)으로 채택된 Java Virtual Threads는 Project Loom이 수년간 개발해 온 경량 스레드 모델입니다. 전통적인 Java 서버 애플리케이션은 요청마다 OS 스레드를 생성하거나 스레드 풀(Thread Pool)을 사용해 동시성을 처리했지만, 수천~수만 건의 I/O 바운드 요청이 집중되는 운영 환경에서는 컨텍스트 스위칭 비용과 메모리 한계가 병목으로 작용합니다.
Virtual Threads를 활용하면 JVM이 소수의 OS 스레드 위에서 수백만 개의 경량 스레드를 스케줄링하므로, 블로킹 I/O가 많은 서비스에서 처리량(throughput)을 크게 높일 수 있습니다. 이 글에서는 Virtual Threads의 동작 원리부터 실제 프로젝트에 적용하는 방법, 성능 측정 시 주의할 점까지 단계별로 살펴봅니다.


Virtual Threads의 동작 원리

Virtual Threadsjava.lang.Thread를 상속하지만, OS 스레드에 1:1로 매핑되지 않습니다. JVM은 내부적으로 캐리어 스레드(Carrier Thread)라 불리는 소수의 OS 스레드 풀을 유지하고, Virtual Thread가 블로킹 작업(예: Socket, InputStream, JDBC 호출)을 만나면 캐리어 스레드를 즉시 반환(unmount)합니다. 블로킹이 해제되면 스케줄러가 다시 캐리어 스레드에 마운트(mount)해 실행을 이어갑니다.

이 방식 덕분에 스레드가 I/O를 기다리는 동안에도 OS 스레드가 낭비되지 않습니다. 반면 기존 플랫폼 스레드는 블로킹 I/O 중에도 OS 스레드를 점유하기 때문에, 동시 접속자 수가 늘어날수록 메모리와 컨텍스트 스위칭 비용이 선형적으로 증가합니다.

핵심 차이: Platform Thread = OS 스레드 1개 점유 / Virtual Thread = 블로킹 시 OS 스레드 즉시 반환


Java Virtual Threads 적용 방법

기본 생성 방법

JDK 21 이상이라면 별도 의존성 없이 바로 사용할 수 있습니다. 아래 예제는 Virtual Thread를 생성하는 대표적인 두 가지 방법을 보여줍니다.

// 방법 1: Thread.ofVirtual() 팩토리 사용
Thread vThread = Thread.ofVirtual()
        .name("virtual-thread-1")
        .start(() -> System.out.println("Hello from " + Thread.currentThread()));

// 방법 2: Executors.newVirtualThreadPerTaskExecutor() 사용 (권장)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        final int taskId = i;
        executor.submit(() -> {
            // 블로킹 I/O 시뮬레이션
            Thread.sleep(Duration.ofMillis(100));
            System.out.printf("Task %d done on %s%n",
                    taskId, Thread.currentThread());
            return taskId;
        });
    }
} // try-with-resources로 shutdown 자동 처리

실행 결과 (일부)

Task 0 done on VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
Task 1 done on VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
Task 2 done on VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1
...

takeaway: Executors.newVirtualThreadPerTaskExecutor()를 사용하면 태스크마다 Virtual Thread가 생성되므로, 기존 스레드 풀 크기 튜닝 없이도 높은 동시성을 달성할 수 있습니다.


Spring Boot 3.2+에서 Virtual Threads 활성화

Spring Boot 3.2 이상에서는 application.properties 한 줄 설정으로 Virtual Threads를 활성화할 수 있습니다. Tomcat의 기본 스레드 풀이 Virtual Threads로 교체됩니다.

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

Java Config 방식으로 직접 설정하려면 아래와 같이 TomcatProtocolHandlerCustomizer를 빈으로 등록합니다.

@Configuration
public class VirtualThreadConfig {

    @Bean
    public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
        // Tomcat 요청 처리 스레드를 Virtual Thread로 교체
        return protocolHandler ->
            protocolHandler.setExecutor(
                Executors.newVirtualThreadPerTaskExecutor()
            );
    }
}

이 설정만으로 I/O 바운드 API 서버에서 처리량이 2~4배 향상되는 사례를 현업 프로젝트에서 확인할 수 있습니다. 단, CPU 바운드 작업이 주를 이루는 서비스라면 개선 효과가 제한적입니다.


성능 비교와 주의사항

벤치마크: Platform Thread vs Virtual Thread

10,000개의 I/O 바운드 태스크(각 100ms 슬립)를 처리하는 시간을 직접 비교해 봅니다. 변경전은 고정 크기 스레드 풀, 변경후는 Virtual Thread 방식입니다.

public class ThreadBenchmark {

    public static void main(String[] args) throws Exception {
        int taskCount = 10_000;

        // 변경전: 고정 크기 스레드 풀 (200개)
        long start = System.currentTimeMillis();
        try (var executor = Executors.newFixedThreadPool(200)) {
            runTasks(executor, taskCount);
        }
        System.out.printf("[Platform] %d tasks → %dms%n",
                taskCount, System.currentTimeMillis() - start);

        // 변경후: Virtual Thread
        start = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            runTasks(executor, taskCount);
        }
        System.out.printf("[Virtual ] %d tasks → %dms%n",
                taskCount, System.currentTimeMillis() - start);
    }

    static void runTasks(ExecutorService executor, int count)
            throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(count);
        for (int i = 0; i < count; i++) {
            executor.submit(() -> {
                try {
                    Thread.sleep(Duration.ofMillis(100)); // I/O 대기 시뮬레이션
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
    }
}

실행 결과

[Platform] 10000 tasks → 5124ms  // 스레드 200개 → 큐잉 발생
[Virtual ] 10000 tasks →  108ms  // Virtual Thread → 즉시 처리

takeaway: I/O 대기가 많은 태스크일수록 Virtual Thread의 처리량 이점이 두드러집니다. 운영 환경에서 스레드 풀 크기 설정에 드는 복잡도도 함께 줄어듭니다.


주의사항: Pinning과 synchronized 블록

Virtual Thread가 synchronized 블록 또는 synchronized 메서드 안에서 블로킹 작업을 수행하면, 캐리어 스레드에 고정(pinned)되어 Virtual Thread의 이점을 잃습니다. 특히 일부 JDBC 드라이버나 레거시 라이브러리에서 빈번하게 발생할 수 있습니다.

// ❌ 주의: synchronized + 블로킹 I/O → 캐리어 스레드 pinning 발생
synchronized (lock) {
    result = jdbcTemplate.query(SQL, rowMapper); // 블로킹 I/O
}

// ✅ 권장: ReentrantLock 사용으로 pinning 회피
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    result = jdbcTemplate.query(SQL, rowMapper);
} finally {
    lock.unlock();
}

Pinning 발생 여부는 JVM 플래그 -Djdk.tracePinnedThreads=full로 확인할 수 있습니다. 실제 프로젝트에 Virtual Threads를 도입할 때 가장 먼저 점검해야 할 항목입니다.


맺음말

Java Virtual Threads는 I/O 바운드 워크로드가 많은 웹 서버, API 게이트웨이, 배치 처리 시스템에서 성능 최적화 효과가 큽니다. 기존 코드 구조를 거의 변경하지 않고도 적용할 수 있어 도입 장벽이 낮은 것도 장점입니다.
단, 아래 상황에서는 도입 전 충분한 검토가 필요합니다.

  • CPU 바운드 연산 중심의 서비스 — 개선 효과가 미미하거나 오히려 오버헤드가 생길 수 있습니다
  • synchronized + 블로킹 I/O가 혼재하는 레거시 코드 — pinning 위험이 높습니다
  • JDK 21 미만 환경 — 공식 지원이 없어 Preview 기능을 사용해야 합니다

Java 21 GA 이후 Virtual Threads는 새로운 동시성의 기본 모델로 자리잡고 있습니다. 더 나아가 Structured Concurrency와 함께 사용하면 계층적 태스크 관리와 오류 전파를 더욱 체계적으로 다룰 수 있습니다. 관련하여 Java 21 주요 신기능 정리, Spring Boot 3 성능 최적화 가이드, Project Loom 심화 분석 글도 함께 참고하길 권장합니다.

참고 자료

반응형