Published on

Spring Boot 3·Java 21 가상스레드 데드락/지연 진단

Authors

서버 스레드 모델을 가상스레드로 바꾸면 "스레드 부족" 문제는 급격히 줄어듭니다. 하지만 운영에서 체감하는 장애 양상은 오히려 더 교묘해집니다. 예를 들어 요청 수는 처리되는데 특정 구간에서만 꼬리 지연(tail latency)이 튀거나, 데드락처럼 보이지만 실제로는 DB 커넥션 풀 고갈, 락 경합, 혹은 가상스레드 핀ning(pinning)으로 인해 플랫폼 스레드가 막혀 전체가 느려지는 상황이 흔합니다.

이 글은 Spring Boot 3(서블릿/Tomcat 또는 WebFlux)에서 Java 21 가상스레드를 적용한 뒤, 데드락·지연이 생겼을 때 "무엇을 먼저 의심하고, 어떤 증거를 어떻게 모으며, 어떤 방향으로 고치는지"를 실전 진단 순서로 정리합니다.

참고로 DB 병목이 의심될 때는 PostgreSQL VACUUM·AUTOVACUUM 튜닝 - bloat로 느려질 때도 함께 보면 원인 분리가 빨라집니다.

가상스레드 적용 체크리스트: 무엇이 바뀌나

Java 21의 가상스레드는 많은 동시 요청을 "싸게" 처리할 수 있게 해주지만, 다음 전제는 그대로입니다.

  • 외부 자원은 여전히 유한합니다: DB 커넥션, Redis 커넥션, HTTP 커넥션 풀, 파일 핸들 등
  • 락은 여전히 락입니다: synchronized, ReentrantLock, DB row lock, 분산 락
  • 블로킹 I/O는 여전히 블로킹입니다: 다만 가상스레드가 park 되며 플랫폼 스레드를 양보할 뿐(항상 그런 것은 아님)
  • "가상스레드가 많아져서" 기존보다 더 쉽게 자원 고갈이 드러날 수 있습니다

즉, 가상스레드는 병목을 없애기보다는 병목 위치를 더 명확하게 드러내는 경우가 많습니다.

장애 양상별 1차 분류: 데드락 vs 지연

운영에서 "데드락"이라고 부르는 증상은 크게 4가지로 나뉩니다.

  1. JVM 레벨 데드락(진짜 모니터 데드락)
  2. DB 레벨 데드락(트랜잭션 교착)
  3. 커넥션 풀 고갈(기다리느라 멈춘 것처럼 보임)
  4. 핀ning/동기화로 플랫폼 스레드가 막혀 전체 처리량이 급감

가상스레드 도입 후에는 3, 4가 특히 자주 등장합니다. 이유는 간단합니다. 요청이 더 많이 동시 실행되면서 "기다리는 요청"도 더 많이 생기고, 그 기다림이 특정 풀/락에 집중될 때 시스템이 멈춘 것처럼 보이기 때문입니다.

Spring Boot 3에서 가상스레드 활성화 방법(기본)

Spring Boot 3.2+ 기준으로는 설정만으로도 톰캣 요청 처리를 가상스레드로 전환할 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

또는 명시적으로 Executor를 구성해 @Async/스케줄러/커스텀 실행 경로도 가상스레드를 사용하게 맞춥니다.

@Configuration
@EnableAsync
public class VirtualThreadConfig {

  @Bean
  public Executor taskExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
  }
}

중요한 점은 "요청 스레드만" 가상스레드가 되어도, 내부에서 사용하는 풀(예: DB 풀, HTTP 클라이언트 풀)은 그대로라는 것입니다. 장애 진단은 결국 이 경계에서 시작합니다.

1단계: 스레드 덤프로 "무엇을 기다리는지"부터 본다

지연/멈춤이 의심되면 가장 먼저 할 일은 스레드 덤프를 떠서 대기 원인이 어디에 몰리는지 확인하는 것입니다.

  • jcmd로 스레드 덤프
jcmd <pid> Thread.print -l > threads.txt

여기서 <pid>는 반드시 인라인 코드로 감싸야 합니다.

덤프에서 자주 보이는 패턴

(A) DB 커넥션 풀 대기

HikariCP를 쓰는 경우, 스택에 아래와 유사한 대기가 반복됩니다.

  • com.zaxxer.hikari.pool.HikariPool.getConnection
  • java.util.concurrent.locks.AbstractQueuedSynchronizer.park

이 패턴이 많으면 "가상스레드 데드락"이 아니라 "DB 커넥션 풀 고갈"일 확률이 큽니다.

(B) synchronized/모니터 락 경합

  • BLOCKED (on object monitor)
  • 특정 클래스/메서드에서 synchronized가 길게 잡혀 있음

가상스레드는 락을 없애지 않습니다. 오히려 동시성이 늘면서 경합이 증가할 수 있습니다.

(C) 플랫폼 스레드가 막힌 듯한 징후

가상스레드는 보통 블로킹 시 park 되어 플랫폼 스레드를 양보하지만, 특정 상황에서는 가상스레드가 플랫폼 스레드를 점유한 채로 블로킹될 수 있습니다(핀ning).

덤프에서 플랫폼 스레드 수가 적고(예: CPU 코어 수 수준), 그 플랫폼 스레드들이 특정 블로킹 지점에서 길게 멈춰 있다면 핀ning 또는 네이티브/동기화 경로를 의심합니다.

2단계: JFR로 "꼬리 지연"과 "핀ning"을 증거로 만든다

스레드 덤프는 순간 스냅샷이고, tail latency는 시간 축에서 봐야 합니다. Java Flight Recorder(JFR)는 가상스레드 진단에서 사실상 필수 도구입니다.

JFR 기록 시작

jcmd <pid> JFR.start name=vt settings=profile duration=120s filename=vt.jfr

2분 정도만 떠도 충분히 유의미한 경우가 많습니다.

JFR에서 특히 볼 것

  • Virtual Thread 관련 이벤트(가상스레드 스케줄링/park/unpark)
  • Monitor Enter/Contended Lock(락 경합)
  • Socket Read/Write, File I/O(블로킹 I/O)
  • Java Monitor Blocked 시간 상위 메서드
  • CPU 사용률은 낮은데 요청 지연이 큰 경우: 대기(락/풀/IO) 집중을 의심

핀ning은 "가상스레드가 플랫폼 스레드를 붙잡고" 오래 블로킹되는 형태로 나타나며, 원인은 대개 아래 중 하나입니다.

  • synchronized 블록 내부에서 블로킹 I/O
  • 네이티브 호출 또는 오래 걸리는 JNI
  • 일부 드라이버/라이브러리의 비우호적 동기화

3단계: 가장 흔한 원인 1위, DB 커넥션 풀 고갈

가상스레드로 요청 동시성이 늘면, 동일한 트래픽에서도 "동시에 DB를 때리는" 요청 수가 증가할 수 있습니다. 이때 DB 커넥션 풀이 기존 설정 그대로면 병목이 즉시 드러납니다.

증상

  • 응답이 전체적으로 느려지고, 특히 P95/P99가 급증
  • CPU는 낮은데 대기 시간이 길다
  • Hikari 메트릭에서 ActiveMaximumPoolSize에 붙어 있고 Pending이 증가

확인 방법

Spring Boot Actuator + Micrometer를 켜면 Hikari 메트릭을 쉽게 확인할 수 있습니다.

management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus

Prometheus 지표 예(이름은 환경에 따라 다를 수 있음):

  • hikaricp_connections_active
  • hikaricp_connections_pending
  • hikaricp_connections_timeout_total

대응 방향

  • 풀 크기를 "무작정" 늘리기 전에 DB가 감당 가능한지 계산
  • 트랜잭션 범위를 줄여 커넥션 점유 시간을 단축
  • N+1 쿼리 제거, 느린 쿼리 튜닝, 인덱스 점검
  • 필요하면 애플리케이션 레벨에서 동시 DB 작업을 제한(세마포어)

아래는 특정 구간에서 DB 동시 접근을 제한하는 단순 예시입니다.

@Component
public class DbConcurrencyGuard {
  private final Semaphore semaphore = new Semaphore(50);

  public <T> T call(Callable<T> work) throws Exception {
    semaphore.acquire();
    try {
      return work.call();
    } finally {
      semaphore.release();
    }
  }
}

<T>는 MDX에서 태그로 오인될 수 있으므로 반드시 인라인 코드로 감싸야 하지만, 코드 블록 안에서는 안전합니다.

4단계: JVM 데드락(모니터 데드락) 확인

"진짜" JVM 데드락은 jcmd/jstack에서 비교적 명확히 드러납니다.

jcmd <pid> Thread.print -l | grep -n "Found one Java-level deadlock" -n

발견되면 해당 모니터/락을 잡는 코드 경로를 역추적해야 합니다.

가상스레드 환경에서 데드락이 더 잘 보이는 이유

가상스레드가 데드락을 만들지는 않지만, 동시 실행 경로가 늘면서 "원래도 잠재적으로 위험했던 락 순서"가 더 자주 충돌할 수 있습니다.

대응은 정석대로입니다.

  • 락 순서 통일
  • 중첩 락 제거
  • synchronized 범위 축소
  • 가능하면 락 대신 lock-free 자료구조/원자 연산 사용

5단계: DB 데드락(트랜잭션 교착) 분리

애플리케이션에서 멈춘 것처럼 보여도, 실제로는 DB가 데드락을 감지하고 한 트랜잭션을 롤백시키는 동안 재시도가 누적되어 지연이 커지는 경우가 있습니다.

확인 포인트

  • DB 로그에 deadlock detected 기록
  • 애플리케이션 로그에 특정 SQLState/에러 코드
  • 동일 엔티티/행을 서로 다른 순서로 업데이트

대응은 다음이 핵심입니다.

  • 업데이트 순서를 항상 동일하게
  • 트랜잭션을 짧게
  • 불필요한 SELECT ... FOR UPDATE 제거
  • 재시도는 지수 백오프와 최대 횟수 제한

6단계: 핀ning(pinning) 유발 코드 제거

가상스레드는 블로킹 호출에서 플랫폼 스레드를 양보할 수 있지만, 아래 패턴은 위험합니다.

  • synchronized 내부에서 네트워크 호출/DB 호출/원격 API 호출
  • 오래 걸리는 파일 I/O를 락 안에서 수행
  • 큰 범위의 임계구역에서 Thread.sleep 같은 대기

나쁜 예

public class PriceCache {
  private final Map<String, BigDecimal> cache = new HashMap<>();

  public synchronized BigDecimal getPrice(String sku) {
    // 원격 호출이 느려지면 synchronized가 길게 잡힘
    return cache.computeIfAbsent(sku, this::fetchRemote);
  }

  private BigDecimal fetchRemote(String sku) {
    // HTTP 호출 등
    return new BigDecimal("10.00");
  }
}

개선 예: 락 범위 최소화

public class PriceCache {
  private final ConcurrentHashMap<String, BigDecimal> cache = new ConcurrentHashMap<>();

  public BigDecimal getPrice(String sku) {
    return cache.computeIfAbsent(sku, this::fetchRemote);
  }

  private BigDecimal fetchRemote(String sku) {
    return new BigDecimal("10.00");
  }
}

또는 "원격 호출은 락 밖에서" 하도록 2단계로 분리하는 것도 좋습니다.

7단계: HTTP 클라이언트/외부 API 풀도 함께 본다

가상스레드로 요청을 많이 처리하게 되면, 외부 API 호출도 동시성이 늘어 다음이 병목이 됩니다.

  • Apache HttpClient/OkHttp 커넥션 풀
  • DNS 지연
  • TLS 핸드셰이크
  • 원격 서비스 rate limit

이때 애플리케이션은 "멈춘 것처럼" 보이지만 실제로는 외부 호출 대기입니다. 스레드 덤프에서 Socket.read 류 대기가 쏟아지면 이 방향을 의심합니다.

대응은 다음 조합이 효과적입니다.

  • 커넥션 풀/타임아웃 설정 점검
  • 호출 동시성 제한(벌크헤드)
  • 재시도 정책 정교화(무한 재시도 금지)

8단계: 관측성(메트릭/로그/트레이싱)로 재현 없이도 잡기

가상스레드 도입 후 문제는 "재현이 어려운 꼬리 지연" 형태로 많이 나타납니다. 따라서 관측성을 같이 올려야 진단 시간이 줄어듭니다.

  • 요청 지연 P50/P95/P99
  • DB 풀: active/pending/timeout
  • GC/메모리(가상스레드 자체는 가볍지만 무한 생성은 위험)
  • 외부 API별 latency/error/rate limit
  • 락 경합 시간(가능하면 JFR 기반 주기적 샘플링)

Kubernetes에서 증상이 "멈춤"으로 보일 때는 애플리케이션 문제인지 노드 리소스 문제인지도 같이 분리해야 합니다. 예를 들어 OOMKilled나 probe 실패로 재시작 루프가 걸리면 지연이 아닌 가용성 문제로 관측됩니다. 이런 경우는 K8s CrashLoopBackOff - OOMKilled·Probe 5분 진단 체크리스트가 그대로 도움이 됩니다.

9단계: 실전 진단 플로우(요약)

운영에서 바로 쓰는 순서로 정리하면 아래가 가장 빠릅니다.

  1. 증상 분류: 전체 지연인지, 특정 API만인지, 에러 동반인지
  2. 스레드 덤프: 대기가 DB 풀인지, 모니터 락인지, 소켓 대기인지
  3. Hikari/외부 풀 메트릭: pending/timeout 증가 여부 확인
  4. JFR 2분: 락 경합 상위, 블로킹 I/O 상위, 핀ning 의심 구간 증거화
  5. 코드 수정 우선순위
    • 락 범위 축소 및 synchronized 내부 블로킹 제거
    • 트랜잭션 축소, 쿼리/인덱스 개선
    • 동시성 제한(벌크헤드/세마포어)
    • 타임아웃/재시도/서킷브레이커 정비

결론: 가상스레드는 "성능 만능"이 아니라 "병목 가시화" 도구

Spring Boot 3와 Java 21 가상스레드는 스레드 모델을 단순화하고 동시성을 크게 늘려주지만, 그만큼 DB/락/외부 API 같은 유한 자원의 병목이 더 빨리, 더 크게 드러납니다. 데드락처럼 보이는 문제도 실제로는 커넥션 풀 고갈이나 락 경합, 핀ning인 경우가 많습니다.

핵심은 "무엇을 기다리는가"를 스레드 덤프로 빠르게 분류하고, JFR로 꼬리 지연과 핀ning을 증거로 만든 뒤, 풀/락/트랜잭션 범위를 줄이는 방향으로 해결하는 것입니다. 이 흐름을 갖추면 가상스레드 전환 이후의 장애 대응 속도가 체감될 정도로 빨라집니다.