- 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 환경에서 동시성 제어·트랜잭션 경계 최적화·쿼리 최적화로 풀 고갈을 막는 방법을 정리합니다.
가상스레드가 DB 풀 고갈을 더 잘 드러내는 이유
플랫폼 스레드 병목이 사라지면, 진짜 병목(커넥션)이 남는다
전통적인 Tomcat/Jetty의 요청 처리 모델은 “요청 1개당 스레드 1개”에 가깝습니다. 플랫폼 스레드가 제한되어 있으면 동시 요청이 많아도 스레드 큐에서 대기가 발생하고, 그만큼 DB로 동시에 들어가는 요청 수가 자연스럽게 제한됩니다.
가상스레드를 켜면 요청당 가상스레드를 거의 무제한으로 만들 수 있어, 다음이 동시에 일어납니다.
- 더 많은 요청이 더 빨리 컨트롤러/서비스 레이어까지 진입
- 더 많은 트랜잭션이 동시에 열림
- 더 많은 커넥션이 동시에 필요해짐
- 결과적으로 HikariCP 풀의
maximumPoolSize를 초과하면서 타임아웃
즉, “스레드 부족으로 인한 자연스러운 백프레셔”가 사라지고, DB 풀이라는 고정된 자원에 요청이 몰립니다.
커넥션 풀 고갈은 단순히 풀 크기만의 문제가 아니다
풀 크기를 무작정 늘리면 해결될 것 같지만, 대부분은 아래 중 하나가 진짜 원인입니다.
- 트랜잭션 범위가 너무 넓어서 커넥션을 오래 점유
- N+1, 느린 쿼리, 락 대기 등으로 커넥션 반환이 늦어짐
- 외부 API 호출을 트랜잭션 안에서 수행해 커넥션이 놀고 있음
- 요청 처리 동시성이 DB가 감당할 수준을 초과
특히 JPA 사용 시 N+1은 가상스레드 환경에서 더 치명적입니다. 한 요청이 커넥션을 오래 잡고, 동시에 더 많은 요청이 들어오면 풀 고갈은 시간문제입니다. 관련해서는 Spring Boot 3에서 JPA N+1 실전 제거법도 함께 점검하는 것이 좋습니다.
Spring Boot 3에서 가상스레드 켜는 방법과 주의점
Spring Boot 3.2 이상 기준으로 가상스레드는 설정으로 활성화할 수 있습니다.
spring:
threads:
virtual:
enabled: true
이 설정은 주로 “요청 처리 스레드”에 영향을 주며, JDBC 드라이버 자체가 논블로킹으로 바뀌는 것은 아닙니다. JDBC 호출은 여전히 블로킹이며, 다만 블로킹 중에도 가상스레드는 캐리어 스레드를 양보할 수 있어 효율이 좋아집니다. 하지만 커넥션 점유 시간 자체가 줄어들지는 않습니다.
증상 진단: 풀 고갈이 진짜인지, 느린 쿼리인지
1) HikariCP 메트릭과 로그로 “대기”를 확인
풀 고갈은 보통 아래 로그로 시작합니다.
Connection is not available, request timed out after ...
우선 connectionTimeout이 너무 짧은지 확인하기 전에, “왜 반환이 늦는지”를 봐야 합니다. Hikari는 다음 설정으로 커넥션 점유 누수를 빠르게 잡을 수 있습니다.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 3000
leak-detection-threshold: 2000
leak-detection-threshold는 운영에서 상시 켜기엔 비용이 있을 수 있지만, 원인 파악 단계에서는 매우 유용합니다.
2) DB에서 “동시에 몇 개가 붙어 있는지” 확인
PostgreSQL이라면 아래로 현재 커넥션과 상태를 확인할 수 있습니다.
select
state,
count(*)
from pg_stat_activity
where datname = current_database()
group by state;
여기서 active가 많고 오래 유지되면 느린 쿼리, idle in transaction이 많으면 트랜잭션 경계 문제일 가능성이 큽니다.
또한 DB가 바쁘고 VACUUM, 락, I/O 문제가 겹치면 커넥션 반환이 지연되어 풀 고갈이 악화됩니다. PostgreSQL에서 정리 작업이 비정상적으로 오래 걸리는 케이스는 PostgreSQL VACUUM 안 끝날 때 원인과 해결법도 참고할 만합니다.
해결 전략 1: “DB가 감당 가능한 동시성”으로 제한하기
가상스레드는 애플리케이션 동시성을 크게 올릴 수 있지만, DB는 그렇지 않습니다. 따라서 가장 확실한 해결은 DB 접근 동시성에 상한을 두는 것입니다.
세마포어로 DB 호출 구간 제한
서비스 레이어에서 DB 접근이 집중되는 구간에 세마포어를 걸어 동시 실행을 제한합니다.
import java.util.concurrent.Semaphore;
import org.springframework.stereotype.Component;
@Component
public class DbConcurrencyLimiter {
// DB 풀 크기보다 약간 작게(예: 풀 20이면 16~18)
private final Semaphore semaphore = new Semaphore(16);
public <T> T execute(CheckedSupplier<T> supplier) {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
return supplier.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (acquired) semaphore.release();
}
}
@FunctionalInterface
public interface CheckedSupplier<T> {
T get() throws Exception;
}
}
사용 예시는 다음과 같습니다.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final DbConcurrencyLimiter limiter;
private final OrderRepository orderRepository;
public OrderService(DbConcurrencyLimiter limiter, OrderRepository orderRepository) {
this.limiter = limiter;
this.orderRepository = orderRepository;
}
@Transactional(readOnly = true)
public OrderDto getOrder(long id) {
return limiter.execute(() -> orderRepository.findDtoById(id));
}
}
- 핵심은 “요청 전체”가 아니라 “DB를 실제로 쓰는 구간”에만 제한을 거는 것입니다.
- 풀 크기를 20으로 두고 동시성을 16 정도로 제한하면, 갑작스런 스파이크에서도 타임아웃 대신 큐잉으로 흡수할 수 있습니다.
왜 풀 크기만 늘리면 안 되나
풀을 20에서 100으로 늘리면 순간적으로 타임아웃은 줄 수 있지만, DB CPU, I/O, 락 경합이 증가해 전체 응답 시간이 늘고 결국 더 큰 장애로 이어질 수 있습니다. 특히 OLTP 성격의 서비스는 “적당한 동시성”을 넘으면 처리량이 오히려 떨어지는 구간이 흔합니다.
해결 전략 2: 트랜잭션 경계 줄이기(커넥션 점유 시간 단축)
풀 고갈의 본질은 “동시에 필요한 커넥션 수”가 아니라 “커넥션을 잡고 있는 시간”인 경우가 많습니다.
흔한 안티패턴: 외부 호출을 트랜잭션 안에서 수행
다음처럼 외부 API 호출(혹은 파일 I/O)을 트랜잭션 내부에서 수행하면, 그 시간 동안 커넥션이 사실상 놀게 됩니다.
@Transactional
public void pay(long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
// 안티패턴: 트랜잭션 안에서 외부 호출
paymentGateway.requestPayment(order);
order.markPaid();
}
개선 방향은 다음 중 하나입니다.
- 외부 호출을 트랜잭션 밖으로 분리
- 이벤트/아웃박스 패턴으로 비동기 처리
- 꼭 필요하면 트랜잭션을 매우 짧게 유지
예시(외부 호출 후 짧은 트랜잭션으로 업데이트):
public void pay(long orderId) {
OrderSnapshot snapshot = orderQueryService.getSnapshot(orderId);
paymentGateway.requestPayment(snapshot);
orderCommandService.markPaid(orderId);
}
@Service
class OrderCommandService {
private final OrderRepository orderRepository;
OrderCommandService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public void markPaid(long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
}
}
readOnly 트랜잭션을 명확히
읽기 요청에 @Transactional(readOnly = true)를 붙이면 JPA 플러시/더티체킹 부담을 줄이고, 일부 DB에서 최적화 힌트를 줄 수 있습니다. 무엇보다 팀 차원에서 트랜잭션 의도를 명확히 만들어 “불필요한 쓰기 트랜잭션”을 줄이는 효과가 큽니다.
해결 전략 3: 쿼리 최적화와 N+1 제거로 “커넥션 반환”을 빠르게
가상스레드 적용 후 풀 고갈이 심해졌다면, 기존에 숨어 있던 느린 쿼리가 동시 실행되며 증폭됐을 가능성이 큽니다.
- N+1: 요청 1개가 쿼리 수십~수백 개로 늘어 커넥션 점유 시간이 증가
- 인덱스 부재: 특정 조건에서 풀스캔으로 지연
- 락 경합: 업데이트 순서가 꼬이거나 범위 락으로 대기
JPA 기반이라면 N+1 제거가 가장 먼저 체감됩니다. 실전 패턴(페치 조인, 배치 사이즈, DTO 프로젝션 등)은 Spring Boot 3에서 JPA N+1 실전 제거법에 정리해두었습니다.
해결 전략 4: HikariCP 설정을 “가상스레드 환경”에 맞게 재점검
핵심은 maximumPoolSize보다 “타임아웃과 대기 전략”
가상스레드에서는 대기 자체가 저렴하므로, 무조건 짧은 connectionTimeout으로 빨리 실패시키기보다 “서비스 성격에 맞는 대기”를 설계하는 편이 낫습니다.
권장 체크리스트:
maximumPoolSize: DB가 감당 가능한 동시 쿼리 수에 맞춤(보통 CPU 코어 수, 쿼리 특성, 락 경합에 따라 결정)connectionTimeout: 스파이크 시 큐잉을 허용할지, 빠르게 503으로 실패시킬지 정책화leakDetectionThreshold: 원인 파악 시 일시적으로 활성화maxLifetime: DB/LB의 커넥션 만료 정책보다 약간 짧게
예시:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 5000
validation-timeout: 1000
max-lifetime: 1740000
keepalive-time: 300000
max-lifetime는 환경에 따라 다르지만, 30분(1800000ms)보다 살짝 짧게 잡는 구성이 흔합니다.
운영에서의 안전장치: 실패를 “예쁘게” 만들기
가상스레드로 동시 요청을 더 많이 처리하게 되면, 장애도 더 빠르게 확산될 수 있습니다. DB 풀이 고갈될 때 아래를 준비하면 “전체 장애”를 줄일 수 있습니다.
- DB 풀 타임아웃 시 500이 아니라 503으로 매핑하고, 재시도 정책을 클라이언트와 합의
- 특정 API(리포트, 검색, 백오피스 등)에 별도 동시성 제한 적용
- 서킷 브레이커/레이트 리미터로 스파이크 흡수
참고로 이런 “병목을 찾아내고 격리하는” 접근은 프론트 성능에서 Long Task를 추적하는 것과 결이 같습니다. 문제의 본질을 계측으로 쪼개는 방법론은 Chrome INP 폭증 원인 추적 - Long Task·TBT 해결도 유사한 관점에서 도움이 됩니다.
실전 적용 순서(추천)
- 가상스레드 활성화 후, Hikari 메트릭과 DB
pg_stat_activity로 “대기 vs 느린 쿼리 vs 트랜잭션 누수”를 구분 - 누수/장시간 점유가 보이면
leak-detection-threshold로 콜스택 확보 - 트랜잭션 경계 축소(외부 호출 분리,
readOnly명확화) - N+1 및 느린 쿼리 제거로 커넥션 반환 시간 단축
- DB 접근 동시성 제한(세마포어 등)으로 상한 설정
- 마지막으로 풀 크기와 타임아웃을 정책에 맞게 조정
마무리
Spring Boot 3 가상스레드는 서버의 스레드 병목을 크게 줄여주지만, 그 순간부터 병목은 DB 커넥션 풀로 이동합니다. 해결의 핵심은 “가상스레드에 맞게 풀을 키우기”가 아니라, 커넥션 점유 시간을 줄이고(DB 작업을 빨리 끝내고), DB가 감당 가능한 동시성으로 제한하는 것입니다.
가상스레드를 성능 개선의 만능키로 쓰기보다, 리소스(특히 DB) 병목을 더 빨리 드러내는 도구로 보고 계측과 구조 개선을 병행하면, 풀 고갈 없이 안정적으로 처리량을 끌어올릴 수 있습니다.