- Published on
Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 스레드를 늘렸는데도 처리량이 늘지 않고, 오히려 HikariPool 타임아웃이 폭발한다면 원인은 대개 “스레드 부족”이 아니라 “DB 커넥션 부족”입니다. Spring Boot 3에서 가상스레드를 켜면 요청당 스레드를 더 쉽게 할당할 수 있어 동시성이 급격히 증가합니다. 이때 애플리케이션은 더 많은 요청을 동시에 DB로 밀어 넣고, 결과적으로 커넥션 풀이 고갈되어 지연이 전파되고 장애로 이어집니다.
이 글은 Spring Boot 3 가상스레드 적용 시 흔히 겪는 DB 커넥션 고갈을 원인 분해하고, 단순히 풀 사이즈만 키우는 방식이 아니라 동시성 제어 + 트랜잭션 경계 정리 + 풀/타임아웃 튜닝 + 관측으로 안정화하는 방법을 다룹니다.
가상스레드가 커넥션 고갈을 “만드는” 방식
가상스레드 자체가 DB 커넥션을 더 소비하는 것은 아닙니다. 문제는 다음 구조에서 발생합니다.
- 플랫폼 스레드 기반: 동시 요청 수가 플랫폼 스레드 수로 자연스럽게 제한됨
- 가상스레드 기반: 요청이 훨씬 더 많이 동시에 실행되며, DB 호출 지점에서 대기하는 스레드가 폭증
- DB는 여전히 제한 자원: 커넥션 풀 크기만큼만 동시 쿼리 수행 가능
즉, 가상스레드는 “대기 비용이 낮은 스레드”를 제공할 뿐이고, DB는 “동시 실행 가능한 작업 수가 제한된 자원”입니다. 가상스레드를 켜는 순간, 애플리케이션은 DB에 대한 압력(pressure) 을 더 크게 만들 수 있습니다.
대표 증상은 다음과 같습니다.
HikariPool-1 - Connection is not available, request timed out after ...ms- 응답 시간이 계단식으로 증가하고, 타임아웃이 연쇄적으로 발생
- DB CPU 또는 락 경합이 증가하거나, 반대로 DB는 한가한데 애플리케이션만 타임아웃(풀 대기)
먼저 확인할 것: “풀 크기”가 아니라 “동시 DB 작업 수”
커넥션 고갈을 풀 사이즈만 늘려 해결하려 하면, DB 서버 리소스 한계나 락 경합을 더 악화시킬 수 있습니다. 먼저 아래 질문부터 답해야 합니다.
- 피크 시점에 동시에 DB를 때리는 요청 수가 얼마나 되는가
- 요청당 DB 커넥션을 잡고 있는 시간(트랜잭션/쿼리 시간)이 얼마나 되는가
- 커넥션 풀이 고갈될 때, 대기는 어디서 발생하는가(풀 대기, DB 실행, 락)
가상스레드 환경에서는 1번이 급증하기 쉽고, 2번이 길어지면 고갈이 더 빨라집니다.
Spring Boot 3에서 가상스레드 활성화와 주의점
Spring Boot 3.2+에서는 설정으로 가상스레드를 활성화할 수 있습니다.
spring:
threads:
virtual:
enabled: true
이 설정은 주로 웹 요청 처리 스레드(서블릿/톰캣 등)와 일부 실행 모델에 영향을 줍니다. 하지만 DB 커넥션 풀은 그대로이며, JDBC는 블로킹 I/O입니다. 즉, 가상스레드로 “블로킹을 싸게” 만들었을 뿐, DB가 처리할 수 있는 동시성은 늘지 않습니다.
해결 전략 1: DB 동시성에 상한을 걸어라(가장 효과적)
가상스레드 환경에서 가장 중요한 처방은 DB로 들어가는 동시 요청 수를 제한하는 것입니다. 목표는 “웹 동시성”과 “DB 동시성”을 분리하는 것입니다.
세마포어로 DB 구간 동시성 제한
애플리케이션 레벨에서 DB 호출 구간에 세마포어를 적용하면, 가상스레드가 아무리 많아도 DB로 들어가는 동시 수를 제어할 수 있습니다.
import java.util.concurrent.Semaphore;
public class DbConcurrencyGuard {
private final Semaphore semaphore;
public DbConcurrencyGuard(int permits) {
this.semaphore = new Semaphore(permits);
}
public <T> T execute(CheckedSupplier<T> supplier) throws Exception {
semaphore.acquire();
try {
return supplier.get();
} finally {
semaphore.release();
}
}
@FunctionalInterface
public interface CheckedSupplier<T> {
T get() throws Exception;
}
}
서비스에서 사용 예시는 다음과 같습니다.
@Service
public class OrderService {
private final DbConcurrencyGuard guard = new DbConcurrencyGuard(40);
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public Order getOrder(long id) throws Exception {
return guard.execute(() -> orderRepository.findById(id)
.orElseThrow());
}
}
permits는 보통maximumPoolSize와 같거나 더 작게 시작합니다.- DB가 무거운 조인/락이 많은 워크로드면 더 작게 잡는 편이 안정적입니다.
핵심은 “풀 고갈”이 아니라 “DB에 들어가는 동시 쿼리 수”를 제어하는 것입니다.
API 레벨에서 벌크헤드(Bulkhead) 적용
특정 엔드포인트만 DB를 많이 소모한다면, 전역 세마포어 대신 엔드포인트별로 격리하는 벌크헤드가 더 낫습니다. 예를 들어 검색 API는 10개, 결제 API는 5개처럼 자원을 분리하면 한 기능의 폭주가 전체를 무너뜨리는 것을 막습니다.
해결 전략 2: 트랜잭션 경계를 줄여 “커넥션 점유 시간”을 줄여라
커넥션 풀은 “동시에 몇 개를 쓸 수 있는가”보다 “각 요청이 얼마나 오래 잡고 있는가”에 크게 좌우됩니다. 가상스레드에서는 요청이 많이 동시에 떠 있기 때문에, 커넥션 점유 시간이 조금만 길어져도 고갈이 빨라집니다.
흔한 안티패턴: 트랜잭션 안에서 외부 호출
@Transactional
public void placeOrder(...) {
// DB 작업
...
// 외부 API 호출(결제/배송/알림 등)
paymentClient.pay(...);
// DB 작업
...
}
이 구조는 외부 API 지연 동안 커넥션을 쥐고 있는 경우가 많습니다(특히 JPA flush, 지연 로딩, 락 등과 결합되면 더 위험). 해결은 다음 중 하나입니다.
- 외부 호출을 트랜잭션 밖으로 이동
- 트랜잭션을 더 작은 단위로 분리
- 이벤트 아웃박스 패턴으로 비동기 처리
읽기 전용 트랜잭션과 fetch 전략 정리
읽기 API에 불필요한 쓰기 트랜잭션이 걸려 있거나, N+1로 인해 쿼리 수가 늘어나면 커넥션 점유 시간이 증가합니다.
@Transactional(readOnly = true)
public OrderDto getOrderView(long id) {
var order = orderRepository.findWithItems(id);
return OrderDto.from(order);
}
그리고 findWithItems 같은 메서드는 필요한 연관을 한 번에 가져오도록 설계합니다.
해결 전략 3: HikariCP 설정을 “가상스레드 친화적으로” 재점검
가상스레드로 전환하면 동시 요청 수가 늘기 때문에, Hikari 설정의 작은 불일치가 더 크게 드러납니다.
최소한 확인할 값
spring:
datasource:
hikari:
maximum-pool-size: 40
minimum-idle: 10
connection-timeout: 2000
validation-timeout: 1000
max-lifetime: 1800000
leak-detection-threshold: 20000
maximum-pool-size: DB가 감당 가능한 동시 쿼리 수에 맞춰야 합니다. 무작정 키우면 DB가 먼저 죽습니다.connection-timeout: 풀 고갈 시 얼마나 기다릴지. 가상스레드에서는 대기가 싸기 때문에 길게 잡고 버티는 선택을 하기 쉬운데, 보통은 짧게 두고 빠르게 실패시키는 편이 장애 전파를 줄입니다.leak-detection-threshold: 커넥션 누수 또는 과도한 점유를 찾는 데 유용합니다. 개발/스테이징에서 특히 효과가 큽니다.
풀 사이즈 산정의 현실적인 기준
정답 공식은 없지만, 시작점은 다음처럼 잡고 부하 테스트로 조정합니다.
- DB가 단일 인스턴스이고 CPU 여유가 크지 않다면
20에서 시작 - 읽기 중심 + 인덱스 최적화가 잘 되어 있으면
40전후도 가능 - 락 경합이 큰 쓰기 중심이면 풀을 키우는 것보다 트랜잭션 단축이 우선
가상스레드에서는 풀을 키워 “동시성”을 올리는 것보다, 앱에서 DB 동시성을 제한하고 쿼리 시간을 줄이는 편이 성공 확률이 높습니다.
해결 전략 4: 타임아웃을 계층별로 정렬해 장애 전파를 막기
커넥션 풀 타임아웃, 쿼리 타임아웃, 웹 요청 타임아웃의 크기 관계가 뒤엉키면, 실패가 느리게 발생하면서 스레드와 요청이 쌓입니다.
권장 정렬 예시입니다.
- 웹 요청 타임아웃: 3초
- 풀 대기 타임아웃(
connection-timeout): 1초~2초 - 쿼리 타임아웃: 1초~2초(업무별 차등)
JPA에서는 힌트로 쿼리 타임아웃을 줄 수 있습니다.
@Query("select o from Order o where o.id = :id")
@QueryHints({
@QueryHint(name = "jakarta.persistence.query.timeout", value = "1500")
})
Optional<Order> findByIdWithTimeout(long id);
DB 드라이버와 DB 종류에 따라 실제 동작이 다를 수 있으니, 반드시 실제로 타임아웃이 걸리는지 확인해야 합니다.
해결 전략 5: 관측 없이는 튜닝도 없다(필수 지표)
가상스레드 전환 후에는 “동시성 증가”가 워낙 크기 때문에, 감으로 튜닝하면 실패합니다. 최소한 다음을 메트릭으로 봐야 합니다.
- Hikari
- active connections
- pending threads(풀 대기)
- connection acquire time
- DB
- 쿼리 실행 시간 분포(p95, p99)
- 락 대기/데드락
- CPU, IOPS
- 애플리케이션
- 요청 p95/p99
- 에러율(타임아웃)
Micrometer를 쓰면 Hikari 메트릭은 보통 자동으로 노출됩니다. 운영에서 증상이 CrashLoopBackOff 로 번지기 전에, 레디니스 조건에 “DB 의존성”을 어떻게 둘지도 함께 점검해야 합니다. 관련해서는 Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단 글의 진단 루틴이 도움이 됩니다.
자주 하는 실수들
실수 1: 가상스레드 켠 뒤 풀만 2배로 늘리기
풀을 키우면 일시적으로 타임아웃은 줄 수 있지만, DB가 감당 못 하면 쿼리 지연과 락 경합이 증가해 더 큰 장애로 돌아옵니다. 먼저 앱에서 DB 동시성을 제한하세요.
실수 2: @Async 와 섞어서 무제한 동시 실행
가상스레드 + 비동기 실행을 섞으면 동시성이 더 폭발합니다. 비동기 작업도 별도 실행기와 별도 동시성 제한을 두는 편이 안전합니다.
실수 3: 트랜잭션 안에서 파일/네트워크 I/O
가상스레드에서는 블로킹이 싸서 “그냥 넣어도 되겠지”가 되기 쉽지만, 트랜잭션은 커넥션을 점유합니다. 외부 호출은 트랜잭션 밖으로 빼는 것이 원칙입니다.
추천 적용 순서(실전 체크리스트)
- 가상스레드 활성화 후 부하 테스트로 Hikari
pending과acquire time확인 leak-detection-threshold를 켜서 커넥션 점유가 긴 코드 경로를 찾기- 트랜잭션 경계 축소(외부 호출 분리, 읽기 전용 적용, N+1 제거)
- 세마포어 또는 벌크헤드로 DB 동시성 상한 설정
- 풀/타임아웃을 계층적으로 정렬하고, 실패를 빠르게 만들기
운영 환경이 Kubernetes라면, 장애가 발생했을 때 재시작 루프와 엮여 증상이 더 커질 수 있습니다. 레디니스/라이브니스 설정까지 함께 점검하는 관점에서 K8s CrashLoopBackOff - Readiness·Liveness 5분 진단도 같이 보는 것을 권합니다.
마무리
Spring Boot 3 가상스레드는 “스레드 부족” 문제를 크게 완화하지만, DB 커넥션 풀 고갈은 오히려 더 쉽게 드러납니다. 해결의 핵심은 풀을 무작정 키우는 것이 아니라 다음 2가지를 동시에 달성하는 것입니다.
- DB로 들어가는 동시성을 제한한다(세마포어/벌크헤드)
- 커넥션 점유 시간을 줄인다(트랜잭션 경계, 쿼리 최적화)
이 두 축을 잡고 Hikari 설정과 타임아웃을 정렬하면, 가상스레드의 장점은 살리면서도 커넥션 고갈로 인한 장애를 안정적으로 줄일 수 있습니다.