JVM이 직접 스케줄링하는 스레드. Java 21에서 정식 기능이 됐다(Project Loom).
먼저 — 기존 스레드는 왜 비쌌나
new Thread()로 만드는 스레드는 OS 스레드와 1:1로 대응한다. OS 스레드 하나는 이런 비용을 갖는다.
- 스택 — 약 1MB의 연속된 메모리를 미리 예약
- 10,000개면 스택만 10GB
- 생성/소멸 — 커널에 요청 → 시스템 콜 비용
- 전환 — 레지스터 저장·복원 + 커널 모드 진입
- 마이크로초 단위지만 초당 수만 번이면 누적된다
그래서 서버는 스레드 풀 크기가 곧 동시 처리 한계였다. 톰캣 기본값 200이 그 숫자다. 여기서 치명적인 건 그 200개가 대부분 '아무 일도 안 하고 기다린다' 는 점이다. DB 응답을 기다리는 스레드는 CPU를 한 톨도 안 쓰면서 자기 자리(OS 스레드)를 계속 차지한다.
가상 스레드의 해법 — 스택을 힙으로 옮긴다
가상 스레드는 진짜 OS 스레드(캐리어 스레드) 위에 올라타서(마운트) 실행된다. 그러다 블로킹을 만나면,
[실행 중] 가상 스레드 A의 스택 → 캐리어의 OS 스택 위에 있음
↓ DB 응답 대기 (블로킹)
[언마운트] A의 스택 프레임을 통째로 힙(Heap)으로 복사
캐리어를 놓아 준다
↓
[다른 일] 빈 캐리어에 가상 스레드 B가 마운트되어 실행
↓ DB 응답 도착
[재마운트] 힙에 있던 A의 스택을 아무 캐리어 위로 다시 올림
핵심은 메모리 위치의 차이다. OS 스레드의 스택은 커널이 잡아 준 고정 크기 영역이라 미리 1MB를 예약해야 하지만, 가상 스레드의 스택은 힙에 놓인 평범한 자바 객체다. 필요한 만큼만 쓰고 GC가 회수한다. 그래서 수십만 개를 만들어도 부담이 없다.
무엇이 빨라지고 무엇이 안 빨라지나
| 효과 | |
|---|---|
| I/O 대기(DB·HTTP·파일) | 대기 중 OS 스레드를 안 붙잡으므로 동시 처리량이 크게 는다 |
| CPU 연산 | 전혀 안 빨라진다. 연산은 결국 코어 위에서 도니 한계는 코어 수 |
"가상 스레드를 켜면 빨라진다"가 아니라 "기다리는 비용이 사라진다" 가 정확한 표현이다.
쓰는 법 — 풀을 만들지 않는다
// 작업마다 새로 만든다. 이게 정상이고 권장이다
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var id : userIds) {
executor.submit(() -> repository.find(id)); // 평범한 블로킹 코드
}
}
스레드 풀이라는 개념이 존재했던 이유는 생성이 비싸서 재사용하려는 것이었다. 가상 스레드는 생성이 싸므로 재사용할 이유가 없고, 오히려 풀로 묶으면 풀 크기가 다시 동시성 상한이 되어 이점을 스스로 없앤다.
Spring Boot에서는 설정 한 줄이다.
spring:
threads:
virtual:
enabled: true # Java 21+ 필요
면접 함정
- ❌ "가상 스레드 = 비동기" → 반대다. 동기 블로킹 코드를 그대로 쓰기 위한 기능이다. 콜백도
Mono도 없이 위에서 아래로 읽히는 코드를 유지한다. - ❌ "CPU 바운드도 빨라진다" → 아니다. 코어 수가 한계다.
- ❌ "켜면 처리량이 오른다" → 스레드 천장을 없앨 뿐이고, 그 다음 천장(커넥션 풀 등)이 곧바로 드러난다.