| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
- javascript
- 가벼운학습지
- 자바스크립트
- 코딩
- SpringBoot4
- jpa
- 성인학습지
- restapi
- js
- 백엔드
- 생활정보
- 가벼운학습지후기
- 프로그래머스
- React Native
- 일본어학습지
- 코테
- 근로기준법
- SWEA
- 마이라이트
- 삼성
- springboot
- 일본어공부
- java
- spring
- 코딩테스트
- 일본어독학
- 마이그레이션
- 알고리즘
- 자바
- 에러해결
- Today
- Total
개발에 AtoZ까지
[JAVA] 가상 스레드, 켜면 방어선이 사라집니다 — Java 21로는 부족한 이유 본문
spring.threads.virtual.enabled=true
한 줄이라 부담 없어 보입니다. 그래서 "일단 켜 보고 성능 비교해 보자"는 얘기가 자주 나오죠. 저도 처음엔 그렇게 생각했습니다.
그런데 공식 문서를 읽어 보니 이 한 줄이 건드리는 범위가 넓었습니다. 톰캣 요청 처리, @Async, 스케줄러, 카프카·래빗MQ 리스너가 동시에 바뀝니다. 그리고 결정적으로, 스레드 풀 크기로 설정해 뒀던 값들이 전부 무효가 됩니다. 풀 크기를 사실상 부하 차단기로 쓰고 있었다면 그 방어선이 사라지는 겁니다.
이 글을 다 읽으면 세 가지가 정리됩니다. 첫째, 우리 애플리케이션이 가상 스레드로 이득을 볼 규모인지 판단할 기준이 생깁니다(공식 문서에 숫자가 있습니다). 둘째, Java 21로 충분한지 25가 필요한지 알 수 있습니다. 셋째, 켜기 전에 반드시 먼저 해야 할 두 가지 작업을 알게 됩니다.
이 글은 벤치마크를 직접 돌린 결과가 아니라 Spring Boot 레퍼런스와 릴리스 노트, OpenJDK JEP 원문, Oracle 가상 스레드 가이드를 읽고 정리한 내용입니다(확인일 2026-08-23). 성능 수치를 약속하는 대신, 공식 문서가 명시한 조건과 경고를 옮겼습니다.

프로퍼티 한 줄이 바꾸는 것과, 조용히 무효가 되는 것
먼저 확인할 것: 정말 켜지긴 하나요
이 함정에 걸리면 성능 비교 자체가 무의미해집니다. 런타임이 Java 21 미만이면 프로퍼티는 조용히 무시됩니다.
Spring Boot의 Threading 열거형 Javadoc이 조건을 명시합니다.
"VIRTUAL — Virtual threads. Active if
spring.threads.virtual.enabledis true and running on Java 21 or later. / PLATFORM — Platform threads. Active if virtual threads are not active."
여기서 헷갈리기 쉬운 게 하나 있습니다. Spring Boot 4.1.1은 Java 17 이상을 요구합니다. 즉 부트를 최신으로 올려도 런타임이 Java 17이면 가상 스레드는 안 켜집니다. 에러도 경고도 없이 그냥 플랫폼 스레드로 돕니다.
"켰다"고 착각한 상태로 성능을 비교하면 잘못된 결론을 내게 됩니다. 부트 업그레이드와 JDK 업그레이드는 별개 결정이라는 점을 먼저 잡아 두시면 좋습니다.
Java 21이면 되는 걸로 알고 있었는데요
프로퍼티가 Spring Boot 3.2에서 들어왔고 그때 조건이 Java 21이었으니, 그렇게 알고 계신 게 자연스럽습니다. 그런데 현행 문서는 다르게 씁니다.
"Virtual threads require Java 21 or later. For the best experience, Java 24 or later is strongly recommended."
이 문장은 3.4.13, 3.5.16, 4.1.1 문서에 모두 똑같이 들어 있습니다. 21은 최소 요건이고, 권장선은 24 이상입니다.
이유는 pinning입니다. 쉽게 말하면, 가상 스레드가 밑에 깔린 실제 스레드(캐리어 스레드)를 붙잡고 놓지 못하는 상황입니다. 붙잡고 있으면 다른 가상 스레드가 그 실제 스레드를 못 쓰니 동시성이 죽습니다.
Java 21 시절 Oracle 문서는 pinning 조건을 두 개로 적었습니다.
"A virtual thread is pinned in the following situations: The virtual thread runs code inside a synchronized block or method; The virtual thread runs a native method or a foreign function"
synchronized가 들어 있죠. 그래서 "가상 스레드를 쓰려면 synchronized를 ReentrantLock으로 다 바꿔야 한다"는 조언이 2023~2024년 글의 표준이 됐습니다.
이게 JDK 24에서 해결됐습니다. JEP 491이 HotSpot 모니터 구현을 바꿔서 synchronized 안에서 블로킹해도 캐리어를 반납하게 만들었습니다. JEP 원문이 직접 이렇게 씁니다.
"We previously recommended solving frequent and long-lived pinning problems by migrating code from using
synchronizedto usingReentrantLock. Once thesynchronizedkeyword no longer pins virtual threads, such migration will no longer be necessary. You need not revert code that has been migrated to useReentrantLockback to usingsynchronized."
그래서 JDK 26 문서에서는 pinning 조건이 하나로 줄었습니다. synchronized가 목록에서 빠졌습니다.
"A virtual thread is pinned when it runs a native method or a foreign function"
여기서 실무 기준선이 나옵니다. JDK 24는 non-LTS이고 Premier Support가 2025년 9월에 이미 끝났습니다. 같은 수정이 들어간 첫 LTS는 Java 25(2025년 9월 GA)입니다. 그래서 "가상 스레드를 제대로 쓰려면 Java 25"가 되는 겁니다.
Java 21에 묶여 있다면 이 조언이 아직 유효합니다. 요청 경로에 synchronized 안에서 블로킹하는 코드나 라이브러리가 있는지 확인해야 합니다. 특히 우리가 못 고치는 JDBC 드라이버 같은 것들이 문제죠.

Java SE 21 문서 — pinning 조건에 synchronized 블록이 들어 있고, ReentrantLock으로 바꾸라고 권합니다 (확인일 2026-08-23)

Java SE 26 문서 — 같은 자리에서 synchronized가 빠지고 native 메서드·foreign function만 남았습니다 (확인일 2026-08-23)
켜면 정확히 무엇이 바뀌나요
프로퍼티 하나가 바꾸는 범위를 릴리스 노트에서 모아 보면 이렇습니다.
| 영역 | 바뀌는 내용 | 도입 |
|---|---|---|
| 웹 요청 처리 | Tomcat·Jetty가 가상 스레드로 요청 처리 (컨트롤러 메서드가 가상 스레드에서 실행) | 3.2 |
@Async·MVC 비동기 |
applicationTaskExecutor가 SimpleAsyncTaskExecutor로 교체 |
3.2 |
| 스케줄링 | taskScheduler가 SimpleAsyncTaskScheduler로 교체 |
3.2 |
| 메시징 | RabbitMQ 리스너, Kafka 리스너에 가상 스레드 실행자 자동 구성 | 3.2 |
| Redis | ClusterCommandExecutor가 가상 스레드 사용 |
3.2 |
| WebSocket | ChannelRegistration에 실행자 등록 |
3.3 |
| Undertow·메트릭 | Undertow 웹 서버, OtlpMeterRegistry |
3.4 |
| HTTP 클라이언트 | JDK HttpClient 기반 자동 구성 클라이언트 | 4.0 |
전역 프로퍼티라는 점이 중요합니다. "리스너만 가상 스레드로 돌리고 싶다"고 해도 웹·@Async·스케줄러가 함께 바뀝니다. 영향 범위를 스테이징에서 먼저 확인하는 게 맞습니다.
그리고 풀 크기 프로퍼티가 무효가 됩니다. 문서가 못박습니다.
"If virtual threads are enabled, properties which configure thread pools don't have an effect anymore. That's because virtual threads are scheduled on a JVM wide platform thread pool and not on dedicated thread pools."
3.2 릴리스 노트도 thread-name-prefix를 제외한 spring.task.execution.* 프로퍼티는 무시된다고 적습니다. 스케줄러 쪽도 "This SimpleAsyncTaskScheduler will ignore any pooling related properties"입니다.
여기가 진짜 위험한 지점입니다
많은 서비스가 server.tomcat.threads.max나 커넥션 풀 크기를 의도적으로 낮게 잡아 뒀습니다. 동시성 제한이 목적이 아니라, 그게 하류로 가는 부하를 막는 자연 방어선이었기 때문입니다.
가상 스레드를 켜면 그 방어선이 사라집니다. 요청이 200개 오면 200개가 동시에 DB로 갑니다.
Oracle 가이드가 대체 방법을 명시합니다.
"Pools are designed to share scarce resources, and virtual threads aren't scarce and therefore should never be pooled! When using virtual threads, if you want to limit the concurrency of accessing some service, you should use a construct designed specifically for that purpose: the
Semaphoreclass."
그리고 DB에 대해서는 이런 문장이 있습니다.
"Database connection pools themselves serve as a semaphore. A connection pool limited to ten connections would block the eleventh thread attempting to acquire a connection. There is no need to add an additional semaphore on top of the connection pool."
여기서 계산해 볼 게 생깁니다. Spring Boot의 기본 커넥션 풀은 HikariCP이고, maximumPoolSize 기본값은 10, connectionTimeout 기본값은 30000ms입니다. 풀이 한도에 닿으면 getConnection()이 최대 30초 블록되고 그 뒤 타임아웃됩니다.
즉 가상 스레드로 요청 동시성을 풀어도 DB 앞단의 상한은 그대로 10개입니다. 11번째 요청부터는 최대 30초를 기다리다 예외가 납니다. 톰캣 스레드 200개로 막혀 있을 때는 안 보였던 문제가 여기서 드러납니다.
그래서 켜기 전에 이 두 가지가 선행 작업입니다.
- 동시성 상한이 필요한 지점을 풀 크기에서
Semaphore로 옮긴다 - 커넥션 풀 크기와 타임아웃·재시도 정책을 다시 계산한다
// 외부 API 호출 동시성을 10개로 제한
@Component
public class ExternalApiClient {
private final Semaphore limiter = new Semaphore(10);
public Response call(Request request) throws InterruptedException {
limiter.acquire();
try {
return restClient.post()
.uri("/v1/orders")
.body(request)
.retrieve()
.body(Response.class);
} finally {
limiter.release();
}
}
}

스레드 풀 크기가 만들던 부하 차단선이 사라지고, Semaphore·커넥션 풀로 다시 세워야 하는 구조
두 번째 함정: 스케줄러만 남으면 앱이 죽습니다
이건 배치나 스케줄러 전용 애플리케이션에서 바로 걸립니다. 문서의 경고 박스 내용입니다.
"One side effect of virtual threads is that they are daemon threads. A JVM will exit if all of its threads are daemon threads. This behavior can be a problem when you rely on
@Scheduledbeans, for example, to keep your application alive."
가상 스레드는 데몬 스레드입니다. JVM은 데몬 스레드만 남으면 종료합니다. @Scheduled로 앱을 살려 두고 있었다면, 가상 스레드를 켠 순간 스케줄러 스레드도 데몬이 되니 JVM이 그냥 끝납니다.
그래서 켜는 커밋에 이걸 같이 넣어야 합니다.
spring:
threads:
virtual:
enabled: true
main:
keep-alive: true # 기본값 false. 가상 스레드를 켜면 함께 필요합니다
두 프로퍼티 모두 기본값이 false입니다.
우리 앱이 이득을 볼 규모인가요
여기에 공식 숫자가 있습니다. 먼저 전제부터 정리하면, 가상 스레드는 빨라지는 게 아닙니다. JEP 444가 정면으로 씁니다.
"Virtual threads are not faster threads — they do not run code any faster than platform threads. They exist to provide scale (higher throughput), not speed (lower latency)."
개별 요청의 응답 시간은 그대로입니다. 늘어나는 건 동시 처리량이죠. "가상 스레드로 응답이 빨라진다"고 쓴 글이 많은데, 문서는 반대로 말합니다.
이득을 보는 조건도 명시돼 있습니다.
"virtual threads can significantly improve application throughput when The number of concurrent tasks is high (more than a few thousand), and The workload is not CPU-bound"
Oracle 어답션 가이드는 판단 기준을 아예 숫자로 줍니다.
"As a rule of thumb, if your application never has 10,000 virtual threads or more, it is unlikely to benefit from virtual threads."
동시 태스크가 1만 개에 한참 못 미치는 내부 관리도구나 사내 배치라면, 가상 스레드보다 다른 병목을 먼저 보는 게 낫다는 뜻입니다.
CPU 바운드 작업은 아예 대상이 아닙니다.
"Virtual threads are suitable for running tasks that spend most of the time blocked, often waiting for I/O operations to complete. However, they aren't intended for long-running CPU-intensive operations."
ThreadLocal은 못 쓰나요
"가상 스레드에서는 ThreadLocal을 쓸 수 없다"는 얘기도 도는데, 사실이 아닙니다. JEP 444가 이렇게 씁니다.
"Virtual threads support thread-local variables (ThreadLocal) and inheritable thread-local variables (InheritableThreadLocal), just like platform threads, so they can run existing code that uses thread locals."
문제가 되는 건 용도입니다. 두 가지로 갈립니다.
괜찮은 용도 — 컨텍스트 전달. 트랜잭션 ID, 사용자 ID 같은 값을 나르는 건 Oracle 문서도 "perfectly reasonable"이라고 합니다.
위험한 용도 — 비싼 객체 캐싱. SimpleDateFormat처럼 만들기 비싼 가변 객체를 ThreadLocal에 캐시하는 패턴이 있습니다. 스레드가 풀에서 재사용되는 걸 전제로 한 최적화죠. 그런데 가상 스레드는 풀링되지도, 재사용되지도 않습니다. 태스크마다 새로 만들어지니 캐시 의도와 정반대로 갑니다. 스레드가 많으면 메모리도 크게 잡아먹습니다.
이런 코드는 DateTimeFormatter처럼 불변 객체로 먼저 교체하는 게 맞습니다.
사용처를 찾는 진단 프로퍼티가 있습니다.
java -Djdk.traceVirtualThreadLocals=true -jar app.jar
# 가상 스레드가 ThreadLocal 값을 설정할 때 스택 트레이스가 찍힙니다 (기본값 false)
Java 25를 쓴다면 대안이 있습니다. Scoped Values가 JEP 506으로 25에서 정식 API가 됐습니다. "프리뷰라서 못 쓴다"는 얘기는 24까지의 얘기입니다. Oracle 문서도 컨텍스트 전달이라면 "the safer and more efficient scoped values"를 고려하라고 권합니다.
켠 뒤에 무엇을 봐야 하나요
pinning 확인 방법이 바뀌었습니다. -Djdk.tracePinnedThreads는 JDK 24에서 제거됐습니다. 커맨드라인에 줘도 아무 효과가 없습니다. 이 옵션으로 안내하는 글이 아직 많으니 주의하시면 좋습니다.
현행 방법은 JFR입니다.
# 기록 시작 (jdk.VirtualThreadPinned 는 기본 활성, 임계 20ms)
jcmd <pid> JFR.start name=vt filename=vt.jfr settings=profile
# 스레드 덤프로 가상 스레드까지 보기
jcmd <pid> Thread.dump_to_file -format=json threads.json
jdk.VirtualThreadPinned 이벤트는 어떤 연산이 블로킹 중이고 이유가 무엇인지 함께 알려줍니다. 임계값은 20ms입니다.
스케줄러 자체도 볼 수 있습니다. 타깃 병렬성은 기본값이 사용 가능한 프로세서 수이고, 캐리어로 쓸 수 있는 플랫폼 스레드 최대치는 기본 256개입니다. JMX의 VirtualThreadSchedulerMXBean으로 모니터링하고 런타임에 병렬성을 바꿀 수도 있습니다.
여기서 알아 둘 게 하나 있습니다. JEP 491 문서가 병렬성을 올리는 게 해법이 아니라고 못박습니다.
"Increasing parallelism would help with some cases, but it does not scale. The maximum number of platform threads available to the scheduler is limited, with a default limit of 256 threads. If many virtual threads were to block inside a synchronized method then no value of parallelism would help."
Spring Boot 3.5부터는 Actuator의 process info에도 가상 스레드 정보가 포함됩니다(JDK 24 이상에서 실행할 때).

Spring Boot 레퍼런스의 Virtual threads 섹션 — Java 24 권고, 풀 프로퍼티 무효 안내, 데몬 스레드 경고가 함께 있습니다 (확인일 2026-08-23)
켤 앱과 켜지 말 앱
민수 씨 팀 사례로 정리해 보겠습니다. Spring Boot 3.5, Java 21, 톰캣 스레드 200, Hikari 풀 20, 외부 결제 API를 호출하는 주문 서버입니다. 피크 동시 요청은 300 정도입니다.
이 팀은 지금 켤 상황이 아닙니다. 이유가 셋입니다. 동시 태스크가 1만 개에 한참 못 미치고, Java 21이라 synchronized pinning이 남아 있고, 풀 20으로 결제 API 호출량을 막고 있었습니다. Java 25 업그레이드와 Semaphore 도입을 먼저 하고 나서 볼 일입니다.
일반화하면 이렇게 갈립니다.
켜도 되는 경우
- Java 25(또는 24 이상)로 올릴 수 있고 Spring Boot 3.2 이상인 요청당 스레드 방식 서버
- 워크로드가 CPU 바운드가 아니고 DB·외부 API·파일 I/O 대기 비중이 큰 경우
- 동시 태스크가 상시 수천~1만 개 이상인 경우
- 커넥션 풀·외부 API 쿼터 같은 하류 상한을 이미 재산정해 둔 경우
이 경우에도 켜는 커밋에 spring.main.keep-alive=true를 함께 넣고, 켜기 전후를 JFR로 계측하셔야 합니다.
기다려야 하는 경우
- Java 21에 묶여 있고 요청 경로에
synchronized안에서 블로킹하는 코드·라이브러리가 있다 — JEP 491이 없으니 캐리어가 고정됩니다 - JDK 24로만 올릴 수 있다 — 피닝은 고쳐졌지만 non-LTS이고 Premier가 2025년 9월에 끝났습니다. 25가 맞습니다
- 동시 태스크가 1만 개에 못 미치는 내부 도구 — 다른 병목을 먼저 봅니다
- 하류 상한을 아직 재산정하지 않았다 — 방어선 없이 켜는 건 위험합니다
켜면 안 되는 경우
- 장시간 CPU 집약 작업(대량 정렬, 이미지·영상 인코딩, 암복호화 루프) — 코어 수를 넘겨 스레드를 늘려도 처리량이 늘지 않습니다
server.tomcat.threads.max같은 풀 크기를 사실상 부하 차단기로 쓰고 있는 앱 — 대체 상한을 만들기 전에는 안 됩니다- ThreadLocal에
SimpleDateFormat같은 비싼 가변 객체를 캐시하는 코드가 남아 있는 앱 - 리액티브·비동기 파이프라인이 주력인 코드 — 문서가 "Avoid mixing synchronous, blocking code with asynchronous frameworks"라고 명시합니다
- 런타임이 Java 21 미만 — 조용히 무시되니 성능 비교 자체가 성립하지 않습니다
- 가상 스레드를 풀에 담아 동시성을 제한하려는 설계 — 문서가 명시적으로 금지합니다
마지막으로 하나. JDK 24 이후에도 pinning이 완전히 사라진 건 아닙니다. JEP는 "nearly all cases"라고 씁니다. native 메서드나 FFM API로 넘어간 코드가 Java로 콜백해 블로킹하는 경우, 클래스 로딩 중 블로킹, 클래스 초기화자 안에서의 블로킹은 여전히 캐리어를 고정합니다.
켜기 전 확인 목록
[ ] 런타임이 Java 21 이상인가 (17이면 조용히 무시됨)
[ ] 가능하면 Java 25(LTS)인가 — 24 이상이 공식 권장선
[ ] Spring Boot 3.2 이상인가
[ ] 동시 태스크가 수천~1만 개 규모인가 (아니면 이득이 적음)
[ ] 워크로드가 I/O 대기 중심인가 (CPU 바운드면 대상 아님)
[ ] 풀 크기로 막고 있던 하류 부하를 Semaphore 로 옮겼는가
[ ] Hikari maximumPoolSize·connectionTimeout 을 다시 계산했는가
[ ] spring.main.keep-alive=true 를 함께 넣었는가
[ ] ThreadLocal 에 비싼 가변 객체를 캐시하는 코드를 정리했는가
[ ] 리액티브 파이프라인과 섞이지 않는가
[ ] JFR 로 켜기 전후를 계측할 준비가 됐는가
켜는 설정과 계측 명령을 한 번에 옮기면 이렇습니다.
# application.yml
spring:
threads:
virtual:
enabled: true
main:
keep-alive: true
# ThreadLocal 사용처 진단
java -Djdk.traceVirtualThreadLocals=true -jar app.jar
# pinning 계측 (jdk.tracePinnedThreads 는 JDK 24 에서 제거됨)
jcmd <pid> JFR.start name=vt filename=vt.jfr settings=profile
jcmd <pid> Thread.dump_to_file -format=json threads.json
# 코드에서 확인할 것
grep -rn "ThreadLocal<" src/main --include=*.java
grep -rn "synchronized" src/main --include=*.java | wc -l
다음에 볼 글
가상 스레드를 켜면 결국 DB 앞단이 병목으로 드러납니다. 커넥션 풀과 트랜잭션 경계를 어떻게 잡을지 고민이 되면 @Transactional 롤백이 안 되는 네 가지 경우와 트랜잭션 격리수준과 갭 락이 짝이 되는 글입니다. 자바 버전 자체를 어디로 올릴지 정하는 문제는 이번 주에 올린 Java LTS 선택 글에서 다뤘습니다. @Async를 쓰고 있다면 required a bean of type that could not be found에서 다루는 빈 등록 문제도 함께 보시면 좋습니다.
가상 스레드 관련 문서는 JDK 버전마다 내용이 달라집니다. 특히 pinning 설명은 21과 26 문서가 서로 다르니, 본인이 쓰는 JDK 버전의 문서를 봐야 합니다. 이 글의 내용은 2026년 8월 23일 확인 기준입니다. 잘못된 내용이나 보충이 필요한 부분이 있으면 댓글로 알려 주시면 반영하겠습니다.
참고한 자료
- Spring Boot 4.1.1 Reference — SpringApplication / Virtual threads, Task Execution and Scheduling (확인일 2026-08-23)
- Spring Boot 3.2 / 3.3 / 3.4 / 3.5 / 4.0 Release Notes — 가상 스레드 대응 범위 (확인일 2026-08-23)
- Spring Boot 4.1.1 API Javadoc —
Threading열거형 (확인일 2026-08-23) - Spring Boot Common Application Properties 부록 —
spring.threads.virtual.enabled,spring.main.keep-alive기본값 (확인일 2026-08-23) - OpenJDK JEP 444 — Virtual Threads, 처리량 개선 조건과 ThreadLocal 주의사항 (확인일 2026-08-23)
- OpenJDK JEP 491 — Synchronize Virtual Threads without Pinning (Release 24) (확인일 2026-08-23)
- OpenJDK JEP 506 — Scoped Values (Release 25, 정식) (확인일 2026-08-23)
- Oracle Java SE 21 / 26 Core Libraries — Virtual Threads, Adoption Guide (확인일 2026-08-23)
- HikariCP 공식 저장소 README —
maximumPoolSize,connectionTimeout기본값 (확인일 2026-08-23) - Oracle Java SE Support Roadmap — JDK 21·24·25·26 지원 시점 (확인일 2026-08-23)
'백엔드 > JAVA' 카테고리의 다른 글
| [JAVA] Oracle JDK 21이 이번 달로 무상 기간이 끝납니다 — 10월 20일 전에 정할 것 (0) | 2026.09.14 |
|---|---|
| [JAVA] Lombok을 계속 쓸까 — record로 넘길 것과 남길 것 (0) | 2026.09.05 |
| [JAVA] 21에 남을까 25로 갈까 — 결정을 미룰 수 없게 만든 건 라이선스입니다 (1) | 2026.08.25 |
| [에러] UnsupportedClassVersionError: class file version 65.0 — 숫자 두 개만 보면 끝납니다 (0) | 2026.08.16 |
| [디자인패턴] Builder Pattern (0) | 2021.06.30 |