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

이 글은 Virtual Threads 시리즈의 4편이다. 전체 시리즈는 Part 1: 소개와 주의점, Part 2: 워크숍 규칙, Part 3: JDBC + Virtual Threads 벤치마크, Part 4: Java 21/25 SPI 설계로 이어진다.
Part 1에서는 Virtual Threads를 어떻게 바라볼지 정리했고, Part 2에서는 워크숍 규칙을 봤고,
Part 3에서는 bluetape4k-exposed 벤치마크에서 JDBC + Virtual Threads가 R2DBC + Coroutines보다 나은
경우가 많다는 걸 봤다.
마지막으로 볼 주제는 라이브러리 설계다. 앞의 글들이 런타임과 workload 이야기였다면, 이번 글은 그 차이를 애플리케이션 코드에서 어떻게 숨길지에 관한 이야기다. 사용자에게는 단순하게, 내부는 JDK별로 다르게.
Java 21과 Java 25를 동시에 지원하면서, 애플리케이션 코드에는 하나의 API만 보여줄 수 있을까?
bluetape4k-projects의 virtualthread 모듈은 이 문제를 공통 API + JDK별 런타임 provider로 나눴다.
애플리케이션 코드가 “지금 Java 21인가, 25인가?”를 매번 따져야 한다면 API가 너무 많은 사정을 밖으로
드러내는 것이다.
그림의 핵심은 애플리케이션이 jdk21/jdk25 구현체를 직접 알지 않는다는 점이다. 애플리케이션은 공통
API만 호출하고, ServiceLoader가 런타임 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 자체를 새로 만든 릴리스가 아니다. 대신 Java 21 이후의 Loom 계열 개선을 함께 가져오는 LTS 지점이다. 완전히 다른 기술이라기보다, 같은 방향으로 다듬어진 다음 장기 지원 지점에 도착한 릴리스에 가깝다.
| 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의 기능 목록에도 Structured Concurrency와 Scoped Values가 들어 있다. 특히 Scoped Values는 Virtual Threads와 같이 쓸 때 ThreadLocal보다 더 예측 가능한 context 전달 모델을 제공한다.
하지만 라이브러리가 Java 25 API를 컴파일 시점 의존성으로 직접 노출하면 Java 21 런타임에서 깨질 수 있다. 그래서 애플리케이션은 공통 API만 보고, 런타임 모듈은 실행 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에서 안전하지 않으면 제외한다 |
애플리케이션 코드는 구현체를 직접 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()}실제 코드는 provider 탐색 중 예외가 나도 전체 선택이 실패하지 않도록 방어한다. 런타임 탐색은 애플리케이션 시작을 망가뜨리면 안 된다. “JDK 25 provider가 현재 JVM에서 뜨지 못한다”는 이유로 Virtual Threads 전체가 죽기보다, JDK 21 provider나 platform 대체 경로로 내려가는 편이 낫다.
Provider 등록
섹션 제목: “Provider 등록”각 런타임 모듈은 service file 하나로 자신을 등록한다.
META-INF/services/io.bluetape4k.concurrent.virtualthread.VirtualThreadRuntimeJava 21 모듈은 Java 21 구현체를 넣는다.
io.bluetape4k.concurrent.virtualthread.jdk21.Jdk21VirtualThreadRuntimeJava 25 모듈은 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 모듈은 필요하면 Java 25에서만 가능한 API를 내부 구현에 사용할 수 있다. 애플리케이션 코드는 이 차이를 모른다.
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 런타임에 Java 25 class file을 올리지 않는 것이다. isSupported()가 있어도 class
loading 단계에서 깨질 수 있다. 그래서 배포 pipeline에서 JDK별 artifact를 분리하거나, runtime image에
맞는 provider만 넣는 것이 안전하다.
Fallback이 필요한 이유
섹션 제목: “Fallback이 필요한 이유”공통 API는 platform 대체 경로도 제공한다. 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-"))}대체 경로는 “성능을 보장한다”가 아니다. 라이브러리가 더 넓은 환경에서 부드럽게 동작하게 하는 장치다.
운영에서는 VirtualThreads.runtimeName()을 metric이나 startup log에 남겨 실제로 어떤 provider가
선택됐는지 확인해야 한다.
예제: 애플리케이션 코드는 바뀌지 않는다
섹션 제목: “예제: 애플리케이션 코드는 바뀌지 않는다”JDK 21에서도, JDK 25에서도 애플리케이션 코드는 같다.
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는 저비용 thread이지 magic thread가 아니다.
- Part 2: 워크숍 규칙은 pooling, semaphore, context, lock discipline을 잡아준다.
- Part 3: batch workload에서는 JDBC + Virtual Threads가 R2DBC + Coroutines보다 나은 경우가 많았다.
- Part 4: 라이브러리는 Java 21/25 차이를 공통 API와 JDK별 provider로 숨길 수 있다.
내가 좋아하는 결론은 이렇다. Virtual Threads는 reactive나 coroutine을 없애는 기술이 아니다. 기존 blocking ecosystem을 다시 측정 가능한 선택지로 만든 기술이다. 그러니 라이브러리가 한쪽만 강요할 필요는 없다. 좋은 라이브러리는 하나의 방식만 강요하기보다, workload와 런타임에 맞는 선택지를 열어두는 쪽에 가깝다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.