본문 바로가기
삵
Java

가상 스레드가 synchronized에서 고정될 때

aappsroot·2026년 10월 17일·조회 0

Tomcat 10.1부터 요청 처리를 가상 스레드로 돌릴 수 있게 되면서, 스레드 풀 튜닝에 쏟던 고민이 한결 줄었다. 그런데 막상 운영에 올려보면 "가상 스레드를 켰는데 동시 처리량이 기대만큼 안 오른다"는 상황을 자주 만난다. 커넥터 설정은 멀쩡한데 캐리어 스레드 몇 개가 계속 바쁘고, 응답은 밀린다. 대개 범인은 코드 어딘가의 synchronized다. 이 글은 그 고정(pinning)을 찾아내고 푸는 순서를 정리한다.

결론부터 말하자면, JDK 21~23에서는 -Djdk.tracePinnedThreads나 JFR의 jdk.VirtualThreadPinned 이벤트로 고정 지점을 찾은 뒤, 길고 잦은 곳의 synchronized를 ReentrantLock으로 바꾸면 된다. JDK 24부터는 JEP 491이 synchronized로 인한 고정을 JVM 차원에서 없앴으므로, 올려만 봐도 상당수가 해결된다.

1. 개요와 대상 버전

대상은 JDK 21(가상 스레드 정식 반영), JDK 22~23, 그리고 JEP 491이 반영된 JDK 24다. 설명과 동작은 Oracle의 Virtual Threads 가이드와 OpenJDK JEP 491 문서를 기준으로 한다.

먼저 용어부터 정리한다. 가상 스레드는 JVM이 관리하는 경량 스레드로, 실제 실행은 플랫폼 스레드(OS 스레드)에 올라타서 이뤄진다. 이때 가상 스레드를 실어 나르는 플랫폼 스레드를 캐리어 스레드라고 부른다. 가상 스레드가 블로킹 작업을 만나면 캐리어에서 내려오고(unmount), 그동안 캐리어는 다른 가상 스레드를 실어 나른다. 이 교대가 가상 스레드의 핵심이다.

고정(pinning)은 이 교대가 막히는 상태다. 가상 스레드가 블로킹되는데도 캐리어에서 내려오지 못하고, 귀한 OS 스레드 하나를 그대로 붙잡고 있는다. 캐리어 수는 기본적으로 CPU 코어 수 수준이라, 고정된 가상 스레드가 몇 개만 생겨도 전체 처리량이 주저앉을 수 있다.

2. 무엇이 고정을 일으키나

Oracle 문서가 명시하는 고정 조건은 두 가지다.

  • 가상 스레드가 synchronized 블록이나 메서드 안에서 실행 중일 때
  • 가상 스레드가 native 메서드나 외부 함수(JNI, Foreign Function and Memory API)를 실행 중일 때

예를 들어 게시글 저장 메서드가 synchronized로 묶여 있고 그 안에서 DB 호출이나 외부 API 호출 같은 블로킹 I/O를 한다고 하자. 요청 100건이 동시에 들어오면 가상 스레드 100개가 생기지만, 그 블로킹 구간에서 캐리어를 놓지 못한다. 가상 스레드를 쓰는데도 사실상 플랫폼 스레드 풀로 되돌아간 꼴이 된다.

한 가지 전제를 기억해 두면 좋다. Oracle 문서의 표현대로 "고정이 애플리케이션을 틀리게 만들지는 않는다. 다만 확장성을 저해할 수 있다." 즉 짧거나 드문 synchronized까지 다 바꿀 필요는 없고, 길고 잦은 고정만 손보면 된다.

3. -Djdk.tracePinnedThreads로 범인 찾기

JDK 21~23에서는 시스템 속성 jdk.tracePinnedThreads로 고정이 일어나는 지점의 스택 트레이스를 찍을 수 있다. full은 전체 스택을, short는 문제 되는 프레임만 출력한다.

# JDK 21~23 에서만 동작
java -Djdk.tracePinnedThreads=full -jar app.jar

고정이 발생하면 아래 형태로 찍힌다. 모니터를 쥔 프레임 끝에 <== monitors:1 표시가 붙는 것이 핵심이다. 이 줄이 바로 범인이다.

Thread[#42,ForkJoinPool-1-worker-3,5,CarrierThreads]
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
    java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
    java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:679)
    java.base/java.lang.VirtualThread.park(VirtualThread.java:593)
    ...
    app//com.example.OrderService.save(OrderService.java:48) <== monitors:1

여기서 한 번 걸리기 쉽다. jdk.tracePinnedThreads는 출력량이 많고, 프로덕션에서 상시로 켜 두기에는 부담스럽다. 그래서 상시 관측에는 JFR 이벤트를 쓴다. jdk.VirtualThreadPinned 이벤트는 기본으로 켜져 있고, 임계값은 20ms다. 즉 20ms보다 길게 고정된 경우만 기록하므로 짧은 고정 노이즈를 걸러 준다.

# 60초 기록 후 고정 이벤트만 추려 보기
java -XX:StartFlightRecording=filename=rec.jfr,duration=60s -jar app.jar

jfr print --events jdk.VirtualThreadPinned rec.jfr
jdk.VirtualThreadPinned {
  startTime = 10:32:14.512
  duration = 215 ms
  stackTrace = [
    com.example.OrderService.save(OrderService.java:48)
    com.example.OrderController.create(OrderController.java:31)
    ...
  ]
}

duration이 수백 ms로 길고 같은 프레임이 반복해서 찍히면 손봐야 할 지점이다. 반대로 어쩌다 한 번, 수 ms짜리라면 그냥 둬도 된다.

4. synchronized를 ReentrantLock으로 전환

길고 잦은 고정이 확인된 자리에서는 synchronized를 java.util.concurrent.locks.ReentrantLock으로 바꾼다. ReentrantLock은 락 대기 중에 가상 스레드가 캐리어에서 내려올 수 있어, 고정이 생기지 않는다.

전환 전 코드다.

synchronized (lockObj) {
    frequentIO();   // 블로킹 I/O - 여기서 고정 발생
}

전환 후 코드다. lock()과 unlock() 사이를 try/finally로 감싸 해제를 보장하는 것이 관례다.

private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    frequentIO();
} finally {
    lock.unlock();
}

전부 다 바꾸는 것은 과하다. Oracle 문서도 "짧은 수명이거나 드문 작업을 지키는 synchronized까지 바꿀 필요는 없다"고 못 박는다. 3절에서 찾아낸 지점만 선택적으로 바꾼다.

5. JDK 24에서 JEP 491 효과 확인

JDK 24에는 JEP 491(Synchronize Virtual Threads without Pinning)이 반영됐다. 핵심은 "가상 스레드가 캐리어와 독립적으로 모니터를 획득, 보유, 해제할 수 있다"는 것이다. JVM이 모니터 소유권을 가상 스레드 단위로 추적하게 바뀌어, synchronized 안에서 블로킹해도 캐리어가 풀린다. 코드를 고치지 않아도 기존 라이브러리까지 함께 혜택을 본다.

확인 방법은 간단하다. JDK 24에서는 jdk.tracePinnedThreads 속성이 제거됐으므로, JFR 이벤트로 본다. 4절에서 ReentrantLock으로 바꾸기 전의 synchronized 코드를 그대로 JDK 24에서 돌리고 같은 JFR 기록을 떠 보면, synchronized로 인한 jdk.VirtualThreadPinned 이벤트가 더는 찍히지 않는다.

# JDK 24: 같은 부하로 기록 후 확인
java -XX:StartFlightRecording=filename=rec24.jfr,duration=60s -jar app.jar
jfr print --events jdk.VirtualThreadPinned rec24.jfr
# synchronized 로 인한 고정 이벤트가 사라진다

처리량은 환경에 따라 다르지만, 캐리어가 블로킹 구간에서 풀리므로 동시 요청이 많을수록 차이가 크게 벌어진다. 정확한 수치는 각자 부하 도구로 전후를 재서 비교하는 것이 맞다. JDK 24로 올릴 수 있는 환경이라면, 코드를 대규모로 고치기보다 런타임을 올리는 쪽이 비용이 낮다.

6. JDK 24에서도 남는 고정

JEP 491은 synchronized 고정을 없앴지만 모든 고정을 없앤 것은 아니다. 다음은 여전히 남는다.

  • native 메서드와 외부 함수 호출(JNI, FFM) 중 블로킹 - 네이티브 프레임이 스택에 있으면 내려올 수 없다
  • 클래스 로딩 중 블로킹, 클래스 초기화 블록 안에서의 블로킹, 다른 스레드의 클래스 초기화 대기

JEP 491 문서는 이들 잔여 사례가 "드물게만 문제를 일으킨다"고 적는다. 그래도 JNI를 많이 쓰는 레거시 연동이 있다면 JDK 24에서도 jdk.VirtualThreadPinned JFR 이벤트는 계속 유효하다. 네이티브 프레임 고정이 의심되면 이 이벤트로 관측한다.

7. 정리

가상 스레드를 켰는데 처리량이 안 오르면 고정을 의심하고, JDK 21~23에서는 -Djdk.tracePinnedThreads로 지점을 찾은 뒤 길고 잦은 synchronized만 ReentrantLock으로 바꾼다. 상시 관측은 JFR의 jdk.VirtualThreadPinned 이벤트로 한다. JDK 24로 올릴 수 있으면 JEP 491 덕에 synchronized 고정은 코드 수정 없이 사라지고, 남는 것은 주로 JNI 같은 네이티브 호출뿐이다.

자주 묻는 질문

jdk.tracePinnedThreads를 프로덕션에 상시로 켜 둬도 되나?

권장하지 않는다. 고정이 날 때마다 스택 트레이스를 찍어 출력량이 많고 성능에도 영향이 있다. 상시 관측에는 기본으로 켜져 있고 20ms 임계값으로 노이즈를 걸러 주는 JFR 이벤트 jdk.VirtualThreadPinned를 쓰고, tracePinnedThreads는 원인 분석 시점에만 임시로 켜는 것이 낫다.

모든 synchronized를 ReentrantLock으로 바꿔야 하나?

아니다. Oracle 문서는 고정이 길고 잦은 지점만 바꾸라고 권한다. 짧은 수명이거나 드물게 실행되는 synchronized는 고정이 생겨도 확장성에 큰 영향이 없으므로 그대로 둬도 된다. 먼저 tracePinnedThreads나 JFR로 실제 문제 지점을 특정한 뒤 선택적으로 전환한다.

JDK 24로 올리면 코드를 안 고쳐도 고정이 사라지나?

synchronized로 인한 고정은 JEP 491 덕에 JVM 차원에서 사라진다. 코드를 바꾸지 않아도 되고 기존 라이브러리도 함께 혜택을 본다. 다만 JNI와 외부 함수 호출, 클래스 로딩이나 초기화 대기에서 생기는 고정은 JDK 24에서도 남는다.

JDK 24에서 jdk.tracePinnedThreads가 동작하지 않는다.

JDK 24에서 synchronized가 더는 고정을 일으키지 않게 되면서 이 시스템 속성은 제거됐다. 남은 네이티브 프레임 고정을 보려면 JFR의 jdk.VirtualThreadPinned 이벤트를 사용한다.

Tomcat에서 가상 스레드를 켜면 고정 문제도 자동으로 피할 수 있나?

아니다. Tomcat 10.1부터 요청 처리를 가상 스레드로 돌릴 수 있지만, 고정은 요청을 처리하는 애플리케이션 코드 안의 synchronized나 네이티브 호출에서 발생한다. 커넥터 설정과 무관하므로, JDK 21~23이라면 코드 차원에서 찾아 풀거나 JDK 24로 올려야 한다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.