본문으로 건너뛰기
목록으로

가상 스레드, 실전에서 언제 이득이고 언제 불리한가 (Java 21+·Spring Boot)

Johny Cho
Software Engineer @ Kurly

스프링 부트 설정 한 줄(spring.threads.virtual.enabled=true)로 활성화할 수 있게 되면서, 가상 스레드(virtual thread)는 "일단 활성화하면 그냥 빨라지는 옵션"처럼 소개되곤 합니다. 절반은 맞고 절반은 위험합니다. 가상 스레드는 특정 종류의 워크로드에서 극적인 확장성을 주지만, 엉뚱한 곳에 활성화하면 이득이 없거나 오히려 성능을 떨어뜨립니다. 언제 이득이고 언제 불리한지 정리합니다.

가상 스레드는 "블로킹 I/O를 기다리는 동안 OS 스레드를 점유하지 않는다"가 전부입니다 — I/O 대기가 많은 서버는 스레드 풀 고갈에서 벗어나고, CPU를 계속 사용하는 작업은 아무 이득이 없습니다.

문제: 스레드 하나가 OS 스레드 하나​

전통적인 스프링 MVC는 요청 하나에 스레드 하나(thread-per-request)를 사용합니다. 이 스레드는 플랫폼 스레드 — 즉 OS 스레드와 1:1입니다. 문제는 그 요청이 DB나 외부 API를 호출하고 응답을 기다리는 동안에도 OS 스레드를 그대로 점유한다는 점입니다.

OS 스레드는 비쌉니다(스택 메모리·컨텍스트 스위칭). 그래서 톰캣 스레드 풀은 보통 200개 안팎으로 제한됩니다. 외부 호출이 느려지면 200개가 전부 대기 상태에 빠져 새 요청을 못 받습니다 — 이게 외부 API 하나가 느려지자 서버 전체가 멈춘 그 상황입니다.

PlantUML 코드
@startuml
skinparam defaultTextAlignment center
skinparam rectangle {
BackgroundColor #F4F6FB
BorderColor #556080
}

package "플랫폼 스레드 (스레드 = OS 스레드)" {
rectangle "요청 1 → OS 스레드 1\n(I/O 대기 중에도 점유)" as t1
rectangle "요청 2 → OS 스레드 2\n(I/O 대기 중에도 점유)" as t2
rectangle "요청 N → OS 스레드 부족\n대기 / 거절" as t3
}

package "가상 스레드 (I/O 블로킹 시 캐리어 반납)" {
rectangle "가상 스레드 수만 개" as v1
rectangle "캐리어(OS) 스레드 소수" as c1
v1 --> c1 : I/O로 블로킹되면 캐리어를\n반납(unmount) → 다른 가상 스레드가 사용
}
@enduml

PlantUML 플랫폼 스레드 vs 가상 스레드

가상 스레드가 하는 일​

가상 스레드(JEP 444, JDK 21에서 정식)는 JVM이 관리하는 가벼운 스레드입니다. 수백만 개를 만들어도 됩니다. 핵심은 블로킹 시 동작입니다.

  • 가상 스레드는 실행될 때 캐리어(carrier) 스레드(실제 OS 스레드)에 잠깐 올라탑니다(mount).
  • 그 안에서 DB·네트워크 I/O로 블로킹되면, JVM이 가상 스레드를 캐리어에서 내려놓고(unmount) 캐리어를 반납합니다. 그 캐리어는 곧바로 다른 가상 스레드를 실행합니다.
  • I/O가 끝나면 가상 스레드는 다시 임의의 캐리어에 배정돼 이어서 실행됩니다.

결과적으로 소수의 OS 스레드로 수만 개의 동시 요청을 처리할 수 있습니다. 코드는 여전히 익숙한 동기·블로킹 스타일 그대로라는 점이 가상 스레드의 핵심 매력입니다.

리액티브(WebFlux)와 무엇이 다른가​

같은 "높은 동시성"을 리액티브로도 얻을 수 있지만, 코드를 작성하는 방식이 다릅니다.

  • 리액티브(WebFlux): 로직을 콜백·연산자 체인과 Mono/Flux 반환 타입으로 재구성해야 합니다.

    return userRepo.findById(id)
    .flatMap(u -> orderRepo.findByUser(u))
    .map(orders -> toResponse(orders));
  • 가상 스레드: 평범한 순차·블로킹 코드를 그대로 작성합니다.

    User u = userRepo.findById(id);
    List<Order> orders = orderRepo.findByUser(u);
    return toResponse(orders);
둘 다 적은 스레드로 높은 동시성을 내지만, 가상 스레드는 익숙한 순차 코드를 유지하는 반면 리액티브는 사고방식과 코드 구조를 바꿔야 합니다.
// 가상 스레드는 풀링하지 않는다 — 요청마다 새로 만든다
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
var user = userClient.fetch(id); // 블로킹 호출이어도 캐리어는 반납된다
var orders = orderClient.fetch(id); // 코드는 그냥 순차·동기 스타일
return merge(user, orders);
});
}

Spring Boot는 3.2부터 설정 한 줄로 톰캣·@Async·리스너 등에 가상 스레드를 적용합니다.

spring:
threads:
virtual:
enabled: true
가상 스레드의 이득은 "블로킹 I/O를 기다리는 시간"에서 나옵니다 — 그 시간 동안 OS 스레드(캐리어)를 반납하므로, I/O 대기가 많을수록 효과가 큽니다.

언제 불리한가 — 세 가지 주의점​

① CPU 바운드 작업엔 이득이 없다​

가상 스레드는 OS 스레드를 더 만들어 주지 않습니다. 실제 병렬 연산 능력은 CPU 코어 수로 제한됩니다. 이미지 변환·암호화·대량 계산처럼 CPU를 계속 소모하는 작업은 블로킹으로 캐리어를 반납할 일이 없어, 가상 스레드로 바꿔도 빨라지지 않습니다. 이러한 일은 기존 스레드 풀로 코어 수에 맞춰 제한하는 게 맞습니다.

② synchronized 핀닝 (JDK 24에서 해결)​

가상 스레드가 synchronized 블록 안에서 블로킹되면, 그 가상 스레드가 캐리어에 고정(pinning) 돼 반납되지 않는 문제가 있었습니다. 자주 실행되는 경로의 synchronized(특히 직접 수정할 수 없는 라이브러리 내부)가 가상 스레드의 확장성을 떨어뜨렸습니다.

이건 JDK 24(2025-03)의 JEP 491에서 해결됐습니다. 모니터가 캐리어가 아니라 가상 스레드 자신에 연결되도록 바뀌어, synchronized 안에서 블로킹해도 캐리어를 반납합니다.

  • JDK 21~23을 사용한다면: 자주 실행되는 경로의 synchronized를 ReentrantLock으로 교체하고, jdk.VirtualThreadPinned JFR 이벤트로 핀닝을 감시합니다.
  • JDK 24+ 라면: 이 걱정은 대부분 사라졌습니다 — 가상 스레드를 적용하기 훨씬 편해진 이유입니다.

③ ThreadLocal·풀링 오해​

  • 가상 스레드는 수만 개가 존재할 수 있어, 각 스레드가 무거운 ThreadLocal을 들고 있으면 메모리가 급증합니다. 요청 컨텍스트 공유는 ThreadLocal 대신 JDK 25에서 정식이 된 Scoped Values 같은 대안이 더 맞습니다.
  • 가상 스레드는 풀링하지 않습니다. 생성 비용이 낮은 자원이라 요청마다 새로 만드는 게 정석입니다(newVirtualThreadPerTaskExecutor). 기존처럼 풀에 넣어 재사용하려 들면 취지가 무색해집니다.
가상 스레드는 "블로킹 I/O가 많은 서버"에 효과적이지, CPU 바운드나 잘못된 동시성 습관까지 고쳐 주지는 않습니다 — 적용하기 전에 병목이 I/O 대기인지부터 확인해야 합니다.

적용 판단 기준​

  • 적용하면 좋은 경우: 요청당 외부 호출·DB·네트워크 대기가 많은 전형적인 MVC 백엔드(마이크로서비스 게이트웨이, 조합 API 등). 동시 커넥션이 많아 톰캣 풀이 병목인 서비스. JDK 24+(핀닝 걱정이 적음).
  • 신중할 경우: JDK 21~23 + 자주 실행되는 경로에 synchronized가 많은 코드(핀닝 점검 필수). CPU 바운드 비중이 큰 워크로드. 이미 리액티브(WebFlux)로 잘 동작하는 시스템(도입 이득이 크지 않음).

리액티브와의 관계도 정리하면, 가상 스레드는 "동기 코드의 단순함 + 높은 동시성" 을 목표로 합니다. 리액티브의 백프레셔·스트림 연산이 꼭 필요한 게 아니라면, 새 프로젝트에서 높은 동시성은 가상 스레드로 가는 편이 코드가 훨씬 단순합니다.

정리​

  • 가상 스레드(JEP 444, JDK 21 정식)는 블로킹 I/O 대기 중 OS 스레드를 반납해, 소수 OS 스레드로 수만 요청을 처리합니다. 코드는 동기·블로킹 스타일 그대로입니다.
  • Spring Boot 3.2 이상은 spring.threads.virtual.enabled=true 한 줄로 적용합니다.
  • I/O 바운드에 효과적, CPU 바운드엔 이득 없음.
  • synchronized 핀닝은 JDK 24(JEP 491) 에서 해결 — 21~23이면 ReentrantLock·JFR로 대비.
  • ThreadLocal 남용·풀링은 주의. 컨텍스트 공유는 Scoped Values, 스레드는 풀링하지 말고 요청마다 생성.
  • 판단: 병목이 I/O 대기이고 JDK 24 이상이라면 적용할 이유가 충분합니다.

참고​