Contents
see List개요
Java 21에서 정식 출시된 Virtual Threads(가상 스레드)는 Java 동시성 프로그래밍의 패러다임을 바꾸는 기술입니다. 2026년 3월 출시된 Java 26에서는 Structured Concurrency가 여섯 번째 프리뷰(JEP 525)를 맞이하며 더욱 성숙한 API로 발전했습니다. 이 글에서는 Virtual Threads의 동작 원리부터 Structured Concurrency, Scoped Values까지 실전 코드 예제와 함께 상세히 설명합니다.
Virtual Threads란?
기존 Java의 Platform Thread는 OS 스레드와 1:1로 매핑되어 스레드 수에 한계가 있고, 컨텍스트 스위칭 비용이 컸습니다. Virtual Threads는 JVM이 관리하는 경량 스레드로, 수백만 개를 동시에 생성할 수 있습니다. I/O 대기 시 캐리어 스레드를 반납하고 다른 가상 스레드가 사용할 수 있어 자원 효율이 극적으로 향상됩니다.
플랫폼 스레드 vs 가상 스레드 비교
| 구분 | Platform Thread | Virtual Thread |
|---|---|---|
| 생성 비용 | 높음 (OS 자원 할당) | 매우 낮음 (JVM 관리) |
| 최대 개수 | 수천 개 수준 | 수백만 개 가능 |
| 스택 크기 | 1MB (기본값) | 필요한 만큼 동적 할당 |
| I/O 블로킹 | OS 스레드 점유 | 캐리어 스레드 반납 |
Virtual Threads 기본 사용법
스레드 생성 방법 3가지
// 방법 1: Thread.ofVirtual() 사용
Thread vThread = Thread.ofVirtual()
.name("virtual-thread-1")
.start(() -> {
System.out.println("가상 스레드 실행: " + Thread.currentThread());
});
vThread.join();
// 방법 2: Thread.startVirtualThread()
Thread t = Thread.startVirtualThread(() -> {
System.out.println("가상 스레드: " + Thread.currentThread().isVirtual());
});
t.join();
// 방법 3: ExecutorService (권장)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
final int taskId = i;
executor.submit(() -> {
// I/O 작업 시뮬레이션
Thread.sleep(Duration.ofMillis(100));
System.out.println("Task " + taskId + " 완료");
});
}
} // AutoCloseable로 자동 종료Spring Boot에서의 적용
Spring Boot 3.2 이상에서는 설정 한 줄로 가상 스레드를 활성화할 수 있습니다.
# application.properties
spring.threads.virtual.enabled=true
# 또는 Java Config
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadsProtocolHandlerCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}Java 24의 synchronized 핀닝 문제 해결 (JEP 491)
Java 21~23에서 Virtual Threads의 대표적인 단점이 있었습니다. synchronized 블록 내에서 I/O 블로킹이 발생하면 가상 스레드가 캐리어 스레드에 고정(pinning)되는 문제였습니다. Java 24의 JEP 491이 이를 해결했습니다.
// Java 24 이전: synchronized 블록에서 I/O 시 캐리어 스레드 고정 (성능 저하)
public synchronized void processLegacy() throws InterruptedException {
Thread.sleep(1000); // 캐리어 스레드 점유 문제 발생
}
// Java 24 이후: synchronized 내부 블로킹에서도 캐리어 스레드 반납
// 코드 변경 없이 JVM 레벨에서 자동 해결
public synchronized void processNew() throws InterruptedException {
Thread.sleep(1000); // 이제 캐리어 스레드를 반납하고 다른 가상 스레드 실행 가능
}
// JFR로 핀닝 이벤트 모니터링
// -Djdk.tracePinnedThreads=short 옵션으로 핀닝 감지 가능Structured Concurrency (JEP 525 - Java 26 여섯 번째 프리뷰)
Structured Concurrency는 관련된 여러 작업을 하나의 작업 단위로 묶어 관리하는 API입니다. 에러 처리와 취소를 일관성 있게 처리할 수 있고, 태스크 누수를 방지합니다.
기본 패턴: 모두 성공 또는 실패
import java.util.concurrent.StructuredTaskScope;
public record UserInfo(String name, List<Order> orders, CreditScore score) {}
public UserInfo fetchUserInfo(long userId) throws Exception {
try (var scope = StructuredTaskScope.open()) {
// 세 가지 I/O 작업을 병렬 실행
var userName = scope.fork(() -> fetchUserName(userId));
var orders = scope.fork(() -> fetchOrders(userId));
var credit = scope.fork(() -> fetchCreditScore(userId));
scope.join(); // 모든 작업 완료 대기
// 결과 수집 (예외 발생 시 자동 전파)
return new UserInfo(
userName.get(),
orders.get(),
credit.get()
);
}
}Java 26 신기능: onTimeout() 메서드
// Java 26 JEP 525: Joiner에 onTimeout() 추가
public String fetchWithTimeout(String url) throws Exception {
try (var scope = StructuredTaskScope.open(
Joiner.<String>anySuccessfulOrThrow()
.onTimeout(Duration.ofSeconds(5), () -> "TIMEOUT")
)) {
var primary = scope.fork(() -> httpGet(url));
var fallback = scope.fork(() -> httpGetFallback(url));
scope.join();
return scope.result();
}
}
// allSuccessfulOrThrow()가 이제 Stream 대신 List 반환
try (var scope = StructuredTaskScope.open(
Joiner.<String>allSuccessfulOrThrow())) {
scope.fork(() -> task1());
scope.fork(() -> task2());
scope.join();
List<String> results = scope.result(); // List<String> 반환 (변경됨)
}Scoped Values: ThreadLocal의 현대적 대안
JEP 512에서 최종 확정된 Scoped Values는 불변 데이터를 스레드 간에 안전하게 공유하는 메커니즘입니다. Virtual Threads와 함께 사용할 때 ThreadLocal보다 훨씬 효율적입니다.
// ThreadLocal 방식 (구식, 메모리 누수 위험)
private static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
// Scoped Values 방식 (권장)
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
// 값 바인딩 및 자동 해제
public Response handleRequest(User user, Request request) {
return ScopedValue.where(CURRENT_USER, user)
.call(() -> processRequest(request)); // 이 범위 내에서만 유효
}
// StructuredTaskScope와 함께 사용 시 자식 스레드에 자동 상속
public void processInScope(User user) throws Exception {
ScopedValue.where(CURRENT_USER, user).run(() -> {
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
User u = CURRENT_USER.get(); // 부모의 바인딩 자동 상속
return processForUser(u);
});
scope.join();
}
});
}실전 예제: 병렬 API 호출 서비스
@Service
public class ProductAggregatorService {
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public ProductDetail getProductDetail(String productId, String reqId) throws Exception {
return ScopedValue.where(REQUEST_ID, reqId).call(() -> {
try (var scope = StructuredTaskScope.open(
Joiner.<Object>allSuccessfulOrThrow()
.onTimeout(Duration.ofSeconds(3), () -> null)
)) {
var info = scope.fork(() -> productService.getInfo(productId));
var reviews = scope.fork(() -> reviewService.getReviews(productId));
var stock = scope.fork(() -> inventoryService.getStock(productId));
var price = scope.fork(() -> pricingService.getPrice(productId));
scope.join();
log.info("[{}] 모든 서비스 응답 완료", REQUEST_ID.get());
return new ProductDetail(
info.get(),
reviews.get(),
stock.get(),
price.get()
);
}
});
}
}
// Virtual Thread 기반 ExecutorService 설정
@Configuration
public class ThreadConfig {
@Bean
public ExecutorService virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}성능 최적화 및 주의사항
ThreadLocal 사용 주의
Virtual Threads는 수백만 개가 생성될 수 있으므로 무거운 객체를 ThreadLocal에 저장하면 메모리 문제가 발생할 수 있습니다. Scoped Values로 대체하거나, 사용 후 반드시 remove()를 호출해야 합니다.
CPU 집약적 작업 분리
Virtual Threads는 I/O 바운드 작업에 최적화되어 있습니다. 대용량 이미지 처리나 암호화 같은 CPU 집약적 작업은 여전히 Platform Thread 기반 스레드 풀을 사용해야 합니다.
// I/O 바운드: 가상 스레드 사용
try (var ioExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
ioExecutor.submit(() -> fetchFromDatabase());
ioExecutor.submit(() -> callExternalApi());
}
// CPU 바운드: 플랫폼 스레드 사용
var cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
cpuExecutor.submit(() -> processLargeImage());정리
Java 26의 Virtual Threads와 Structured Concurrency는 Java 동시성 프로그래밍을 근본적으로 변화시키고 있습니다. 가상 스레드로 Thread-per-Request 모델을 높은 처리량으로 구현할 수 있고, Structured Concurrency로 복잡한 병렬 작업을 안전하게 관리할 수 있습니다. Scoped Values로 불변 컨텍스트 데이터를 효율적으로 전파하는 패턴까지 익히면, 리액티브 프로그래밍의 복잡성 없이도 고성능 서버 애플리케이션을 구축할 수 있습니다.