Java Virtual Thread
가상 스레드(Virtual Thread)?
최근에 진행중인 프로젝트에서 맡은 거래 엔진 자원의 효용성을 높이기 위해 이것저것 공부하고 있다. 그러던 중 한 1년에 알게 된 Virtual Thread를 다시 공부하게 됐다. 이번엔 이를 다뤄보고 적용할 수 있지 없을지 판단해보겠다.
자바 전통의 플랫폼 스레드(OS 스레드)는 생성·전환 비용이 높고, 스택이 고정(1MB ±)이라 메모리도 많이 소모한다. I/O-blocking 중심의 웹 서버처럼 “대기 시간이 긴 짧은 작업”이 수천 ~ 수만 개 쌓이면 플랫폼 스레드 방식은 금세 한계에 부딪힌다.
이를 해결하기 위해 JDK 21부터 가상 스레드가 정식 도입되었다. 가상 스레드는 JVM 안에서 스케줄링되는 초경량 스레드로, 필요한 순간에만 소수의 캐리어(플랫폼) 스레드 위에서 실행된다. 따라서
- 생성·종료가 매우 빠르다 (수백 ns 수준)
- 스택이 필요한 만큼만 힙에 확장되므로 메모리 사용량이 작다(수 KB)
- 1 프로세스에서 수백만 개도 무난히 만들 수 있다
언제 쓰면 좋은가
권장 | 비권장 |
---|---|
HTTP 요청 당 스레드(Thread-per-Request) 웹 서버 | 순수 CPU-bound 대규모 연산 |
데이터베이스·파일·네트워크 블로킹 I/O 작업 | JNI·FFM 등 네이티브 코드를 오래 실행 |
짧고 잦은 스케줄러·타이머 작업 | 긴 시간 synchronized 로 잠기는 코드 |
핵심은 “대기 시간이 길지만 CPU 는 거의 안 쓰는” 작업을 폭넓게 동시 처리할 때 효과가 극대화된다는 점이다.
코드로 맛보기
Spring Boot에서 가상 스레드를 활용하면 웹 애플리케이션의 동시성을 간단히 높일 수 있다. Spring Boot 3.2부터 가상 스레드를 공식 지원하며, JDK 21 환경에서 application.properties
나 yml
파일에 단 한 줄을 추가하는 것만으로 활성화할 수 있다.
1. 가장 단순한 사용법
application.properties
파일에 아래 설정을 추가하면, 내장된 Tomcat 웹 서버가 들어오는 모든 요청을 가상 스레드에서 처리하게 된다.
# application.properties
spring.threads.virtual.enabled=true
설정 후 간단한 컨트롤러를 만들어 현재 스레드가 가상 스레드인지 확인할 수 있다.
@RestController
public class HelloVirtualController {
@GetMapping("/hello")
public String hello() {
// "현재 스레드는 가상 스레드인가? true"가 출력된다.
System.out.println("현재 스레드는 가상 스레드인가? " + Thread.currentThread().isVirtual());
return "안녕, Virtual Thread!";
}
}
이 설정만으로 서버는 모든 HTTP 요청을 별도의 가상 스레드에서 처리하므로, 요청 처리 중 발생하는 I/O 대기 시간에 플랫폼 스레드가 낭비되지 않는다.
2. 블로킹 I/O를 포함한 웹 서버 예시
가상 스레드의 진가는 외부 API 호출이나 데이터베이스 조회 같은 블로킹(Blocking) I/O 작업에서 드러난다. 아래 코드는 5초간 대기하여 블로킹 상황을 흉내 낸다.
@RestController
@RequiredArgsConstructor
public class BlockingController {
private final BlockingIOService blockingIOService;
@GetMapping("/blocking")
public String blockingRequest() throws InterruptedException {
// 서비스 계층에서 5초간 스레드를 블로킹한다.
blockingIOService.blocking();
return "블로킹 요청 처리 완료: " + Thread.currentThread().toString();
}
}
@Service
public class BlockingIOService {
public void blocking() throws InterruptedException {
Thread.sleep(5000); // DB 조회, 외부 API 호출 등으로 인한 I/O 대기 상황 시뮬레이션
}
}
가상 스레드 환경에서는 위와 같은 블로킹 코드가 수천 개 동시에 요청되어도 각 요청을 가벼운 가상 스레드에 할당하여 효율적으로 처리한다. 기존 플랫폼 스레드 방식이었다면 수십~수백 개의 스레드 풀이 금방 고갈되어 성능 저하가 발생했을 것이다.
3. @Async
비동기 처리와 함께 사용하기
Spring의 @Async
기능을 가상 스레드와 결합하면, 백그라운드에서 실행되는 비동기 작업들을 훨씬 가볍게 처리할 수 있다. 작업마다 가상 스레드를 생성하는 Executor
를 빈으로 등록하면 된다.
// AsyncConfig.java
@Configuration
@EnableAsync
public class AsyncConfig {
// Spring의 기본 비동기 Task Executor를 가상 스레드 기반으로 변경한다.
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public Executor asyncTaskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
// AsyncService.java
@Service
public class MyAsyncService {
@Async
public CompletableFuture performAsyncTask(int taskNumber) throws InterruptedException {
System.out.println(taskNumber + "번 비동기 작업 시작: " + Thread.currentThread());
Thread.sleep(200); // 비동기 작업 내 I/O 대기 시뮬레이션
return CompletableFuture.completedFuture(taskNumber);
}
}
이제 @Async
어노테이션이 붙은 메서드는 호출될 때마다 새로운 가상 스레드에서 실행된다. 덕분에 수만 개의 비동기 작업을 동시에 실행해도 시스템에 거의 부담을 주지 않는다.
주의할 점
- CPU-bound 연산: 순수하게 CPU만 많이 사용하는 작업은 가상 스레드의 이점이 없다. 이런 작업은 여전히 기존의 플랫폼 스레드 풀(ForkJoinPool 등)로 관리하는 것이 효율적이다.
synchronized
키워드:synchronized
블록 내에서 스레드가 오래 머물면, 해당 가상 스레드가 캐리어 스레드를 점유하여 다른 가상 스레드의 실행을 막는 고정(pinning) 현상이 발생할 수 있다.ReentrantLock
사용을 권장한다.- 애플리케이션 조기 종료: 모든 스레드가 데몬(daemon) 속성을 가진 가상 스레드일 경우, JVM이 주 작업이 끝났다고 판단해 애플리케이션을 조기 종료시킬 수 있다. 이를 방지하려면
application.properties
에spring.main.keep-alive=true
를 설정하여 데몬이 아닌 스레드를 하나 유지시키는 것이 좋다.
생각
가상 스레드는 복잡한 비동기/논블로킹 코드를 작성하지 않고도, 단순하고 직관적인 동기식 코드만으로 대규모 동시성을 달성할 수 있게 해주는 강력한 도구다. 특히 외부 연동이 잦은 대부분의 웹 애플리케이션 환경에서 가상 스레드를 도입하면 적은 노력으로 큰 성능 향상을 기대할 수 있다. 앞으로 프로젝트를 진행함에 있어서 한 번 적용해봄직하다.