Published on

Spring Boot 3 가상스레드 도입 시 장애 7가지

Authors

서버 스레드 고갈을 줄이기 위해 Spring Boot 3에서 가상스레드(virtual thread)를 켜는 팀이 많습니다. 하지만 가상스레드는 "그냥 켜면 빨라진다"가 아니라, 병목의 위치를 바꿉니다. 기존에는 톰캣 워커 스레드가 부족해 쓰러지던 서비스가, 이제는 DB 커넥션 풀·락·동기화·관측(Observability)·외부 I/O 제한 같은 곳에서 더 빨리 한계에 닿습니다.

아래는 운영에서 반복적으로 등장하는 장애 7가지를 원인, 증상, 진단 포인트, 해결책 순서로 정리한 글입니다. (가상스레드 자체의 동작 원리나 Deadlock/TPS 저하 심화 진단은 별도 글도 참고하세요: Spring Boot 3 가상스레드 도입 후 Deadlock·TPS 저하 진단)

1) DB 커넥션 풀 고갈: 스레드는 늘었는데 DB가 막힘

왜 생기나

가상스레드는 요청당 스레드를 "싸게" 만들기 때문에, 애플리케이션은 동시에 더 많은 요청을 처리하려고 합니다. 하지만 DB 커넥션 풀은 여전히 제한적입니다. 결과적으로 대기열이 DB 커넥션 풀 앞에 길게 늘어서며 지연이 폭발합니다.

대표 증상

  • 응답 지연이 갑자기 증가하고 타임아웃이 늘어남
  • HikariCP 경고 로그(획득 타임아웃) 증가
  • DB CPU는 높지 않은데 애플리케이션 TPS가 떨어짐

진단 포인트

  • Hikari 메트릭: hikaricp.connections.active, pending
  • 슬로우 쿼리보다 커넥션 획득 대기가 길어지는지 확인
  • 스레드 덤프에서 DB 커넥션 획득 관련 대기 다수

대응 방법

  1. 풀 크기를 무작정 키우기보다, 먼저 병렬성을 제한합니다.
  • 엔드포인트별 동시성 제한(세마포어)
  • Bulkhead 패턴
  1. 쿼리당 커넥션 점유 시간을 줄입니다.
  • 트랜잭션 범위 축소
  • N+1 제거
  • 불필요한 직렬화/대용량 결과 제한
  1. 풀 크기 조정은 DB가 감당 가능한 범위에서만
# application.yml 예시
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 10
      connection-timeout: 2000
      max-lifetime: 1800000
// 엔드포인트 단위 동시성 제한(간단 예시)
import java.util.concurrent.Semaphore;

@RestController
class ReportController {
  private final Semaphore dbBulkhead = new Semaphore(30); // 풀보다 약간 크게/작게 조정

  @GetMapping("/reports")
  public ResponseEntity<String> reports() throws InterruptedException {
    dbBulkhead.acquire();
    try {
      // DB 작업
      return ResponseEntity.ok("ok");
    } finally {
      dbBulkhead.release();
    }
  }
}

2) 동기화/락 경쟁 심화: synchronized가 병목으로 부상

왜 생기나

플랫폼 스레드 시절에는 동시성이 제한되어 락 경쟁이 덜 보이던 코드가, 가상스레드 도입으로 동시 요청 수가 늘며 락 경합이 갑자기 표면화됩니다. 특히 다음이 위험합니다.

  • synchronized로 보호되는 전역 캐시/맵
  • 큰 범위의 임계영역
  • 단일 인스턴스에 몰리는 집계/통계 업데이트

대표 증상

  • CPU는 낮은데 지연이 증가
  • 스레드 덤프에서 동일 모니터 락 대기 다수
  • TPS가 특정 수준 이상 올라가지 않음

대응 방법

  • 임계영역 최소화
  • ConcurrentHashMap, LongAdder 등 락 경합이 적은 구조 사용
  • 단일 공유자원 업데이트는 비동기 큐로 분리
// 나쁜 예: 전역 synchronized로 모든 요청이 직렬화됨
class BadCounter {
  private long count;
  public synchronized void inc() { count++; }
}

// 개선 예: 경합 완화
import java.util.concurrent.atomic.LongAdder;
class GoodCounter {
  private final LongAdder count = new LongAdder();
  public void inc() { count.increment(); }
  public long value() { return count.sum(); }
}

3) 블로킹 I/O가 많아져서 "외부 시스템"이 먼저 죽음

왜 생기나

가상스레드는 블로킹 I/O에서 특히 효과가 좋습니다. 문제는 그 효과 때문에 애플리케이션이 외부 시스템에 훨씬 더 공격적으로 요청을 보내게 된다는 점입니다.

  • 결제/인증/검색 같은 외부 API
  • 내부 마이크로서비스 호출
  • 메시지 브로커

외부가 버티지 못하면, 결과적으로 우리 서비스가 타임아웃과 재시도로 더 큰 폭주를 유발합니다.

대표 증상

  • 특정 외부 연동 구간에서 타임아웃 급증
  • 재시도 폭증으로 외부/내부 모두 부하 증가
  • 5xx가 연쇄적으로 발생

대응 방법

  • 호출 단에 동시성 제한 + 타임아웃 + 서킷브레이커
  • 재시도는 지수 백오프와 지터 필수
  • 대용량 응답은 스트리밍/페이지네이션
// Resilience4j Bulkhead(동시성 제한) 개념 예시
// 실제 설정은 프로젝트 표준에 맞게 적용
import java.time.Duration;

class ExternalClient {
  String call() {
    // timeout, circuit breaker, bulkhead 등을 적용
    return "ok";
  }
}

외부 연동에서 타임아웃/게이트웨이 오류가 섞여 보이면, 인프라 레벨 진단도 같이 필요합니다. 예를 들어 프록시/게이트웨이 계층에서 메시지 크기 제한이 애매하게 걸리면 4xx 대신 5xx로 보일 수 있습니다. 상황이 비슷하다면 이 글의 접근법도 참고할 만합니다: EKS에서 413 없이 502? gRPC 최대 메시지 해결

4) 요청 폭주로 메모리 압박 증가, 결국 OOM 또는 OOM Kill

왜 생기나

가상스레드는 스레드 자체 비용은 낮지만, 요청이 늘면 다음이 같이 늘어납니다.

  • 요청/응답 버퍼
  • JSON 직렬화 객체
  • DB 결과 로딩
  • 로깅/추적 컨텍스트

즉, 스레드 문제가 아니라 힙과 네이티브 메모리가 먼저 한계에 닿을 수 있습니다. 컨테이너 환경에서는 커널이 프로세스를 강제 종료(OOM Kill)할 수도 있습니다.

대표 증상

  • GC 빈도 증가, STW 증가
  • RSS 증가, 컨테이너 재시작
  • 노드 로그에 OOM Kill 흔적

대응 방법

  • 엔드포인트별 최대 동시 처리량 제한
  • 큰 payload 제한(업로드/다운로드)
  • 스트리밍 처리 도입
  • GC/힙 사이즈를 컨테이너 제한에 맞게 재조정

리눅스/쿠버네티스에서 실제로 OOM Kill이 발생했는지 빠르게 확인하는 방법은 아래 글이 실전적입니다: 리눅스 OOM Kill 원인 추적 - dmesg·cgroup·journalctl

# 컨테이너/노드에서 OOM Kill 흔적 확인(예시)
dmesg -T | grep -i "killed process" 

# cgroup 메모리 이벤트(환경에 따라 경로 상이)
cat /sys/fs/cgroup/memory.events 2>/dev/null || true

5) ThreadLocal 기반 컨텍스트 전파 문제: 로깅/트레이싱이 깨짐

왜 생기나

많은 라이브러리와 사내 코드가 ThreadLocal에 의존합니다(MDC, 테넌트 컨텍스트, 사용자 컨텍스트 등). 가상스레드에서는 스레드 생성/전환이 더 흔하고, 실행 모델이 달라지면서 컨텍스트가 누락되거나 예상과 다르게 남아있는 문제가 발생할 수 있습니다.

대표 증상

  • 로그에 traceId/requestId가 간헐적으로 비어 있음
  • 다른 사용자의 컨텍스트가 섞여 보임
  • APM에서 트랜잭션 연결이 끊김

대응 방법

  • 가능한 경우 ThreadLocal 대신 명시적 파라미터 전달
  • 비동기 경계에서 컨텍스트 복사/정리
  • MDC는 반드시 try/finally로 제거
import org.slf4j.MDC;

public String handle(String requestId) {
  MDC.put("requestId", requestId);
  try {
    // 비즈니스 로직
    return "ok";
  } finally {
    MDC.remove("requestId");
  }
}

6) 스케줄러/배치/리스너에서 "무제한 동시성"이 열려버림

왜 생기나

웹 요청 처리만 가상스레드로 바꾼다고 끝나지 않습니다. 운영 장애는 종종 백그라운드에서 터집니다.

  • @Scheduled 작업이 겹쳐 실행
  • 메시지 리스너가 과도하게 병렬 처리
  • 배치가 DB와 외부 API를 동시에 과다 호출

가상스레드로 실행되면 병렬성이 쉽게 올라가서, 의도치 않게 백그라운드가 시스템 전체 리소스를 잠식할 수 있습니다.

대표 증상

  • 특정 시간대에만 DB 풀 고갈/지연
  • 스케줄러가 밀리면서 중복 실행
  • 큐 소비가 급증했다가 장애로 전환

대응 방법

  • 스케줄러는 단일 실행 보장(락, 리더 선출)
  • 리스너/배치는 명시적 동시성 제한
  • 작업 큐 기반으로 처리량을 제어
// 스케줄 작업 겹침 방지: 단순 예시(분산 환경에서는 분산락 필요)
import java.util.concurrent.atomic.AtomicBoolean;

@Component
class Job {
  private final AtomicBoolean running = new AtomicBoolean(false);

  @Scheduled(fixedDelay = 10000)
  public void run() {
    if (!running.compareAndSet(false, true)) return;
    try {
      // 작업 수행
    } finally {
      running.set(false);
    }
  }
}

7) 진단 방식의 함정: 스레드 수가 많아져 관측/덤프가 어려움

왜 생기나

가상스레드는 매우 많이 생성될 수 있습니다. 그 자체가 문제는 아니지만, 운영에서 장애가 나면 다음이 어려워집니다.

  • 스레드 덤프가 방대해 분석이 느려짐
  • 모니터링 지표가 플랫폼 스레드 기준으로 설계되어 오해 유발
  • "스레드가 많다"를 곧바로 장애로 오판

대표 증상

  • 덤프/프로파일링 시점에 성능이 더 나빠짐
  • 원인 분석이 늦어져 장애 시간이 길어짐

대응 방법

  • 관측 포인트를 스레드가 아니라 대기 지점으로 옮깁니다.
    • DB 커넥션 대기
    • 외부 호출 대기
    • 락 대기
    • 큐 적체
  • APM에서 지연의 Top N을 "대기"로 분해해 보는 설정 강화
  • 부하 테스트 시 가상스레드 도입 전후로 SLO 기반 비교
# 자바 프로세스 기본 확인(예시)
jcmd `pgrep -f your-app` Thread.print | head -n 50

# 톱레벨에서 스레드가 아니라 지연 원인을 찾는 데 집중
# (DB, HTTP client, lock contention, queue backlog 메트릭을 함께 본다)

Spring Boot 3에서 가상스레드 적용 최소 설정 예시

Spring Boot 3 계열에서는 웹 요청 처리에 가상스레드를 적용하는 옵션을 제공합니다. 실제 키 이름/지원 범위는 사용하는 웹 스택(서블릿 기반, 리액티브)과 버전에 따라 다를 수 있으니, 적용 후 반드시 부하 테스트로 검증하세요.

# application.yml 예시(서블릿 스택에서 가상스레드 사용)
spring:
  threads:
    virtual:
      enabled: true

또한 애플리케이션 코드에서 별도 Executor를 만들어 쓰는 경우, 가상스레드용 Executor를 명시하는 편이 안전합니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class VirtualThreadConfig {
  @Bean(destroyMethod = "close")
  ExecutorService virtualThreadExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
  }
}

운영 체크리스트: "스레드" 대신 "풀과 한도"를 점검

가상스레드 도입 후 장애를 줄이려면, 아래 순서로 보는 것이 효율적입니다.

  1. DB 커넥션 풀: active/pending/timeout
  2. 외부 호출: 동시성 제한, 타임아웃, 재시도 정책
  3. 락/동기화: 전역 synchronized, 단일 공유자원
  4. 메모리: 힙, RSS, 컨테이너 제한, OOM Kill
  5. 컨텍스트: MDC/트레이싱/보안 컨텍스트 전파
  6. 백그라운드: 스케줄러/리스너 동시성
  7. 관측: 지연의 "대기" 분해와 병목 Top N

가상스레드는 분명 강력하지만, 그 힘 때문에 숨겨진 병목이 더 빨리 드러납니다. 스레드 고갈이 사라진 뒤 무엇이 한계가 되는지(풀, 락, 외부 I/O, 메모리)를 구조적으로 제한하고 관측하면, 가상스레드는 안정성과 처리량을 동시에 끌어올리는 카드가 됩니다.