백엔드 면접 용어 사전
Java·객체지향Virtual Thread · virtual thread · Project Loom

가상 스레드

블로킹을 만나면 OS 스레드를 놓아 주는 JVM 관리 스레드. 블로킹 코드를 그대로 쓰면서 수만 동시성을 얻는다.

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 바운드도 빨라진다" → 아니다. 코어 수가 한계다.
  • "켜면 처리량이 오른다" → 스레드 천장을 없앨 뿐이고, 그 다음 천장(커넥션 풀 등)이 곧바로 드러난다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 모던 자바·스프링 — 가상 스레드·JEP 491·리액티브·jOOQ·Boot 4