콘텐츠로 이동

Virtual Threads 2편: 실전에서 바로 걸리는 규칙들

실전 virtual thread 규칙이 checklist로 정리되는 소개용 일러스트
쓸모 있는 rule은 JDBC pool, lock, shutdown path와 부딪혀도 버티는 rule이다.

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

Part 1은 큰 그림이었다. 이번 글은 조금 더 손에 잡히는 규칙을 다룬다. 근거는 bluetape4k-workshop/virtualthreads/rules의 예제들이다. 이 예제들은 “Virtual Threads를 쓰면 된다”에서 끝나지 않는다. “이렇게 쓰면 기대만큼 효과가 나지 않는다”까지 같이 보여준다.

Practical virtual thread rules from bluetape4k-workshop
Virtual Threads를 켰다고 끝이 아니다. pool, semaphore, context, lock에서 바로 차이가 나는 실전 규칙들.

규칙 1. I/O 대기가 대부분이면 blocking synchronous code를 그대로 살릴 수 있다

섹션 제목: “규칙 1. I/O 대기가 대부분이면 blocking synchronous code를 그대로 살릴 수 있다”

Rule2WriteBlockingSynchronousCode는 같은 흐름을 세 가지 방식으로 보여준다. CompletableFuture, Virtual Thread, Coroutines다. 여기서 Virtual Thread 예제의 장점은 control flow가 거의 그대로 읽힌다는 점이다.

Executors.newVirtualThreadPerTaskExecutor().use { executor ->
val price = executor.submit<Int> { readPriceInEur() }
val rate = executor.submit<Float> { readExchangeRateEurToUsd() }
val netAmount = price.get() * rate.get()
val tax = executor.submit<Float> { readTax(netAmount) }
val grossAmount = netAmount * (1.0F + tax.get())
grossAmount.toInt() shouldBeEqualTo 108
}

이 코드는 non-blocking처럼 보이려고 꾸미지 않는다. blocking function을 그대로 task로 나누고, 필요한 지점에서 get()으로 결과를 모은다. I/O wait가 대부분인 request-response 작업에서는 이 단순함이 큰 장점이다.

다만 CPU-bound 작업까지 이런 식으로 늘리면 안 된다. Virtual Thread가 CPU core를 새로 만들어주지는 않는다. 계산 자체가 무거우면 결국 core 수가 한계가 된다.

규칙 2. Virtual Thread를 pool에 넣지 않는다

섹션 제목: “규칙 2. Virtual Thread를 pool에 넣지 않는다”

Rule3DoNotPoolVirtualThreads의 메시지는 명확하다. Virtual Thread는 재사용하려고 pooling하는 대상이 아니다.

// 비추천: virtual thread factory를 ThreadPoolExecutor에 넣는다.
Executors.newCachedThreadPool(Thread.ofVirtual().factory()).use { executor ->
executor.submit { Thread.sleep(1000) }
}

이렇게 쓰면 이름은 Virtual Thread지만 사고방식은 여전히 pool이다. 기본값은 thread-per-task executor가 되어야 한다.

// 추천: task마다 새 virtual thread를 만든다.
Executors.newVirtualThreadPerTaskExecutor().use { executor ->
executor.submit { Thread.sleep(1000) }
executor.submit { Thread.sleep(1000) }
}

Virtual Thread 생성은 싸다. 제한해야 할 것은 thread 수가 아니라 뒤쪽 자원이다.

규칙 3. 동시성 제한은 FixedThreadPool이 아니라 Semaphore로 한다

섹션 제목: “규칙 3. 동시성 제한은 FixedThreadPool이 아니라 Semaphore로 한다”

Rule4UseSemaphoreInsteadOfFixedThreadPools는 Virtual Thread에서 가장 중요한 운영 규칙을 보여준다. 동시성 제한은 thread pool size로 하지 말고, 보호하려는 자원 앞에서 한다.

private val semaphore = Semaphore(8)
private fun useSemaphoreToLimitConcurrency(): String {
semaphore.acquire()
return try {
sharedResource()
} finally {
semaphore.release()
}
}

그리고 executor는 여전히 virtual-thread-per-task를 쓴다.

Executors.newVirtualThreadPerTaskExecutor().use { executor ->
val futures = List(100) {
executor.submit {
useSemaphoreToLimitConcurrency()
}
}
futures.forEach { it.get() }
}

이 패턴이 중요한 이유는 단순하다. Virtual Thread는 많이 만들어도 되지만, DB connection이나 외부 API quota는 무한하지 않다. semaphore는 “어떤 자원을 보호하는가”를 코드에 직접 드러낸다.

규칙 4. ThreadLocal보다 ScopedValue를 먼저 고려한다

섹션 제목: “규칙 4. ThreadLocal보다 ScopedValue를 먼저 고려한다”

Rule5UseThreadLocalVariablesCarefullyInheritableThreadLocalScopedValue를 비교한다. ThreadLocal은 migration 때는 편하지만, Virtual Thread 수가 많아질수록 context 관리 비용과 누수 위험이 커진다.

ScopedValue는 scope가 끝나면 값이 사라진다.

private val scopedValue = ScopedValue.newInstance<String>()
ScopedValue.where(scopedValue, "zero").run {
scopedValue.get() shouldBeEqualTo "zero"
ScopedValue.where(scopedValue, "one").run {
scopedValue.get() shouldBeEqualTo "one"
}
scopedValue.get() shouldBeEqualTo "zero"
}

structured task scope와 같이 쓰면 child task에서도 scope context를 읽을 수 있다.

structuredTaskScopeAll { scope ->
scope.fork {
scopedValue.get() shouldBeEqualTo "zero"
-1
}
scope.join().throwIfFailed()
}

주의할 점도 있다. ScopedValue는 아무 thread 생성 방식에나 자동으로 전파되는 만능 context가 아니다. 전파 경계가 구조화된 scope인지 확인해야 한다.

규칙 5. lock은 작게 잡고, Java 21에서는 pinning도 의식한다

섹션 제목: “규칙 5. lock은 작게 잡고, Java 21에서는 pinning도 의식한다”

Rule6UseSynchronizedBlocksAndMethodsCarefully는 Java 21 기준으로 synchronized pinning 위험을 피하기 위해 ReentrantLock을 쓰는 예제를 둔다.

private val lock = ReentrantLock()
virtualFuture {
lock.withLock {
exclusiveResource()
}
}.await()

Java 25에서는 JEP 491 덕분에 synchronized pinning 부담이 줄었다. 그래도 이 규칙은 여전히 의미가 있다. pinning이 줄어도 긴 critical section은 병목이다. lock 안에서 오래 blocking하는 코드는 나중에 profiler를 열었을 때 “왜 여기서 다 기다리고 있지?”라는 질문을 만들기 쉽다.

virtualthreads/rules의 메시지는 이렇게 요약된다.

하지 말 것대신 할 것
Virtual Thread를 pool에 넣기newVirtualThreadPerTaskExecutor()
FixedThreadPool로 동시성 제한Semaphore로 downstream resource 제한
ThreadLocal에 큰 context 저장ScopedValue 또는 명시적 context 전달
긴 lock 안에서 blockinglock scope 축소, 필요하면 ReentrantLock
CPU-bound 작업까지 virtual thread로 밀어넣기CPU-bound는 core 수와 executor 전략을 별도 설계

Virtual Threads는 기존 blocking code를 최소한의 변경으로 다시 쓸 수 있게 해준다. 하지만 제한, context, lock을 대충 처리해도 된다는 뜻은 아니다. 쉬워진 만큼 잘못 쓰기도 쉬워진다. Part 3에서는 이 규칙을 실제 database benchmark에 대입해 본다.

댓글

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