콘텐츠로 이동

Virtual Threads 1편: 싸지만 마법은 아닌 스레드

명시적 한계 안에서 많은 가벼운 실행 경로를 다루는 소개용 일러스트
Virtual thread는 값싼 thread지만, blocking을 무제한으로 써도 된다는 뜻은 아니다.

이 글은 Virtual Threads 시리즈의 1편이다. 전체 시리즈는 Part 1: 소개와 주의점, Part 2: workshop rules, Part 3: JDBC + Virtual Threads benchmark, Part 4: Java 21/25 SPI 설계로 이어진다.

Virtual Threads를 처음 들으면 살짝 의심부터 든다. “이제 blocking code를 그대로 써도 됩니다”라니, 이렇게 좋은 말에는 보통 조건이 붙는다. 여기에도 조건이 있다. Virtual Threads는 마법이 아니라, Java가 thread-per-request 스타일을 훨씬 낮은 비용으로 다시 쓸 수 있게 해 주는 기술이다.

Practical map for using virtual threads with blocking IO, backpressure, diagnostics, and cleanup
Blocking code를 다시 꺼내도 된다는 반가운 소식. 단, timeout과 resource limit까지 같이 챙길 때만.

전통적인 servlet/JDBC 코드는 읽기 쉽다. 위에서 아래로 내려가고, 중간에 갑자기 callback 미로가 열리지도 않는다.

fun handle(request: Request): Response {
val user = userRepository.findById(request.userId)
val orders = orderRepository.findRecent(user.id)
return Response(user, orders)
}

문제는 이 단순함의 비용을 platform thread가 치른다는 점이었다. 요청이 DB, HTTP, filesystem 같은 I/O를 기다리는 동안 OS thread도 같이 묶인다. 요청 수가 많아지면 application은 CPU보다 thread 수에서 먼저 막힌다.

JEP 444는 Java 21에서 Virtual Threads를 정식 기능으로 만들었다. Virtual Thread는 java.lang.Thread이지만 OS thread와 1:1로 묶이지 않는다. blocking I/O에서 멈추면 carrier thread에서 내려오고, 나중에 다시 올라탄다. 그래서 “동기 코드처럼 쓰면서도 높은 동시성”이라는 조합이 가능해진다.

Executors.newVirtualThreadPerTaskExecutor().use { executor ->
val futures = urls.map { url ->
executor.submit<String> {
URL(url).readText()
}
}
val pages = futures.map { it.get() }
}

이 구조를 그림으로 보면 더 단순하다. task는 virtual thread에 맡기고, 제한은 thread가 아니라 뒤쪽 resource에 둔다.

중요한 단서가 있다. Virtual Thread는 “더 빠른 thread”가 아니다. CPU-bound 작업을 더 빨리 계산하지 않는다. 효과는 많은 작업이 대부분의 시간을 기다리는 I/O-heavy service에서 난다.

Virtual Thread를 처음 쓸 때 가장 흔한 습관은 pool부터 만들려는 것이다. 오래된 JVM 개발자의 손은 ExecutorService를 보면 반사적으로 pool size를 찾는다.

// 주의: virtual thread 자체를 제한하려고 pool처럼 다룬다.
val executor = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory())

Virtual Thread는 비싼 자원이 아니므로 pooling 대상이 아니다. 제한해야 하는 것은 thread 수가 아니라 뒤쪽 자원이다. DB connection, remote API concurrency, file descriptor, queue depth 같은 것들이다.

private val permits = Semaphore(32)
fun <T> withRemoteLimit(block: () -> T): T {
permits.acquire()
return try {
block()
} finally {
permits.release()
}
}

Virtual Thread는 많이 만들어도 된다. DB connection은 아니다. 이 차이를 놓치면 “Virtual Threads로 바꿨더니 성능이 좋아졌다”가 아니라 “Virtual Threads로 바꿨더니 병목이 DB로 옮겨 갔다”가 된다.

Virtual Threads가 특히 잘 맞는 곳은 다음과 같다.

잘 맞는 작업이유
JDBC transaction기존 blocking API를 유지하면서 동시성을 높일 수 있다
HTTP client call요청당 하나의 sequential flow를 보존하기 쉽다
batch partition 처리partition별 작업을 단순한 task로 쪼갤 수 있다
legacy SDK wrappercallback/reactive API로 억지 변환하지 않아도 된다

반대로 다음은 조심해야 한다.

조심할 작업이유
CPU-bound loopthread 수를 늘려도 core 수를 넘는 계산량은 빨라지지 않는다
unbounded fan-outVirtual Thread는 싸지만 downstream은 싸지 않다
blocking inside global lockJava 21에서는 pinning 문제가 커질 수 있다
ThreadLocal-heavy codethread 수가 많아지면 memory footprint가 커질 수 있다

Java 21의 pinning과 Java 25에서 달라진 점

섹션 제목: “Java 21의 pinning과 Java 25에서 달라진 점”

Java 21에서 Virtual Thread를 쓸 때 synchronized 안에서 blocking하면 carrier thread가 pinning될 수 있었다. 그래서 초반 가이드는 “blocking 가능성이 있는 synchronized 영역을 줄이고, 필요하면 ReentrantLock을 보라”는 식이었다.

JEP 491은 Java 24에서 synchronized가 Virtual Thread를 pinning하지 않도록 바꿨고, Java 25에도 이 개선이 포함된다. 이 변화 덕분에 Java 25 계열에서는 synchronized를 무조건 피해야 한다는 조언이 약해졌다.

하지만 “이제 아무 lock 안에서 아무 I/O나 해도 된다”는 뜻은 아니다. JEP 491도 native code나 일부 JVM 내부 경계에서는 pinning이 남을 수 있다고 설명한다. 그리고 lock을 오래 잡는 코드는 Virtual Thread와 상관없이 병목이 되기 쉽다.

실전 규칙은 이렇게 잡는 편이 안전하다.

  • Java 21에서는 jdk.tracePinnedThreads와 JFR로 pinning을 직접 확인한다.
  • Java 25에서는 synchronized 부담은 줄어들지만 lock scope는 여전히 좁힌다.
  • native/FFM/JNI 경계에서 blocking하는 코드는 별도로 관찰한다.

ThreadLocal은 되지만 공짜는 아니다

섹션 제목: “ThreadLocal은 되지만 공짜는 아니다”

Java 21의 Virtual Thread는 ThreadLocal을 지원한다. 기존 library migration에는 큰 장점이다. 다만 지원된다는 것과 마음껏 써도 된다는 것은 다르다.

request마다 Virtual Thread를 만들면 request-scope ThreadLocal은 thread 종료와 함께 사라진다. 이건 platform thread pool보다 오히려 안전한 면도 있다. 하지만 Virtual Thread 수가 매우 많아지면 각 thread에 큰 object를 넣는 방식은 memory cost로 돌아온다.

Java 25에서는 JEP 506으로 Scoped Values가 정식 기능이 됐다. request context처럼 “부모 scope에서 읽기 전용으로 내려보내고, scope가 끝나면 사라지는 값”에는 ThreadLocal보다 더 맞는 모델이다.

Kotlin에서 자연스럽게 쓰는 방식

섹션 제목: “Kotlin에서 자연스럽게 쓰는 방식”

Kotlin Coroutines와 Virtual Threads는 경쟁 관계라기보다 선택지가 다르다.

CoroutinesVirtual Threads
suspend API가 이미 있으면 자연스럽다blocking API가 이미 있으면 자연스럽다
structured concurrency가 언어/라이브러리 중심이다Java thread/tooling 모델을 그대로 쓴다
async driver와 잘 맞는다JDBC, blocking SDK, legacy API와 잘 맞는다
cancellation 전파를 설계해야 한다interrupt/timeout/lifecycle을 설계해야 한다

bluetape4k에서는 둘 다 쓴다. 새 API가 suspend 중심이면 Coroutines가 잘 맞는다. 이미 검증된 blocking API가 있고 bottleneck이 waiting이라면 Virtual Threads가 더 간단하다.

Virtual Threads를 production에 넣기 전에 최소한 이 정도는 확인한다.

항목확인할 것
concurrency limitthread가 아니라 downstream resource를 제한하는가
timeoutFuture.get, DB query, HTTP call, batch partition에 timeout이 있는가
cancellationinterrupt를 삼키지 않는가
diagnosticsJFR/thread dump/metrics에서 virtual thread 상태를 볼 수 있는가
lock scopeblocking I/O가 긴 lock 안에 있지 않은가
ThreadLocal큰 object나 connection을 thread-local로 들고 있지 않은가

Part 1의 결론은 이렇다. Virtual Threads는 “비동기 코드를 안 배워도 된다”가 아니라 “기존 blocking code를 최소한의 변경으로 다시 쓸 수 있다”에 가깝다. 대신 그만큼 기본기는 더 중요해진다. resource limit, timeout, cleanup, 관찰성을 같이 챙겨야 한다.

Part 2에서는 bluetape4k-workshop/virtualthreads/rules의 예제를 바탕으로 pooling, semaphore, ScopedValue, lock 사용 규칙을 더 구체적으로 본다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.