Published on

Spring Boot 3 가상스레드 장애 - 블로킹 I/O 진단

Authors

서버 스레드가 부족해서 터지는 장애는 익숙합니다. 그런데 Spring Boot 3에서 가상스레드를 켰는데도, 트래픽이 조금만 늘면 응답이 급격히 느려지고 타임아웃이 발생하는 경우가 있습니다. 겉으로 보면 "가상스레드는 많이 만들 수 있다"는 기대와 반대로 동작하죠.

이 글은 이런 상황을 가정합니다.

  • Spring Boot 3.x, Java 21 기반
  • spring.threads.virtual.enabled=true 또는 Tomcat 가상스레드 사용
  • 장애 증상은 p95, p99 레이턴시 폭증, 간헐적 5xx, 연결 타임아웃
  • 원인은 "가상스레드 자체"가 아니라, 가상스레드 위에서 발생하는 블로킹 I/O 및 풀 고갈

핵심은 하나입니다. 가상스레드는 블로킹을 "없애지" 않습니다. 블로킹이 생겨도 플랫폼 스레드를 오래 점유하지 않게 설계된 것이지, 외부 리소스가 막히는 현실을 바꾸지 않습니다. 특히 JDBC, 커넥션 풀, HTTP 클라이언트 커넥션 풀, DNS, 파일 I/O, 락 경합 같은 병목은 그대로 남고, 오히려 더 많은 동시성이 들어오면서 병목이 더 빨리 드러납니다.

장애 패턴: "스레드는 많은데" 왜 느릴까

가상스레드 환경에서 흔한 장애 패턴은 다음과 같습니다.

  1. DB 커넥션 풀 고갈

    • 요청마다 JDBC 커넥션을 기다리며 블로킹
    • 가상스레드는 많이 생기지만, 결국 DB 커넥션 수가 병목
  2. HTTP 클라이언트 커넥션 풀 고갈

    • 외부 API 호출이 느려지거나 keep-alive, 커넥션 제한으로 대기
  3. 동기 로깅, 파일 I/O, 네트워크 파일시스템(NFS) 지연

    • 로그 백엔드가 동기 플러시, 디스크가 느리거나 스토리지 이슈
  4. 락 경합

    • synchronized, ReentrantLock, 캐시 로더 단일 락, 세션 저장소 락 등
  5. DNS/프록시/SSL 핸드셰이크 지연

    • 외부 호출이 많은 서비스에서 간헐적 스파이크 유발

가상스레드의 장점은 "스레드 부족"을 완화하는 것이지, "리소스 부족"을 해결하지 않습니다. 그래서 진단은 스레드 수가 아니라 대기(wait)와 블로킹(block) 원인을 찾아야 합니다.

1단계: 가상스레드가 실제로 적용됐는지 확인

먼저 정말로 가상스레드가 요청 처리에 쓰이고 있는지 확인합니다.

Spring Boot 설정 확인

application.yml:

spring:
  threads:
    virtual:
      enabled: true

Tomcat을 쓴다면 Boot가 내부적으로 executor를 가상스레드로 바꿔줍니다. 다만 다음을 꼭 확인하세요.

  • Undertow, Jetty 등 컨테이너별 지원/동작 차이
  • 커스텀 TaskExecutor를 따로 등록해 기존 플랫폼 스레드 풀을 계속 쓰는 경우

로그로 빠르게 확인

요청 핸들러에서 스레드 정보를 한 번 찍어보면 확실합니다.

@GetMapping("/debug/thread")
public String thread() {
    var t = Thread.currentThread();
    return "name=" + t.getName() + ", isVirtual=" + t.isVirtual();
}

여기서 isVirtual=true가 나오지 않으면, "가상스레드 장애"가 아니라 "가상스레드 미적용"입니다.

2단계: 증상을 수치로 고정하기 (레드라인 정의)

장애 분석에서 가장 중요한 것은 "느리다"를 숫자로 바꾸는 것입니다.

  • p95, p99 응답시간
  • 요청 동시성
  • DB 커넥션 사용률, 대기 시간
  • 외부 API 호출 시간과 타임아웃 빈도

Micrometer를 쓰면 최소한 다음은 대시보드에서 보이게 하세요.

  • http.server.requests 레이턴시
  • HikariCP 메트릭
    • hikaricp.connections.active
    • hikaricp.connections.pending
    • hikaricp.connections.acquire

HikariCP는 병목이 가장 자주 터지는 지점입니다. pending이 올라가면, 가상스레드가 많아도 결국 커넥션을 못 얻어 기다리는 상황입니다.

3단계: JFR로 "블로킹 I/O"를 잡아내기

가상스레드 진단에서 JFR(Java Flight Recorder)은 거의 필수입니다. 스레드 덤프만으로는 "어디서 기다리는지"를 충분히 못 보는 경우가 많습니다.

운영에서 안전한 JFR 시작

다음은 비교적 보수적인 설정 예시입니다.

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

여기서 <pid> 같은 표기는 MDX에서 JSX로 오인될 수 있으니 반드시 인라인 코드로 표기했습니다.

수집한 파일은 JDK Mission Control(JMC)로 열어 다음 이벤트를 집중적으로 봅니다.

  • Java Monitor Blocked
  • Thread Park
  • Socket Read/Write
  • File Read/Write
  • JDK Virtual Thread 관련 이벤트(환경에 따라 노출)

JFR에서 자주 나오는 "진짜 원인" 시그널

  • Socket Read가 특정 외부 호스트에서 길게 누적
  • Java Monitor Blocked가 특정 락에서 집중
  • Thread Park가 커넥션 풀 획득 경로에서 반복

이 결과를 보면 "가상스레드가 느린" 게 아니라, "어디서 기다리는지"가 명확해집니다.

4단계: 대표 원인 1 - DB 커넥션 풀 고갈

가상스레드는 요청을 많이 동시에 처리할 수 있게 해줍니다. 그런데 DB 커넥션 풀은 기본값이 작거나, DB가 감당 못 하는 수준으로 제한되어 있습니다.

전형적인 증상

  • 애플리케이션 스레드는 많아 보이는데 응답이 점점 느려짐
  • DB CPU는 낮은데 애플리케이션 타임아웃 증가
  • Hikari pending 증가, acquire 시간 증가

빠른 확인: Hikari 설정과 타임아웃

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 20
      connection-timeout: 2000

가상스레드를 켠다고 maximum-pool-size를 무작정 올리면, DB가 먼저 죽습니다. 대신 다음 순서로 접근합니다.

  1. 쿼리 자체가 느린지 확인
  2. 트랜잭션 범위가 불필요하게 긴지 확인
  3. 동시성 대비 풀 크기가 너무 작은지 확인

특히 트랜잭션 범위가 넓으면 커넥션을 오래 쥐고 있어 풀 고갈이 빨리 옵니다.

쿼리/락 문제까지 이어질 때

커넥션 풀 고갈의 뿌리가 데드락이나 락 경합인 경우가 많습니다. 이 경우 DB에서 "왜 대기하는지"까지 들어가야 합니다.

5단계: 대표 원인 2 - 외부 HTTP 호출의 블로킹과 풀 제한

외부 API 호출이 있는 서비스는, 가상스레드 적용 후 동시성이 늘면서 외부 호출이 한꺼번에 몰립니다. 그러면 다음이 병목이 됩니다.

  • HTTP 클라이언트 커넥션 풀 제한
  • 원격 서버의 rate limit
  • 프록시/로드밸런서 keep-alive 정책
  • DNS 지연

RestClient 또는 WebClient를 "동기"로 쓰는 경우

Spring의 RestClient는 동기 호출입니다. 가상스레드에서 동기 호출을 하는 것 자체는 문제라기보다, 커넥션 풀과 타임아웃을 제대로 잡지 않으면 장애가 됩니다.

Apache HttpClient5 기반 예시:

@Bean
RestClient restClient() {
    var cm = new PoolingHttpClientConnectionManager();
    cm.setMaxTotal(200);
    cm.setDefaultMaxPerRoute(50);

    var httpClient = HttpClients.custom()
            .setConnectionManager(cm)
            .evictExpiredConnections()
            .build();

    var requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient);
    requestFactory.setConnectTimeout(1_000);
    requestFactory.setConnectionRequestTimeout(1_000);
    requestFactory.setReadTimeout(2_000);

    return RestClient.builder()
            .requestFactory(requestFactory)
            .build();
}

포인트는 다음입니다.

  • connectionRequestTimeout: 풀에서 커넥션을 못 얻고 대기하는 시간을 제한
  • readTimeout: 원격이 느릴 때 무한 대기 방지
  • 풀 크기(maxTotal, perRoute)를 트래픽과 외부 API 특성에 맞게 조정

JFR에서 Socket Read가 길게 보이거나, 애플리케이션 레이턴시가 외부 호출 시간과 강하게 상관이면 여기부터 의심합니다.

6단계: 대표 원인 3 - 동기 로깅/파일 I/O로 인한 지연

가상스레드 환경에서 로그가 병목이 되는 경우가 생각보다 많습니다.

  • 장애 시 로그가 폭증
  • 디스크 I/O가 느려짐
  • 로그 백엔드가 동기 flush 또는 네트워크 전송에서 대기

확인 방법

  • JFR의 File Write, Socket Write 이벤트에서 로깅 경로 확인
  • 로그 레벨을 일시적으로 낮추고 레이턴시가 즉시 회복되는지 확인

개선 방향

  • 비동기 로깅(예: Logback AsyncAppender) 적용
  • 로그 샘플링 또는 rate limit
  • 장애 시 디버그 로그 폭증 방지 가드

7단계: 대표 원인 4 - 락 경합과 동기화 블록

가상스레드는 동시성을 크게 늘리기 때문에, 기존에는 티가 안 나던 락이 갑자기 병목이 됩니다.

전형적인 패턴:

  • synchronized로 감싼 캐시 로딩
  • 단일 ConcurrentHashMap에 대한 과도한 compute
  • 세션 저장소, 토큰 갱신 로직의 전역 락

JFR에서 보는 법

  • Java Monitor Blocked에서 특정 클래스/메서드에 시간이 몰리는지 확인

개선 예시

  • 락 범위를 줄이기
  • 읽기 많은 구조는 ReadWriteLock 또는 lock-free 구조 검토
  • 캐시 로더는 single-flight 패턴을 쓰되, 키 단위로 분리

8단계: "가상스레드라서" 생기는 오해와 운영 팁

오해 1: 스레드가 많으니 타임아웃을 늘리면 된다

타임아웃을 늘리면 장애가 "더 늦게" 터질 뿐, 더 큰 큐잉과 더 큰 장애로 돌아오는 경우가 많습니다. 타임아웃은 원인 제거 전까지는 방화벽 역할만 합니다.

오해 2: 가상스레드면 리액티브가 필요 없다

가상스레드로 많은 경우가 단순해지는 건 사실입니다. 하지만 외부 호출이 많고 backpressure가 중요한 시스템에서는 여전히 리액티브가 유리할 수 있습니다. 중요한 건 패러다임이 아니라, 병목 리소스에 대한 제어입니다.

운영 팁: "대기"를 메트릭으로 올려라

  • DB acquire 시간
  • 외부 호출 connection request 대기
  • 큐 길이, 풀 pending

이런 지표가 있어야 장애가 "스레드"가 아니라 "리소스" 문제라는 걸 빠르게 확정할 수 있습니다.

9단계: 실전 체크리스트 (15분 트리아지)

  1. 요청 스레드가 진짜 가상스레드인지 확인 (isVirtual)
  2. p95, p99가 언제부터 튀는지, 동시성과 함께 확인
  3. Hikari pending, acquire 상승 여부 확인
  4. 외부 HTTP 호출 타임아웃/재시도 폭증 여부 확인
  5. JFR 2분 수집 후 Socket Read, Thread Park, Monitor Blocked 상위 스택 확인
  6. 로그 레벨 낮춰서 즉시 회복되는지 확인
  7. 원인 지점에 타임아웃, 벌크헤드(풀 분리), 서킷브레이커 적용

운영에서 "빠르게 원인을 좁히는" 접근은 다른 장애에도 동일하게 적용됩니다. 예를 들어 쿠버네티스에서 이미지 풀 실패를 10분 내로 진단하는 흐름은 K8s ImagePullBackOff - registry auth·CA 10분 진단 같은 트리아지 글과 결이 같습니다.

결론: 가상스레드 장애의 본질은 I/O와 풀이다

Spring Boot 3의 가상스레드는 서버 동시성 모델을 단순하게 만들고, 스레드 부족 문제를 크게 줄여줍니다. 하지만 장애가 사라지는 것은 아닙니다. 병목은 더 자주 다음 형태로 나타납니다.

  • DB 커넥션 풀, 느린 쿼리, 긴 트랜잭션
  • 외부 HTTP 커넥션 풀 제한과 느린 원격
  • 동기 로깅/파일 I/O
  • 락 경합

따라서 진단은 "스레드가 몇 개인가"가 아니라, "어디서 기다리는가"를 찾아야 합니다. JFR로 블로킹 지점을 고정하고, 풀과 타임아웃을 재설계하며, 트랜잭션과 외부 호출을 짧게 만드는 것이 가장 빠른 해결책입니다.