자바 가상 스레드를 스프링 부트에서 켤 때 히카리 풀과 톰캣 스레드, synchronized 고정, 스레드 로컬을 같은 표에 둔다. 자바 21과 24, 스프링 부트 3.2 플래그, keep-alive, jcmd 덤프, 피닝 추적, JDBC 블로킹, 개발자, 백엔드, API 기준으로 켠 뒤에 느려지는 자리를 나눈다. 코틀린 코루틴 글과 각을 섞지 않는다.
자바 21에서 가상 스레드를 켰는데 응답이 더 느려진 적 있으신가요. 켜는 줄은 스프링 부트 3.2부터 spring.threads.virtual.enabled=true 한 줄입니다. 톰캣 스레드 200이 풀리면서 히카리 풀이 먼저 바닥납니다. 플랫폼 스레드가 막던 대기열이 커넥션 대기로만 옮겨 갑니다. 공식 문서는 자바 24 이상을 강하게 권합니다. synchronized 고정이 21에서는 캐리어를 붙잡고, 스케줄 잡은 데몬이라 스프링은 keep-alive를 같이 켜라고 적습니다. 저는 플래그보다 풀 크기와 고정 로그를 먼저 봅니다.
풀이 고갈된 새벽은 피지바운서 케이스를, 코루틴은 코틀린 스프링을, 고루틴은 고 동시성을 보면 자리가 겹치지 않습니다. 숫자는 2026년 8월 공개 문서 기준입니다.
플래그만 켠 밤에 히카리가 먼저 바닥난다
켜는 순간 요청마다 가상 스레드가 생기고, 디비를 기다리는 줄이 히카리 기본값 10에 몰립니다. 톰캣 최대 스레드 200이 자연 상한처럼 보이던 자리는 사라집니다. 스프링 부트 문서는 가상 스레드를 켜면 스레드 풀을 조절하던 속성이 더 이상 효과가 없다고 적습니다. 스케줄은 JVM 전역 플랫폼 풀에서 돌아가고, 전용 톰캣 풀에 기대던 숫자가 비기 때문입니다.
국내 팀에서 본 구성은 흔합니다. 스프링 부트 3.3, 자바 21, 아마존 알디에스 포스트그레, 히카리 10, 인스턴스 2대. 피크에 동시 요청이 80을 넘기면 액추에이터의 활성 커넥션이 10에 붙고, 대기 시간이 초 단위로 뜹니다. 로그에는 스레드 고갈이 아니라 Connection is not available, request timed out만 남습니다. 플래그를 되돌리면 다시 톰캣 대기열이 막고, 디비 세션은 잠잠해집니다.
히카리 기본 최대는 10입니다. 톰캣 기본 최대는 200입니다. 가상 스레드 이전에는 200개 중 일부만 디비를 쳐서 10으로도 버텼습니다. 켠 뒤에는 요청 수만큼 디비를 두드릴 수 있습니다. 풀을 올리기 전에 알디에스 max_connections와 인스턴스 수를 곱셈으로 맞춰야 합니다. 그 계산을 빼먹은 밤은 커넥션 풀 고갈과 같은 알람이 납니다.
한 줄 가드 | 플래그를 켜기 전에 히카리 최대, 커넥션 타임아웃, 알디에스 세션 한도를 같은 표에 적습니다. 톰캣 스레드 숫자는 더 이상 디비 앞의 댐이 아닙니다.
가상 스레드는 빨라지지 않는다
제이에피 444는 가상 스레드가 코드를 더 빨리 돌리는 스레드가 아니라고 적습니다. 처리량을 올리는 자리이지, 지연을 깎는 자리가 아닙니다. CPU가 바쁜 정렬, 암호화, 이미지 변환은 코어 수를 넘긴 스레드가 도움이 되지 않습니다. 도움이 되는 자리는 동시 요청이 많고, 그 요청이 소켓과 JDBC를 기다리는 자리입니다.
그래서 벤치 숫자만 보고 켜면 실망합니다. 워밍 없이 CPU가 100%인 잡을 가상 스레드로 바꾸면 처리량은 그대로이거나 줄어듭니다. 블로킹 IO가 긴 조회, 외부 API, 파일 읽기에서만 요청당 스레드 스타일이 다시 싸집니다. 외부 호출이 한꺼번에 늘면 회로 차단기가 같이 필요합니다. 스레드가 싸졌다고 재시도를 무한히 늘리면, 이번엔 상대 서버가 먼저 죽습니다.
고루틴과 비교하면 느낌이 비슷합니다. 고도 고루틴을 풀에 넣지 말라고 가르칩니다. 제이에피도 가상 스레드를 풀에 넣지 말라고 적습니다. 비싼 자원을 나누려고 쓰던 스레드 풀 습관을 그대로 가져오면, 요청마다 커넥션을 새로 여는 코드가 나옵니다. 그 습관은 고 동시성 글의 워커 풀 자리와 다릅니다. 여기는 요청당 하나씩 만들고, 제한은 세마포어나 풀로 겁니다.
요청당 스레드가 싸지면 디비 풀이 다음 병목이 된다
플랫폼 200과 가상 수천을 같은 표에 올리면
고르는 기준은 로고가 아니라, 디비와 외부 호출이 요청을 얼마나 붙잡는가입니다. 아래 숫자는 공식 기본값과 2026년 8월 스프링 부트 4.1 문서 기준입니다.
30은 만능이 아닙니다. 인스턴스가 3대면 디비 앞에는 90이 섭니다. 알디에스 기본 100에서 슈퍼유저와 모니터링을 빼면 여유가 없습니다. 피지바운서를 트랜잭션 모드로 두면 앱 풀을 올려도 디비 세션은 고정됩니다. 숫자를 올리기 전에 pg_stat_activity의 대기 이벤트를 봅니다. 대기 이벤트가 Client가 아니라 락이면, 풀을 올려도 느립니다.
synchronized와 네이티브가 캐리어를 붙잡는 자리
자바 21에서는 synchronized 블록 안에서 블로킹이 나면 가상 스레드가 캐리어에 고정됩니다. 네이티브 호출과 외국 함수도 같습니다. 고정 자체가 틀린 결과는 아닙니다. 다만 그 동안 운영체제 스레드 하나가 놀지 못합니다. 자주, 길게 붙으면 처리량이 떨어집니다. 스프링이 자바 24를 강하게 권하는 이유 중 하나가 여기입니다. 제이에피 491이 자바 24에서 synchronized 고정을 거의 걷어냈습니다.
자바 21을 당장 못 올리는 팀은 긴 아이오를 감싼 synchronized를 리엔트런트락으로 바꿉니다. 시작 때만 도는 초기화나 메모리 연산은 그대로 둬도 됩니다. 고정이 의심되면 스테이징에서 -Djdk.tracePinnedThreads=short를 켭니다. JFR 이벤트 jdk.VirtualThreadPinned는 기본 임계가 20ms입니다. 덤프는 옛 jstack이 아니라 아래 명령을 씁니다.
고정 추적과 가상 스레드 덤프
java -Djdk.tracePinnedThreads=short -jar app.jar
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
# 덤프에 가상 스레드가 보이면 켠 것이다. 옛 jstack 목록에는 플랫폼만 남는다
스레드 로컬도 같이 봅니다. 가상 스레드는 요청마다 생기므로, 커넥션이나 큰 버퍼를 스레드 로컬에 넣어 재사용하는 패턴은 메모리만 돕니다. 제이에피는 그 습관을 버리라고 적습니다. 로그 엠디씨처럼 짧은 값은 당장 깨지지 않습니다. 다만 요청마다 큰 객체를 붙이면 힙이 먼저 경고합니다. 코틀린 코루틴의 스레드 로컬 전파와 다른 함정입니다. 코루틴은 디스패처를 나누는 글을 보면 됩니다.
jcmd JSON 덤프와 피닝 추적. 옛 jstack만으로는 가상 스레드가 안 보인다
스테이징에서 켤 순서
프로덕션 플래그보다 스테이징 부하가 먼저입니다. 순서는 버전 확인, 풀 표, 고정 로그, 피크의 두 배 부하, 일주일 관측입니다. 자바 21이면 고정 로그를 필수 칸에 넣고, 24 이상이면 같은 로그를 하루만 켜 둡니다. 스케줄 잡이 있는 서비스는 keep-alive를 빼먹지 않습니다.
액추에이터 메트릭이 없다면 스테이징만 잠시 엽니다. 프로덕션에서 액추에이터를 넓히지 않습니다. 대기 pending이 0이 아닌데 활성 커넥션이 최대에 붙어 있으면 풀이 작습니다. pending이 0이고 응답만 느리면 쿼리입니다. 쿼리는 실행 계획 글로 넘깁니다. 런타임 예외 알림은 센트리처럼 요청 아이디를 남기는 쪽이 편합니다.
웹플럭스를 이미 쓰는 서비스는 이 플래그가 메인 경로가 아닙니다. 리액티브 스택은 스레드 모델이 다릅니다. 블로킹 JDBC를 웹플럭스 안에서 돌리던 팀을 가상 스레드 MVC로 되돌리는 선택은 별도 이전입니다. 이 글은 서블릿 MVC, 톰캣, 히카리를 전제로 합니다.