- Published on
Spring Boot 3 가상 스레드 후 DB 커넥션 고갈 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 Spring Boot 3 가상 스레드(virtual thread)를 적용했더니 응답이 빨라지기는커녕, 피크 타임에 HikariPool-1 - Connection is not available 같은 커넥션 고갈 에러가 더 자주 발생하는 경우가 있습니다.
이 현상은 “가상 스레드가 DB 커넥션을 더 많이 만든다”가 아니라, 애플리케이션이 동시에 더 많은 작업을 시작할 수 있게 되면서 제한된 DB 커넥션 풀을 더 빨리/더 오래 점유하게 되는 구조적 문제로 보는 게 맞습니다. 즉, 스레드가 가벼워진 만큼 병목이 DB로 이동합니다.
이 글에서는 Spring Boot 3에서 가상 스레드를 켰을 때 커넥션 고갈이 왜 심해지는지, 그리고 HikariCP/트랜잭션/동시성 제어를 통해 안정적으로 해결하는 방법을 단계별로 정리합니다.
1) 가상 스레드 적용 후 커넥션 고갈이 심해지는 이유
1-1. 플랫폼 스레드 시대에는 “스레드 수”가 자연스러운 브레이크였다
기존 Tomcat 플랫폼 스레드 기반에서는 maxThreads 같은 값이 사실상 “동시 요청 상한” 역할을 했습니다. DB가 느려도 스레드 수가 제한되어 있으니, 동시에 DB에 달려드는 요청 수가 어느 정도 캡이 씌워졌습니다.
1-2. 가상 스레드는 동시성을 크게 올리고, 병목을 노출한다
가상 스레드는 블로킹 I/O에서 효율이 좋아서, 같은 CPU에서 더 많은 동시 작업을 처리할 수 있습니다. 문제는 DB 커넥션 풀은 여전히 maximumPoolSize로 제한되어 있다는 점입니다.
- 요청이 동시에 더 많이 들어옴
- 각 요청이 트랜잭션을 길게 잡거나(느린 쿼리, 외부 API 호출 포함)
- 커넥션을 오래 점유하면
- 풀은 금방 바닥나고 대기열이 길어짐
결과적으로 “예전에는 스레드 한계 때문에 덜 드러나던” 커넥션 부족이 가상 스레드 환경에서 더 빠르게 터집니다.
1-3. 흔한 악화 요인
- 트랜잭션 범위가 넓어서 커넥션 홀드 시간이 김
- N+1 쿼리로 요청당 쿼리 수가 많음
- 커넥션 풀은 작은데, 요청 동시성이 크게 증가
- DB 락 대기 또는 데드락으로 쿼리가 길게 멈춤
락 대기/데드락이 커넥션을 장시간 붙잡는 케이스는 특히 치명적입니다. 관련 진단은 아래 글도 함께 참고하면 좋습니다.
2) 먼저 “커넥션 고갈”을 계측으로 확정하기
2-1. HikariCP 메트릭 확인
Spring Boot Actuator + Micrometer를 쓰면 Hikari 메트릭을 바로 볼 수 있습니다.
application.yml 예시:
management:
endpoints:
web:
exposure:
include: health,metrics,prometheus
Prometheus 기준으로 보면 다음 지표가 핵심입니다.
hikaricp_connections_active: 사용 중인 커넥션 수hikaricp_connections_idle: 유휴 커넥션 수hikaricp_connections_pending: 커넥션을 기다리는 스레드 수
패턴이 아래처럼 보이면 “풀 고갈”이 맞습니다.
active가maximumPoolSize에 붙어서 떨어지지 않음pending가 지속적으로 증가- 타임아웃 에러가 발생
2-2. 스레드 덤프보다 “쿼리 대기”를 먼저 봐야 한다
가상 스레드는 스레드 덤프가 길고 해석이 어려울 수 있습니다. 커넥션 고갈 상황에서는 DB에서 어떤 쿼리가 오래 걸리는지가 더 중요합니다.
- PostgreSQL:
pg_stat_activity,pg_locks - MySQL:
performance_schema,SHOW PROCESSLIST
특히 락 대기/데드락이 있으면 커넥션이 오래 묶입니다.
3) 해결 전략 1: 트랜잭션 범위 줄이기 (가장 효과 큼)
커넥션 고갈의 본질은 “커넥션을 너무 오래 잡고 있다”입니다. 가장 먼저 해야 할 일은 트랜잭션 범위를 최소화하는 것입니다.
3-1. 흔한 안티패턴: 외부 호출을 트랜잭션 안에서 수행
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
@Transactional
public void placeOrder(Long userId) {
// 1) DB 커넥션 획득
var order = orderRepository.save(new Order(userId));
// 2) 외부 API 호출(수백 ms~수 초)
paymentClient.charge(userId, order.getId());
// 3) 후속 업데이트
order.markPaid();
}
}
이 코드는 외부 API 호출 동안에도 DB 커넥션을 물고 있을 확률이 높습니다. 가상 스레드로 동시 요청이 늘면, 이 패턴이 풀을 순식간에 잠급니다.
3-2. 개선: 트랜잭션을 쪼개고, 외부 호출은 밖으로 빼기
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
public void placeOrder(Long userId) {
Long orderId = createOrder(userId);
// 트랜잭션 밖에서 외부 호출
paymentClient.charge(userId, orderId);
markOrderPaid(orderId);
}
@Transactional
protected Long createOrder(Long userId) {
var order = orderRepository.save(new Order(userId));
return order.getId();
}
@Transactional
protected void markOrderPaid(Long orderId) {
var order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
}
}
주의할 점:
- 이렇게 쪼개면 “원자성”이 깨질 수 있으므로, 실패 시 보상 트랜잭션이나 outbox 패턴 같은 설계가 필요할 수 있습니다.
- 그럼에도 커넥션 고갈을 줄이는 데는 매우 직접적인 효과가 있습니다.
4) 해결 전략 2: HikariCP 설정을 가상 스레드에 맞게 재조정
4-1. 풀 크기를 무작정 키우면 안 되는 이유
가상 스레드 환경에서 흔히 하는 실수는 maximumPoolSize를 크게 올리는 것입니다. 하지만 DB는 커넥션이 늘수록 컨텍스트 스위칭/락 경합/메모리 사용량이 증가하고, 오히려 성능이 떨어질 수 있습니다.
풀 크기는 대략적으로 아래를 같이 고려해야 합니다.
- DB 인스턴스 CPU, 코어 수
- 쿼리 특성(짧은 OLTP인지, 긴 리포트성인지)
- DB의
max_connections및 워크로드
4-2. 우선순위 높은 Hikari 설정
application.yml 예시:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 2000
validation-timeout: 1000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 5000
핵심 포인트:
connection-timeout을 너무 길게 두면, 대기열이 쌓이며 지연이 전파됩니다. 짧게 두고 빠르게 실패시키는 전략도 유효합니다.leak-detection-threshold는 “진짜 릭”뿐 아니라 “커넥션 홀드 시간이 긴 코드”를 찾아내는 데도 도움됩니다.minimum-idle을maximum-pool-size에 맞추면 피크 때 커넥션 생성 지연을 줄일 수 있지만, DB 리소스도 같이 늘어납니다.
5) 해결 전략 3: 동시성 제한을 애플리케이션에서 명시적으로 걸기
가상 스레드의 핵심은 “많이 동시에 시작할 수 있다”인데, DB는 그렇지 못합니다. 그래서 DB로 들어가는 동시 작업 수를 제한하는 장치가 필요합니다.
5-1. 세마포어로 DB 접근 구간 캡 씌우기
간단하면서도 효과적인 방법입니다.
@Component
public class DbConcurrencyLimiter {
// DB 풀과 비슷하거나 약간 작은 값으로 시작
private final java.util.concurrent.Semaphore semaphore = new java.util.concurrent.Semaphore(20);
public <T> T execute(java.util.concurrent.Callable<T> action) {
try {
semaphore.acquire();
return action.call();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
semaphore.release();
}
}
}
서비스에서 사용:
@Service
public class UserService {
private final UserRepository userRepository;
private final DbConcurrencyLimiter limiter;
public UserService(UserRepository userRepository, DbConcurrencyLimiter limiter) {
this.userRepository = userRepository;
this.limiter = limiter;
}
public User getUser(Long id) {
return limiter.execute(() -> userRepository.findById(id).orElseThrow());
}
}
이 방식의 장점:
- 트래픽이 급증해도 DB로 향하는 동시 요청이 제한되어 풀 고갈을 완화
- “대기”가 애플리케이션 내부에서 통제되며, Hikari 대기열 폭증을 줄임
주의:
- 무분별하게 모든 요청에 적용하면 전체 처리량이 줄 수 있습니다.
- 우선은 커넥션을 오래 잡는 API, 무거운 쿼리 경로에 선택적으로 적용하세요.
5-2. Bulkhead 패턴(격벽)으로 엔드포인트별 분리
특정 API가 DB 커넥션을 독점해 전체 장애로 번지는 걸 막으려면 “엔드포인트별 동시성 풀”을 분리하는 게 효과적입니다.
- 결제/정산 같은 무거운 API는 작은 동시성
- 조회성 API는 별도 제한
Resilience4j Bulkhead를 고려할 수 있지만, 단순 세마포어만으로도 충분히 시작 가능합니다.
6) 해결 전략 4: 쿼리/락 문제를 먼저 제거해야 한다
가상 스레드 적용 후 커넥션 고갈이 갑자기 심해졌다면, 숨겨진 락 경합이 드러났을 가능성이 큽니다.
체크리스트:
- 특정 테이블 업데이트가 집중되는지
- 인덱스 부재로 인해 업데이트/삭제가 테이블을 과도하게 스캔하는지
- 동일 로우를 여러 트랜잭션이 경쟁하는지
- 트랜잭션 격리 수준이 과도하게 높은지
PostgreSQL 기준으로 락 대기와 데드락 진단은 아래 글을 참고해도 좋습니다.
7) Spring Boot 3에서 가상 스레드 적용 시 운영 팁
7-1. 가상 스레드 활성화
Spring Boot 3.2+ 기준으로 설정만으로도 적용이 가능합니다.
spring:
threads:
virtual:
enabled: true
웹 서버/서블릿 컨테이너 구성에 따라 적용 범위가 달라질 수 있으니, 배포 전 실제로 요청 처리 스레드가 가상 스레드로 동작하는지 로그/메트릭으로 확인하세요.
7-2. “가상 스레드 + 블로킹 JDBC”는 괜찮지만, 풀 전략이 더 중요해진다
JDBC는 블로킹이지만, 가상 스레드는 블로킹에 강합니다. 따라서 성능 자체는 좋아질 수 있습니다.
다만 성능이 좋아졌다는 건 동시에 더 많은 DB 작업을 시도한다는 뜻이기도 하니, 아래 3가지를 세트로 가져가야 합니다.
- 트랜잭션 범위 최소화
- Hikari 풀 크기/타임아웃 재조정
- 애플리케이션 레벨 동시성 제한
8) 실전 적용 순서(권장)
- Hikari 메트릭으로
pending증가와 타임아웃을 확인해 “진짜 풀 고갈”인지 확정 - 릭 탐지 및 느린 구간 찾기:
leak-detection-threshold로 커넥션 홀드가 긴 코드 위치 파악 - 트랜잭션 범위 축소: 외부 호출/무거운 계산을 트랜잭션 밖으로 이동
- 락/데드락/느린 쿼리 제거: 인덱스, 쿼리 플랜, 트랜잭션 설계 점검
- Hikari 설정 재조정: 풀 크기와 타임아웃을 현실적인 값으로
- 동시성 제한 도입: 세마포어 또는 bulkhead로 DB 접근량 캡
9) 마무리: 가상 스레드는 “병목을 숨기지 않는다”
Spring Boot 3 가상 스레드는 서버의 동시성을 크게 올려주지만, DB 커넥션은 유한합니다. 그래서 가상 스레드 적용 후 커넥션 고갈이 발생했다면, 이는 가상 스레드의 문제가 아니라 **DB 병목과 커넥션 점유 시간(트랜잭션/락/느린 쿼리/외부 호출)**이 한꺼번에 드러난 결과일 가능성이 큽니다.
가장 효과가 큰 처방은 다음 한 줄로 요약됩니다.
- 커넥션을 오래 잡지 말고(트랜잭션 축소), 동시에 너무 많이 잡지 말자(동시성 제한).
이 두 가지를 먼저 잡으면, 가상 스레드의 장점(높은 동시성, 낮은 스레드 오버헤드)을 안정적으로 가져가면서도 DB 커넥션 고갈을 실무적으로 해결할 수 있습니다.