- Published on
Spring Boot 3 가상스레드 적용 트러블슈팅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 자원이 남아도는데 동시 요청이 늘면 응답이 급격히 느려지거나, 특정 구간에서 스레드가 잔뜩 쌓이며 타임아웃이 발생한다면 가상스레드(virtual thread) 도입을 고려하게 됩니다. Spring Boot 3는 JDK 21 환경에서 가상스레드 기반 실행을 비교적 쉽게 켤 수 있지만, 실제 운영에 적용하면 성능이 좋아지기는커녕 새로운 병목이나 미묘한 오류를 만나기 쉽습니다.
이 글은 Spring Boot 3에서 가상스레드를 적용할 때 자주 겪는 트러블슈팅을 증상 중심으로 묶고, 원인과 해결책을 체크리스트 형태로 제공합니다. 특히 MVC 기반 동기 처리에서 가상스레드의 효과를 최대화하려면 DB 풀, 외부 I/O, 트랜잭션 경계, 컨텍스트 전파까지 함께 설계해야 합니다.
참고로 트랜잭션 관련 이슈가 섞여 보인다면 아래 글도 같이 보면 원인 분리가 빨라집니다.
가상스레드 적용 전: 기대치와 전제 조건
가상스레드는 "스레드 수를 늘려도 OS 스레드를 폭발시키지 않고" 블로킹 I/O 중심 워크로드의 동시성을 크게 올려주는 기술입니다. 하지만 다음을 전제로 합니다.
- 애플리케이션이 실제로 블로킹 I/O(HTTP 호출, DB, 파일, 메시지 대기 등)를 많이 한다.
- 병목이 CPU가 아니라 대기 시간(wait)이었다.
- DB 커넥션, 외부 API 동시 호출 제한, 레이트리밋 등 "다른 자원"이 먼저 터지지 않도록 조정한다.
가상스레드를 켰다고 해서 DB 커넥션이 10개인데 1,000 동시 요청을 처리할 수는 없습니다. 스레드는 늘어나도 커넥션을 기다리는 요청이 늘 뿐이고, 오히려 타임아웃이 더 빨리 나타날 수 있습니다.
Spring Boot 3에서 가상스레드 켜는 방법
가장 흔한 방식은 설정으로 활성화하는 것입니다. (JDK 21 권장)
spring:
threads:
virtual:
enabled: true
이 설정은 주로 Spring MVC의 요청 처리 스레드(서블릿 컨테이너 작업 스레드)와 일부 비동기 실행기 구성에 영향을 줍니다. 다만 "모든 곳"이 자동으로 가상스레드가 되는 것은 아닙니다. 예를 들어 별도로 만든 Executor 나 @Async 실행기, 배치 잡, 스케줄러 등은 별도 설정이 필요할 수 있습니다.
적용 확인 코드
운영에서 "진짜 가상스레드로 돌고 있는지" 확인하려면 로그로 확인하는 습관이 좋습니다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ThreadCheckController {
private static final Logger log = LoggerFactory.getLogger(ThreadCheckController.class);
@GetMapping("/thread")
public String thread() {
Thread t = Thread.currentThread();
log.info("name={}, isVirtual={}", t.getName(), t.isVirtual());
return "ok";
}
}
가상스레드가 아니라면 설정 미적용, JDK 버전, 컨테이너 구성(예: Undertow 사용), 또는 배포 환경의 런타임이 다른 경우를 의심해야 합니다.
트러블슈팅 1: 성능이 좋아지지 않고 오히려 느려졌다
대표 증상
- 처리량이 크게 늘지 않는다.
- 평균 응답은 비슷한데 p95, p99가 악화된다.
- 타임아웃이 증가한다.
원인 1) DB 커넥션풀이 병목
가상스레드는 요청을 많이 "동시에" 실행하게 만들 수 있습니다. 하지만 DB 커넥션풀 크기가 그대로라면, 많은 요청이 커넥션을 기다리며 대기열이 길어지고 지연이 커집니다.
점검
- HikariCP
active가 상시 풀 사이즈에 붙어 있는지 pending혹은 커넥션 획득 대기 시간이 급증하는지- DB 자체의 최대 커넥션/리소스가 충분한지
해결
- 풀 크기를 무작정 키우기 전에 DB가 감당 가능한지 먼저 확인
- 쿼리 튜닝, 인덱스, N+1 제거가 선행
- 외부 API 호출과 DB 호출이 한 요청에 섞여 있다면, 트랜잭션 구간을 최소화해 커넥션 점유 시간을 줄이기
spring:
datasource:
hikari:
maximum-pool-size: 30
connection-timeout: 3000
maximum-pool-size 는 시스템 전체 동시성 목표와 DB 사양에 맞춰 결정해야 하며, 가상스레드 도입 시 "요청 스레드 수" 와 "DB 커넥션 수" 의 균형이 더 중요해집니다.
원인 2) 외부 API 동시 호출이 폭증해 상대 시스템이 먼저 죽음
가상스레드로 동시성을 늘리면 외부 API도 더 많이 동시에 호출하게 됩니다. 상대 시스템의 레이트리밋이나 커넥션 제한에 걸리면 지연과 실패가 늘어납니다.
해결
- 클라이언트 측 동시성 제한(세마포어, 벌크헤드)
- 타임아웃/재시도 정책 정비
- 서킷브레이커 적용
동시성 제한의 가장 단순한 형태는 세마포어입니다.
import java.util.concurrent.Semaphore;
public class Bulkhead {
private final Semaphore semaphore = new Semaphore(50);
public <T> T call(CheckedSupplier<T> supplier) throws Exception {
semaphore.acquire();
try {
return supplier.get();
} finally {
semaphore.release();
}
}
@FunctionalInterface
public interface CheckedSupplier<T> {
T get() throws Exception;
}
}
가상스레드는 "막아야 할 곳" 을 더 선명하게 드러냅니다. 무제한 동시 호출은 대부분 운영에서 독이 됩니다.
트러블슈팅 2: 스레드는 가상스레드인데도 대기열이 쌓인다
대표 증상
jstack혹은 스레드 덤프에서 특정 락 또는 모니터 대기가 많다.- 처리량이 특정 지점에서 더 이상 늘지 않는다.
원인 1) synchronized, 전역 락, 커넥션/클라이언트 내부 락
가상스레드는 블로킹 I/O에 강하지만, 락 경합에는 만능이 아닙니다. 특히 아래 패턴은 동시성 증가 시 병목이 됩니다.
synchronized로 보호되는 전역 캐시- 단일 인스턴스에 접근하는 레거시 라이브러리
- HTTP 클라이언트/커넥션 매니저의 잘못된 공유 방식
해결
- 락 범위를 줄이거나 락 없는 구조로 변경
- 캐시는
ConcurrentHashMap과computeIfAbsent등으로 전환 - 클라이언트 인스턴스 공유 전략 점검(스레드 세이프 여부)
트러블슈팅 3: @Async, 스케줄러는 여전히 플랫폼 스레드 같다
대표 증상
- 웹 요청은
isVirtual=true인데@Async내부는false - 배치/스케줄러 작업이 OS 스레드를 많이 점유
원인
spring.threads.virtual.enabled=true 는 "모든 실행기" 를 강제하지 않습니다. @Async 는 별도 TaskExecutor 를 사용하고, 스케줄러도 별도 풀을 씁니다.
해결: 가상스레드 기반 Executor 명시
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;
import java.util.concurrent.Executors;
@Configuration
public class VirtualThreadExecutors {
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
}
환경에 따라 기본 빈 이름이 다르게 적용될 수 있으므로, 실제로 @Async 가 어떤 실행기를 쓰는지 확인하고 맞춰 주는 것이 안전합니다.
트러블슈팅 4: 트랜잭션이 꼬이거나, 예상과 다른 시점에 커밋된다
대표 증상
@Transactional이 걸린 메서드에서 비동기 호출 후 데이터 정합성이 깨짐- 트랜잭션 안에서 외부 API 호출이 오래 걸리며 락이 길게 유지됨
원인 1) 트랜잭션 컨텍스트는 스레드 로컬에 묶인다
Spring 트랜잭션은 기본적으로 스레드 로컬 기반입니다. 같은 요청이라도 실행 스레드가 바뀌면 트랜잭션 컨텍스트가 이어지지 않습니다. 가상스레드는 "스레드" 이긴 하지만, @Async 나 별도 실행기로 작업을 넘기는 순간 스레드가 달라집니다.
해결
- 트랜잭션 경계 안에서 비동기 작업을 실행하지 않는다.
- 꼭 필요하면 트랜잭션을 분리하고, 이벤트/메시지 기반으로 후처리한다.
- 프록시 기반
@Transactional이 무효화되는 일반 원인도 함께 점검한다.
관련 체크리스트는 아래 글이 도움이 됩니다.
실전 팁: 외부 I/O는 트랜잭션 밖으로
가상스레드 도입 시 외부 API 호출을 트랜잭션 안에서 수행하면 "동시 요청 증가" 와 함께 DB 락 점유 시간이 길어져 전체가 느려질 수 있습니다.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
public OrderService(OrderRepository orderRepository, PaymentClient paymentClient) {
this.orderRepository = orderRepository;
this.paymentClient = paymentClient;
}
public void placeOrder(Long orderId) {
// 1) 결제는 트랜잭션 밖에서
paymentClient.pay(orderId);
// 2) DB 업데이트는 짧게
confirmOrder(orderId);
}
@Transactional
public void confirmOrder(Long orderId) {
orderRepository.confirm(orderId);
}
}
트러블슈팅 5: MDC, SecurityContext, TraceId가 중간에 사라진다
대표 증상
- 요청 로그는 traceId가 있는데, 내부 호출 로그부터 traceId가 비어 있음
SecurityContextHolder정보가 비어 권한 오류 발생
원인
컨텍스트 전파는 보통 스레드 로컬에 의존합니다. 가상스레드 자체는 스레드 로컬을 지원하지만, 작업을 다른 실행기로 넘기거나 비동기 경계가 생기면 전파가 끊길 수 있습니다.
해결
- 비동기 경계를 최소화
- Spring의
TaskDecorator로 MDC 전파 - 관측성 도구(마이크로미터 트레이싱 등) 설정 점검
아래는 MDC를 전파하는 TaskDecorator 예시입니다.
import org.slf4j.MDC;
import org.springframework.core.task.TaskDecorator;
import java.util.Map;
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
if (contextMap != null) MDC.setContextMap(contextMap);
try {
runnable.run();
} finally {
if (previous != null) MDC.setContextMap(previous);
else MDC.clear();
}
};
}
}
트러블슈팅 6: 운영에서 502·504가 늘었다
대표 증상
- 애플리케이션은 살아있는데, 로드밸런서에서 502 또는 504 증가
- 피크 시간대에만 발생
원인
가상스레드로 동시성이 올라가면 서버는 더 많은 요청을 "받아" 처리하려고 합니다. 그 결과 다음이 함께 악화될 수 있습니다.
- 백엔드(DB, 외부 API) 지연으로 응답이 늦어짐
- 큐잉이 길어져 로드밸런서 idle timeout에 걸림
- 헬스체크가 지연되어 인스턴스가 비정상으로 판정
해결
- 서버 타임아웃과 로드밸런서 타임아웃 정합성 맞추기
- DB/외부 API 병목을 먼저 제거하거나, 동시성 상한을 둔다
- p95 기준으로 타임아웃을 재설계
ALB 환경이라면 아래 체크리스트도 함께 보면 원인 분리가 빠릅니다.
트러블슈팅 7: 가상스레드 도입 후 메모리가 빨리 찬다
대표 증상
- 동시 요청이 늘수록 RSS가 급증
- GC가 잦아지고 지연이 커짐
원인
가상스레드는 플랫폼 스레드보다 훨씬 가볍지만, "공짜" 는 아닙니다. 요청이 폭증하면 다음이 함께 증가합니다.
- 요청 객체/버퍼/JSON 파싱 결과 등 힙 사용량
- 대기 중인 작업 수
- HTTP 클라이언트 커넥션/버퍼
해결
- 무제한 동시성 대신 상한 설정(서블릿 컨테이너, 클라이언트, 벌크헤드)
- 요청 바디/응답 바디 크기 제한
- 스트리밍 처리로 메모리 피크 낮추기
운영 적용 체크리스트
1) 지표부터 잡기
- HikariCP: 커넥션 획득 대기 시간
- 서블릿 요청 처리 시간: p95, p99
- 외부 API: 타임아웃/재시도 횟수, 동시 호출 수
- GC, 힙 사용량
2) 동시성의 상한을 설계하기
가상스레드는 "더 많이 동시에" 를 쉽게 만들어 주지만, 운영에서는 "어디까지" 가 더 중요합니다.
- DB 커넥션 수를 기준으로 서버 동시 처리량을 제한
- 외부 API별 동시 호출 제한
- 레이트리밋, 벌크헤드, 큐잉 전략
3) 트랜잭션은 짧게, 외부 I/O는 밖으로
- 트랜잭션 안에서 네트워크 호출 금지에 가깝게 운영 규칙화
- 오래 걸리는 작업은 이벤트/메시지로 분리
마무리
Spring Boot 3에서 가상스레드는 "블로킹 I/O 중심" 서비스의 동시성을 크게 끌어올릴 수 있지만, 그만큼 숨겨진 병목(DB 풀, 외부 API 레이트리밋, 락 경합, 타임아웃 정합성)이 빠르게 드러납니다. 적용 자체는 설정 한 줄로 끝나도, 운영 안정화는 병목 자원의 상한을 설계하고 관측 지표를 정리하는 작업에 가깝습니다.
가상스레드 적용 후 문제가 생겼다면, 먼저 "스레드" 가 아니라 "다른 공유 자원" 이 병목인지부터 확인하고, 트랜잭션 경계와 컨텍스트 전파를 점검하는 순서로 접근하면 해결 속도가 빨라집니다.