콘텐츠로 이동

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

하나의 SPI가 Java 21과 Java 25 runtime 구현을 연결하는 소개용 일러스트
SPI 경계가 충분히 단순하면, 하나의 public API 뒤에 두 runtime 선택지를 숨길 수 있다.

이 글은 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-projectsvirtualthread module은 이 문제를 공통 API + JDK별 runtime provider로 나눴다. application code가 “지금 Java 21인가, 25인가?”를 매번 따져야 한다면 API가 너무 많은 사정을 밖으로 드러내는 것이다.

그림의 핵심은 application이 jdk21/jdk25 구현체를 직접 알지 않는다는 점이다. application은 공통 API만 호출하고, ServiceLoader가 runtime provider를 고른다. 그러면 JDK별 차이는 배포 artifact와 provider 내부로 들어간다.

ServiceLoader module layout for Java 21 and Java 25 virtual thread runtimes
Java 21과 25 차이는 내부로 숨기고, application code에는 하나의 Virtual Threads API만 보여주는 방법.

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에 가깝다.

ReleaseVirtual Threads 관점의 의미
Java 21Virtual Threads final, thread-per-task API 사용 가능
Java 24JEP 491synchronized pinning 완화
Java 25LTS. 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에 맞게 교체하는 구조가 필요하다.

핵심 interface는 작아야 한다.

interface VirtualThreadRuntime {
val runtimeName: String
val priority: Int
fun isSupported(): Boolean
fun threadFactory(prefix: String = "vt-"): ThreadFactory
fun executorService(): ExecutorService
}

여기서 중요한 설계는 세 가지다.

항목이유
runtimeNamediagnostics와 log에서 어떤 provider가 선택됐는지 보인다
priorityJDK 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")
}

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으로 내려가는 편이 낫다.

각 runtime module은 service file 하나로 자신을 등록한다.

META-INF/services/io.bluetape4k.concurrent.virtualthread.VirtualThreadRuntime

Java 21 module은 Java 21 구현체를 넣는다.

io.bluetape4k.concurrent.virtualthread.jdk21.Jdk21VirtualThreadRuntime

Java 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는 이 차이를 모른다.

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만 넣는 것이 안전하다.

공통 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을 남길 수 있습니다.