- Published on
Spring Boot 3 가상스레드에서 HikariCP 고갈 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 스레드가 부족해서 처리량이 안 나오던 시대에는 “스레드를 늘리면 된다”가 정답인 경우가 많았습니다. 하지만 Spring Boot 3에서 가상 스레드(virtual threads)를 적용하면, 애플리케이션은 더 많은 요청을 동시에 처리할 수 있게 되고 그 결과 DB 커넥션 풀이 병목으로 튀어나오면서 HikariCP 고갈(Connection pool exhausted) 문제가 더 자주, 더 크게 드러납니다.
이 글은 “가상 스레드가 문제다”가 아니라, 가상 스레드가 숨겨져 있던 병목을 드러내는 촉매라는 관점에서 접근합니다. 진단 포인트(증상/지표/스레드 덤프) → 설정 → 코드 패턴 → 운영 체크리스트 순으로 정리합니다.
1) 증상: 가상 스레드 적용 후 왜 더 자주 터질까?
대표 로그/예외는 다음과 같습니다.
SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms- API 응답 지연이 늘고, 특정 구간에서 타임아웃이 연쇄적으로 발생
- DB CPU/락/IO가 먼저 치솟거나, 반대로 DB는 여유 있는데 풀만 고갈되기도 함
가상 스레드는 “스레드 생성 비용”을 낮춰 동시에 더 많은 작업이 DB로 몰리는 구조를 만들 수 있습니다. 즉,
- 기존(플랫폼 스레드)에는 요청이 스레드 수에 의해 자연스럽게 제한(암묵적 백프레셔)
- 가상 스레드에서는 그 제한이 약해져 DB 커넥션 풀이 사실상 유일한 게이트가 됨
이때 풀 고갈은 단순히 maximumPoolSize를 늘리면 해결되는 문제가 아니라, 커넥션을 오래 쥐고 있는 코드/트랜잭션 경계/락 경합/외부 호출 혼입 같은 구조적 문제가 있음을 의미합니다.
2) 먼저 확인할 것: “풀 크기”가 아니라 “커넥션 점유 시간”
HikariCP 고갈은 보통 두 가지 중 하나입니다.
- 동시 요청 수가 커넥션 수를 초과(정상적인 포화)
- 커넥션을 너무 오래 점유(비정상적인 장기 점유)
가상 스레드 환경에서는 2번이 특히 치명적입니다. 커넥션을 잡은 채로 오래 대기하는 작업이 있으면(예: 외부 API 호출, 파일 IO, 락 대기, 대용량 처리) 가상 스레드가 많아질수록 “그 오래 잡고 있는 커넥션”의 동시 개수가 늘어 풀을 빠르게 잠식합니다.
핵심 지표
- Hikari metrics
hikaricp.connections.activehikaricp.connections.pendinghikaricp.connections.timeout
- DB 측 지표
- 평균 쿼리 시간, 락 대기 시간, 커넥션 수, 슬로우 쿼리
운영 중 ALB 502/504가 함께 보이면(업스트림 타임아웃) 애플리케이션의 스레드/커넥션 대기가 원인일 수 있습니다. 필요하면 AWS ALB 502·504 난사 - 원인별 해결 체크리스트도 같이 점검하세요.
3) Spring Boot 3 가상 스레드 적용 방식과 함정
Spring Boot 3.2+에서는 다음처럼 간단히 켤 수 있습니다.
spring:
threads:
virtual:
enabled: true
이 설정은 주로 서블릿 요청 처리 스레드(Tomcat/Jetty/Undertow의 요청 처리) 쪽에 영향을 줍니다. 즉, “요청 처리 동시성”이 급격히 늘 수 있습니다.
하지만 DB 접근(JDBC)은 여전히 블로킹 IO이며, 커넥션 풀은 제한된 리소스입니다. 가상 스레드는 블로킹을 ‘싸게’ 만들 뿐, DB 커넥션이라는 희소 자원을 무한히 늘려주지 않습니다.
4) 해결 전략 1: 풀을 무작정 키우지 말고, 상한을 설계하라
(1) maximumPoolSize는 DB가 감당 가능한 범위로
풀을 키우면 순간적으로 타임아웃은 줄 수 있지만, DB 동시 실행이 늘어 락 경합/버퍼캐시 미스/IO 병목이 폭발할 수 있습니다.
권장 접근:
- DB 인스턴스 스펙과 쿼리 성격에 맞춰 DB가 안정적으로 처리 가능한 동시 쿼리 수를 먼저 산정
- 그 범위 내에서
maximumPoolSize를 설정 - 애플리케이션은 그 위로 올라가지 않도록 백프레셔를 걸어야 함
예시 설정:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 3000
validation-timeout: 1000
max-lifetime: 1740000 # 29m (LB/DB idle timeout보다 짧게)
idle-timeout: 600000
leak-detection-threshold: 5000
connection-timeout을 너무 길게 두면(예: 30s) 장애가 길게 전파됩니다. 짧게 두고 빠르게 실패시키는 편이 회복에 유리합니다.leak-detection-threshold는 임시로 켜서 “커넥션을 오래 잡는 코드”를 찾는 데 씁니다(상시 ON은 비용/노이즈 가능).
(2) 요청 동시성 자체를 제한(가상 스레드의 역설)
가상 스레드를 켰다면, DB가 병목인 서비스는 오히려 동시성 상한이 필요합니다.
- API 레벨에서 동시 실행 제한(세마포어)
- 특정 기능(리포트/배치성 API)만 별도 제한
간단한 세마포어 예시:
import java.util.concurrent.Semaphore;
@Component
public class DbConcurrencyLimiter {
// DB가 감당 가능한 동시 트랜잭션 수로 조정
private final Semaphore semaphore = new Semaphore(20);
public <T> T execute(Callable<T> action) throws Exception {
semaphore.acquire();
try {
return action.call();
} finally {
semaphore.release();
}
}
}
컨트롤러/서비스에서:
public OrderDto getOrder(long id) {
try {
return limiter.execute(() -> orderService.getOrder(id));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
이 방식은 “가상 스레드로 무한 동시성”을 열어두지 않고, DB 처리량에 맞춰 애플리케이션을 정렬합니다.
5) 해결 전략 2: 트랜잭션 경계를 줄이고, 커넥션 점유 시간을 단축
Hikari 고갈의 본질은 “커넥션을 잡고 있는 시간”입니다. 다음 패턴을 특히 조심해야 합니다.
(1) 트랜잭션 내부에서 외부 API 호출
나쁜 예:
@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
Order order = orderRepository.save(new Order(cmd));
// 외부 결제 승인 호출(느릴 수 있음)을 트랜잭션 안에서 수행
paymentClient.approve(order.getId());
order.markPaid();
}
- 결제 API가 500ms~2s만 느려져도 커넥션이 그 시간 동안 점유됩니다.
- 가상 스레드로 동시 요청이 늘면 “느린 외부 호출”만큼 커넥션이 묶여 풀 고갈이 가속됩니다.
개선 예(트랜잭션 분리 + 이벤트/비동기):
public void placeOrder(PlaceOrderCommand cmd) {
long orderId = createOrder(cmd); // 짧은 트랜잭션
requestPayment(orderId); // 트랜잭션 밖
}
@Transactional
protected long createOrder(PlaceOrderCommand cmd) {
Order order = orderRepository.save(new Order(cmd));
return order.getId();
}
protected void requestPayment(long orderId) {
paymentClient.approve(orderId);
// 승인 결과 반영은 별도 트랜잭션
markPaid(orderId);
}
@Transactional
protected void markPaid(long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
}
분산 트랜잭션/보상 로직이 필요한 경우, 사가(Saga) 패턴에서 “중복 보상 실행”까지 고려해야 합니다. 관련해서는 Saga 패턴 보상 트랜잭션 중복 실행 방지법도 함께 참고하면 트랜잭션 분리 후의 안정성을 높일 수 있습니다.
(2) @Transactional 범위가 과도하게 넓음
- 조회 API인데도 서비스 전체가
@Transactional로 묶여 있음 - 불필요한
OpenEntityManagerInView로 뷰 렌더링까지 영속성 컨텍스트가 유지
점검:
spring:
jpa:
open-in-view: false
그리고 읽기 전용 트랜잭션을 명시해 불필요한 락/플러시를 줄입니다.
@Transactional(readOnly = true)
public OrderDto getOrder(long id) {
return orderRepository.findDtoById(id);
}
(3) N+1 쿼리 / 대용량 페이징 실수
가상 스레드로 요청이 늘면 N+1 쿼리는 “DB를 더 빨리 태워먹는 장치”가 됩니다.
- fetch join / entity graph / DTO projection으로 쿼리 수 자체를 줄이기
- 페이징 시 count 쿼리 비용 점검
6) 해결 전략 3: 타임아웃 계층을 정렬(Timeout Budget)
풀 고갈은 “대기” 문제이므로 타임아웃 정렬이 중요합니다.
- HTTP 요청 타임아웃(클라이언트/ALB/Ingress)
- 서버 처리 타임아웃
- DB 커넥션 획득 타임아웃(
connectionTimeout) - 쿼리 타임아웃
권장 원칙:
- DB 커넥션 획득 타임아웃 < HTTP 타임아웃
- 쿼리 타임아웃을 명시해 “커넥션을 잡고 무한 대기”를 막기
JPA 쿼리 타임아웃 예:
import org.springframework.data.jpa.repository.QueryHints;
import jakarta.persistence.QueryHint;
@QueryHints(@QueryHint(name = "jakarta.persistence.query.timeout", value = "1000"))
Optional<Order> findById(Long id);
DB 락 대기(특히 MySQL/InnoDB, Postgres lock)로 커넥션이 오래 묶이면 풀 고갈은 쉽게 재현됩니다.
7) 해결 전략 4: 관측 가능성(Observability)로 “누가 커넥션을 잡고 있나”를 찾기
(1) Micrometer + Hikari 지표 수집
Spring Boot Actuator를 사용하면 Hikari 지표를 쉽게 볼 수 있습니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
대시보드에서 최소한 다음을 봅니다.
- active가 max에 붙어 있는가?
- pending이 증가하는가?
- timeout 카운트가 증가하는가?
(2) Leak detection 로그로 “장기 점유” 위치 찾기
leakDetectionThreshold를 3~10초로 잠시 켠 뒤, 어떤 코드 경로에서 커넥션이 오래 잡히는지 확인합니다.
주의: leak detection은 “진짜 누수”뿐 아니라 “오래 사용”도 잡습니다. 목적은 병목 위치를 찾는 것입니다.
(3) 스레드 덤프/프로파일링
가상 스레드 환경에서는 스레드 수가 많아도 정상일 수 있으므로, “몇 개가 있냐”보다 “무엇을 기다리냐”가 중요합니다.
- DB 커넥션 획득 대기
- DB 락 대기
- 외부 HTTP 호출 대기
이 중 하나가 커넥션 점유와 결합되어 있으면 풀 고갈로 이어집니다.
8) 자주 나오는 오해와 정리
오해 1) “가상 스레드면 DB도 더 빨라진다”
아닙니다. JDBC는 블로킹이고, DB는 제한된 동시 처리량을 가집니다. 가상 스레드는 애플리케이션의 스레드 병목을 줄여줄 뿐이며, DB 병목은 더 잘 드러납니다.
오해 2) “maximumPoolSize를 크게 올리면 끝”
일시적으로 타임아웃은 줄 수 있지만, DB가 감당 못하면 전체 지연이 더 커지고 락 경합이 심해져 장애가 커질 수 있습니다. 풀 크기는 “DB가 처리 가능한 동시성”에 맞춰야 합니다.
오해 3) “WebFlux로 바꾸면 해결”
DB가 JDBC(블로킹)인 한, 반쪽짜리입니다. R2DBC로 끝까지 논블로킹으로 가거나, 최소한 트랜잭션 경계/쿼리 최적화/백프레셔가 필요합니다.
9) 실전 체크리스트(적용 순서 추천)
hikaricp.connections.active/pending/timeout지표 확보leakDetectionThreshold로 장기 점유 코드 경로 찾기(임시)- 트랜잭션 내부 외부 호출 제거, 트랜잭션 범위 축소
- N+1/슬로우 쿼리 제거, 쿼리 타임아웃 설정
connectionTimeout을 짧게, 타임아웃 계층 정렬- 풀 크기는 DB 한계 내에서 조정
- DB 병목이면 애플리케이션에 동시성 상한(세마포어/레이트 리밋) 도입
가상 스레드는 “더 많은 요청을 동시에 처리할 기회”를 주지만, 그 기회를 실제 성능으로 바꾸려면 **DB 커넥션이라는 희소 자원에 대한 설계(점유 시간 단축 + 백프레셔 + 관측)**가 필수입니다. HikariCP 고갈은 그 설계가 필요하다는 가장 명확한 신호입니다.