스프링부트에서 가상 스레드 켜기 전 체크리스트
설정 한 줄로 켜지는 건 맞지만
스프링부트 3.2 부터는 가상 스레드를 켜는 데 정말로 설정 한 줄이면 됩니다.
spring:
threads:
virtual:
enabled: true이 옵션을 켜면 톰캣의 요청 처리 스레드 풀이 가상 스레드 기반으로 바뀝니다. 애플리케이션 코드를 한 줄도 안 고쳐도 됩니다. 문제는 바로 이 지점입니다 — 너무 쉽게 켜지기 때문에, 앞선 장들에서 다룬 함정들을 점검하지 않고 그냥 켜버리는 경우가 많습니다. 이 장은 켜기 전에 확인할 목록입니다.
1. Java 버전부터 확인하세요
pinning 장에서 다뤘듯, synchronized pinning 문제는 Java 24 에서 해결됐습니다. 그 이전 버전이라면 synchronized 블록 안의 블로킹 I/O 를 먼저 걷어내거나, 최소한 -Djdk.tracePinnedThreads=full 로 얼마나 pinning 이 발생하는지부터 측정해야 합니다.
java -version
# Java 25 (LTS) 이상 권장, 최소 Java 242. 커넥션 풀 크기를 재검토하세요
가상 스레드로 동시 요청 처리량이 늘어나면, DB 커넥션 풀·외부 API 클라이언트의 커넥션 제한이 새로운 병목으로 드러납니다. HikariCP 를 쓴다면 maximum-pool-size 를 손대기 전에, 먼저 DB 서버가 감당 가능한 동시 연결 수를 인프라팀과 확인하세요. 애플리케이션 쪽 숫자만 올려서 해결되는 문제가 아닙니다.
spring:
datasource:
hikari:
maximum-pool-size: 50 # 무작정 늘리지 말고 DB 쪽 한계부터 확인3. synchronized 캐시, 레이트 리미터 코드를 검색하세요
애플리케이션 코드 안에서 synchronized 키워드를 전체 검색해 보세요. 특히 캐시 구현체, 레이트 리미터, 세마포어를 직접 구현한 유틸리티 클래스에 이런 패턴이 몰려 있습니다. Java 24 이상이라면 대부분은 걱정하지 않아도 되지만, 네이티브 라이브러리를 감싼 래퍼가 있다면 여전히 점검 대상입니다.
4. ThreadLocal 을 쓰는 인터셉터·필터를 확인하세요
스프링에서 MDC(로깅 컨텍스트), 커스텀 HandlerInterceptor, 트레이싱 라이브러리는 대부분 ThreadLocal 을 씁니다. 이 자체는 가상 스레드에서도 정상 동작하지만, 무거운 객체를 담고 있는지 점검할 가치가 있습니다. 특히 로깅 프레임워크의 MDC 는 요청마다 새로 생성·해제되는 비용이 이미 있었기 때문에 대체로 안전한 편이지만, 팀에서 직접 만든 컨텍스트 홀더는 한 번씩 훑어보세요.
5. 스레드 풀을 직접 만든 코드가 있는지 찾으세요
Executors.newFixedThreadPool, new ThreadPoolExecutor(...) 를 애플리케이션 코드에서 직접 호출하는 곳이 있다면, 가상 스레드로 바꿀 가치가 있는지 하나씩 판단하세요. I/O 대기가 많은 작업이면 Executors.newVirtualThreadPerTaskExecutor() 로, CPU 바운드 작업이면 기존 고정 크기 풀을 그대로 유지합니다. 모든 스레드 풀을 무조건 가상 스레드로 바꾸는 건 앞 장에서 다룬 것처럼 오히려 손해입니다.
6. 카나리 배포로 먼저 측정하세요
이 모든 점검을 마쳤어도, 전체 트래픽에 한 번에 적용하지 마세요. 스프링부트 설정 하나로 켜지는 만큼 되돌리는 것도 쉽지만, pinning 이나 예상치 못한 GC 압박은 실제 프로덕션 트래픽 패턴에서만 드러나는 경우가 많습니다. 트래픽의 일부(카나리 그룹)에만 먼저 적용하고, jdk.tracePinnedThreads 로그와 GC 빈도, 응답 시간 분포(특히 p99)를 최소 며칠간 관찰한 뒤 전면 적용하는 순서를 권합니다.
체크리스트 요약
| 항목 | 확인 방법 |
|---|---|
| Java 버전 24 이상인가 | java -version |
| synchronized 안에 블로킹 I/O 가 있는가 | -Djdk.tracePinnedThreads=full 로 측정 |
| DB/외부 API 커넥션 풀이 새 병목이 될 수 있는가 | 인프라 쪽 동시 연결 한계 확인 |
| 무거운 객체를 담은 ThreadLocal 이 있는가 | 코드 검색 + 힙 덤프 비교 |
| CPU 바운드 작업까지 가상 스레드로 옮기려 하는가 | 작업 성격별로 분리 유지 |
| 전체 트래픽에 한 번에 적용하는가 | 카나리 배포로 단계적 전환 |
이 체크리스트를 다 통과했다면, spring.threads.virtual.enabled=true 는 정말로 설정 한 줄로 끝나는 작업이 됩니다. 순서를 반대로 하면 — 먼저 켜고 문제가 생기면 그때 고치겠다는 접근 — 넷플릭스 사례처럼 트래픽이 몰리는 순간에야 문제를 발견하게 됩니다.