| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 생활정보
- 삼성
- 알고리즘
- 자바스크립트
- 에러해결
- 백엔드
- React Native
- js
- 코딩테스트
- 마이라이트
- 코딩
- 자바
- 일본어독학
- restapi
- jpa
- 가벼운학습지
- 근로기준법
- 가벼운학습지후기
- javascript
- springboot
- SpringBoot4
- 일본어학습지
- 일본어공부
- 성인학습지
- java
- 프로그래머스
- spring
- 마이그레이션
- SWEA
- 코테
- Today
- Total
개발에 AtoZ까지
[Spring] 가상 스레드가 왔는데 WebFlux를 계속 써야 하나요 — JDK 24가 경계를 다시 그었습니다 본문
[Spring] 가상 스레드가 왔는데 WebFlux를 계속 써야 하나요 — JDK 24가 경계를 다시 그었습니다
AtoZ 개발자 2026. 9. 13. 20:04Spring Boot 4로 올리는 작업을 시작하면 빌드 파일과 서버 설정을 어차피 다시 만지게 됩니다. 그러면 팀 안에서 꼭 이 질문이 나오죠. "이참에 WebFlux로 가야 하나요?" 아니면 반대로 "가상 스레드 나왔으니 WebFlux 걷어내면 안 되나요?"
2026년의 답은 예전과 다릅니다. 판단 기준이 Spring 쪽이 아니라 JDK 쪽에서 바뀌었기 때문입니다. JEP 491이 JDK 24에 들어가면서 "synchronized 때문에 가상 스레드는 실전에서 못 쓴다"는 오래된 반론이 공식 문서에서 사라졌습니다. 다만 그 혜택은 Boot 버전이 아니라 런타임 JDK 버전에 붙습니다.
직접 벤치마크를 돌린 글이 아닙니다. Oracle의 Java SE 21·25 가상 스레드 문서, OpenJDK JEP 491, Spring 공식 레퍼런스와 릴리스 노트를 열어 대조한 내용입니다(확인일 2026년 9월 5일). 수치는 전부 공식 기본값이지 제 측정값이 아닙니다.

가상 스레드가 없애는 상한과 그대로 남는 상한은 다릅니다
왜 하필 지금 이걸 정해야 하나요
강제하는 건 성능이 아니라 지원 일정입니다.
| 세대 | 무상(OSS) 지원 종료 | 상용 지원 종료 |
|---|---|---|
| 3.5.x | 2026-06-30 (종료) | 2032-06-30 |
| 4.0.x | 2026-12-31 | 2027-12-31 |
| 4.1.x | 2027-07-31 | 2028-07-31 |
3.5는 3.x의 마지막 마이너입니다. 무상 지원이 6월 30일에 끝났으니 오늘 기준 67일째 무상 보안 패치가 없는 상태죠. 4.0의 무상 지원도 117일 뒤인 12월 31일에 끝납니다. 목표는 4.0이 아니라 4.1.x고요. 세대 선택 기준은 3의 무료 지원이 6월에 끝났습니다에 정리해 뒀습니다.
Boot 4는 Servlet 6.1을 베이스라인으로 잡았고, 가이드 표현대로 Undertow가 "아직 이 기준과 호환되지 않아" 지원이 빠졌습니다. 영구 퇴출은 아니라 호환 버전이 나오면 표준 서블릿 경로로 다시 배포할 수 있고요. 지금 선택지는 Tomcat 11.0.x와 Jetty 12.1.x입니다. 참고로 같은 제거 목록의 WebJars는 기능이 없어진 게 아닙니다. 빠진 건 webjars-locator-core 기반 구현이고 webjars-locator-lite로 대체됐습니다.
경계를 바꾼 건 Spring이 아니라 JDK입니다
Java 21 문서는 가상 스레드가 pinned 되는 경우를 둘로 듭니다. synchronized 블록·메서드 안에서 실행할 때, 그리고 native 메서드나 foreign function을 실행할 때죠. 앞의 것 때문에 "라이브러리 어딘가의 synchronized가 캐리어 스레드를 붙잡는다"는 걱정이 따라다녔습니다.
Java 25 문서의 같은 자리는 이렇게 줄어 있습니다.
"A virtual thread is pinned when it runs a native method or a foreign function."
synchronized가 빠졌습니다. 근거는 JEP 491 "Synchronize Virtual Threads without Pinning"이고 상태는 Closed/Delivered에 Release는 24입니다.
여기서 반드시 갈라 봐야 합니다. Spring Boot 4.1.1은 Java 17부터 26까지 돌아갑니다. "Boot 4로 올렸으니 pinning은 해결됐다"고 넘어가기 쉬운데, JEP 491은 JDK 21 LTS로 백포트되지 않았습니다. Boot 버전이 아니라 런타임 JDK를 24 이상(실무에서는 25 LTS)으로 올려야 적용됩니다. Java 17이나 21로 Boot 4를 돌리는 팀에는 예전 조건이 그대로고요.
pinning이 완전히 사라진 것도 아닙니다. JEP 491은 클래스 로딩으로 블록될 때, 클래스 초기화 블록 안에서 블록될 때, 다른 스레드의 클래스 초기화를 기다릴 때를 잔존 사유로 남겨 뒀습니다. native 메서드와 foreign function도 그대로고요.

Java SE 25 가상 스레드 문서의 pinning 정의 (확인일 2026-09-05)
진단 명령은 JDK 버전마다 다릅니다
여기서 오래된 글을 그대로 따라 하면 아무 일도 일어나지 않습니다.
# JDK 21 ~ 23 에서만 동작하는 진단 플래그
java -Djdk.tracePinnedThreads=full -jar app.jar
# JDK 24 이상 — 위 시스템 프로퍼티는 JEP 491 로 제거되어 지정해도 효과가 없다
# 대신 JFR 이벤트로 본다 (기본 활성, 임계값 20ms)
jfr print --events jdk.VirtualThreadPinned recording.jfr
JEP 491 원문 표현이 "이 시스템 프로퍼티를 제거하며, 커맨드라인에 지정해도 아무 효과가 없다"입니다. 오류가 아니라 조용히 무시되니, 로그가 안 찍히는 걸 보고 "pinning이 없구나"로 오독하기 딱 좋습니다.
jdk.VirtualThreadPinned 이벤트는 없어진 게 아니라 의미가 바뀌었습니다. JDK 24 이상에서는 synchronized로는 기록되지 않고, 대신 이벤트에 pinning 사유와 캐리어 스레드 식별자가 붙어 원인을 좁히기 쉬워졌죠. 드라이버가 안전한지도 남의 글 대신 본인이 고정한 버전에서 이 이벤트로 확인하시면 됩니다.
그럼 이제 WebFlux는 안 써도 되나요
아닙니다. 가상 스레드가 대체하는 건 요청 하나가 OS 스레드 하나를 차지하는 비용이지 Reactive Streams의 백프레셔가 아닙니다. Spring 문서는 발행자가 속도를 못 늦추면 buffer·drop·fail 중 무엇을 할지 정해야 한다고 명시하는데요. 이 "수요 신호" 계층이 가상 스레드에는 없습니다. 수만 개 동시 연결에 SSE·WebSocket으로 데이터를 밀어내는 서비스라면 여기가 핵심 자산이죠.
반대 방향도 분명합니다. 스택 선택에 대해 Spring 문서가 직접 쓴 문장이고요.
"If you have blocking persistence APIs (JPA, JDBC) or blocking networking APIs, Spring MVC is the best choice for common architectures."
같은 문서가 "리액티브·논블로킹이 일반적으로 애플리케이션을 더 빠르게 만들지는 않는다"고도 못 박습니다. 기대할 이득은 속도가 아니라 적은 스레드와 적은 메모리로 확장하는 능력이죠. 큰 팀에는 가파른 학습 곡선도 경고합니다.

Spring Framework WebFlux 개요의 스택 선택 기준 (확인일 2026-09-05)
두 스택이 배타적이지도 않습니다. WebFlux는 WebFluxConfigurer#configureBlockingExecution에 VirtualThreadTaskExecutor를 꽂아 블로킹 컨트롤러 메서드를 가상 스레드로 넘길 수 있고, Reactor는 reactor.schedulers.defaultBoundedElasticOnVirtualThreads=true로 boundedElastic을 가상 스레드 기반으로 바꿀 수 있습니다.
스위치 한 줄만 켜면 병목이 옮겨갑니다
spring.threads.virtual.enabled=true는 Boot 3.2에서 들어왔고 Java 21 이상에서 동작합니다. 켜면 바뀌는 곳은 릴리스 노트에 목록으로 나와 있습니다.
| 바뀌는 곳 | 내용 |
|---|---|
| Tomcat·Jetty | 요청 처리에 가상 스레드 사용 |
applicationTaskExecutor |
SimpleAsyncTaskExecutor로 교체 |
taskScheduler |
SimpleAsyncTaskScheduler로 교체 |
| 메시지 리스너 | RabbitMQ·Kafka 리스너, Redis ClusterCommandExecutor, Pulsar |
| Boot 4 추가분 | JDK HttpClient 기반 자동 구성 HTTP 클라이언트 |
목록에 없는 것도 같이 보셔야 합니다. JDBC 호출은 여전히 블로킹이고, 문서가 나열한 적용 대상에 Reactor Netty 이벤트 루프는 없습니다. 블로킹을 없앤 게 아니라 싸게 만든 것이죠.
조용히 무효가 되는 설정도 있습니다. Boot 4.1.1 문서는 가상 스레드가 켜지면 SimpleAsyncTaskScheduler가 풀링 관련 속성을 전부 무시한다고 적어 뒀습니다. spring.task.execution.pool.* 튜닝값이 아무 일도 하지 않게 되는 겁니다.
병목 이동은 더 중요합니다. Tomcat 11 커넥터 기본값은 maxThreads=200, maxConnections=8192이고 useVirtualThreads는 false입니다. 스위치를 켜면 200이라는 방파제가 사라지는데, HikariCP의 maximumPoolSize 기본값은 여전히 10이고 connectionTimeout은 30000ms죠. 대기하던 곳이 스레드 큐에서 커넥션 획득 지점으로 옮겨갈 뿐입니다.
Spring Framework 7.0이 이걸 겨냥해 @ConcurrencyLimit을 넣었습니다. 문서 표현이 "가상 스레드에는 일반적으로 스레드 풀 상한이 없어 특히 유용하다"고요.
@Configuration
@EnableResilientMethods
class ResilienceConfig { }
@Service
class PartnerApiClient {
// 외부 API 로 부하가 통째로 몰리지 않도록 상한을 다시 건다
@ConcurrencyLimit(20)
public Receipt confirm(Order order) { ... }
}
# 스위치와 같은 배포에서 재산정할 값들
spring.threads.virtual.enabled=true
spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.connection-timeout=3000
가상 스레드 스케줄러가 쓸 수 있는 플랫폼 스레드 수에도 기본 상한 256이 있습니다. 무한대가 아니라는 뜻이죠.
ThreadLocal도 훑어보세요. 컨텍스트 전달 용도는 Oracle 문서가 "가상 스레드에서도 전혀 문제없다"고 합니다. 문제는 SimpleDateFormat 같은 비싼 객체를 캐싱해 둔 코드죠. 가상 스레드는 풀링도 재사용도 되지 않아 태스크마다 새 인스턴스가 생깁니다.

데이터 접근 방식과 런타임 JDK가 갈림길을 만듭니다
지금 옮길 팀, 다음 분기로 미룰 팀
지금 정리하시면 되는 경우. JPA나 JDBC를 쓰고 요청당 원격 호출이 한두 번인 CRUD·백오피스 API라면, Boot 4.1로 올리면서 MVC를 유지하고 런타임을 Java 25로 올린 뒤 스위치를 켜는 조합이 맞습니다. 공식 문서가 "블로킹 영속성 API를 쓰면 MVC가 최선"이라고 직접 쓴 경우거든요. "WebFlux가 빠르다더라"로 도입했는데 코드에 block()이나 JPA 호출이 섞여 있는 팀도 여기 해당합니다.
기다리셔도 되는 경우. JDK 21에 고정돼 있고 24/25로 못 올리는 팀은 순서를 바꾸지 마세요. Java 21 문서 기준으로 synchronized 안의 블로킹은 여전히 캐리어 스레드를 잡습니다. JDK 승격이 먼저고 스위치가 나중입니다. 그 근거는 Java 21로는 부족한 이유에 적어 뒀습니다. 스레드 고갈 알람이 없는 팀도 이번엔 Boot 4 이전만 하셔도 됩니다.
하지 않으셔야 하는 일. 잘 돌아가는 WebFlux 애플리케이션을 "가상 스레드 나왔으니까"만으로 MVC로 재작성하는 것, JPA/JDBC를 그대로 둔 채 신규 서비스에 WebFlux를 얹는 것, 커넥션 풀과 타임아웃을 손대지 않은 채 스위치만 켜고 배포하는 것입니다. 끝까지 논블로킹으로 가려면 R2DBC로 갈아타야 하는데, 스펙은 아직 1.0.0.RELEASE이고 Spring Data R2DBC는 JPA의 대체재가 아닙니다.
자주 듣는데 사실이 아닌 이야기
"가상 스레드는 더 빠르다." Oracle 문서 표현은 정반대입니다. 더 빠른 스레드가 아니고, 존재 이유는 속도(낮은 지연)가 아니라 규모(높은 처리량)라고 못 박죠. p50은 거의 그대로고 바뀌는 건 처리량과 스레드 고갈 시점입니다.
"Spring MVC는 스트리밍·SSE를 못 한다." 합니다. SseEmitter, ResponseBodyEmitter, StreamingResponseBody를 지원하고 Flux·Mono 반환도 받습니다. 다만 응답 쓰기는 여전히 블로킹이고 별도 스레드에서 수행되죠. 차이는 가능하냐가 아니라 연결 하나가 스레드를 붙잡느냐입니다.
"두 스타터를 같이 넣으면 WebFlux로 뜬다." 반대입니다. spring-boot-starter-web과 spring-boot-starter-webflux가 함께 있으면 Boot는 MVC를 자동 구성합니다. WebClient를 쓰려고 webflux 스타터를 넣는 팀이 많아서 그렇게 정해졌죠. 강제하려면 WebApplicationType.REACTIVE를 명시해야 합니다.
켜기 전 확인 목록
[ ] 목표 세대를 4.1.x 로 잡았는가 (4.0 은 2026-12-31 종료)
[ ] Undertow 를 쓰는가 → Tomcat 11 / Jetty 12.1 이전을 선행
[ ] 런타임 JDK 가 24 이상인가 → 21 이면 승격이 스위치보다 먼저
[ ] 데이터 접근이 JPA/JDBC 인가 → 그렇다면 MVC 유지가 공식 권고
[ ] 백프레셔·SSE·WebSocket 이 실제 요구사항인가 → 그렇다면 WebFlux 유지
[ ] HikariCP maximumPoolSize 를 기본 10 에서 재산정했는가
[ ] 외부 API 호출에 @ConcurrencyLimit 을 걸었는가
[ ] spring.task.execution.pool.* 이 무효화되는 걸 확인했는가
[ ] ThreadLocal 에 비싼 객체를 캐싱하는 코드가 남아 있는가
다음에 볼 글
런타임 JDK를 21에 두느냐 25로 올리느냐가 이 글의 전제인데요. 그 판단은 Java 21로는 부족한 이유에 정리해 뒀습니다. Boot 세대를 아직 안 정하셨다면 3의 무료 지원이 6월에 끝났습니다를 먼저 보시는 편이 순서가 맞고요.
다음에는 같은 업그레이드 흐름에서 HTTP 클라이언트 쪽을 보겠습니다. RestTemplate을 언제까지 두어도 되는지 기준이 7.0에서 달라졌거든요.
이 글은 Oracle Java SE 문서와 OpenJDK JEP, Spring 공식 레퍼런스와 릴리스 노트를 기준으로 정리했습니다(확인일 2026년 9월 5일). Spring Boot 4.2.0이 11월 30일에 예정돼 있어 버전 숫자는 그때 다시 확인하셔야 합니다. 잘못된 내용이나 보충이 필요한 부분이 있으면 댓글로 알려 주시면 반영하겠습니다.
참고한 자료
- Oracle Core Libraries — Virtual Threads (Java SE 21·25) — pinning 정의 변화, 1만 개 경험칙, JFR 이벤트 표 (확인일 2026-09-05)
- OpenJDK JEP 491 — Release 24, 잔존 pinning 사유, 시스템 프로퍼티 제거 (확인일 2026-09-05)
- Spring Framework 7.0 레퍼런스 — WebFlux 개요(스택 선택·백프레셔), Resilience(
@ConcurrencyLimit) (확인일 2026-09-05) - Spring Boot 3.2 릴리스 노트 —
spring.threads.virtual.enabled적용 대상 목록 (확인일 2026-09-05) - Spring Boot 4.1.1 레퍼런스 — 시스템 요구사항, 태스크 실행·스케줄링, 리액티브 웹 (확인일 2026-09-05)
- Spring Boot 4.0 마이그레이션 가이드 / Spring 지원 일정 API — Undertow, 세대별 종료일 (확인일 2026-09-05)
- Tomcat 11 HTTP Connector / HikariCP README / Reactor 3 Schedulers — 기본값 (확인일 2026-09-05)
'백엔드 > Spring' 카테고리의 다른 글
| [Spring] Micrometer로 갈까 OpenTelemetry 에이전트를 붙일까 — 둘 다 켜면 두 번 찍힙니다 (0) | 2026.09.12 |
|---|---|
| [Spring] Boot가 고정한 Logback은 아직 패치 전입니다 — Log4j2로 갈아탈 이유가 될까요 (0) | 2026.09.11 |
| [Spring] Boot 4로 올리면 JUnit 6이 딸려옵니다 — 그런데 그 6.0.3은 보안 지원 밖입니다 (0) | 2026.09.10 |
| [Spring] Boot 4로 올리면 Jackson이 3으로 갈립니다 — 지금 옮길 팀과 springdoc 때문에 못 옮기는 팀 (0) | 2026.09.09 |
| [Spring] 로컬 DB를 뭘로 띄울까 — Docker Compose와 Testcontainers 나눠 쓰기 (0) | 2026.09.08 |
| [MySQL] 8.0을 그냥 두면 RDS 요금이 붙습니다 — 8.4와 9.7 사이에서 고르기 (0) | 2026.09.02 |
| [Spring] Security 7로 올리면 컴파일부터 깨집니다 — 6.x에서 미리 지울 코드 (0) | 2026.09.01 |