Java 21의 LTS 출시와 Spring Boot 3.2의 등장으로, 자바 진영의 동시성 모델은 Virtual Threads(Project Loom)라는 거대한 전환점을 맞이했습니다. 이제 spring.threads.virtual.enabled=true 설정 한 줄이면 기존의 무거운 플랫폼 스레드(Platform Thread) 대신, 가볍고 무한에 가까운 가상 스레드를 사용할 수 있게 되었습니다.
하지만 "설정만 켜면 성능이 10배가 된다"는 마법 같은 이야기는 반은 맞고 반은 틀립니다. 기존 스레드 모델에 최적화된 라이브러리나 코딩 습관을 그대로 가져갈 경우, 오히려 심각한 장애를 유발할 수 있기 때문입니다.
이번 글에서는 Virtual Threads 도입 시 실무에서 반드시 체크해야 할 심화 주제인 Pinning(고정) 문제와 ThreadLocal 메모리 이슈를 중점적으로 다루어 보겠습니다.
1. Virtual Threads의 환상과 현실
Virtual Thread의 핵심은 Blocking I/O가 발생할 때 스레드를 재우지 않고(Unmount), 다른 작업을 처리한다는 것입니다. 이를 통해 물리적인 OS 스레드(Carrier Thread) 갯수를 늘리지 않고도 수만, 수십만 개의 동시 요청을 처리할 수 있습니다.
기존에는 1개의 요청 당 1개의 OS 스레드(1-to-1)가 필요했지만, Virtual Thread는 M개의 가상 스레드가 N개의 OS 스레드(M-to-N) 위에서 스케줄링 됩니다.
하지만 이 메커니즘이 동작하려면, 가상 스레드가 "나 이제 쉴게(Blocking)"라고 JVM에게 신호를 주고 빠져나올 수 있어야 합니다. 만약 특정 상황에서 빠져나오지 못하고 물리 스레드를 붙잡고 늘어진다면 어떻게 될까요?
2. 가장 큰 함정: Pinning (고정) 이슈
Virtual Thread가 특정 코드 블록에 진입했을 때, Carrier Thread(실제 OS 스레드)에 '고정(Pin)'되어 Unmount가 불가능해지는 현상이 발생합니다. 이렇게 되면 해당 스레드는 Blocking I/O 작업을 만나도 멈춰 서 있게 되고, 소중한 물리 스레드 자원을 낭비하게 됩니다.
Pinning이 발생하는 조건
synchronized블록 또는 메서드 내부에서 Blocking 작업이 일어날 때- 네이티브 메서드(JNI)를 호출할 때
특히 synchronized 키워드는 자바 생태계 전반에 퍼져 있기 때문에 가장 주의해야 합니다.
// [Bad Pattern] Pinning 발생!
public synchronized void heavyDatabaseCall() {
// 이 블록 안에서 I/O가 발생하면
// Virtual Thread는 Carrier Thread를 물고 놓아주지 않습니다.
repository.findAll();
}
위 코드에서 repository.findAll()이 실행되는 동안, 이 작업을 수행 중인 OS 스레드는 완전히 멈춥니다. 만약 OS 스레드가 10개밖에 없는데 10개의 요청이 동시에 저 메서드를 호출한다면? 전체 시스템이 멈추는(Deadlock과 유사한) 현상이 발생합니다.
해결책: ReentrantLock 사용
synchronized 대신 java.util.concurrent.locks.ReentrantLock을 사용하면 Pinning 문제를 피할 수 있습니다. JVM이 이를 감지하고 스레드 스케줄링을 정상적으로 수행합니다.
// [Best Practice] ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();
public void heavyDatabaseCall() {
lock.lock();
try {
// 이제 I/O가 발생해도 Virtual Thread가 Unmount 됩니다.
// Carrier Thread는 다른 가상 스레드를 처리하러 갑니다.
repository.findAll();
} finally {
lock.unlock();
}
}
synchronized를 사용합니다. Virtual Threads 도입 전, 사용 중인 라이브러리가 최신 버전(Pinning 이슈가 해결된 버전)인지 반드시 확인해야 합니다.3. 메모리 폭탄: ThreadLocal 오염
기존 스레드 풀(Thread Pool) 모델에서는 스레드 개수가 제한적(예: 200개)이었습니다. 따라서 ThreadLocal에 다소 무거운 객체를 저장해도 전체 메모리 사용량은 예측 가능했습니다.
하지만 Virtual Thread는 생성 비용이 저렴하여 요청마다 새로 생성됩니다. 만약 수만 개의 가상 스레드가 동시에 실행되는데, 각 스레드가 ThreadLocal에 1MB짜리 객체를 들고 있다면 어떻게 될까요?
| 구분 | 스레드 수 | ThreadLocal 크기 | 총 메모리 사용량 |
|---|---|---|---|
| 기존 스레드 풀 | 200개 | 1 MB | 약 200 MB (안정적) |
| Virtual Threads | 100,000개 | 1 MB | 약 100 GB (OOM 발생) |
Virtual Thread 환경에서는 ThreadLocal 사용을 최소화해야 합니다. 특히 무거운 객체를 캐싱하는 용도로 사용하는 것은 지양해야 하며, 꼭 필요하다면 데이터의 크기를 아주 작게 유지해야 합니다.
대안: Scoped Values (JEP 446)
Java 21(Preview)부터는 ThreadLocal의 대안으로 Scoped Values를 제공합니다. 이는 불변(Immutable)이며, 스레드의 생명주기와 명확하게 결합되어 있어 메모리 누수 위험이 적고 가상 스레드 환경에 최적화되어 있습니다.
4. 스레드 풀(Thread Pool)을 없애라
우리는 습관적으로 ExecutorService를 사용하여 스레드 풀을 만들고, 작업을 큐에 쌓아 처리해 왔습니다. 하지만 Virtual Thread는 풀링(Pooling)이 필요 없습니다.
생성 비용이 거의 0에 가깝기 때문에, 작업을 할 때마다 새로운 스레드를 만들고 작업이 끝나면 가비지 컬렉터(GC)에 의해 수거되도록 두는 것이 디자인 의도에 맞습니다.
// [Don't] 가상 스레드를 풀링하지 마세요.
ExecutorService badPool = Executors.newFixedThreadPool(100, Thread.ofVirtual().factory());
// [Do] 필요할 때마다 새로운 가상 스레드 실행기를 사용하세요.
ExecutorService goodExecutor = Executors.newVirtualThreadPerTaskExecutor();
마치며: 은탄환은 없다
Spring Boot에서 Virtual Threads를 적용하는 것은 매력적이지만, CPU 집약적인 작업(이미지 처리, 암호화 등)에서는 성능 향상이 거의 없거나 오히려 컨텍스트 스위칭 오버헤드로 인해 느려질 수 있습니다. I/O Blocking이 주가 되는 웹 애플리케이션에서 가장 큰 효과를 발휘한다는 점을 기억해야 합니다.
실무 도입 전, 반드시 -Djdk.tracePinnedThreads=full 옵션을 켜고 테스트를 수행하여 Pinning 현상이 발생하는지 모니터링하시기 바랍니다.
'IT > Java' 카테고리의 다른 글
| Java Virtual Threads (Project Loom) 입문 및 성능 튜토리얼 (0) | 2025.11.11 |
|---|---|
| ConcurrentModificationException (반복 중 컬렉션 변경 오류) 발생 원인과 해결 방법 (0) | 2025.03.14 |
| 클래스 로드 오류 해결 방법 (ClassNotFoundException 및 NoClassDefFoundError) (0) | 2025.03.11 |
| Java OutOfMemoryError (메모리 부족 오류) 원인과 해결 방법소개 (1) | 2025.03.10 |
| Java 인증서 추가 방법 ( SunCertPathBuilderException 해결 ) (0) | 2021.10.27 |
댓글