Java 25에서 가상 스레드를 실무에 올릴 때 확인한 것들
가상 스레드를 켜는 건 한 줄이지만, 진짜 문제는 피닝(JDK 24에서 해결)이 아니라 백프레셔가 사라진 자리다. JDK 25 기준으로 다시 정리한다.
몇 년째 "가상 스레드를 써야지" 하면서도 미뤄 왔다. JDK 21에서 정식화됐을 때는 아직 synchronized 피닝 같은 함정이 남아 있었고, 운영에 올렸다가 발목 잡힌 사례도 적잖게 들렸기 때문이다. 그런데 JDK 25가 LTS로 나오고 그 사이 JDK 24에서 결정적인 문제들이 정리되면서, 더 미룰 이유가 사라졌다. 이번에 실제 서비스 하나를 가상 스레드로 올려 보며 확인한 것들을 정리한다.
결론부터 말하면, 가상 스레드를 켜는 건 설정 한 줄이다. 하지만 "스레드를 늘렸다"가 아니라 병목이 어디로 옮겨갔는지를 이해하지 못하면, 처리량은 올라가는데 에러율도 같이 올라가는 상황을 만난다. 그리고 2023년에 널리 퍼진 조언 중 일부는 JDK 25에서 이미 틀린 말이 됐다. 이 두 가지가 이 글의 핵심이다.
왜 가상 스레드인가 — 스레드 부족이라는 오래된 벽
기존의 플랫폼 스레드는 OS 스레드와 1:1로 묶인다. 스레드 하나가 보통 1MB 안팎의 스택을 잡고, 컨텍스트 스위칭도 커널을 거친다. 그래서 스레드를 무한정 만들 수 없고, 서버는 보통 수백 개 규모의 스레드 풀로 요청을 처리한다.
문제는 이 모델이 블로킹 I/O가 많은 워크로드에서 금방 한계에 부딪힌다는 점이다. 요청 하나가 DB나 외부 API를 기다리는 동안 그 스레드는 아무 일도 못 하고 묶여 있다. 200개짜리 풀이라면 동시에 블로킹되는 작업이 200개를 넘는 순간부터 요청이 큐에 쌓이고 지연이 급격히 늘어난다. CPU는 놀고 있는데 스레드가 없어서 못 받는, 전형적인 "스레드 기아" 상황이다.
그래서 업계는 비동기·리액티브로 도망쳤다. CompletableFuture 체인, WebFlux 같은 것들이다. 확장성은 얻었지만 대가가 컸다. 순차적으로 읽히던 코드가 콜백과 연산자 체인으로 쪼개지고, 예외는 엉뚱한 곳에서 터지고, 스택 트레이스는 쓸모가 없어진다. 디버깅과 유지보수 비용이 코드 전체에 깔린다.
가상 스레드는 이 딜레마를 정면으로 겨냥한다. 블로킹 코드를 그대로 쓰면서도 비동기만큼 확장하자는 것이다.
가상 스레드는 어떻게 동작하는가
가상 스레드는 OS가 아니라 JVM이 관리하는 가벼운 스레드다. 생성 비용이 수백 바이트 수준이라 수십만 개를 띄워도 부담이 적다. 핵심은 실행 방식이다. 가상 스레드는 실제로 실행될 때 캐리어 스레드(실제 플랫폼 스레드)에 올라탄다(mount). 그리고 블로킹 지점을 만나면 캐리어에서 내려오고(unmount), 캐리어는 그동안 다른 가상 스레드를 실행한다.
여기서 캐리어 스레드는 ForkJoinPool로 관리되며, 병렬도(parallelism)는 사용 가능한 CPU 코어 수와 같다. 이 풀 크기를 바꾸는 공식 설정은 없다. 즉 캐리어는 코어 수만큼으로 희소하고, 가상 스레드는 사실상 무제한으로 많다. 수만 개의 블로킹 가상 스레드가 몇 개의 캐리어 위에서 교대로 실행되는 그림이다.
코드 모델은 단순하다. 요청마다 가상 스레드 하나를 쓰고, 내부는 평범한 블로킹 코드로 둔다.
1// 요청마다 가상 스레드 하나. 내부는 그대로 blocking.2try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {3 for (var request : requests) {4 executor.submit(() -> {5 var user = userClient.fetch(request.userId()); // blocking I/O6 var order = orderClient.fetch(request.orderId()); // blocking I/O7 return render(user, order);8 });9 }10}바뀐 것은 코드 스타일이 아니다. 비동기로 뒤틀어 놓았던 코드를 다시 평범한 순차 코드로 되돌릴 수 있다는 점이다. fetch()가 I/O를 기다리는 동안 가상 스레드는 캐리어를 반납하므로, 블로킹이 더는 비싸지 않다. Spring Boot라면 spring.threads.virtual.enabled=true 한 줄로 요청 스레드를 가상 스레드로 바꿀 수 있다. 여기까지만 보면 "설정 한 줄"이 맞다.
하지만 — 2023년의 공포, 피닝은 이미 해결됐다
가상 스레드를 미뤄 온 가장 큰 이유가 **피닝(pinning)**이었다. JDK 21 시절에는 가상 스레드가 synchronized 블록 안에서 블로킹하면 캐리어에서 내려오지 못했다. 캐리어가 그 가상 스레드에 "고정"되는 것이다. 이런 가상 스레드가 많아지면 코어 수만큼뿐인 캐리어가 전부 묶여, 다른 가상 스레드가 아예 실행되지 못하는 기아나 데드락까지 생길 수 있었다.
그래서 당시의 정석 조언은 하나였다. "가상 스레드를 쓸 거면 synchronized를 ReentrantLock으로 바꿔라." 수많은 코드베이스가 락을 갈아엎었다. 내가 미뤄 온 이유도 여기에 있었다. 레거시 전체의 synchronized를 손대는 건 부담스러웠다.
그런데 여기서 반전이 있다. JDK 24의 JEP 491이 모니터 소유권 추적 방식을 통째로 바꿨다. 모니터를 캐리어가 아니라 가상 스레드의 정체성으로 추적하도록 재설계해서, 가상 스레드가 synchronized 블록 안에서도 안전하게 unmount 할 수 있게 됐다. 결과적으로 JDK 24부터 synchronized는 더 이상 피닝을 일으키지 않는다.
즉 JDK 25에서 "피닝 때문에 synchronized를 바꿔야 한다"는 말은 이미 틀렸다. 내가 미뤄 온 가장 큰 이유 자체가 사라진 셈이다. 블로그 글을 그대로 믿고 레거시 락을 갈아엎을 뻔했는데, 자기 JDK 버전에서 확인하는 것이 먼저라는 교훈을 여기서 얻었다.
물론 피닝이 완전히 사라진 건 아니다. 여전히 남는 경우가 있다.
- 네이티브 프레임: JNI나 FFM(Foreign Function & Memory)으로 네이티브 코드에 진입한 동안은 안전하게 unmount 할 수 없다.
- 클래스 로딩·초기화: 클래스 초기화가 진행되는 구간.
- 리눅스의 파일 I/O: JDK에 아직 io_uring 기반의 production-ready 통합이 없어 파일 I/O는 캐리어를 묶을 수 있다.
다만 이 경우들은 범위가 좁고, 일반적인 애플리케이션 코드에서 연쇄적인 정지로 이어질 가능성은 낮다. 그래도 운영 투입 전에는 반드시 측정해야 한다. JDK 25에서는 JFR이 jdk.VirtualThreadPinned 이벤트를 기본 20ms 임계값으로 남긴다. 참고로 예전에 쓰던 -Djdk.tracePinnedThreads 시스템 프로퍼티는 JDK 24에서 제거됐다.
1# 피닝 이벤트를 JFR로 확인2java -XX:StartFlightRecording=filename=app.jfr -jar app.jar3jfr print --events jdk.VirtualThreadPinned app.jfr4
5# 캐리어별로 묶인 가상 스레드까지 보려면6jcmd <pid> Thread.dump_to_file -format=json threads.json진짜 병목은 옮겨갔다 — 이게 핵심이다
피닝이 정리되자, 더 중요한 문제가 드러난다. 사실 이게 가상 스레드를 운영에 올릴 때 가장 먼저 부딪히는 벽이다.
플랫폼 스레드 시절, 200개짜리 스레드 풀은 사실 의도치 않은 백프레셔 장치였다. 요청 스레드가 200개뿐이니 DB 커넥션을 동시에 요구하는 작업도 많아야 200개였다. 커넥션 풀이 작아도 요청 스레드 수가 자연스럽게 상한을 걸어 준 것이다.
가상 스레드는 이 천장을 치운다. 이제 요청 1만 개가 동시에 들어오면, 1만 개의 가상 스레드가 전부 커넥션 풀 10개를 향해 달려든다. 결과는 커넥션 풀 고갈이다. InfoQ의 JDBC 벤치마크에서는 가상 스레드로 처리량이 147% 늘었는데 에러율은 37%로 플랫폼 스레드와 똑같았다. 양쪽 모두 커넥션 풀을 포화시켰기 때문이다. 처리량만 보고 "성공"이라고 판단하면 놓치는 지점이다.
해결의 핵심은 발상의 전환이다. 작업의 격리(가상 스레드, 무제한)와 자원의 제한(명시적 상한)을 분리해야 한다. 가상 스레드는 요청마다 자유롭게 만들되, 커넥션 풀·외부 API 호출·파일 디스크립터처럼 바운드된 자원마다 명시적인 세마포어로 동시성을 제한한다.
1// 커넥션 풀 크기에 맞춰 동시 접근을 명시적으로 제한한다.2private static final Semaphore DB_PERMITS = new Semaphore(50);3
4public Order loadOrder(long id) {5 DB_PERMITS.acquireUninterruptibly();6 try {7 return jdbc.queryForObject(SQL, orderMapper, id); // blocking8 } finally {9 DB_PERMITS.release();10 }11}여기서 가상 스레드를 풀링해서 동시성을 제한하려는 유혹을 반드시 경계해야 한다. 목표는 맞지만 도구가 틀렸다. 가상 스레드는 수백 바이트짜리 일회용 자원이라, 풀에 가둬 재사용하는 건 설계 의도를 정면으로 거스른다. 동시성을 제한하고 싶으면 가상 스레드 풀이 아니라 세마포어다.
실무에서는 맨 세마포어가 최소한이고, 그 이상이 필요하면 Resilience4j의 Bulkhead를 쓰는 편이 낫다. 세마포어에 메트릭·타임아웃·폴백·Micrometer 연동까지 붙은, "세마포어 강화판"이라고 보면 된다.
요청 컨텍스트는 ThreadLocal이 아니라 Scoped Values로
가상 스레드를 요청당 하나씩 만들면 ThreadLocal이 애매해진다. 스레드가 수만~수십만 개로 늘어나므로 각 스레드에 붙은 ThreadLocal 값도 그만큼 늘어나고, InheritableThreadLocal로 상속이라도 하면 복사 비용까지 커진다. 게다가 ThreadLocal은 가변이라 remove()를 깜빡하면 누수가 난다.
JDK 25에서 정식화된 Scoped Values(JEP 506)가 이 자리를 대신한다. 여러 버전의 프리뷰를 거쳐 드디어 정식이 됐다. 값이 불변이고, 바인딩된 범위 안에서만 보이며, 범위를 벗어나면 자동으로 사라진다. set()이나 remove()가 아예 없어서 누수가 구조적으로 불가능하다.
1private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();2
3// 이 범위 안에서만 값이 보인다. 벗어나면 자동으로 사라진다.4ScopedValue.where(REQUEST_ID, requestId).run(() -> {5 service.process(); // 내부 어디서든 REQUEST_ID.get()6});값을 잠깐 다르게 쓰고 싶으면 중첩 where(...).run(...)으로 재바인딩하면 되고, 중첩 범위를 벗어나면 바깥 값이 복원된다. 정식화 과정에서 바뀐 건 사실상 하나뿐이다. orElse(...)가 더 이상 null을 받지 않는다. API가 그만큼 안정적으로 넘어왔다는 뜻이다. 요청 ID, 인증 주체처럼 요청 범위가 분명한 컨텍스트에 쓰기에 가장 적합하고, 가상 스레드·구조적 동시성과 함께 쓸 때 특히 빛을 본다.
구조적 동시성은 아직 프리뷰 — 방향만 익혀 둔다
가상 스레드로 하위 작업 여러 개를 동시에 띄우는 건 쉽다. 하지만 "하나가 실패하면 나머지를 취소하고, 에러를 깔끔한 스택 트레이스로 전파한다" 같은 수명 주기 관리는 수동으로 하면 금방 복잡해진다. 누수된 하위 작업이 남거나 취소가 새는 식이다. 구조적 동시성(JEP 505)은 이 묶음을 하나의 단위로 다루게 해 준다. 사실 정확성 측면에서는 가상 스레드 자체보다 이쪽이 더 중요하다는 평가도 있다.
JDK 25에서는 API가 한 번 더 다듬어졌다. 이제 생성자가 아니라 정적 팩토리 open()으로 열고, 완료 정책은 Joiner로 주입한다. 예전의 ShutdownOnFailure 같은 서브클래스는 사라졌다.
1// JEP 505 (프리뷰) — 두 호출을 묶고, 하나라도 실패하면 함께 취소2try (var scope = StructuredTaskScope.open()) {3 var user = scope.fork(() -> userClient.fetch(id));4 var order = scope.fork(() -> orderClient.fetch(id));5 scope.join(); // 둘 다 끝날 때까지 대기6 return new Result(user.get(), order.get());7}다만 이건 JDK 25에서도 여전히 프리뷰다. --enable-preview로 켜야 하고 API가 더 바뀔 수 있다. 그러니 지금 운영에 넣기보다, "하위 작업을 묶어 한 번에 성공/실패와 취소를 다룬다"는 모델을 이해해 두는 선에서 그친다. 방향이 분명하므로 정식화됐을 때 옮기기 쉽다.
정리 — 체크리스트와 판단 기준
- 피닝:
JDK 24(JEP 491)부터synchronized는 피닝을 일으키지 않는다. 락을 갈아엎을 필요 없다. 남은 피닝은 네이티브·클래스로딩·파일 I/O 정도이고,jdk.VirtualThreadPinnedJFR 이벤트로 측정 후 판단한다. - 진짜 할 일: 가상 스레드는 스레드 풀이라는 백프레셔를 제거한다. 커넥션 풀 등 바운드 자원마다 세마포어(또는 Bulkhead)로 동시성을 제한해야 풀 고갈을 막는다. 가상 스레드는 절대 풀링하지 않는다.
- 컨텍스트: 요청 단위 컨텍스트는
ThreadLocal대신 Scoped Values(JDK 25 정식)로. - 구조적 동시성: 아직 프리뷰 — 방향만 익혀 둔다.
가상 스레드를 켜는 건 설정 한 줄이지만, 제대로 올렸다는 기준은 "스레드를 늘렸다"가 아니다. 백프레셔가 사라진 자리에 세마포어를 놓았는가, 피닝을 측정했는가, 컨텍스트를 Scoped Values로 옮겼는가가 기준이다. 그리고 하나 더 — 2년 전 블로그를 그대로 믿지 말고, 반드시 내 JDK 버전에서 확인할 것. 이번에 synchronized 조언이 뒤집힌 걸 직접 겪고 나니, 그게 가장 오래 남는 교훈이었다.