Java 가상 스레드 — 빠른 가이드

발행: (2026년 2월 1일 오후 09:12 GMT+9)
16 min read
원문: Dev.to

I’m ready to translate the article for you, but I’ll need the full text you’d like translated. Could you please paste the content (excluding the source line you already provided) here? Once I have it, I’ll translate it into Korean while preserving all formatting, markdown, and code blocks.

01 · 가상 스레드란 무엇인가

Project Loom 이전에는 Java에 스레드가 하나뿐이었습니다: 플랫폼 스레드 (OS 커널 스레드와 1:1 매핑).

Project Loom은 두 번째, 경량 스레드 유형으로 가상 스레드를 도입합니다.

  • 플랫폼 스레드 – 운영 체제에 의해 스케줄링되며, 커널 스레드와 1:1 매핑됩니다.
  • 가상 스레드 – JVM이 사용자 모드에서 스케줄링하며, M:N 방식으로 커널 스레드에 매핑됩니다.

두 종류 모두 java.lang.Thread 로 표현됩니다.

주요 특성

  • 플랫폼(운영 체제) 스레드에 비해 매우 가볍습니다.
  • 수백만 개의 가상 스레드를 안전하게 생성할 수 있습니다.
  • 개발자가 단순한 블로킹 스타일 코드를 작성하면서도 높은 확장성을 유지할 수 있게 합니다.

사고 모델

가상 스레드캐리어(OS) 스레드
JVM이 관리하는 경량 스레드 (~1 KB)실제 운영 체제 스레드
수백만 개를 생성할 수 있음작고 고정된 풀 (≈ CPU 코어 수)
블로킹이 저렴하고 안전함진정으로 희소한 자원

블로킹 비교

OS 스레드 블록

RestTemplate blocks an OS thread
- Thread is idle during I/O
- Under load → thread exhaustion

가상 스레드 블록

- JVM suspends the virtual thread
- Carrier thread is released immediately
- Scales safely under high concurrency

Why we need virtual threads?

02 · Enable in Spring Boot

One property. No code changes.

# application.yml
server:
  servlet:
    threads:
      virtual-threads-enabled: true

Requirements

  • Java 21+ (final, not preview)
  • Spring Boot 3.2+

What Changes

  • 각 HTTP 요청이 새로운 가상 스레드에서 실행됩니다.
  • 컨트롤러, 서비스, RestTemplate 등은 그대로 유지됩니다.

What virtual-threads-enabled: true Actually Does

Tomcat의 전체 서블릿 스레드 풀을 요청당 가상 스레드 실행기로 교체합니다. 들어오는 모든 HTTP 요청은 즉시 새로운 가상 스레드에 할당됩니다. 고정된 풀 크기가 없으며—Tomcat은 제한을 두지 않으며, JVM이 스레드를 관리합니다.

Consequences

  • 전체 요청 라이프사이클(DispatcherServlet 진입부터 응답 전송까지)이 가상 스레드에서 실행됩니다.
  • 해당 체인 내의 모든 블로킹 호출(RestTemplate, JDBC, Thread.sleep())은 이미 가상 스레드에서 실행되고 있기 때문에 비용이 적습니다.

The Manual Offload Approach

Tomcat의 기본 OS 스레드 풀은 accept와 dispatch를 처리한 뒤, 작업을 가상 스레드 실행기(예: CompletableFuture.supplyAsync)에 명시적으로 넘깁니다. 요청을 받아들인 OS 스레드는 즉시 해제됩니다.

Spring MVC는 반환된 CompletableFuture를 처리하는 방법을 알고 있습니다:

  1. 서블릿 처리를 일시 중단합니다.
  2. Future가 완료되면 재개합니다.

Key point: Spring MVC는 Future를 기다리는 동안 서블릿 스레드를 블로킹하지 않으며, 내부적으로 콜백을 등록하고 스레드를 즉시 해제합니다.

서비스와 클라이언트 레이어는 두 접근 방식 모두에서 변경되지 않습니다.

03 · Spring Boot에서 가상 스레드 도입 전략

Context

기존 Spring Boot 마이크로서비스:

  • Spring MVC(서블릿 스택)로 들어오는 HTTP 요청을 처리합니다.
  • 차단형 클라이언트(RestTemplate, JDBC)를 사용해 여러 하위 서비스에 호출합니다.

Constraints

  • 현재 구현 수정하거나 재작성할 수 없습니다.
  • 코드베이스에 synchronized 블록/메서드와 ThreadLocal(SecurityContext, MDC, request attributes)에 대한 의존도가 높습니다.
  • 서비스는 하위 서비스 간 I/O‑중심 집계 작업을 수행합니다.
  • 부하가 걸릴 때 스레드 차단으로 인해 확장성 문제가 발생합니다.

Goal: 기존 동작을 깨뜨리거나 미묘한 런타임 위험을 도입하지 않으면서 Java Virtual Threads를 활용해 동시성 및 확장성을 개선합니다.

Spring Boot에서는 두 가지 접근 방법을 제공합니다:

  1. 프로퍼티 기반 가상 스레드(spring.threads.virtual.enabled=true).
  2. CompletableFuture를 이용한 수동 오프로드를 가상 스레드 실행기에 전달.

Option 1: Property‑Based Virtual Threads (Global Enablement)

Description

다음 설정을 활성화하면

spring.threads.virtual.enabled=true

Tomcat의 서블릿 스레드 풀을 요청당 가상 스레드 실행기로 교체합니다.

각 HTTP 요청은:

  • 새로운 가상 스레드에 할당됩니다.
  • 필터 → 컨트롤러 → 서비스 → 응답 전체가 해당 가상 스레드에서 실행됩니다.
  • 차단 호출(RestTemplate, JDBC, Thread.sleep)을 저비용으로 수행합니다.

Benefits

  • 코드 변경이 전혀 필요 없음.
  • 애플리케이션 전체에 일관된 동작 제공.
  • 차단 I/O에 대한 자동 확장성 확보.

Risks and Limitations

Pinning Risk

synchronized 블록은 carrier thread를 고정(pinning)합니다. 고정은 눈에 보이지 않으며 전역적이어서, 동시 접근이 캐리어 스레드 풀을 고갈시킬 수 있습니다.

[Virtual Thread] → synchronized block ← carrier pinned → blocking I/O

동시 요청 N개가 고정 구역에 들어가면 N개의 캐리어 스레드가 필요합니다. CPU 수에 비례하는 캐리어만 존재하므로 애플리케이션이 정체될 수 있습니다.

ThreadLocal Assumptions Break

  • 가상 스레드는 짧게 살아가며 요청 간 재사용되지 않습니다.
  • ThreadLocal 데이터는 단일 요청을 넘어선 지속성을 갖지 않습니다.

스레드 재사용을 전제로 ThreadLocal에 데이터를 캐시하는 코드는 오작동할 수 있습니다.

Lack of Control

  • 격리 경계가 없으므로 특정 엔드포인트나 코드 경로만 제외할 수 없습니다.
  • 문제 해결을 위해서는 전역 설정을 바꾸어야 하며, 이는 전체 애플리케이션 재시작을 요구할 수 있습니다.

Option 2: Manual Offloading to a Virtual‑Thread Executor

Description

Tomcat의 OS 스레드 풀은 accept/dispatch 용도로 유지하고, 차단 작업을 가상 스레드 실행기로 명시적으로 오프로드합니다. 예:

CompletableFuture.supplyAsync(() -> {
    // blocking I/O, JDBC, RestTemplate, etc.
}, Executors.newVirtualThreadPerTaskExecutor());

Spring MVC는 반환된 CompletableFuture를 이미 처리할 수 있습니다:

  • 서블릿 처리를 일시 중단(suspend)합니다.
  • future가 완료되면 콜백을 등록해 처리를 재개합니다.

Benefits

  • 세밀한 제어 – 실제로 차단이 필요한 부분만 오프로드합니다.
  • synchronizedThreadLocal에 의존하는 중요한 섹션은 OS 스레드에서 그대로 실행되어 고정 및 ThreadLocal 문제를 회피할 수 있습니다.

Drawbacks

  • 차단이 발생하는 경계마다 코드 변경이 필요합니다.
  • 프로퍼티 기반 접근보다 침투도가 높으며, 모든 차단 호출을 감사하고 래핑해야 합니다.

채택 경로 선택

기준속성 기반 (전역)수동 오프로드
필요한 코드 변경없음예 (블로킹 호출을 래핑)
가상 스레드에서 실행되는 코드 제어없음 (전체 요청)선택적
기존 synchronized / ThreadLocal 사용에 미치는 영향잠재적으로 위험 (핀 고정, ThreadLocal 상태 손실)핵심 구역을 OS 스레드에 유지 가능
운영 단순성매우 간단 (단일 속성)복잡함 (executor 관리)
캐리어 스레드 고갈 위험높음 (전역 핀 고정)낮음 (오프로드된 부분만)

추천

  • 코드베이스에 synchronized 블록이 최소이고 ThreadLocal 상태에 크게 의존하지 않는 경우, 속성 기반 접근 방식이 확장성으로 가는 가장 빠른 경로를 제공합니다.
  • 애플리케이션이 synchronized 구역, ThreadLocal 기반 보안 컨텍스트, 또는 기타 스레드 친화성 패턴을 많이 사용하는 경우, 수동 오프로드 전략이 더 안전합니다. 이를 통해 가상 스레드 사용을 실제 블로킹 I/O 경로에만 제한할 수 있습니다.

빠른 참고

# Enable virtual threads globally (Spring Boot 3.2+)
server:
  servlet:
    threads:
      virtual-threads-enabled: true
// Manual offload example
Executor vtExecutor = Executors.newVirtualThreadPerTaskExecutor();

CompletableFuture<String> result = CompletableFuture.supplyAsync(() -> {
    // Blocking call (e.g., RestTemplate, JDBC)
    return restTemplate.getForObject(url, String.class);
}, vtExecutor);

UI‑Res 리라이팅: synchronized 및 ThreadLocal‑종속 코드

옵션 2: 가상 스레드에 수동 오프로드 (선택적 채택)

설명

  • Tomcat은 기본 OS‑thread 서블릿 풀을 계속 사용합니다.
  • 기존 코드는 OS 스레드에서 그대로 실행됩니다.
  • I/O‑집중 로직은 다음과 같이 명시적으로 오프로드합니다
CompletableFuture.supplyAsync(task, virtualThreadExecutor);
  • Spring MVCCompletableFuture 반환 타입을 기본적으로 지원하여 요청 처리를 일시 중단하고 서블릿 스레드를 해제한 뒤, Future가 완료되면 다시 재개합니다.

ThreadLocal 고려 사항

오프로드는 하드 스레드 경계를 생성합니다. 다음과 같은 컨텍스트는

  • SecurityContext
  • MDC 추적 데이터

자동으로 전파되지 않으며 수동으로 캡처하고 복원해야 합니다. 이 경계는 명시적이며 제어 가능합니다.

장점

  • 기존 가정(synchronized, ThreadLocal)을 유지합니다.
  • 레거시 코드에서 캐리어‑스레드 고정(pinning)을 방지합니다.
  • 가상 스레드를 필요한 경우에만 선택적으로 사용할 수 있습니다.
  • 점진적인 마이그레이션이 가능합니다.
  • OS‑스레드와 가상‑스레드 실행 간 명확한 격리를 제공합니다.

단점

  • 약간 더 많은 보일러플레이트 코드가 필요합니다.
  • 명시적인 컨텍스트 전파가 요구됩니다.
  • 가상 스레드 사용을 의식적으로 적용해야 합니다.

결과

긍정적부정적
I/O‑집중 엔드포인트에 대한 확장성 향상컨텍스트 전파를 위한 추가 보일러플레이트
기존 synchronized 코드를 리팩터링할 필요 없음오프로드 경계를 유지하기 위한 규율 필요
예측 가능한 런타임 동작
명확한 마이그레이션 경로

최종 판단

속성 기반 가상 스레드 접근 방식은 이미 가상 스레드에 친화적인 코드베이스에만 적합합니다.
이 시스템에서는 수동 오프로드가 가장 안전하고 효과적인 전략이며, 가상 스레드의 이점을 제공하면서도 정확성과 운영 안정성을 유지합니다.

04 · 함정 (프로덕션 전 읽기)

synchronized가 캐리어 스레드를 고정시킴

문제

  • 가상 스레드가 캐리어에 붙어버림.
  • 9개의 동시 요청 + 8개의 캐리어 → 교착 상태.

해결: ReentrantLock 사용

// Pins carrier
public synchronized Product fetch(String id) {
    return restTemplate.getForObject("/p/{id}", Product.class, id);
}

// Safe
private final ReentrantLock lock = new ReentrantLock();

public Product fetch(String id) {
    lock.lock();
    try {
        return restTemplate.getForObject("/p/{id}", Product.class, id);
    } finally {
        lock.unlock();
    }
}

수동 오프로드 시 ThreadLocal 컨텍스트 손실

무엇이 깨지는가

  • MDC
  • SecurityContext
  • RequestAttributes

해결: 컨텍스트 캡처 및 복원

Map<String, String> mdc = MDC.getCopyOfContextMap();
SecurityContext sec = SecurityContextHolder.getContext();

return CompletableFuture.supplyAsync(() -> {
    if (mdc != null) MDC.setContextMap(mdc);
    SecurityContextHolder.setContext(sec);
    try {
        return service.doWork();
    } finally {
        MDC.clear();
        SecurityContextHolder.clearContext();
    }
}, ioExecutor);

💡 이 문제는 전역적으로 virtual-threads-enabled: true를 사용할 때 존재하지 않습니다.

풀링된 Executor 사용 시 ThreadLocal 누수

문제

  • Executor를 고정 풀로 교체하면 요청 간에 ThreadLocal이 누수됩니다.

해결: 올바른 Executor 강제 적용

@Bean
public Executor ioExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

네이티브(JNI) 호출이 조용히 고정됨

예시

  • 일부 JDBC 드라이버
  • 암호화 라이브러리

피해를 제한

private static final ExecutorService NATIVE_POOL =
    Executors.newFixedThreadPool(10);

public Future<String> callNative(String input) {
    return NATIVE_POOL.submit(() -> nativeLib.process(input));
}

핀 로깅 활성화 (개발 전용)

-XX:+PrintVirtualThreadPinning

MVC와 WebFlux를 함께 사용

두 스타터가 모두 존재하면 Spring은 MVC를 선택하고 경고를 표시하지 않습니다.

규칙

  • 가상 스레드 → spring-boot-starter-web 유지.
  • starter-webflux 제거.

가상 스레드에서 CPU‑집약 작업

안티패턴

  • 무거운 연산
  • 이미지 처리
  • 암호화 루프

올바른 분리

// I/O work
CompletableFuture.supplyAsync(
    () -> restTemplate.getForObject(...),
    Executors.newVirtualThreadPerTaskExecutor());

// CPU work
CompletableFuture.supplyAsync(
    () -> heavyComputation(data),
    ForkJoinPool.commonPool());

최종 요약

Virtual Threads는 재작성할 수 없는 차단형 I/O‑집중 Spring Boot 서비스에 가장 적합한 선택입니다.
이들은 반응형 복잡성 없이 확장성, 단순성 및 프로덕션 안전성을 제공합니다.

Back to Blog

관련 글

더 보기 »

SPRING BOOT 예외 처리

Java & Spring Boot 예외 처리 노트 1. Exception이란? Exception = 프로그램의 정상 흐름을 방해하는 원하지 않는 상황. 예외 처리의 목표...

RUST 켜기

Java에서 Rust로 가는 나의 여정: 기술 스택 변경 안녕하세요, 제 이름은 Garik이고 오늘은 제가 기술 스택을 바꾸기로 결심한 이야기를 여러분과 공유하고 싶습니다. ...