Published on

Spring Boot 3 가상스레드 적용 시 멈춤 원인 7가지

Authors

Spring Boot 3에서 가상스레드(virtual thread, Project Loom)를 켰는데도 서비스가 "멈춘 것처럼" 보이는 상황이 종종 발생합니다. 가상스레드는 "스레드 생성 비용"과 "대기 중인 스레드 점유" 문제를 크게 줄여주지만, 애플리케이션이 실제로 기다리는 자원(커넥션, 락, 외부 시스템, 풀의 큐 등)이 그대로라면 정체는 형태만 바뀌어 재현됩니다.

이 글은 Spring Boot 3에서 가상스레드를 적용한 뒤 발생하는 멈춤/정체의 대표 원인 7가지를 "증상"과 "진단 포인트", "해결 방향" 중심으로 정리합니다.

참고: 트래픽이 몰릴 때의 전반적인 방어 설계는 Spring Boot 대용량 트래픽 대비 API Rate Limiting 설계도 함께 보면 좋습니다. 가상스레드만으로는 외부 의존성 병목을 해결할 수 없습니다.

가상스레드 적용 전 확인: 정말 가상스레드로 실행 중인가

"가상스레드를 켰다"고 생각했지만 실제로는 플랫폼 스레드에서 돌고 있는 경우가 있습니다. 우선 실행 중인 요청 스레드가 가상스레드인지 로그로 확인해 보세요.

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class ThreadCheckController {

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

또한 Spring Boot 3에서 설정은 보통 다음 형태입니다.

spring:
  threads:
    virtual:
      enabled: true

이제부터는 "가상스레드로 실행 중"이라는 전제하에, 멈춤의 실질 원인을 7가지로 나눠 점검합니다.

1) DB 커넥션 풀 고갈: 가상스레드의 최대 함정

증상

  • 요청이 일정 시점부터 급격히 느려지고, CPU는 놀고 있는데 응답이 안 옴
  • 스레드 덤프에서는 많은 가상스레드가 DB 커넥션 획득에서 대기
  • HikariCP 메트릭에서 active는 max에 붙어 있고 pending이 계속 증가

가상스레드는 "대기 중인 스레드가 OS 스레드를 붙잡지 않는다"는 장점이 있지만, DB 커넥션은 여전히 제한 자원입니다. 즉, 가상스레드를 많이 만들수록 "커넥션을 기다리는 작업"이 폭발적으로 늘어날 수 있습니다.

진단 포인트

  • HikariCP 메트릭 확인
    • hikaricp.connections.active
    • hikaricp.connections.pending
    • hikaricp.connections.timeout
  • 슬로우 쿼리 로그 및 DB 측 대기 이벤트

해결 방향

  • 풀 크기만 무작정 늘리지 말고, 쿼리/트랜잭션 시간을 줄여 회전율을 올리기
  • 커넥션 획득 타임아웃을 짧게 두고 빠르게 실패시키기
  • 동시에 DB를 때리는 요청 수를 제한(세마포어, 벌크헤드, 레이트 리밋)
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 2000
      validation-timeout: 1000

DB 자체 병목이 의심되면 인덱스/테이블 상태도 같이 봐야 합니다. PostgreSQL이라면 PostgreSQL 인덱스가 느릴 때 - Bloat·VACUUM·REINDEX 같은 점검이 실제로 "가상스레드 적용 후 더 잘 드러나는" 정체를 해결하는 경우가 많습니다.

2) 락 경합 및 synchronized로 인한 핀(pin) 현상

증상

  • 특정 구간에서 처리량이 갑자기 1개씩만 진행되는 느낌
  • 스레드 덤프에서 동일 모니터 락을 기다리는 스레드가 다수
  • 가상스레드가 캐리어(carrier) 스레드에 "붙어" 떨어지지 않는 구간이 생김

가상스레드는 블로킹 I/O에서 잘 쉬지만, synchronized 블록이나 모니터 락 경합이 심하면 가상스레드가 캐리어 스레드를 오래 점유하는 형태로 병목이 생길 수 있습니다(일반적으로 "핀"이라고 부르는 현상).

진단 포인트

  • jstack 또는 JFR에서 모니터 락 경합 확인
  • 특정 전역 락(캐시 갱신, 시퀀스 발급, 단일 큐 접근 등) 존재 여부

해결 방향

  • 전역 락을 없애거나 범위를 줄이기
  • ReentrantLock + 공정성/타임아웃 적용 검토
  • 동시성 컬렉션 사용(ConcurrentHashMap 등)
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

public class TokenBucketRegistry {
  private final ConcurrentHashMap<String, ReentrantLock> locks = new ConcurrentHashMap<>();

  public void withLock(String key, Runnable action) {
    var lock = locks.computeIfAbsent(key, k -> new ReentrantLock());
    lock.lock();
    try {
      action.run();
    } finally {
      lock.unlock();
    }
  }
}

핵심은 "가상스레드로 바꾸면 락 문제가 사라진다"가 아니라, 락 경합이 있으면 더 많은 가상스레드가 그 앞에서 대기열을 만들며 체감 멈춤이 커질 수 있다는 점입니다.

3) 외부 HTTP 호출의 무제한 동시성: 커넥션 풀, DNS, 원격 서버가 병목

증상

  • 특정 외부 API 연동 구간에서 요청이 줄줄이 대기
  • 원격이 느린 순간에 전체가 같이 멈춤
  • Netty/Apache HTTP 클라이언트 커넥션 풀 대기 증가

가상스레드를 쓰면 "동시 호출"을 쉽게 늘릴 수 있지만, 외부 시스템은 그만큼 받아주지 못합니다. 특히 다음 자원이 병목이 됩니다.

  • HTTP 클라이언트 커넥션 풀 크기
  • DNS 조회 지연
  • 원격 서버의 레이트 리밋, 큐잉, 429/5xx

진단 포인트

  • 클라이언트 메트릭(커넥션 풀 대기)
  • 원격 API 응답 코드(429, 503 등) 증가
  • 분산 트레이싱으로 외부 구간 지연 확인

해결 방향

  • 외부 호출에 동시성 제한(벌크헤드)
  • 타임아웃을 짧게, 재시도는 제한적으로
  • 캐시/서킷 브레이커 적용
import java.time.Duration;
import java.util.concurrent.Semaphore;

import org.springframework.web.client.RestClient;

public class PartnerClient {
  private final RestClient restClient;
  private final Semaphore bulkhead = new Semaphore(50); // 외부 호출 동시성 제한

  public PartnerClient(RestClient.Builder builder) {
    this.restClient = builder
      .requestFactory(rf -> {
        rf.setConnectTimeout(Duration.ofSeconds(1));
        rf.setReadTimeout(Duration.ofSeconds(2));
      })
      .build();
  }

  public String call() {
    boolean acquired = bulkhead.tryAcquire();
    if (!acquired) {
      throw new IllegalStateException("partner bulkhead full");
    }
    try {
      return restClient.get().uri("https://partner.example/api").retrieve().body(String.class);
    } finally {
      bulkhead.release();
    }
  }
}

외부가 로드밸런서 뒤에 있다면, 로드밸런서 타임아웃(예: 502/504)도 함께 봐야 합니다. 특히 10분 전후로 끊기는 패턴은 AWS ALB 502/504 10분 타임아웃 진단 가이드 체크리스트가 그대로 적용됩니다.

4) 타임아웃 미설정으로 인한 "영원히 기다림"

증상

  • 특정 요청이 끝나지 않고 계속 열린 상태
  • 스레드 수는 늘지만 완료 수가 증가하지 않음
  • 장애 시 회복이 느리고, 재시작 전까지 계속 쌓임

가상스레드는 기다리는 비용이 낮아서 "더 오래" 기다리게 만들 수 있습니다. 플랫폼 스레드였다면 스레드 고갈로 빨리 터졌을 문제가, 가상스레드에서는 조용히 누적됩니다.

진단 포인트

  • HTTP 클라이언트 connect/read timeout 존재 여부
  • JDBC 쿼리 타임아웃 설정 여부
  • 서블릿 컨테이너의 요청 타임아웃, 비동기 타임아웃

해결 방향

  • 모든 I/O에 타임아웃을 명시
  • 상위 레벨에서 전체 요청 데드라인을 두고 전파
import org.springframework.transaction.support.TransactionTemplate;

public class QueryService {
  private final TransactionTemplate tx;

  public QueryService(TransactionTemplate tx) {
    this.tx = tx;
    this.tx.setTimeout(3); // 초 단위 트랜잭션 타임아웃
  }
}

DB 드라이버/프레임워크별로 쿼리 타임아웃 옵션이 다르니, "트랜잭션 타임아웃"과 "소켓 타임아웃"을 분리해서 챙기는 것이 안전합니다.

5) 블로킹 작업을 잘못된 풀에서 실행: 스케줄러/Executor 병목

증상

  • @Async 작업이 밀려서 실행이 안 됨
  • 스케줄링 작업이 늦게 실행되거나 한 번 밀리면 계속 밀림
  • 가상스레드 적용했는데도 특정 비동기 작업은 여전히 제한된 스레드 풀에서 대기

가상스레드를 켰다고 해서 모든 비동기 실행기가 자동으로 가상스레드로 바뀌는 것은 아닙니다. 프로젝트에 커스텀 TaskExecutor가 있거나, 라이브러리가 별도 풀을 쓰면 그 풀의 큐가 병목이 됩니다.

진단 포인트

  • @Async가 사용하는 executor가 무엇인지 확인
  • 큐 길이, active thread 수 모니터링

해결 방향

  • 블로킹 성격 작업은 가상스레드 executor로 분리
  • CPU 바운드 작업은 제한된 플랫폼 스레드 풀에서 실행(무제한 가상스레드로 돌리면 컨텍스트 스위칭만 늘 수 있음)
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync
public class AsyncConfig {

  @Bean(name = "virtualThreadExecutor")
  public Executor virtualThreadExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
  }
}

그리고 @Async("virtualThreadExecutor")처럼 명시적으로 붙여 "어떤 작업을 어디서 돌릴지"를 통제하는 것이 좋습니다.

6) 관측성(로그/트레이싱) 동기 전송으로 인한 I/O 정체

증상

  • 트래픽이 늘면 응답이 느려지는데, 원인이 DB도 외부 API도 아닌 듯함
  • 로그 전송/수집기 장애 시 전체 요청이 같이 느려짐
  • APM 에이전트 또는 로깅 appender가 블로킹

가상스레드 환경에서 로그 한 줄이 "동기 네트워크 I/O"로 나가면, 그 자체가 병목이 됩니다. 특히 장애 상황에서 로그가 폭주하면 수집기가 막히고, 애플리케이션은 로그 쓰기에서 대기하면서 멈춘 것처럼 보일 수 있습니다.

진단 포인트

  • 로깅 appender 설정(동기 파일 I/O, 동기 TCP 전송 여부)
  • APM/OTel exporter가 배치 전송인지, 큐가 꽉 차면 어떤 동작인지

해결 방향

  • 비동기 로깅(AsyncAppender) 적용
  • exporter 큐/배치 설정 조정, 백프레셔 정책 확인
  • 장애 시 로그 레벨을 낮추는 운영 플래그 준비
<!-- logback-spring.xml 예시: 비동기 로깅 -->
<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>

  <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>8192</queueSize>
    <discardingThreshold>0</discardingThreshold>
    <appender-ref ref="CONSOLE" />
  </appender>

  <root level="INFO">
    <appender-ref ref="ASYNC" />
  </root>
</configuration>

7) "스레드 문제"가 아니라 GC/메모리/큐 적체로 인한 정체

증상

  • 응답이 간헐적으로 멈췄다가 한꺼번에 처리됨
  • CPU가 순간적으로 튀고, 응답 지연이 스파이크 형태
  • 메모리 사용량이 계속 증가하거나, GC pause가 길어짐

가상스레드는 생성이 싸다 보니, 요청이 밀릴 때 "일단 쌓아두는" 형태가 되기 쉽습니다. 그 결과로 다음이 발생합니다.

  • 대기 중인 작업 객체가 늘어 힙 사용량 증가
  • 큐 적체로 레이턴시가 선형적으로 증가
  • GC가 자주/길게 발생해 전체가 멈춘 것처럼 보임

진단 포인트

  • JFR에서 GC pause, allocation rate 확인
  • 메트릭에서 힙 사용량, GC 시간, 큐 길이 확인
  • 요청 대기열(서버 커넥션 backlog, 톰캣 accept queue 등) 확인

해결 방향

  • 시스템이 감당 가능한 수준에서 "입구"를 제한(레이트 리밋, 큐 제한, 벌크헤드)
  • 큰 객체/버퍼 생성 줄이기(특히 JSON 직렬화, 대용량 문자열)
  • 응답 바디 스트리밍/페이징으로 메모리 압력 낮추기

가상스레드는 "대기"를 싸게 만들 뿐, "무한 대기열"을 허용하면 결국 다른 계층에서 터집니다. 그래서 멈춤을 해결하려면 동시성 제한과 타임아웃이 함께 가야 합니다.

실전 점검 순서(체크리스트)

  1. 요청 스레드가 실제로 가상스레드인지 확인(Thread#isVirtual)
  2. DB 커넥션 풀 pending 증가 여부 확인
  3. 스레드 덤프/JFR로 락 경합, 핀 구간 확인
  4. 외부 HTTP 호출의 커넥션 풀/타임아웃/동시성 제한 확인
  5. 모든 I/O에 타임아웃이 있는지 확인
  6. @Async 및 스케줄러 executor 병목 확인
  7. GC/메모리/큐 적체로 인한 레이턴시 스파이크 확인

마무리

Spring Boot 3 가상스레드는 "스레드 고갈"을 완화하는 강력한 도구지만, 멈춤의 원인은 대개 스레드가 아니라 제한 자원(커넥션, 락, 외부 시스템)과 무제한 대기, 그리고 관측성/큐/메모리 문제에서 발생합니다.

가상스레드 적용 후 멈춤이 보인다면, "스레드 수" 대신 "무엇을 기다리는지"를 찾아야 합니다. 커넥션 풀과 타임아웃, 동시성 제한을 먼저 잡고, 그 다음에 락/핀과 GC를 보정하면 대부분의 멈춤은 재현 가능하게 줄어듭니다.