Contents
see List왜 Virtual Threads인가
Java의 전통적인 스레드 모델은 OS 스레드와 1:1로 대응되는 플랫폼 스레드(Platform Thread)를 기반으로 했습니다. 플랫폼 스레드는 하나당 약 1~2MB의 스택 메모리를 소비하고, 생성 및 컨텍스트 스위칭 비용이 높아 수만 개 이상의 동시 요청을 처리하기 어렵습니다. Java 21 LTS에서 정식 출시된 Virtual Threads(가상 스레드)는 이 근본적인 한계를 해결합니다.
Virtual Threads의 동작 원리
가상 스레드는 JVM이 직접 관리하는 경량 스레드입니다. 수백만 개를 동시에 생성해도 메모리가 고갈되지 않으며, 소수의 OS 스레드(캐리어 스레드) 위에서 다수의 가상 스레드가 M:N 방식으로 스케줄링됩니다.
// 플랫폼 스레드 생성 (기존 방식)
Thread platformThread = new Thread(() -> {
System.out.println("Platform thread: " + Thread.currentThread());
});
platformThread.start();
// 가상 스레드 생성 (Java 21+)
Thread virtualThread = Thread.ofVirtual().start(() -> {
System.out.println("Virtual thread: " + Thread.currentThread());
});
// ExecutorService로 가상 스레드 풀 사용
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// I/O 작업 — 블로킹 시 캐리어 스레드를 다른 가상 스레드에 양보
Thread.sleep(Duration.ofMillis(100));
return "done";
});
}
} // try-with-resources로 자동 종료
플랫폼 스레드 vs 가상 스레드 성능 비교
// 성능 비교 실험 코드
import java.util.concurrent.*;
import java.time.*;
public class ThreadBenchmark {
static void simulateIO() throws InterruptedException {
Thread.sleep(10); // I/O 시뮬레이션
}
public static void main(String[] args) throws Exception {
int taskCount = 10_000;
// 플랫폼 스레드 풀 (최대 200개 제한)
long start = System.currentTimeMillis();
try (var executor = Executors.newFixedThreadPool(200)) {
var futures = new ArrayList>();
for (int i = 0; i < taskCount; i++) {
futures.add(executor.submit(() -> { simulateIO(); return null; }));
}
for (var f : futures) f.get();
}
System.out.printf("Platform threads: %dms%n", System.currentTimeMillis() - start);
// 결과: 약 500ms (200개 스레드 * 10ms * 10,000/200 순서)
// 가상 스레드 (제한 없음)
start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = new ArrayList>();
for (int i = 0; i < taskCount; i++) {
futures.add(executor.submit(() -> { simulateIO(); return null; }));
}
for (var f : futures) f.get();
}
System.out.printf("Virtual threads: %dms%n", System.currentTimeMillis() - start);
// 결과: 약 15ms (동시 실행)
}
}
Spring Boot에서의 가상 스레드 활성화
Spring Boot 3.2 이상에서는 단 한 줄의 설정으로 Tomcat의 요청 처리 스레드를 모두 가상 스레드로 전환할 수 있습니다.
# application.properties
spring.threads.virtual.enabled=true
# application.yml
spring:
threads:
virtual:
enabled: true
// 또는 Java Config로 명시적 설정
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
}
Java 25의 추가 개선사항
Structured Concurrency (구조화된 동시성)
가상 스레드를 기반으로 하는 Structured Concurrency는 여러 서브태스크를 병렬로 실행하면서 생명주기와 오류 처리를 일관되게 관리하는 현대적 접근법입니다.
import java.util.concurrent.StructuredTaskScope;
public class OrderService {
record OrderDetails(User user, Product product, Inventory inventory) {}
public OrderDetails fetchOrderDetails(long orderId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 세 API 호출을 병렬 실행
var userFuture = scope.fork(() -> userService.findById(orderId));
var productFuture = scope.fork(() -> productService.findByOrder(orderId));
var inventoryFuture = scope.fork(() -> inventoryService.checkStock(orderId));
scope.join(); // 모두 완료 대기
scope.throwIfFailed(); // 하나라도 실패하면 나머지 취소
return new OrderDetails(
userFuture.get(),
productFuture.get(),
inventoryFuture.get()
);
}
}
}
Scoped Values
ThreadLocal의 단점을 개선한 Scoped Values는 가상 스레드 환경에서 메모리 오버헤드 없이 컨텍스트 데이터를 효율적으로 전달합니다.
// ThreadLocal (기존): 가상 스레드 수백만 개에서 메모리 문제
static final ThreadLocal CURRENT_USER = new ThreadLocal<>();
// ScopedValue (Java 25): 불변, 자동 정리, 낮은 메모리 사용
static final ScopedValue CURRENT_USER = ScopedValue.newInstance();
public void handleRequest(User user, Runnable handler) {
ScopedValue.where(CURRENT_USER, user).run(handler);
// handler 실행 중에만 CURRENT_USER 사용 가능, 이후 자동 해제
}
public void someDeepMethod() {
User user = CURRENT_USER.get(); // 어디서든 안전하게 접근
// ...
}
Record Patterns과 Pattern Matching for Switch
Java 21에서 완성된 패턴 매칭은 복잡한 타입 체크와 캐스팅을 간결하게 표현합니다.
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}
// Java 17 이전 방식
double getArea_old(Shape shape) {
if (shape instanceof Circle c) {
return Math.PI * c.radius() * c.radius();
} else if (shape instanceof Rectangle r) {
return r.width() * r.height();
} else if (shape instanceof Triangle t) {
return 0.5 * t.base() * t.height();
}
throw new IllegalArgumentException();
}
// Java 21 패턴 매칭 for switch
double getArea(Shape shape) {
return switch (shape) {
case Circle(var r) -> Math.PI * r * r;
case Rectangle(var w, var h) -> w * h;
case Triangle(var b, var h) -> 0.5 * b * h;
};
}
// 가드 조건 추가
String classify(Object obj) {
return switch (obj) {
case Integer i when i < 0 -> "음수";
case Integer i when i == 0 -> "영";
case Integer i -> "양수";
case String s when s.isEmpty() -> "빈 문자열";
case String s -> "문자열: " + s;
case null -> "null";
default -> "기타: " + obj.getClass().getSimpleName();
};
}
마이그레이션 시 주의사항
가상 스레드는 I/O 중심(I/O-bound) 작업에서 탁월하지만, CPU 집약적(CPU-bound) 작업에서는 기존 스레드 풀이 더 효율적입니다. 또한 synchronized 블록 내에서 블로킹 I/O가 발생하면 가상 스레드가 캐리어 스레드를 고정(pinning)하는 문제가 Java 24에서 해결되었습니다. Java 25 환경에서는 이 제약이 없으므로, 가상 스레드 마이그레이션을 고려 중이라면 Java 25 LTS를 타겟으로 설정하는 것을 권장합니다.