Published on

Spring Boot 3 가상스레드 도입 후 CPU 폭증 원인

Authors

서버 스레드 모델을 바꾸는 건 성능 최적화의 끝판왕처럼 보이지만, Spring Boot 3.x에서 가상스레드(virtual thread)를 도입한 뒤 오히려 CPU가 폭증하는 사례가 꽤 자주 나옵니다. 특히 기존에는 tomcat-200 같은 플랫폼 스레드 수가 제한되어 있어 “느리지만 버티던” 시스템이, 가상스레드로 전환하면서 동시성이 급격히 늘고 그 결과 숨겨진 병목이 CPU로 튀어나오는 패턴이 많습니다.

이 글은 “가상스레드가 느리다”가 아니라, 가상스레드가 병목을 노출시키는 방식과 그때 CPU가 왜 치솟는지, 무엇을 먼저 의심해야 하는지에 집중합니다. 가상스레드 기본 개념과 DB 풀 고갈 관점은 아래 글도 함께 보면 맥락이 더 잘 이어집니다.

가상스레드 전환 시 CPU가 오르는 전형적 시나리오

가상스레드는 “블로킹 I/O 대기”를 저렴하게 처리하는 데 강합니다. 문제는 다음 조건이 겹치면 CPU가 급상승할 수 있다는 점입니다.

  1. 동시 요청 수가 급증한다 (서버가 더 많은 요청을 동시에 처리하려고 시도)
  2. 애플리케이션 내부에 CPU 바운드 작업 또는 락 경합 또는 짧은 블로킹 반복이 있다
  3. 외부 시스템(DB, Redis, 타 API)이 느려서 대기 중인 작업이 폭발적으로 쌓인다
  4. 결과적으로 스케줄러, GC, 컨텍스트 스위칭, 락 경쟁이 증가하며 CPU가 상승한다

즉, “가상스레드가 CPU를 먹는다”기보다는, 이전에는 스레드 제한이 자연스러운 백프레셔 역할을 하던 것이 사라지면서 병목이 다른 형태로 나타나는 경우가 많습니다.

원인 1: 동시성 폭증으로 인한 락 경합(Contended Lock)

가상스레드로 전환하면 처리 가능한 동시성이 늘어납니다. 이때 애플리케이션 코드에 다음 요소가 있으면 CPU가 빠르게 올라갑니다.

  • synchronized 블록이 넓거나 자주 호출됨
  • 전역 캐시 갱신, 전역 카운터, 단일 큐 등 공유 자원 접근
  • ConcurrentHashMap도 특정 키에 핫스팟이 생기면 경합이 커짐
  • 로그 appender, MDC, 메트릭 레지스트리 등 공용 구조에 과도한 업데이트

재현 예시 코드

아래처럼 요청마다 전역 락을 잡고 짧은 작업을 반복하면, 스레드 수가 늘수록 경합이 증가해 CPU가 치솟습니다.

@RestController
class LockHotspotController {
  private final Object lock = new Object();
  private long counter = 0;

  @GetMapping("/hot")
  public String hot() {
    for (int i = 0; i < 100_000; i++) {
      synchronized (lock) {
        counter++;
      }
    }
    return "ok";
  }
}

진단 포인트

  • jstack 또는 Java Flight Recorder에서 BLOCKED / WAITING 증가
  • JFR 이벤트에서 Java Monitor Blocked 빈도 증가
  • CPU 사용률은 높지만 TPS가 크게 늘지 않음

개선 방향

  • 락 범위를 줄이거나, 요청 단위 로컬 상태로 전환
  • 전역 카운터는 LongAdder 고려
  • 핫 키가 생기는 자료구조 접근 패턴 개선
  • 로깅/메트릭을 비동기 또는 샘플링 기반으로 조정

원인 2: DB 풀, HTTP 커넥션 풀 같은 “외부 풀”이 병목인데 동시성만 늘어난 경우

가상스레드를 켜면 서버는 더 많은 요청을 동시에 받아 처리하려 합니다. 하지만 DB 커넥션 풀(HikariCP), 외부 HTTP 클라이언트 풀, Redis 커넥션 풀 같은 자원이 그대로면 어떤 일이 생길까요?

  • 요청은 많이 들어옴
  • 많은 가상스레드가 풀에서 리소스를 기다리며 대기
  • 대기열이 길어지고 타임아웃/리트라이가 증가
  • 리트라이와 타임아웃 처리 비용이 CPU를 추가로 사용

여기서 중요한 포인트는 “대기 자체는 CPU를 거의 쓰지” 않지만, 타임아웃 처리, 예외 생성, 리트라이, 백오프 없는 재시도 루프가 있으면 CPU가 올라간다는 점입니다.

안 좋은 리트라이 예시

public String callWithBadRetry() {
  while (true) {
    try {
      return restClient.get()
          .uri("https://downstream/api")
          .retrieve()
          .body(String.class);
    } catch (Exception e) {
      // 백오프 없이 즉시 재시도: 장애 시 CPU를 태우는 패턴
    }
  }
}

개선 예시: 백오프와 상한

public String callWithBackoff() throws InterruptedException {
  int maxAttempts = 5;
  long backoffMs = 100;

  for (int i = 1; i <= maxAttempts; i++) {
    try {
      return restClient.get()
          .uri("https://downstream/api")
          .retrieve()
          .body(String.class);
    } catch (Exception e) {
      if (i == maxAttempts) throw e;
      Thread.sleep(backoffMs);
      backoffMs = Math.min(backoffMs * 2, 1000);
    }
  }
  throw new IllegalStateException("unreachable");
}

가상스레드에서는 Thread.sleep(...)도 부담이 적지만, 재시도 정책이 없으면 가상스레드가 많아질수록 장애 시 CPU가 더 빨리 무너질 수 있습니다.

원인 3: 짧은 블로킹 호출이 매우 빈번해서 스케줄링 오버헤드가 커지는 경우

가상스레드는 블로킹 시 “파킹”되며 캐리어 스레드에서 내려옵니다. 그런데 블로킹이 아주 짧고 빈번하면, 다음이 반복됩니다.

  • 파킹/언파킹
  • 스케줄링
  • 컨텍스트 전환

이 오버헤드가 누적되면 CPU가 증가할 수 있습니다. 보통 다음 형태가 문제를 만듭니다.

  • 매우 짧은 네트워크 호출을 초당 수만 번
  • 락을 짧게 잡았다가 바로 풀기 반복
  • 폴링 기반 대기(아래 원인 4와도 연결)

체크 포인트

  • 요청당 외부 호출 횟수, 내부 큐 폴링 횟수 확인
  • APM에서 span 수가 비정상적으로 많아졌는지 확인

원인 4: 바쁜 대기(Spin) 또는 폴링 루프가 동시성 증가로 폭발

가상스레드 도입 전에는 플랫폼 스레드 수가 제한되어 폴링이 있어도 “동시에 폴링하는 스레드 수”가 적었습니다. 가상스레드로 전환하면, 같은 폴링 로직이 수천 개로 늘어나 CPU가 급상승할 수 있습니다.

문제 코드 예시

public void waitUntilReady(AtomicBoolean ready) {
  while (!ready.get()) {
    // 아무 것도 안 하고 계속 루프를 돌면 CPU 100%에 가까워짐
  }
}

개선 코드 예시

public void waitUntilReady(AtomicBoolean ready) throws InterruptedException {
  while (!ready.get()) {
    Thread.sleep(10);
  }
}

더 나은 방식은 CountDownLatch, Semaphore, CompletableFuture 같은 동기화 도구로 이벤트 기반 대기를 만드는 것입니다.

원인 5: 관측(로그/메트릭/트레이싱) 비용이 동시성 증가로 증폭

가상스레드는 요청 동시성을 늘립니다. 그 결과 다음 비용이 선형 또는 그 이상으로 커집니다.

  • 요청당 로그 라인 수 증가에 따른 문자열 포맷/JSON 직렬화 비용
  • 구조적 로깅에서 MDC 복사 비용
  • Micrometer 태그 카디널리티 폭발(예: userId 같은 태그)
  • 분산 트레이싱 span 생성 비용

특히 “CPU는 올랐는데 애플리케이션 코드 변경이 없다”면, 가상스레드 도입이 관측 비용을 증폭시켰을 가능성을 꼭 봐야 합니다.

개선 체크리스트

  • INFO 로그를 요청당 여러 줄 찍고 있지 않은지
  • 예외 스택트레이스를 과도하게 출력하지 않는지
  • 메트릭 태그에 고카디널리티 값이 들어가지 않는지
  • 샘플링(트레이싱) 비율을 낮춰도 되는지

원인 6: GC 압력 증가(짧은 객체 폭발)와 예외 폭탄

동시성이 늘면 “초당 처리량”이 늘지 않아도 “동시에 살아있는 객체 수”가 늘 수 있습니다.

  • 대기 중인 요청 컨텍스트가 메모리에 오래 남음
  • 타임아웃/리트라이로 예외 객체가 폭증
  • JSON 파싱/직렬화가 병목일 때 객체 생성이 급증

GC가 바빠지면 CPU는 올라가고, 지연시간은 튀며, 다시 타임아웃이 늘어 악순환이 됩니다.

진단 포인트

  • GC 로그에서 Young GC 빈도 급증, Allocation Rate 증가
  • JFR에서 Object Allocation in New TLAB 상위 스택 확인

원인 7: 가상스레드가 아닌 곳에 남아 있는 블로킹 I/O와 혼합 모델 문제

Spring Boot 3에서 가상스레드는 주로 서블릿 스택(Tomcat/Jetty/Undertow)에서 “요청 처리 스레드”에 적용됩니다. 하지만 애플리케이션 내부에서 다음이 섞이면 예상치 못한 병목이 생깁니다.

  • @Async 기본 executor가 플랫폼 스레드 풀로 남아 있음
  • 스케줄러, 메시지 리스너, 배치 스레드가 별도 풀에서 동작
  • 라이브러리가 내부적으로 고정 스레드 풀을 사용

이 경우 일부 작업은 가상스레드로 늘어나고, 일부는 고정 풀에 막혀 대기열이 쌓이면서 CPU가 튈 수 있습니다(특히 타임아웃/리트라이와 결합될 때).

Spring에서 가상스레드 활성화 확인

spring.threads.virtual.enabled=true

그리고 @Async나 사용자 executor를 쓴다면, 가상스레드 executor로 명시하는 편이 안전합니다.

@Configuration
@EnableAsync
class AsyncConfig {
  @Bean
  public Executor taskExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
  }
}

주의할 점은, 무작정 모든 비동기 작업을 가상스레드로 바꾸기보다 “어떤 작업이 CPU 바운드인지, 어떤 작업이 I/O 바운드인지”부터 나누는 것입니다.

실전 진단 순서: CPU 폭증을 빠르게 좁히는 체크리스트

1) CPU가 진짜 애플리케이션에서 쓰이는지부터 확인

  • 컨테이너 환경이면 cgroup 제한과 스로틀링을 확인
  • top에서 Java 프로세스가 CPU를 쓰는지, 커널(system) 비중이 큰지 확인

2) 스레드 덤프에서 상태 분포를 확인

  • RUNNABLE이 많으면 CPU 바운드 가능성
  • BLOCKED가 많으면 락 경합
  • WAITING이 많으면 외부 리소스 대기(하지만 리트라이/타임아웃 로직을 함께 점검)

3) JFR로 상위 핫스팟을 찍어 결론을 낸다

운영에서도 짧게 JFR을 떠서 다음을 봅니다.

  • Method Profiling 상위 메서드
  • Java Monitor Blocked
  • Thread Park
  • Allocation top stack

4) 외부 풀 지표를 함께 본다

  • HikariCP: active, idle, pending
  • HTTP 클라이언트: pool pending, connection acquisition time
  • Redis: timeouts, reconnect

가상스레드 전환 후 CPU가 올랐다면, 대부분 이 지표 중 하나가 먼저 나빠집니다.

가상스레드 도입 시 “CPU 폭증”을 예방하는 설계 원칙

  1. 동시성은 늘리되, 백프레셔는 유지
    • DB 풀, 외부 호출 동시성 제한(세마포어 등)을 명시적으로 둔다
  2. 리트라이는 반드시 백오프와 상한을 둔다
    • 즉시 재시도는 장애 시 CPU를 태운다
  3. 락 경합을 제거하거나 분산
    • 전역 락, 핫 키, 단일 큐를 우선 의심
  4. 관측 비용을 예산화
    • 로그/트레이싱/메트릭은 동시성 증가 시 비용이 폭발한다
  5. CPU 바운드 작업은 별도 풀 또는 제한
    • 이미지 처리, 대용량 압축, 암호화, 복잡한 정렬/집계 등은 동시성 제한이 필요

마무리: 가상스레드는 “성능 부스터”가 아니라 “병목 증폭기”일 수 있다

Spring Boot 3.x 가상스레드는 블로킹 I/O 기반 서버에 큰 이점을 주지만, 동시에 기존에 스레드 제한이 가려주던 문제를 드러냅니다. CPU 폭증은 대개 다음 중 하나로 수렴합니다.

  • 락 경합
  • 리트라이/타임아웃 폭탄
  • 폴링/스핀 같은 바쁜 대기
  • 관측 비용 증폭
  • GC 압력 증가
  • 외부 풀 병목과 혼합 모델의 부작용

가장 빠른 해결 루트는 “추측”이 아니라, JFR로 상위 핫스팟을 확정하고, 풀 지표와 함께 병목을 하나씩 제거하는 것입니다. 가상스레드는 그 다음에야 진짜 효과가 나기 시작합니다.