가상 스레드가 멈추는 순간 — Pinning 문제
넷플릭스가 겪은 일
2024년 7월, 넷플릭스는 가상 스레드를 도입한 서비스 하나가 트래픽이 몰리는 시점마다 전체적으로 멈추는 장애를 겪었습니다. 원인을 추적해 보니 synchronized 블록 안에서 블로킹 I/O 를 호출하는 코드가 있었고, 이게 가상 스레드를 캐리어 스레드에 "고정(pin)"시켜 버렸습니다. 캐리어 스레드가 다 고정되자 새로운 가상 스레드는 실행될 자리를 못 찾고 무한정 대기했습니다. 겉보기엔 데드락과 똑같은 증상이지만 원인은 완전히 다릅니다.
이 사례를 먼저 꺼낸 이유는, pinning 이 이론적 우려가 아니라 실제로 대형 서비스를 멈춰 세운 문제이기 때문입니다.
synchronized 안에서는 왜 언마운트가 안 되는가
앞 장에서 가상 스레드는 블로킹 지점을 만나면 캐리어 스레드에서 언마운트된다고 했습니다. 그런데 이 언마운트가 항상 가능한 건 아닙니다. synchronized 블록이나 메서드 안에서 블로킹 호출을 만나면, JVM 은 언마운트를 하지 않고 캐리어 스레드를 그대로 붙잡아 둡니다. 이 상태를 pinning 이라고 부릅니다.
public class LegacyCache {
private final Map<String, String> cache = new HashMap<>();
public synchronized String get(String key) {
String value = cache.get(key);
if (value == null) {
value = fetchFromRemoteStore(key); // 블로킹 I/O
cache.put(key, value);
}
return value;
}
}fetchFromRemoteStore 가 응답을 기다리는 동안, 이 가상 스레드는 캐리어 스레드를 계속 점유합니다. 캐리어 스레드 수는 보통 CPU 코어 수만큼(예: 8개)뿐인데, get() 을 동시에 호출하는 가상 스레드가 8개를 넘는 순간부터 나머지는 캐리어 스레드가 빌 때까지 순서대로 기다립니다. 가상 스레드를 수만 개 띄워도 실질적인 동시 처리량이 캐리어 스레드 수로 되돌아가 버리는 겁니다 — 애초에 가상 스레드를 쓴 이유가 무색해집니다.
왜 이런 제약이 생겼을까요. synchronized 는 JVM 이 관리하는 모니터 락인데, 이 락은 원래 OS 스레드(정확히는 스레드 ID) 단위로 소유권을 추적하도록 설계되어 있었습니다. 가상 스레드를 언마운트하면서 락 소유권을 유지하려면 이 저수준 구조를 바꿔야 했고, 그게 바로 다음 절에서 설명할 JEP 491 의 작업이었습니다.
JEP 491 — Java 24 에서 해결된 부분
Java 21~23 까지는 synchronized pinning 이 구조적인 한계였습니다. Java 24 에 포함된 JEP 491("Synchronize Virtual Threads without Pinning")이 이 문제를 해결해서, synchronized 블록 안에서도 가상 스레드가 정상적으로 언마운트됩니다. Java 25 LTS 부터는 이 개선이 기본 동작입니다.
그렇다고 pinning 이 완전히 사라진 건 아닙니다. 여전히 pinning 이 발생하는 두 가지 경우가 남아 있습니다.
- 네이티브 메서드 호출 중 — JNI 로 네이티브 코드를 부르는 동안은 여전히 캐리어 스레드가 고정됩니다.
Object.wait()를 사용하는 코드 — 오래된 동기화 패턴이 남아있는 레거시 코드베이스에서 종종 나타납니다.
지금 운영 중인 버전을 확인하라
이 장에서 가장 실용적인 조언은 하나입니다. 당신의 서비스가 Java 24 미만이라면, synchronized 블록 안에 블로킹 I/O 가 있는지 반드시 점검하세요. 특히 레거시 캐시 구현, 커넥션 풀 코드, 오래된 서드파티 라이브러리에 이런 패턴이 자주 숨어 있습니다.
java -Djdk.tracePinnedThreads=full -jar app.jar이 JVM 옵션을 켜면, 가상 스레드가 pinning 되는 순간 스택 트레이스를 로그로 남깁니다. short 대신 full 을 쓰면 더 상세한 정보가 나옵니다. 운영 환경에 배포하기 전에 스테이징에서 이 옵션으로 한 번 돌려보는 걸 강하게 권합니다. 로그에 아무것도 안 찍히면 그것만으로도 안심할 근거가 됩니다.
Java 24 이상이라면 synchronized 자체는 더 이상 걱정할 게 아닙니다. 다만 습관적으로 synchronized 를 ReentrantLock 으로 바꿔야 한다는 조언이 인터넷에 아직도 많이 남아 있는데, Java 24 이상에서는 이 조언이 더 이상 필요하지 않다는 점도 함께 알아두시면 좋겠습니다.