Virtual Threads 4편: Java 21과 Java 25를 하나의 API로 감추기

이 글은 Virtual Threads 시리즈의 4편이다. 전체 시리즈는 Part 1: 소개와 주의점, Part 2: workshop rules, Part 3: JDBC + Virtual Threads benchmark, Part 4: Java 21/25 SPI 설계로 이어진다.
Part 1에서는 Virtual Threads를 어떻게 바라볼지 정리했고, Part 2에서는 workshop rules를 봤고,
Part 3에서는 bluetape4k-exposed benchmark에서 JDBC + Virtual Threads가 R2DBC + Coroutines보다 나은
경우가 많다는 걸 봤다.
마지막으로 볼 주제는 library 설계다. 앞의 글들이 runtime과 workload 이야기였다면, 이번 글은 그 차이를 application code에서 어떻게 숨길지에 관한 이야기다. 사용자에게는 단순하게, 내부는 JDK별로 다르게.
Java 21과 Java 25를 동시에 지원하면서, application code에는 하나의 API만 보여줄 수 있을까?
bluetape4k-projects의 virtualthread module은 이 문제를 공통 API + JDK별 runtime provider로 나눴다.
application code가 “지금 Java 21인가, 25인가?”를 매번 따져야 한다면 API가 너무 많은 사정을 밖으로
드러내는 것이다.
그림의 핵심은 application이 jdk21/jdk25 구현체를 직접 알지 않는다는 점이다. application은 공통
API만 호출하고, ServiceLoader가 runtime provider를 고른다. 그러면 JDK별 차이는 배포 artifact와
provider 내부로 들어간다.

Java 21과 Java 25의 차이
섹션 제목: “Java 21과 Java 25의 차이”Java 21은 Virtual Threads의 기준선이다. JEP 444로 Virtual Threads가
정식 기능이 됐다. Executors.newVirtualThreadPerTaskExecutor(), Thread.ofVirtual(), thread dump/JFR
관찰성 같은 기본 도구가 여기서 출발한다.
Java 25는 Virtual Threads 자체를 새로 만든 release가 아니다. 대신 Java 21 이후의 Loom 계열 개선을 함께 가져오는 LTS 지점이다. 완전히 다른 기술이라기보다, 같은 방향으로 다듬어진 다음 장기 지원 지점에 도착한 release에 가깝다.
| Release | Virtual Threads 관점의 의미 |
|---|---|
| Java 21 | Virtual Threads final, thread-per-task API 사용 가능 |
| Java 24 | JEP 491로 synchronized pinning 완화 |
| Java 25 | LTS. Scoped Values final, Structured Concurrency preview 유지 |
JDK 25 project page의 feature list에도 Structured Concurrency와 Scoped Values가 들어 있다. 특히 Scoped Values는 Virtual Threads와 같이 쓸 때 ThreadLocal보다 더 예측 가능한 context 전달 모델을 제공한다.
하지만 library가 Java 25 API를 compile-time dependency로 직접 노출하면 Java 21 runtime에서 깨질 수 있다. 그래서 application은 공통 API만 보고, runtime module은 실행 JDK에 맞게 교체하는 구조가 필요하다.
공통 API: VirtualThreadRuntime
섹션 제목: “공통 API: VirtualThreadRuntime”핵심 interface는 작아야 한다.
interface VirtualThreadRuntime { val runtimeName: String val priority: Int
fun isSupported(): Boolean fun threadFactory(prefix: String = "vt-"): ThreadFactory fun executorService(): ExecutorService}여기서 중요한 설계는 세 가지다.
| 항목 | 이유 |
|---|---|
runtimeName | diagnostics와 log에서 어떤 provider가 선택됐는지 보인다 |
priority | JDK 25 provider가 가능하면 JDK 21 provider보다 먼저 선택된다 |
isSupported() | classpath에 있어도 현재 JVM에서 안전하지 않으면 제외한다 |
application code는 구현체를 직접 import하지 않는다.
import io.bluetape4k.concurrent.virtualthread.VirtualThreads
VirtualThreads.executorService().use { executor -> val answer = executor.submit<Int> { 21 * 2 }.get() println("${VirtualThreads.runtimeName()} -> $answer")}Runtime 선택: ServiceLoader
섹션 제목: “Runtime 선택: ServiceLoader”VirtualThreads object는 ServiceLoader로 provider를 찾고, 지원 가능한 provider 중 priority가 높은
것을 선택한다.
object VirtualThreads { private val providers: List<VirtualThreadRuntime> by lazy { ServiceLoader.load(VirtualThreadRuntime::class.java) .filter { provider -> provider.isSupported() } .sortedByDescending { provider -> provider.priority } .toList() }
fun runtime(): VirtualThreadRuntime = providers.firstOrNull() ?: PlatformThreadRuntime
fun runtimeName(): String = runtime().runtimeName
fun threadFactory(prefix: String = "vt-"): ThreadFactory = runtime().threadFactory(prefix)
fun executorService(): ExecutorService = runtime().executorService()}실제 code는 provider 탐색 중 exception이 나도 전체 선택이 실패하지 않도록 방어한다. runtime discovery는 application startup을 망가뜨리면 안 된다. “JDK 25 provider가 현재 JVM에서 뜨지 못한다”는 이유로 Virtual Threads 전체가 죽기보다, JDK 21 provider나 platform fallback으로 내려가는 편이 낫다.
Provider 등록
섹션 제목: “Provider 등록”각 runtime module은 service file 하나로 자신을 등록한다.
META-INF/services/io.bluetape4k.concurrent.virtualthread.VirtualThreadRuntimeJava 21 module은 Java 21 구현체를 넣는다.
io.bluetape4k.concurrent.virtualthread.jdk21.Jdk21VirtualThreadRuntimeJava 25 module은 Java 25 구현체를 넣고 더 높은 priority를 준다.
class Jdk25VirtualThreadRuntime : VirtualThreadRuntime { override val runtimeName: String = "jdk25" override val priority: Int = 25
override fun isSupported(): Boolean = Runtime.version().feature() >= 25
override fun threadFactory(prefix: String): ThreadFactory = Thread.ofVirtual().name(prefix, 0).factory()
override fun executorService(): ExecutorService = Executors.newVirtualThreadPerTaskExecutor()}Java 21 구현체는 priority를 낮게 둔다.
class Jdk21VirtualThreadRuntime : VirtualThreadRuntime { override val runtimeName: String = "jdk21" override val priority: Int = 21
override fun isSupported(): Boolean = Runtime.version().feature() >= 21
override fun threadFactory(prefix: String): ThreadFactory = Thread.ofVirtual().name(prefix, 0).factory()
override fun executorService(): ExecutorService = Executors.newVirtualThreadPerTaskExecutor()}공통 API는 같지만, JDK 25 module은 필요하면 Java 25에서만 가능한 API를 내부 구현에 사용할 수 있다. application code는 이 차이를 모른다.
Gradle dependency 모양
섹션 제목: “Gradle dependency 모양”compile classpath에는 공통 API만 둔다.
dependencies { implementation("io.github.bluetape4k:bluetape4k-virtualthread-api")}runtime에는 배포 JDK에 맞는 구현체만 넣는다.
dependencies { runtimeOnly("io.github.bluetape4k:bluetape4k-virtualthread-jdk21") // or runtimeOnly("io.github.bluetape4k:bluetape4k-virtualthread-jdk25")}주의할 점은 Java 21 runtime에 Java 25 class file을 올리지 않는 것이다. isSupported()가 있어도 class
loading 단계에서 깨질 수 있다. 그래서 배포 pipeline에서 JDK별 artifact를 분리하거나, runtime image에
맞는 provider만 넣는 것이 안전하다.
Fallback이 필요한 이유
섹션 제목: “Fallback이 필요한 이유”공통 API는 platform fallback도 제공한다. JDK 17 이하나 provider가 없는 환경에서는 cached platform thread executor로 내려간다.
private object PlatformThreadRuntime : VirtualThreadRuntime { override val runtimeName: String = "platform-fallback" override val priority: Int = Int.MIN_VALUE override fun isSupported(): Boolean = true override fun executorService(): ExecutorService = Executors.newCachedThreadPool(threadFactory("pt-"))}fallback은 “성능을 보장한다”가 아니다. library가 더 넓은 환경에서 부드럽게 동작하게 하는 장치다.
운영에서는 VirtualThreads.runtimeName()을 metric이나 startup log에 남겨 실제로 어떤 provider가
선택됐는지 확인해야 한다.
예제: application code는 바뀌지 않는다
섹션 제목: “예제: application code는 바뀌지 않는다”JDK 21에서도, JDK 25에서도 application code는 같다.
class BlockingReportService( private val repository: ReportRepository,) { fun generate(ids: List<Long>): List<Report> = VirtualThreads.executorService().use { executor -> ids.map { id -> executor.submit<Report> { repository.loadAndRender(id) } }.map { future -> future.get() } }}이 예제에서 repository.loadAndRender(id)가 JDBC나 blocking SDK를 써도 된다. Virtual Thread가
blocking wait를 감당하기 때문이다. 물론 Part 1에서 본 것처럼 DB connection pool, timeout, downstream
concurrency는 별도로 제한해야 한다. API가 단순해졌다고 운영 환경의 제약까지 사라지는 것은 아니다.
시리즈 결론
섹션 제목: “시리즈 결론”Virtual Threads는 Java/Kotlin backend에서 실용적인 선택지가 됐다.
- Part 1: Virtual Threads는 cheap thread이지 magic thread가 아니다.
- Part 2: workshop rules는 pooling, semaphore, context, lock discipline을 잡아준다.
- Part 3: batch workload에서는 JDBC + Virtual Threads가 R2DBC + Coroutines보다 나은 경우가 많았다.
- Part 4: library는 Java 21/25 차이를 공통 API와 JDK별 provider로 숨길 수 있다.
내가 좋아하는 결론은 이렇다. Virtual Threads는 reactive나 coroutine을 없애는 기술이 아니다. 기존 blocking ecosystem을 다시 측정 가능한 선택지로 만든 기술이다. 그러니 library가 한쪽만 강요할 필요는 없다. 좋은 library는 하나의 방식만 강요하기보다, workload와 runtime에 맞는 선택지를 열어두는 쪽에 가깝다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.