Contents
see ListSpring Boot 3.2에서 도입된 Virtual Threads 지원이 3.3에서 더욱 안정화되었다. 단 한 줄의 설정으로 기존 동기 코드를 그대로 유지하면서 처리량을 수 배 향상시킬 수 있다. 이 글에서는 Spring Boot에서 Virtual Threads를 적용하는 방법과 실전 주의사항을 다룬다.
Virtual Threads 활성화
# application.yml - 이 한 줄로 전체 웹 요청에 Virtual Thread 적용
spring:
threads:
virtual:
enabled: true
이 설정을 활성화하면 Spring MVC의 모든 요청 처리, @Async 메서드, 스케줄링 작업이 자동으로 Virtual Thread에서 실행된다.
프로젝트 설정
<!-- pom.xml -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
</parent>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
</dependencies>
컨트롤러 - 코드 변경 불필요
Virtual Threads를 사용해도 기존 동기 방식의 컨트롤러 코드를 변경할 필요가 없다. Spring이 자동으로 Virtual Thread에서 실행한다.
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
private final ExternalApiClient apiClient;
public ProductController(ProductService productService, ExternalApiClient apiClient) {
this.productService = productService;
this.apiClient = apiClient;
}
@GetMapping("/{id}")
public ResponseEntity<ProductDetail> getProduct(@PathVariable Long id) {
// 이 전체가 Virtual Thread에서 실행됨
// DB 쿼리에서 블로킹되어도 OS 스레드를 점유하지 않음
Product product = productService.findById(id);
// 외부 API 호출도 블로킹이지만 Virtual Thread이므로 효율적
PriceInfo price = apiClient.getPrice(product.getSku());
List<Review> reviews = apiClient.getReviews(product.getSku());
return ResponseEntity.ok(new ProductDetail(product, price, reviews));
}
@GetMapping
public ResponseEntity<Page<Product>> listProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(
productService.findAll(PageRequest.of(page, size))
);
}
}
서비스 레이어에서 동시 호출
@Service
public class DashboardService {
private final UserRepository userRepo;
private final OrderRepository orderRepo;
private final AnalyticsClient analyticsClient;
// 여러 데이터 소스를 동시에 조회
public DashboardData getDashboard(Long userId) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 3개 작업을 동시에 실행 (각각 Virtual Thread)
var userTask = scope.fork(() -> userRepo.findById(userId).orElseThrow());
var ordersTask = scope.fork(() -> orderRepo.findRecentByUserId(userId, 10));
var statsTask = scope.fork(() -> analyticsClient.getUserStats(userId));
scope.join();
scope.throwIfFailed();
return new DashboardData(
userTask.get(),
ordersTask.get(),
statsTask.get()
);
} catch (Exception e) {
throw new RuntimeException("대시보드 조회 실패", e);
}
}
}
HikariCP 커넥션 풀 설정 (핵심)
Virtual Threads를 사용할 때 가장 중요한 설정이 DB 커넥션 풀이다. Virtual Thread는 수만 개가 동시에 실행될 수 있지만, DB 커넥션은 제한적이므로 적절한 설정이 필수다.
# application.yml
spring:
datasource:
hikari:
# Virtual Thread에서는 커넥션 풀 크기를 신중하게 설정
# 기본값 10은 대부분의 경우 충분
maximum-pool-size: 20
# 커넥션 대기 타임아웃
connection-timeout: 5000
# Virtual Thread와 함께 사용 시 semaphore 기반 제한 권장
# 동시 DB 접근 수를 제한하여 커넥션 풀 고갈 방지
minimum-idle: 10
// Semaphore로 동시 DB 접근 수 제한
@Configuration
public class VirtualThreadConfig {
@Bean
public Semaphore dbAccessSemaphore(
@Value("${spring.datasource.hikari.maximum-pool-size:20}") int poolSize) {
// 커넥션 풀 크기에 맞춰 세마포어 설정
return new Semaphore(poolSize);
}
}
@Service
public class ProductService {
private final ProductRepository productRepo;
private final Semaphore dbSemaphore;
public Product findById(Long id) {
try {
dbSemaphore.acquire();
try {
return productRepo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
} finally {
dbSemaphore.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("DB 접근 대기 중 인터럽트", e);
}
}
}
@Async와 Virtual Threads
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public TaskExecutor taskExecutor() {
// Virtual Thread 기반 TaskExecutor
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
}
@Service
public class NotificationService {
@Async
public CompletableFuture<Void> sendNotifications(List<String> userIds, String message) {
// 이 메서드는 Virtual Thread에서 비동기 실행
for (String userId : userIds) {
notificationClient.send(userId, message);
}
return CompletableFuture.completedFuture(null);
}
}
스케줄링과 Virtual Threads
@Configuration
@EnableScheduling
public class SchedulingConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(
Executors.newScheduledThreadPool(0, Thread.ofVirtual().factory())
);
}
}
@Component
public class DataSyncScheduler {
@Scheduled(fixedRate = 60000) // 1분마다
public void syncExternalData() {
// Virtual Thread에서 실행
List<ExternalSource> sources = sourceRepository.findAll();
for (ExternalSource source : sources) {
syncService.sync(source); // 블로킹 I/O도 OK
}
}
}
모니터링과 디버깅
# JFR(Java Flight Recorder)로 Virtual Thread 모니터링
java -XX:StartFlightRecording=duration=60s,filename=vthread.jfr \
-jar myapp.jar
# 쓰레드 덤프에서 Virtual Thread 확인
jcmd <PID> Thread.dump_to_file -format=json threads.json
// Actuator에서 Virtual Thread 정보 노출
@Component
public class VirtualThreadMetrics {
private final MeterRegistry registry;
@Scheduled(fixedRate = 5000)
public void recordMetrics() {
// 활성 Virtual Thread 수 측정
long virtualCount = Thread.getAllStackTraces().keySet().stream()
.filter(Thread::isVirtual)
.count();
registry.gauge("jvm.threads.virtual.active", virtualCount);
}
}
성능 비교 결과
동일한 Spring Boot 애플리케이션에서 DB 조회 + 외부 API 호출이 포함된 엔드포인트를 테스트한 결과:
| 항목 | Platform Thread (200개 풀) | Virtual Thread |
|---|---|---|
| 동시 요청 1,000개 | 평균 2.1초 | 평균 0.3초 |
| 동시 요청 10,000개 | 타임아웃 다수 | 평균 0.8초 |
| 메모리 사용 | 약 800MB | 약 200MB |
| CPU 사용률 | 85% | 45% |
Virtual Threads는 I/O 바운드 작업이 많은 일반적인 웹 애플리케이션에서 즉각적인 성능 향상을 제공한다. Spring Boot 3.2 이상과 Java 21 이상을 사용한다면 한 줄 설정으로 바로 적용할 수 있으므로, 새 프로젝트는 물론 기존 프로젝트에서도 적극 도입을 권장한다.