- Published on
Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 Spring Boot 3 가상 스레드(virtual threads)를 적용하면, 기존 플랫폼 스레드 기반의 요청 처리보다 훨씬 많은 동시 요청을 “싸게” 처리할 수 있습니다. 문제는 스레드는 늘었는데 DB 커넥션은 그대로라는 점입니다. 결과적으로 “애플리케이션은 더 많은 요청을 동시에 처리하려고 시도하지만, DB 커넥션 풀이 먼저 바닥나면서 지연/타임아웃이 발생”하는 형태의 장애가 쉽게 만들어집니다.
이 글에서는 가상 스레드를 켠 뒤 흔히 겪는 **DB 커넥션 고갈(connection pool exhaustion)**을 어떻게 진단하고, 어떤 레버(풀 사이즈, 타임아웃, 트랜잭션 경계, 동시성 제한, 쿼리/인덱스, 아키텍처)를 어떤 순서로 조정해야 안전한지 정리합니다.
> 참고로 네트워크 레벨에서도 “동시 연결”이 급증하면 커널/노드 리소스가 먼저 터질 수 있습니다. EKS 환경이라면 conntrack 포화로 연결 드랍이 발생하는 케이스도 함께 점검하세요: EKS conntrack 테이블 포화로 연결 끊김 해결법
1) 왜 가상 스레드가 DB 커넥션 고갈을 더 잘 드러내나
가상 스레드의 핵심: 블로킹을 “감당”할 수 있게 됨
가상 스레드는 블로킹 I/O에서 플랫폼 스레드를 점유하지 않도록 설계되어, 요청당 스레드를 많이 만들어도 비용이 낮습니다. 그래서 기존엔 톰캣 스레드 풀(예: 200)이 자연스럽게 동시성을 제한하던 상황에서, 가상 스레드를 켜면 동시 요청 수가 훨씬 커질 수 있습니다.
하지만 DB는 다릅니다.
- DB 커넥션은 비싸고, 서버/DB가 수용 가능한 동시 세션 수가 제한적입니다.
- HikariCP 같은 풀은 기본적으로 10~수십 개 수준으로 시작합니다.
즉, 가상 스레드 도입 후에는 다음의 불균형이 생깁니다.
- (이전)
요청 동시성≈서버 스레드 수로 제한됨 → DB 풀 고갈이 덜 드러남 - (이후)
요청 동시성↑↑ (가상 스레드) → DB 풀은 그대로 → 풀 고갈이 바로 병목
장애 양상
대표적인 로그/증상은 다음과 같습니다.
HikariPool-1 - Connection is not available, request timed out after ...ms- API p95/p99 지연 급증, 스루풋은 오히려 떨어짐(큐잉)
- DB CPU가 낮은데도 커넥션 대기만 길어짐(락/슬로우쿼리/트랜잭션 장기화)
2) 먼저 확인할 것: “풀을 키우면 해결”이 아닌 이유
풀 사이즈를 키우는 건 필요할 수 있지만, 무작정 늘리면 다음 문제가 생깁니다.
- DB의 동시 처리 한계(코어 수, 락 경합, 버퍼 캐시, I/O)가 먼저 병목이 됨
- 커넥션 수 증가로 컨텍스트 스위치/락 경합/메모리 사용이 증가
- 쿼리가 느린 상태에서 커넥션만 늘리면 “느린 쿼리 × 더 많은 동시 실행”로 DB를 더 압박
따라서 순서는 보통 이렇게 가는 게 안전합니다.
- 커넥션을 오래 잡는 원인 제거(트랜잭션/쿼리/락)
- 애플리케이션 동시성 제한(가상 스레드 시대의 백프레셔)
- 그 다음에 풀/DB 설정을 필요한 만큼 조정
3) 진단 체크리스트 (장애 재현 없이도 확인 가능)
3.1 HikariCP 메트릭으로 “대기”인지 “실제 사용”인지 분리
Actuator + Micrometer를 켜면 다음 메트릭이 유용합니다.
hikaricp.connections.activehikaricp.connections.idlehikaricp.connections.pending(대기 중인 스레드)hikaricp.connections.max
pending이 치솟는데 DB CPU는 낮다면, 대개는:
- 트랜잭션이 길어 커넥션 반환이 늦거나
- 락 경합/슬로우쿼리로 커넥션이 오래 점유되거나
- 애플리케이션이 DB로 과도한 동시 요청을 던지고 있는 상황입니다.
3.2 스레드 덤프 대신 “요청-DB 경계”를 본다
가상 스레드는 수가 많아 스레드 덤프만으로는 결론이 잘 안 납니다. 대신:
- 엔드포인트별 DB 호출 횟수(N+1)
- 트랜잭션 범위(외부 API 호출을 트랜잭션 안에서 하는지)
- 커넥션 점유 시간(쿼리 시간 + 애플리케이션 처리)
을 추적하는 편이 빠릅니다.
3.3 네트워크/커널 리소스도 동시성 증가에 영향
가상 스레드로 동시 요청이 늘면 DB 커넥션뿐 아니라 노드의 커널 리소스도 압박받습니다. EKS라면 conntrack 포화로 DB 연결이 끊기는 증상도 나올 수 있으니 함께 점검해보세요: EKS conntrack 테이블 포화로 연결 끊김 해결법
4) Spring Boot 3에서 가상 스레드 켜는 방법과 주의점
4.1 설정
Spring Boot 3.2+ 기준으로 가상 스레드를 켜는 가장 간단한 방법은 다음입니다.
spring:
threads:
virtual:
enabled: true
이 설정은 주로 서블릿 컨테이너(톰캣/제티) 요청 처리 스레드에 가상 스레드를 사용하도록 돕습니다.
주의할 점:
- 가상 스레드는 “동시성 상한”을 사실상 풀어버립니다. 따라서 DB/외부 API/내부 락 같은 공유 자원에 대한 백프레셔를 반드시 설계해야 합니다.
5) 해결 전략 1: 커넥션 점유 시간을 줄여라 (가장 효과적)
5.1 트랜잭션 범위를 최소화
가장 흔한 실수는 트랜잭션 안에서 외부 API 호출, 파일 I/O, 메시지 발행 등을 수행해 커넥션을 불필요하게 오래 잡는 것입니다.
나쁜 예(트랜잭션이 외부 호출을 포함):
@Service
public class OrderService {
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
@Transactional
public void placeOrder(OrderRequest req) {
// DB 커넥션 획득
Order order = orderRepository.save(new Order(req));
// 외부 네트워크 호출(느릴 수 있음) -> 커넥션을 잡은 채로 대기
paymentClient.pay(order.getId(), req.amount());
order.markPaid();
}
}
개선 예(외부 호출을 트랜잭션 밖으로 분리 + 상태 전이만 트랜잭션):
@Service
public class OrderService {
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
public void placeOrder(OrderRequest req) {
Long orderId = createOrder(req); // 짧은 트랜잭션
paymentClient.pay(orderId, req.amount());
markPaid(orderId); // 짧은 트랜잭션
}
@Transactional
public Long createOrder(OrderRequest req) {
Order saved = orderRepository.save(new Order(req));
return saved.getId();
}
@Transactional
public void markPaid(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
}
}
외부 호출 실패 시 보상/재처리까지 고려해야 하는 서비스라면, 사가/보상 트랜잭션 패턴으로 확장하는 게 안전합니다. 관련 설계는 MSA Saga 보상 트랜잭션 실패 재처리 설계도 참고할 만합니다.
5.2 슬로우 쿼리/N+1 제거
가상 스레드를 켠 뒤 커넥션 풀이 고갈되는 서비스는 대개 “한 요청이 DB를 너무 오래/너무 자주” 만집니다.
- N+1: 요청당 쿼리가 폭증 → 커넥션 점유 시간이 늘고 DB도 과부하
- 인덱스 미스/풀스캔: 쿼리 1개가 오래 걸리면 커넥션 1개가 오래 묶임
우선순위:
- 상위 트래픽 API의 쿼리 플랜 확인
- 슬로우 쿼리 로그/pg_stat_statements 등으로 상위 지연 쿼리 제거
- N+1은 fetch join, batch size, DTO projection 등으로 감소
6) 해결 전략 2: “동시 DB 접근”에 백프레셔를 걸어라
가상 스레드 환경에서 핵심은 DB에 들어가는 동시성을 제어하는 것입니다. 예전에는 톰캣 스레드 풀이 그 역할을 했지만, 이제는 애플리케이션 레벨에서 명시적으로 걸어야 합니다.
6.1 세마포어로 DB 구간 동시성 제한 (간단하고 강력)
DB 호출을 감싸는 방식으로 “동시에 DB를 쓰는 요청 수”를 제한합니다.
@Component
public class DbConcurrencyLimiter {
// 풀의 max(예: 30)보다 약간 작은 값으로 시작
private final Semaphore semaphore = new Semaphore(25);
public <T> T execute(Callable<T> action) {
boolean acquired = false;
try {
acquired = semaphore.tryAcquire(200, java.util.concurrent.TimeUnit.MILLISECONDS);
if (!acquired) {
throw new IllegalStateException("DB is busy; apply backpressure");
}
return action.call();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (acquired) semaphore.release();
}
}
}
사용 예:
@Service
public class ProductService {
private final DbConcurrencyLimiter limiter;
private final ProductRepository repo;
public ProductService(DbConcurrencyLimiter limiter, ProductRepository repo) {
this.limiter = limiter;
this.repo = repo;
}
public Product get(Long id) {
return limiter.execute(() -> repo.findById(id).orElseThrow());
}
}
이 패턴의 장점:
- DB가 바쁠 때 빠르게 실패/대기를 선택할 수 있어 폭주를 막음
- 풀 고갈로 인한 긴 타임아웃(수 초~수십 초) 대신, 짧은 대기 후 429/503 등으로 응답 가능
실제 운영에서는 예외를 잡아 503으로 변환하고 Retry-After를 주거나, 내부 호출이라면 재시도 정책을 붙입니다.
6.2 Bulkhead(격벽)로 엔드포인트별 격리
한 API가 DB를 과도하게 쓰면 다른 API까지 같이 죽습니다. Resilience4j Bulkhead(세마포어 기반)를 쓰면 엔드포인트/유스케이스별로 동시성을 분리할 수 있습니다.
@Bean
public io.github.resilience4j.bulkhead.Bulkhead catalogBulkhead() {
var config = io.github.resilience4j.bulkhead.BulkheadConfig.custom()
.maxConcurrentCalls(20)
.maxWaitDuration(java.time.Duration.ofMillis(100))
.build();
return io.github.resilience4j.bulkhead.Bulkhead.of("catalog", config);
}
7) 해결 전략 3: HikariCP 튜닝은 “증상 완화”가 아니라 “정합”을 맞추는 작업
가상 스레드 적용 후에는 HikariCP 설정을 기본값으로 두기 어렵습니다. 다만 목표는 “크게 키우기”가 아니라 애플리케이션의 동시성/DB 용량과 일치시키기입니다.
7.1 권장 시작점(예시)
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 1000 # 풀 고갈 시 오래 기다리지 않게
validation-timeout: 500
idle-timeout: 600000
max-lifetime: 1800000
가이드:
connection-timeout을 너무 길게 두면(기본 30초) 장애 시 요청이 서버에 쌓여 더 큰 장애로 번집니다.maximum-pool-size는 DB의 max connections, 인스턴스 크기, 쿼리 특성, 그리고 애플리케이션 인스턴스 수를 함께 고려해야 합니다.- 예: DB max connections 300, 앱 6대면 이론상 50이 상한이지만, 실제론 여유를 두고 더 낮게 잡는 편이 안전합니다.
7.2 풀을 키우기 전에 “요청당 커넥션 점유 시간”부터 줄여라
풀을 10→30으로 늘렸는데도 고갈된다면, 대부분은 풀 사이즈 문제가 아니라:
- 트랜잭션이 길거나
- 락/슬로우쿼리로 점유가 길거나
- 동시성 제한이 없어 DB로 폭주하고 있거나
중 하나입니다.
8) 운영에서 자주 놓치는 포인트
8.1 커넥션 누수(leak) 감지
커넥션을 반납하지 않는 코드(스트리밍 결과셋, 예외 처리 누락 등)가 있으면 가상 스레드에서 더 빨리 터집니다.
- Hikari의 leak detection을 임시로 켜서 스택 트레이스를 확보합니다.
spring:
datasource:
hikari:
leak-detection-threshold: 2000
(상시 활성화는 오버헤드가 있을 수 있어, 문제 재현/조사 시에만 권장)
8.2 DB 락 경합이 “풀 고갈”로 보이는 경우
동시 업데이트가 많은 테이블에서 락 대기가 길어지면, 쿼리는 실행 중이지만 실제로는 락을 기다리며 커넥션을 점유합니다. 이때는:
- 트랜잭션 격리 수준/락 범위 조정
- 업데이트 핫스팟 분산(샤딩 키, 파티셔닝, 카운터 테이블 재설계)
- 낙관적 락/재시도
같은 접근이 필요합니다.
8.3 스레드는 늘었는데 DB 외에도 “다른 병목”이 먼저일 수 있음
가상 스레드로 동시성이 늘면, DB 외에도 DNS, 로드밸런서, NAT, conntrack 같은 병목이 먼저 나타날 수 있습니다. 특히 컨테이너/쿠버네티스 환경에서는 네트워크 계층 이슈가 DB 타임아웃처럼 보일 때가 많습니다.
9) 적용 순서 요약(실전 체크리스트)
- 가상 스레드 활성화 후, Hikari 메트릭(
active/pending)과 DB 슬로우쿼리/락을 함께 본다. - 트랜잭션 범위를 줄이고(외부 호출 분리), N+1/슬로우쿼리를 제거해 커넥션 점유 시간을 단축한다.
- 세마포어/Bulkhead로 DB 동시 접근을 제한해 백프레셔를 만든다.
- 그 다음에 HikariCP 풀 사이즈와 타임아웃을 DB 용량과 인스턴스 수에 맞게 조정한다.
- 마지막으로 네트워크/커널(conntrack 등) 병목 가능성까지 점검한다.
10) 결론
Spring Boot 3의 가상 스레드는 “스레드 부족” 문제를 크게 완화하지만, 그 대가로 기존에 스레드 풀이 해주던 자연스러운 동시성 제한이 사라집니다. 그래서 DB 커넥션 풀 고갈은 가상 스레드 도입 후 가장 흔하게 마주치는 새로운 병목입니다.
해결의 핵심은 풀을 무작정 키우는 것이 아니라:
- 커넥션을 오래 잡는 원인을 제거하고(트랜잭션/쿼리/락),
- DB로 들어가는 동시성을 명시적으로 제한하며(백프레셔),
- 그 위에서 HikariCP를 정합 있게 맞추는 것
입니다. 이 순서대로 접근하면 가상 스레드의 장점(높은 동시성, 낮은 스레드 비용)을 살리면서도 DB 안정성을 확보할 수 있습니다.