스레드 풀 크기가 200이면 동시 요청도 200개가 한계다. 스레드를 수십만 개 만들 수 있다면 이 제약 자체가 사라지지 않을까?

Java 21에서 정식 도입된 Virtual Thread(JEP 444)는 이 전제를 현실로 만들었어요. OS 스레드와 1:1로 매핑되던 기존 방식 대신, JVM이 직접 스케줄링하는 경량 스레드를 제공합니다.

Virtual Thread란?

OS 스레드가 아닌 JVM이 관리하는 경량 스레드입니다. 블로킹 I/O를 만나면 캐리어 스레드에서 분리(unmount)되어 다른 가상 스레드에게 양보하므로, 블로킹 코드를 그대로 쓰면서도 높은 동시성을 달성할 수 있어요.

기존 플랫폼 스레드의 한계

Virtual Thread를 이해하려면 먼저 기존 방식이 왜 문제였는지를 알아야 해요.

OS 스레드와 1:1 매핑

기존 Java의 Thread는 OS 스레드와 1:1로 매핑됩니다. new Thread().start()를 호출하면 OS 커널이 실제 스레드를 하나 만들어요.

PLAINTEXT
Java Thread  ←→  OS Thread  ←→  CPU 코어
(1:1 매핑)

이게 뭐가 문제일까요?

메모리 비용

플랫폼 스레드 하나당 기본 스택 크기가 약 1MB 예요. 스레드 1,000개면 1GB, 10,000개면 10GB. 서버에서 동시 요청이 수만 건이 들어오면 스레드만으로 메모리가 바닥납니다.

컨텍스트 스위칭

OS가 스레드를 전환할 때마다 레지스터 저장·복원, 캐시 무효화 등의 비용이 발생해요. 스레드 수가 수천 개를 넘어가면 실제 작업보다 스위칭에 CPU 시간을 더 쓰게 되는 상황이 벌어집니다.

그래서 스레드 풀을 썼어요

이런 한계 때문에 ThreadPoolExecutor로 스레드 수를 제한하고 재사용했어요. 하지만 이건 근본적인 해결이 아니라, 동시에 처리할 수 있는 요청 수를 스레드 풀 크기로 제한하는 것 입니다.

JAVA
// 기존 방식: 스레드 풀 크기 = 동시 처리 한계
ExecutorService executor = Executors.newFixedThreadPool(200);
// → 201번째 요청은 앞의 요청이 끝날 때까지 대기

이 구조는 결국 "스레드가 비싸니까 아껴 쓰자"라는 우회 전략이에요. Virtual Thread는 이 전제 자체를 뒤집습니다.

Virtual Thread의 구조

캐리어 스레드와 Continuation

Virtual Thread의 핵심 구조를 그림으로 보면 이렇습니다.

PLAINTEXT
Virtual Thread 1  ──┐
Virtual Thread 2  ──┤    mount/unmount
Virtual Thread 3  ──┼──→  Carrier Thread (ForkJoinPool)  ──→  OS Thread
Virtual Thread 4  ──┤         (기본: CPU 코어 수)
   ...            ──┘
  • 캐리어 스레드(Carrier Thread): 실제 OS 스레드에 매핑된 플랫폼 스레드. ForkJoinPool로 관리되며, 기본적으로 CPU 코어 수만큼 존재해요.
  • Continuation: 가상 스레드의 실행 상태(스택 프레임, 지역 변수 등)를 힙에 저장하는 객체. 블로킹 시점에 현재 상태를 저장하고, 나중에 복원해서 이어 실행합니다.

블로킹 시 동작 흐름

가상 스레드가 I/O 블로킹을 만났을 때:

  1. 가상 스레드가 Socket.read() 같은 블로킹 호출을 합니다
  2. JVM이 이를 감지하고 가상 스레드를 캐리어 스레드에서 unmount 합니다
  3. 가상 스레드의 스택은 힙 메모리에 Continuation으로 저장됩니다
  4. 캐리어 스레드는 즉시 ** 다른 가상 스레드를 mount**하여 실행합니다
  5. I/O가 완료되면 가상 스레드가 다시 어떤 캐리어 스레드에 mount되어 이어서 실행됩니다

"블로킹 코드를 써도 괜찮은 건가?"라는 의문이 여기서 풀려요. 가상 스레드가 블로킹되면 캐리어 스레드는 즉시 다른 가상 스레드를 실행하므로, OS 스레드가 낭비되지 않습니다.

메모리 비용 비교

항목플랫폼 스레드Virtual Thread
스택 크기~1MB (고정)수백 바이트~수 KB (동적)
10만 개 생성~100GB (사실상 불가능)~수백 MB
생성 비용OS 시스템 콜 필요일반 객체 생성 수준
스케줄링OS 커널JVM (ForkJoinPool)

Virtual Thread 생성 방법

Thread.ofVirtual()

가장 기본적인 생성 방법입니다.

JAVA
// 가상 스레드 생성 및 시작
Thread vt = Thread.ofVirtual()
    .name("my-virtual-thread")
    .start(() -> {
        System.out.println("현재 스레드: " + Thread.currentThread());
        // 블로킹 I/O도 OK — JVM이 알아서 unmount 해준다
    });

vt.join(); // 완료 대기

기존의 Thread.ofPlatform()과 대칭적으로 설계되어 있어서 전환이 쉬워요.

Executors.newVirtualThreadPerTaskExecutor()

실무에서 가장 많이 쓰게 될 방식이에요. ** 작업마다 새 가상 스레드를 하나씩 만듭니다.**

JAVA
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 10,000개의 작업을 동시에 실행 — 각각 가상 스레드 하나씩
    List<Future<String>> futures = new ArrayList<>();

    for (int i = 0; i < 10_000; i++) {
        final int taskId = i;
        futures.add(executor.submit(() -> {
            // 외부 API 호출 시뮬레이션
            Thread.sleep(Duration.ofSeconds(1));
            return "작업 " + taskId + " 완료";
        }));
    }

    // 결과 수집
    for (Future<String> future : futures) {
        String result = future.get();
        // 결과 처리
    }
}

이 코드에서 주목할 점은 Thread.sleep()이 블로킹 호출인데도 괜찮다는 거예요. 가상 스레드에서는 sleep 시 캐리어 스레드를 반납하므로, 10,000개가 동시에 sleep해도 캐리어 스레드 몇 개만으로 충분합니다.

Thread.startVirtualThread()

단발성 작업에 쓰기 좋은 간편 메서드예요.

JAVA
Thread.startVirtualThread(() -> {
    // 간단한 비동기 작업
    System.out.println("가상 스레드에서 실행 중");
});

가상 스레드 여부 확인

JAVA
Thread current = Thread.currentThread();

if (current.isVirtual()) {
    System.out.println("가상 스레드입니다");
} else {
    System.out.println("플랫폼 스레드입니다");
}

Pinning 문제 — 가장 중요한 주의사항

Virtual Thread를 쓸 때 반드시 알아야 하는 함정이 하나 있어요. 바로 pinning(고정) 입니다.

synchronized 블록에서의 문제

가상 스레드가 synchronized 블록 안에서 블로킹 I/O를 수행하면, ** 캐리어 스레드에서 unmount되지 못하고 고정 **됩니다.

JAVA
// ❌ 나쁜 예: synchronized + 블로킹 I/O → pinning 발생
public synchronized String fetchData() {
    // 이 블로킹 호출 동안 캐리어 스레드가 고정된다
    return httpClient.send(request, BodyHandlers.ofString()).body();
}

왜 이런 일이 벌어질까요? synchronized는 OS 수준의 모니터 락을 사용하는데, 이 락을 잡은 상태에서는 JVM이 가상 스레드의 스택을 힙으로 옮길 수 없어요. 결국 캐리어 스레드가 그 가상 스레드에 묶여버립니다.

ReentrantLock으로 해결

해결 방법은 synchronizedReentrantLock으로 바꾸는 거예요.

JAVA
// ✅ 좋은 예: ReentrantLock 사용 → pinning 없음
private final ReentrantLock lock = new ReentrantLock();

public String fetchData() {
    lock.lock();
    try {
        // 블로킹 I/O — 캐리어 스레드에서 정상적으로 unmount된다
        return httpClient.send(request, BodyHandlers.ofString()).body();
    } finally {
        lock.unlock();
    }
}

ReentrantLockjava.util.concurrent 패키지의 락이라 JVM이 가상 스레드의 unmount를 처리할 수 있습니다.

Pinning 감지하기

JVM 옵션으로 pinning이 발생하는 지점을 찾을 수 있어요.

BASH
# pinning 발생 시 스택 트레이스 출력
java -Djdk.tracePinnedThreads=full MyApp

# 짧은 요약만 출력
java -Djdk.tracePinnedThreads=short MyApp

Virtual Thread를 도입하면서 가장 먼저 점검해야 할 것이 바로 pinning입니다. 코드베이스와 라이브러리에서 synchronized + 블로킹 I/O 패턴을 찾아 ReentrantLock으로 전환해야 해요.

기존 스레드 풀과의 차이 — 풀링하면 안 돼요

기존에 ThreadPoolExecutor를 쓰던 습관 때문에 가상 스레드도 풀링하려는 실수를 할 수 있어요.

풀링이 필요 없는 이유

JAVA
// ❌ 하면 안 되는 것: 가상 스레드를 풀링
ExecutorService pool = Executors.newFixedThreadPool(100,
    Thread.ofVirtual().factory());
// → 풀 크기 100이 동시성을 제한해 버린다

// ✅ 올바른 방법: 작업마다 새로 생성
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// → 작업 수만큼 가상 스레드가 생기고, 끝나면 버린다

플랫폼 스레드를 풀링하는 이유는 ** 생성 비용이 비싸기 때문 **이에요. 가상 스레드는 생성 비용이 거의 없으므로 풀링할 이유가 없습니다. 오히려 풀 크기가 병목이 돼요.

동시성 제한이 필요하면?

가상 스레드에서 동시 실행 수를 제한하고 싶다면 Semaphore를 씁니다.

JAVA
// 동시 DB 커넥션을 50개로 제한
Semaphore semaphore = new Semaphore(50);

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> {
            semaphore.acquire();
            try {
                // DB 작업 — 동시에 최대 50개만 실행
                return queryDatabase();
            } finally {
                semaphore.release();
            }
        });
    }
}

이 패턴이 중요한 이유가 있어요. 가상 스레드를 수만 개 만들 수 있다고 해서 DB 커넥션 풀도 수만 개를 열 수 있는 건 아닙니다. 하류 리소스에 대한 동시성 제한은 여전히 필요하고, 이때 Semaphore가 딱이에요.

Structured Concurrency — 구조화된 동시성

Java 21에서 프리뷰로 도입된 Structured Concurrency는 가상 스레드와 함께 쓰면 강력해요.

JAVA
// 구조화된 동시성 (프리뷰 기능)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    // 두 작업을 동시에 실행
    Subtask<String> userTask = scope.fork(() -> fetchUser(userId));
    Subtask<String> orderTask = scope.fork(() -> fetchOrders(userId));

    scope.join();           // 모든 작업 완료 대기
    scope.throwIfFailed();  // 실패 시 예외 전파

    // 두 결과를 합쳐서 응답 생성
    return new UserProfile(userTask.get(), orderTask.get());
}
// scope를 벗어나면 미완료 작업은 자동 취소된다

부모 스코프가 닫히면 자식 스레드도 자동으로 정리돼요. CompletableFutureallOf()보다 코드가 직관적이고 에러 처리도 깔끔합니다.

ShutdownOnFailure vs ShutdownOnSuccess

StructuredTaskScope에는 두 가지 주요 정책이 있어요.

ShutdownOnFailure — 하나라도 실패하면 나머지를 즉시 취소합니다. "모든 결과가 필요한" 경우에 쓰입니다.

JAVA
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<UserProfile> profile = scope.fork(() -> fetchProfile(userId));
    Subtask<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
    Subtask<PaymentInfo> payment = scope.fork(() -> fetchPayment(userId));

    scope.join().throwIfFailed();

    // 셋 다 성공해야 여기 도달
    return new Dashboard(profile.get(), orders.get(), payment.get());
}

ShutdownOnSuccess — 하나라도 성공하면 나머지를 즉시 취소합니다. "가장 빠른 응답 하나만 필요한" 경우에 쓰입니다.

JAVA
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    // 여러 미러 서버에 동시에 요청 — 가장 빠른 응답을 채택
    scope.fork(() -> fetchFrom("mirror-kr.example.com"));
    scope.fork(() -> fetchFrom("mirror-us.example.com"));
    scope.fork(() -> fetchFrom("mirror-eu.example.com"));

    scope.join();
    return scope.result();  // 가장 먼저 성공한 결과 반환
}

ScopedValue와의 조합

Structured Concurrency는 ScopedValue와 함께 쓸 때 진가가 발휘돼요. 부모의 ScopedValue를 자식 태스크가 복사 없이 공유합니다.

JAVA
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

public Response handle(String requestId) {
    return ScopedValue.where(REQUEST_ID, requestId).call(() -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // 자식 태스크에서 REQUEST_ID 접근 가능 — 복사 비용 없음
            Subtask<User> user = scope.fork(() -> {
                log.info("요청 {}: 사용자 조회", REQUEST_ID.get());
                return fetchUser();
            });
            Subtask<List<Order>> orders = scope.fork(() -> {
                log.info("요청 {}: 주문 조회", REQUEST_ID.get());
                return fetchOrders();
            });

            scope.join().throwIfFailed();
            return new Response(user.get(), orders.get());
        }
    });
}

InheritableThreadLocal과 달리 값을 복사하지 않으므로, Virtual Thread 수만 개가 같은 ScopedValue를 읽어도 메모리 사용량이 늘지 않아요. 더 자세한 내용은 Scoped Values 글을 참고하세요.

Spring Boot에서 Virtual Thread 적용

Spring Boot 3.2부터 한 줄 설정으로 Virtual Thread를 활성화할 수 있어요.

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

이 설정만 켜면 Tomcat의 요청 처리 스레드가 가상 스레드로 바뀝니다. 기존 컨트롤러 코드를 수정할 필요가 없어요.

JAVA
@RestController
public class UserController {

    @GetMapping("/users/{id}")
    public User getUser(@PathVariable Long id) {
        // 이 블로킹 호출이 가상 스레드에서 실행된다
        // 캐리어 스레드를 점유하지 않으므로 동시 요청 처리량이 크게 늘어난다
        return userRepository.findById(id).orElseThrow();
    }
}

다만, 앞서 말한 pinning 문제 때문에 라이브러리 내부에 synchronized + 블로킹 I/O 패턴이 있으면 효과가 떨어질 수 있어요. HikariCP, JDBC 드라이버 등이 이 문제를 해결한 버전인지 확인해야 합니다.

WebFlux가 필요 없어지는 건 아닙니다

Virtual Thread가 나왔으니 WebFlux는 이제 필요 없는 걸까요? 둘은 해결하는 문제가 다릅니다.

Virtual Thread의 영역

  • ** 블로킹 I/O를 효율적으로** 만들어 줍니다
  • 기존의 익숙한 동기 코드 스타일을 유지하면서 높은 동시성을 달성해요
  • thread-per-request 모델의 확장성 문제를 해결합니다

WebFlux의 영역

  • ** 배압(Backpressure)** 제어: 생산자가 소비자보다 빠를 때 데이터 흐름을 조절해요
  • ** 스트림 합성 **: 여러 비동기 데이터 소스를 선언적으로 합치고 변환합니다
  • ** 이벤트 기반 아키텍처 **: 데이터가 도착할 때마다 반응하는 모델이 필요한 경우에요
PLAINTEXT
Virtual Thread         → "블로킹 코드를 효율적으로"
WebFlux (Reactive)     → "논블로킹 스트림 + 배압 제어"

Virtual Thread는 동기 코드의 확장성을 높이고, WebFlux는 비동기 스트림 처리에 특화되어 있어요. 둘 다 필요한 상황이 있습니다.

어떤 걸 선택해야 할까요?

상황추천
REST API, DB CRUD 중심Virtual Thread (동기 코드 유지)
실시간 스트리밍, SSEWebFlux
배압 제어가 필요한 데이터 파이프라인WebFlux
기존 Spring MVC 앱 성능 개선Virtual Thread
팀이 리액티브에 익숙하지 않은 경우Virtual Thread

주의할 점

synchronized + 블로킹 I/O = pinning

앞서 다뤘지만 가장 흔하게 겪는 문제예요. 특히 라이브러리 내부에 synchronized가 숨어있는 경우가 많습니다. HikariCP, JDBC 드라이버 등이 pinning 문제를 해결한 버전인지 확인해야 해요. -Djdk.tracePinnedThreads=short로 발생 지점을 찾을 수 있습니다.

ThreadLocal에 무거운 객체를 넣으면 메모리 폭발

플랫폼 스레드는 풀에 200개 정도였으니 ThreadLocal도 200개였지만, 가상 스레드는 수십만 개가 될 수 있어요. ThreadLocal에 캐시나 커넥션 같은 무거운 객체를 넣으면 메모리가 폭발합니다. 가능하면 ScopedValue(프리뷰)를 사용하세요.

CPU 바운드 작업에는 효과 없음

Virtual Thread의 이점은 I/O 대기 시간에 캐리어 스레드를 양보 하는 데서 옵니다. CPU를 계속 쓰는 수학 계산 같은 작업에는 양보할 타이밍이 없으므로 플랫폼 스레드와 차이가 없어요.

자주 나오는 질문

Q: Virtual Thread와 코루틴(Kotlin Coroutine)의 차이는?

  • Virtual Thread는 JVM 수준에서 구현되어 기존 Java 코드와 호환됩니다. 코루틴은 언어 수준(컴파일러)에서 CPS 변환을 해요.
  • Virtual Thread는 블로킹 API를 그대로 쓸 수 있지만, 코루틴은 suspend 함수를 별도로 작성해야 합니다.

Q: Virtual Thread를 쓰면 스레드 풀이 아예 필요 없나?

  • 가상 스레드 자체는 풀링할 필요가 없어요. 하지만 DB 커넥션 풀, HTTP 커넥션 풀처럼 ** 하류 리소스의 풀링 **은 여전히 필요합니다.

Q: 모든 블로킹 호출에서 unmount가 되나?

  • java.io, java.net, Thread.sleep() 등 JDK 내장 블로킹 API는 대부분 가상 스레드를 인식하도록 리팩터링되었어요. 하지만 JNI를 통한 네이티브 블로킹이나 synchronized 안의 블로킹은 unmount되지 않습니다.

정리

항목핵심
매핑JVM이 스케줄링하는 경량 스레드, OS 스레드와 M:N 매핑
블로킹 시 동작캐리어 스레드에서 unmount → 다른 가상 스레드에게 양보
pinningsynchronized 안에서 블로킹하면 발생 → ReentrantLock으로 전환
풀링불필요. 작업마다 새로 만든다. 동시성 제한은 Semaphore
적합한 작업I/O 바운드. CPU 바운드에는 효과 없음
WebFlux와의 관계대체가 아닌 다른 접근. 배압/스트림이 필요하면 WebFlux
댓글 로딩 중...