Published on

Spring Boot 3 가상스레드 도입 후 Deadlock·TPS 저하 진단

Authors

서론

Spring Boot 3(자바 21 기반)에서 가상 스레드(Virtual Thread, Project Loom) 를 켜면 “스레드 부족” 문제는 크게 완화됩니다. 하지만 운영에서는 종종 반대의 현상이 관측됩니다.

  • DB Deadlock이 증가하거나(또는 락 대기가 폭증)
  • TPS가 기대만큼 오르지 않거나 오히려 하락하고
  • p95/p99 지연이 튀며, GC/CPU/DB 커넥션/락 경합이 함께 악화

핵심은 가상 스레드가 동시성(concurrency)을 싸게 만들어 “원래 숨겨져 있던 병목”을 더 빨리/더 크게 드러낸다는 점입니다. 즉, 가상 스레드 자체가 문제라기보다 동시성 증가로 인해 DB 커넥션 풀, 락, 트랜잭션 경계, 외부 API 타임아웃, 스레드 고정(pinning) 같은 요소가 병목으로 변합니다.

이 글에서는 Spring Boot 3에서 가상 스레드 적용 후 Deadlock 및 TPS 저하를 진단하는 순서, 그리고 재현/관측/개선 포인트를 실전 관점에서 정리합니다.

> DB Deadlock 자체의 재현과 인덱스/락 설계 쪽은 별도 글인 MySQL Deadlock 1213 재현·로그·인덱스로 해결도 함께 참고하면 원인 규명 속도가 빨라집니다.


가상 스레드 적용의 전제: 무엇이 바뀌나

플랫폼 스레드 vs 가상 스레드

  • 플랫폼 스레드(커널 스레드): 생성/컨텍스트 스위칭 비용이 큼 → 보통 “스레드 풀”로 제한
  • 가상 스레드: JVM이 스케줄링하는 경량 스레드 → 대량 생성 가능

가상 스레드는 I/O에서 블로킹이 발생해도 “스레드 부족” 문제가 덜하지만, 다음은 그대로입니다.

  • DB 커넥션 수는 유한 (HikariCP max pool)
  • DB 락/인덱스 설계는 그대로
  • 외부 API의 QPS 제한/타임아웃은 그대로
  • JVM 내부 동기화/모니터 락은 그대로

즉, 스레드는 늘었는데 리소스는 그대로면 병목이 더 잘 드러납니다.


증상 분류: Deadlock vs ‘Deadlock처럼 보이는’ 정체

먼저 “진짜 데드락”과 “그냥 오래 기다리는 상태”를 구분해야 합니다.

  • 진짜 DB Deadlock: MySQL이면 1213 Deadlock found when trying to get lock 같은 에러가 발생하고, 한 트랜잭션이 롤백됨
  • 락 대기/커넥션 대기: 에러 없이 응답이 느려지고, 타임아웃/스레드 대기가 늘어남
  • 애플리케이션 레벨 데드락: synchronized, ReentrantLock, 잘못된 락 순서로 JVM 모니터가 교착

가상 스레드 도입 후 “Deadlock이 늘었다”는 보고 중 상당수는 실제로는:

  1. 커넥션 풀 고갈 → 트랜잭션 시작이 지연
  2. 락 경합 증가 → lock wait 증가
  3. 타임아웃 정책 부재 → 대기열이 길어짐

같은 현상입니다.


진단 0단계: 가상 스레드가 제대로 적용됐는지 확인

Spring Boot 3.2+ 기준으로는 다음 설정으로 Tomcat/Jetty 요청 처리를 가상 스레드로 전환할 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

확인 포인트:

  • 실제 요청 처리 스레드 이름/타입이 가상 스레드인지(스레드 덤프/로그)
  • 비동기 처리(@Async, Scheduler)는 별도 executor를 쓰는지

가상 스레드가 “HTTP 요청 스레드”에만 적용되고, 내부 비동기 executor는 여전히 플랫폼 스레드 풀이라면 병목 양상이 달라질 수 있습니다.


진단 1단계: TPS 저하의 80%는 ‘커넥션 풀’에서 시작한다

가상 스레드로 동시 요청이 늘면 가장 먼저 부딪히는 벽이 DB 커넥션 풀입니다.

관측 지표

Micrometer/HikariCP에서 아래를 봅니다.

  • hikaricp.connections.active
  • hikaricp.connections.pending (대기 중)
  • hikaricp.connections.timeout

pending이 증가하거나 timeout이 발생하면, CPU가 남아도 TPS는 떨어집니다. 요청 스레드가 커넥션을 기다리며 쌓이기 때문입니다.

빠른 판별법

  • p95 지연이 커지는데 DB 쿼리 시간 자체는 크게 안 늘었다
  • DB CPU는 낮은데 애플리케이션 TPS가 떨어진다
  • 스레드 덤프에서 com.zaxxer.hikari.pool.HikariPool.getConnection 대기가 많다

개선 방향

  • 풀을 무작정 키우기 전에, DB가 감당 가능한 동시 커넥션인지 확인
  • 트랜잭션 범위를 줄여 커넥션 점유 시간을 줄임
  • 느린 쿼리/인덱스 개선으로 커넥션 회전율을 높임

> “가상 스레드니까 커넥션도 크게 늘려도 되겠지”는 위험합니다. DB는 스레드가 아니라 락/버퍼/CPU/IO로 병목이 오기 쉬워 커넥션 증가가 오히려 데드락과 락 경합을 악화시킬 수 있습니다.


진단 2단계: Deadlock은 ‘동시성 증가’로 재현 확률이 폭발한다

가상 스레드는 동시 실행 요청 수를 늘려 서로 다른 순서로 같은 자원을 잡는 케이스를 더 자주 만들고, 결과적으로 데드락 재현 확률을 올립니다.

MySQL 관점에서 체크할 것

  • 동일 테이블/레코드를 여러 트랜잭션이 갱신하는 경로가 있는지
  • 인덱스 미스/범위 락으로 불필요하게 넓은 락을 잡는지
  • SELECT ... FOR UPDATE가 과도한 범위를 잠그는지

MySQL이라면 Deadlock 로그를 활성화하고, 애플리케이션에서는 해당 예외를 재시도 정책으로 감싸는 것이 일반적입니다(단, 멱등성/부작용 고려).

관련해서 Deadlock을 재현하고 인덱스로 줄이는 접근은 MySQL Deadlock 1213 재현·로그·인덱스로 해결에서 더 깊게 다룹니다.

Spring에서 재시도 예시

Spring Retry를 쓰거나(또는 Resilience4j) 트랜잭션 경계 밖에서 제한적으로 재시도합니다.

@Configuration
@EnableRetry
class RetryConfig {
}

@Service
class OrderService {

  @Retryable(
      retryFor = {
          org.springframework.dao.DeadlockLoserDataAccessException.class,
          org.springframework.dao.CannotAcquireLockException.class
      },
      maxAttempts = 3,
      backoff = @Backoff(delay = 50, multiplier = 2.0)
  )
  @Transactional
  public void placeOrder(Long orderId) {
    // UPDATE/INSERT ...
  }
}

주의:

  • 재시도는 트랜잭션 단위로 해야 함(부분 커밋 금지)
  • 외부 API 호출이 섞이면 재시도 시 부작용이 커짐 → Outbox/사가 고려

진단 3단계: 가상 스레드의 ‘Pinning’이 숨어 있는지 확인

가상 스레드는 “블로킹이 싸다”가 장점이지만, 특정 상황에서는 가상 스레드가 캐리어(플랫폼) 스레드를 붙잡아(pinning) 병목을 만들 수 있습니다.

대표적으로:

  • synchronized 블록 안에서 블로킹 I/O
  • 네이티브/일부 드라이버 호출에서의 블로킹

결과:

  • 가상 스레드가 많아도 실제로는 몇 개 플랫폼 스레드가 묶여 처리량이 떨어짐

JDK 진단 힌트

JDK에는 가상 스레드 관련 진단 옵션/이벤트가 있고, JFR(Java Flight Recorder)로도 확인할 수 있습니다. 운영에서는 “가상 스레드 핀ning 이벤트”를 켜고, 특정 코드 경로에서 플랫폼 스레드 점유가 길어지는지 확인합니다.

실무 팁:

  • synchronized 범위를 최소화
  • 락 안에서 네트워크/DB 호출 금지
  • 가능하면 ReentrantLock/구조 개선(락 순서 통일)

진단 4단계: TPS 저하가 ‘타임아웃 부재’로 증폭되는지

가상 스레드는 대기 비용이 낮아 “일단 기다리자”가 쉬워집니다. 하지만 타임아웃이 없으면 대기열이 무한정 길어져 장애가 커집니다.

점검 포인트:

  • HTTP 클라이언트 connect/read timeout 설정
  • DB 쿼리 타임아웃(Statement timeout)
  • Spring MVC/서버 레벨 요청 타임아웃

특히 MSA에서 상위 호출이 무한 대기하면 연쇄 장애가 납니다. gRPC라면 deadline 전파가 핵심이며, 관련 패턴은 gRPC MSA에서 DEADLINE_EXCEEDED 연쇄 장애 차단에서 잘 정리되어 있습니다.

RestClient/WebClient 타임아웃 예시(개념)

(환경에 따라 Apache HC5/Netty 설정이 다르므로, “반드시 타임아웃을 명시한다”는 방향이 중요합니다.)

@Bean
RestClient restClient(RestClient.Builder builder) {
  return builder
      .requestFactory(new JdkClientHttpRequestFactory(
          HttpClient.newBuilder()
              .connectTimeout(Duration.ofMillis(300))
              .build()
      ))
      .build();
}

추가로, 서버 전체의 동시 처리량을 무제한으로 열어두면 DB/외부의 한계에 의해 지연이 폭증합니다. 가상 스레드 환경에서도 동시성 제한(벌크헤드) 는 여전히 중요합니다.


진단 5단계: 동시성 증가가 DB 락 경합을 키우는 패턴

가상 스레드 적용 후 데드락/락 대기가 늘 때 자주 보이는 코드 패턴입니다.

(1) 같은 순서가 아닌 업데이트

  • A 트랜잭션: userorder
  • B 트랜잭션: orderuser

동시성이 높아질수록 교착 가능성이 올라갑니다.

(2) 범위 조건 업데이트/인덱스 미스

인덱스가 없어서 많은 레코드를 스캔하며 락을 넓게 잡으면, 동시성 증가 시 락 경합이 급격히 커집니다.

(3) 긴 트랜잭션

  • 트랜잭션 안에서 외부 API 호출
  • 트랜잭션 안에서 파일 I/O
  • 불필요한 조회/로직이 길게 수행

가상 스레드로 요청이 늘면 “긴 트랜잭션”이 커넥션/락을 오래 점유해 병목을 만듭니다.


실전 체크리스트: 어디부터 보면 빨리 좁혀지나

1) 애플리케이션

  • 스레드 덤프에서 대기 지점이 커넥션 풀인지, 락인지 확인
  • @Transactional 범위가 과도한지(외부 호출 포함 여부)
  • synchronized + 블로킹 호출 여부(핀ning 의심)
  • 재시도 정책이 데드락을 폭발시키는지(무제한 재시도 금지)

2) DB

  • Deadlock 로그(원인 쿼리/인덱스)
  • lock wait 증가 여부
  • 커넥션 수 증가가 DB CPU/IO를 어떻게 바꾸는지

3) 운영/플랫폼

  • Pod/노드 리소스(특히 CPU 스로틀링)
  • HPA가 지연을 따라가지 못하는지

K8s 환경에서 지연이 늘고 재시작이 반복되면 원인이 애플리케이션이 아니라 OOM/Probe일 수도 있습니다. 이 경우 K8s CrashLoopBackOff - OOMKilled·Probe·Exit 137 진단 체크리스트가 도움이 됩니다.


권장 처방: 가상 스레드 환경에서의 ‘안전한’ 처리량 확장

가상 스레드의 목표는 “무한 동시성”이 아니라, 블로킹 I/O를 더 효율적으로 처리하는 것입니다. 따라서 아래 조합이 가장 안정적으로 효과를 냅니다.

  1. 커넥션 풀/DB 용량에 맞춘 동시성 제한(벌크헤드)
  2. 트랜잭션 짧게: 외부 호출은 트랜잭션 밖으로
  3. 쿼리/인덱스 최적화로 락 범위 축소
  4. 데드락 예외는 제한적 재시도(멱등성 보장)
  5. 타임아웃/서킷브레이커/리트라이 정책 정교화
  6. synchronized/락 설계를 점검해 핀ning 가능성 제거

마무리

Spring Boot 3에서 가상 스레드를 켰는데 Deadlock이 늘거나 TPS가 떨어진다면, “가상 스레드가 느리다”가 아니라 동시성 증가로 인해 병목이 이동했을 가능성이 큽니다. 진단은 다음 순서가 가장 빠릅니다.

  • 커넥션 풀 대기 → 락/데드락 → 핀ning → 타임아웃/벌크헤드

가상 스레드는 만능이 아니지만, 병목을 정확히 찾아내고(관측) 트랜잭션/락/타임아웃/동시성 제한을 정리하면, 기존 플랫폼 스레드 대비 더 예측 가능한 처리량과 지연을 얻을 수 있습니다.