
개요
Java 21에서 정식 기능으로 자리잡은 Virtual Threads(가상 스레드)는 JVM 동시성 프로그래밍의 패러다임을 바꾸고 있습니다. 기존 플랫폼 스레드(Platform Thread)는 OS 스레드와 1:1로 매핑되어 수천 개 이상의 스레드를 생성할 경우 컨텍스트 스위칭 비용과 메모리 부담이 급격히 증가합니다. 반면 Virtual Threads는 JVM이 직접 스케줄링하는 경량 스레드로, 수십만 개를 동시에 운용해도 자원 소모가 극히 적습니다.
이 글에서는 Virtual Threads의 동작 원리를 이해하고, 실제 프로젝트에 적용하는 방법을 단계별로 살펴봅니다. Spring Boot 3.x 환경에서의 설정 방법과, 블로킹 I/O 집약적 애플리케이션에서 처리량을 극적으로 향상시키는 패턴까지 다룹니다.
Virtual Threads의 동작 원리
Virtual Threads는 Project Loom의 핵심 결과물입니다. JVM 내부적으로 소수의 캐리어 스레드(Carrier Thread)—실제 OS 스레드—위에서 수많은 가상 스레드를 다중화(multiplex)하여 실행합니다. 가상 스레드가 I/O 대기, sleep, 또는 블로킹 호출에 진입하면 JVM은 해당 가상 스레드를 캐리어 스레드에서 분리(unmount)하고, 다른 가상 스레드를 즉시 마운트합니다. 이를 협력적 스케줄링(cooperative scheduling)이라 부릅니다.
주목할 핵심 지표는 다음과 같습니다.
- 스택 메모리: 플랫폼 스레드는 기본 약 512KB~1MB, 가상 스레드는 수 KB부터 시작해 필요에 따라 동적으로 확장
- 생성 비용: 플랫폼 스레드 생성은 수십~수백 µs, 가상 스레드는 수 µs 이하
- 최대 동시 실행 수: 플랫폼 스레드는 수천 개, 가상 스레드는 수백만 개 수준도 가능
단, CPU 연산 집약적(compute-bound) 작업에는 이점이 거의 없습니다. Virtual Threads의 진가는 I/O 대기 시간이 긴 블로킹 코드에서 발휘됩니다.
플랫폼 스레드 vs Virtual Threads 비교
아래 예제는 HTTP 요청을 시뮬레이션하는 1만 개의 작업을 각 방식으로 실행하는 코드입니다. 동일한 로직이지만 스레드 생성 방식의 차이만으로 처리 시간이 크게 달라집니다.
변경전 — 플랫폼 스레드 기반 고정 스레드 풀
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class PlatformThreadDemo {
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
// 고정 크기 스레드 풀: 200개 이상 동시 요청 시 큐잉 발생
try (ExecutorService executor = Executors.newFixedThreadPool(200)) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
try {
// 외부 API 호출 시뮬레이션 (블로킹 I/O)
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
System.out.println("소요 시간: " + (System.currentTimeMillis() - start) + "ms");
// 예상 출력: 소요 시간: ~5000ms (200개씩 순차 처리)
}
}변경후 — Virtual Threads 기반 무제한 동시 실행
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualThreadDemo {
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
// newVirtualThreadPerTaskExecutor: 태스크마다 가상 스레드 생성
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
try {
// 동일한 블로킹 I/O 시뮬레이션
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
}
System.out.println("소요 시간: " + (System.currentTimeMillis() - start) + "ms");
// 예상 출력: 소요 시간: ~110ms (1만 개 가상 스레드 동시 실행)
}
}핵심 포인트: 코드 변경은
Executors팩토리 메서드 한 줄뿐이지만, 처리 시간이 약 45배 단축됩니다. 기존 블로킹 스타일 코드를 그대로 유지하면서 처리량을 획기적으로 개선할 수 있습니다.
Spring Boot 3.x에서 Virtual Threads 활성화하기
Spring Boot 3.2 이상에서는 설정 한 줄로 내장 톰캣(Tomcat)의 요청 처리 스레드를 모두 Virtual Threads로 전환할 수 있습니다.
먼저 application.yml에 다음 설정을 추가합니다.
spring:
threads:
virtual:
enabled: true # Tomcat 요청 스레드를 Virtual Threads로 전환별도로 @Bean을 통해 특정 스레드 풀만 선택적으로 Virtual Threads로 교체할 수도 있습니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executors;
@Configuration
public class ThreadConfig {
/**
* @Async 작업에 Virtual Threads 적용
* Spring 6.1+ 에서는 VirtualThreadTaskExecutor 사용 가능
*/
@Bean(name = "virtualThreadExecutor")
public java.util.concurrent.Executor virtualThreadExecutor() {
// Thread.ofVirtual().name("vt-async-", 0).factory()로 이름 지정 가능
return Executors.newVirtualThreadPerTaskExecutor();
}
}import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class ReportService {
// 지정한 Virtual Thread 풀에서 비동기 실행
@Async("virtualThreadExecutor")
public void generateReport(Long reportId) {
// 외부 스토리지 I/O, DB 쿼리 등 블로킹 작업
System.out.println("리포트 생성 중: " + reportId
+ " | 스레드: " + Thread.currentThread());
// 출력 예: 리포트 생성 중: 42 | 스레드: VirtualThread[#35]/runnable@...
}
}핵심 포인트:
spring.threads.virtual.enabled=true설정만으로도 웹 요청 처리 전체가 Virtual Threads 기반으로 전환됩니다. 단,synchronized블록 내에서의 블로킹 호출은 피닝(pinning) 현상—가상 스레드가 캐리어 스레드에 고정되는 문제—을 유발할 수 있으므로, 해당 코드는ReentrantLock으로 교체하는 것이 권장됩니다.
주의사항과 모범 사례
Virtual Threads를 현업에서 도입할 때 반드시 숙지해야 할 패턴이 있습니다.
ThreadLocal 사용 주의: 가상 스레드 수가 수십만 개에 달하면 ThreadLocal에 저장되는 객체 수도 그만큼 증가해 메모리 압박이 생길 수 있습니다. Java 20에서 도입된 Scoped Values(ScopedValue)를 대안으로 검토합니다.
스레드 풀 불필요: Executors.newVirtualThreadPerTaskExecutor()는 태스크마다 새 가상 스레드를 생성해도 비용이 낮아 풀링이 불필요합니다. 오히려 가상 스레드를 풀에 넣으면 설계 의도에 어긋납니다.
피닝 현상 진단: JVM 옵션 -Djdk.tracePinnedThreads=full을 추가하면 피닝이 발생한 스택 트레이스를 콘솔에 출력합니다. 이로써 원인을 빠르게 식별할 수 있습니다.
데이터베이스 연결 풀: HikariCP 등의 연결 풀 크기는 여전히 DB 서버의 최대 연결 수에 종속됩니다. 가상 스레드 수가 아무리 많아도 DB 연결이 부족하면 대기가 발생하므로, 연결 풀 크기는 워크로드에 맞게 별도 튜닝해야 합니다.
맺음말
Java Virtual Threads는 리액티브 프로그래밍(Reactive Programming)의 복잡한 비동기 코드 없이도, 기존의 동기 블로킹 스타일을 유지하면서 높은 동시 처리량을 달성할 수 있게 해줍니다. 특히 외부 API 호출, 파일 I/O, 데이터베이스 쿼리가 빈번한 서버 애플리케이션에서 효과가 두드러집니다.
Spring Boot 3.2 이상 환경이라면 설정 변경 한 줄로 즉시 전환이 가능하며, 하위 호환성도 높아 기존 코드를 대규모로 수정할 필요가 없습니다. 다음 단계로는 Project Loom 공식 문서를 통해 Structured Concurrency와 Scoped Values를 함께 살펴보길 권합니다. 두 기능은 Virtual Threads와 결합할 때 코드의 안전성과 가독성을 한층 높여줍니다.
관련 주제로는 CompletableFuture 패턴 개선, Spring WebFlux와의 비교, 그리고 GraalVM Native Image에서의 Virtual Threads 지원 현황도 함께 참고하면 도움이 됩니다.
'프로그래밍 PROGRAMMING > 자바 JAVA AND FRAMEWORKS' 카테고리의 다른 글
| Spring Boot 테스트 전략: @SpringBootTest vs 슬라이스 테스트 선택 (0) | 2026.03.09 |
|---|---|
| Java HashMap 성능 최적화 초기 용량과 로드 팩터를 제대로 설정하는 법 (0) | 2026.03.07 |
| [JAVA/SPRING] Java Virtual Threads로 고성능 서버 구축하기 스레드 전환 비용 줄이는 방법 (0) | 2026.03.06 |
| Spring AI로 Java 애플리케이션에 LLM 연동하기 (0) | 2026.03.05 |
| [JAVA/SPRING] Java 캐시 전략 총정리: Caffeine부터 Redis 이중 캐시까지 (0) | 2026.03.04 |