- Published on
Spring Boot 3 가상 스레드로 DB 풀 고갈 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려져서 가상 스레드(virtual thread)를 도입했는데, 오히려 HikariPool-1 - Connection is not available 같은 오류가 더 자주 터지는 경험을 하는 팀이 많습니다. Spring Boot 3는 가상 스레드를 비교적 쉽게 켤 수 있지만, DB 연결 풀은 물리 리소스라서 무한히 늘릴 수 없습니다.
핵심은 이렇습니다.
- 가상 스레드는
요청 스레드를 가볍게 만들 뿐, DB 연결은 그대로희소 자원입니다. - 동시 요청이 늘어나면, 더 많은 요청이 동시에 DB에 접근하려고 하며 풀 고갈이 더 빨리 발생합니다.
- 해결은 "풀을 크게"만이 아니라,
DB를 때리는 동시성 자체를 제한하고연결 점유 시간을 줄이는방향으로 가야 합니다.
아래에서는 Spring Boot 3에서 가상 스레드를 적용할 때 DB 풀 고갈이 왜 생기는지, 그리고 운영에서 통하는 해결 패턴(설정, 코드, 관측)을 단계별로 정리합니다.
1) 가상 스레드가 DB 풀 고갈을 더 잘 드러내는 이유
플랫폼 스레드 기반 톰캣에서는 동시 처리량이 maxThreads에 의해 상한이 걸립니다. 예를 들어 톰캣 스레드가 200이면, 동시에 DB를 때릴 수 있는 요청도 대략 그 근처에서 제한됩니다(물론 비동기, 내부 스레드풀을 쓰면 달라짐).
하지만 가상 스레드를 켜면 요청당 스레드를 만드는 비용이 줄어들어, 애플리케이션이 훨씬 더 많은 동시 요청을 처리하려고 합니다. 이때 병목이 스레드에서 DB 풀로 이동합니다.
즉, 이전에는 스레드 부족으로 요청이 대기하던 것이, 이제는 DB 커넥션 부족으로 대기하거나 타임아웃이 나는 구조로 바뀝니다.
전형적인 증상
- 응답 지연이 계단식으로 증가하다가 특정 시점부터 급격히 타임아웃
- HikariCP 메트릭에서
active가maximumPoolSize에 붙고pending이 증가 - DB 쿼리 시간 자체는 짧은데도 커넥션 대기 시간이 길어짐
2) Spring Boot 3에서 가상 스레드 켜기(기본)
Spring Boot 3.2+ 기준으로 가장 간단한 방법은 설정 한 줄입니다.
spring:
threads:
virtual:
enabled: true
이 설정은 기본적으로 웹 요청 처리에 가상 스레드를 사용하도록 돕습니다(서블릿 스택 기준). 다만, 이 한 줄로 모든 문제가 해결되는 것이 아니라, 이제부터가 진짜 시작입니다.
3) 먼저 진단: "DB 풀 고갈"이 맞는지 확인하는 체크리스트
가상 스레드 도입 후 장애가 나면, 아래를 먼저 확인해야 합니다.
3-1. HikariCP 메트릭 확인
Actuator와 Micrometer를 쓴다면 다음을 봅니다.
hikaricp.connections.activehikaricp.connections.idlehikaricp.connections.pendinghikaricp.connections.max
pending이 지속적으로 증가하면서 active가 max에 붙으면, 전형적인 풀 고갈입니다.
3-2. 커넥션 점유 시간이 긴지 확인
풀 고갈은 "동시 요청이 많아서"도 생기지만, 더 흔한 원인은 커넥션을 너무 오래 잡고 있는 것입니다.
- 트랜잭션 범위가 넓음
- 외부 API 호출을 트랜잭션 안에서 수행
- N+1로 쿼리가 늘어나 점유 시간이 증가
- 느린 쿼리(인덱스 미스)
3-3. 스레드 덤프 대신 "커넥션 대기"를 본다
가상 스레드 환경에서는 스레드 수가 많아 덤프 해석이 더 어려워질 수 있습니다. 이때는 애플리케이션 스레드보다 커넥션 풀 대기와 트랜잭션 범위를 먼저 보는 것이 효율적입니다.
4) 해결 전략 1: 풀 크기만 키우지 말고, 상한을 설계하라
가장 먼저 떠올리는 해결책은 maximumPoolSize를 올리는 것입니다. 하지만 DB는 커넥션 수가 늘수록 컨텍스트 스위칭, 락 경합, 버퍼 캐시 압박 등으로 오히려 성능이 나빠질 수 있습니다.
권장 접근
- DB가 감당 가능한 커넥션 상한을 먼저 정한다
- 애플리케이션 인스턴스 수를 고려해 인스턴스당 풀 크기를 배분한다
예시:
- DB가 안전하게 처리 가능한 총 커넥션이 200
- 앱 파드가 10개
- 인스턴스당
maximumPoolSize는 대략 15~20 범위
설정 예시는 다음과 같습니다.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 1500
validation-timeout: 500
max-lifetime: 1800000
leak-detection-threshold: 20000
connection-timeout을 너무 길게 두면 장애가 "느리게" 번집니다. 짧게 두고 빠르게 실패시키는 편이 보호에 유리합니다.leak-detection-threshold는 누수 의심 시에만 일시적으로 켜는 것이 좋습니다(오버헤드 가능).
5) 해결 전략 2: DB 호출 동시성 제한(벌크헤드)로 풀 고갈을 차단
가상 스레드는 요청 동시성을 크게 올릴 수 있으니, DB로 들어가는 동시성은 별도로 제한해야 합니다. 가장 효과적인 패턴이 Bulkhead입니다.
5-1. Semaphore 기반 벌크헤드(가장 단순하고 강력)
DB 접근 직전에 세마포어로 동시 실행 수를 제한합니다.
import java.util.concurrent.Semaphore;
import org.springframework.stereotype.Component;
@Component
public class DbBulkhead {
private final Semaphore semaphore = new Semaphore(30); // DB 동시 실행 상한
public <T> T execute(CheckedSupplier<T> supplier) throws Exception {
boolean acquired = semaphore.tryAcquire();
if (!acquired) {
throw new IllegalStateException("DB bulkhead rejected");
}
try {
return supplier.get();
} finally {
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 DbBulkhead bulkhead;
private final OrderRepository orderRepository;
public OrderService(DbBulkhead bulkhead, OrderRepository orderRepository) {
this.bulkhead = bulkhead;
this.orderRepository = orderRepository;
}
@Transactional(readOnly = true)
public Order findOrder(String id) throws Exception {
return bulkhead.execute(() -> orderRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("not found")));
}
}
이 방식의 장점은 명확합니다.
- 풀 고갈 전에 애플리케이션이 스스로 DB 트래픽을 제한
- 장애 시 대기열이 무한정 쌓이는 것을 방지
- 가상 스레드 환경에서 특히 효과적(요청 스레드가 많아도 DB는 보호)
실무에서는 이 거부를 429 또는 503으로 매핑하고, 클라이언트 재시도 정책과 함께 운영합니다.
6) 해결 전략 3: 트랜잭션 범위 최소화(커넥션 점유 시간 단축)
풀 고갈의 본질은 커넥션 점유 시간과 동시 점유 개수의 곱이 커졌다는 뜻입니다. 벌크헤드로 동시성을 제한했다면, 다음은 점유 시간을 줄여야 합니다.
6-1. 트랜잭션 안에서 외부 호출 금지
다음 패턴은 매우 위험합니다.
@Transactional
public void placeOrder(String userId) {
// 1) DB 조회
// 2) 외부 결제 API 호출
// 3) DB 업데이트
}
외부 API가 1초만 느려져도 커넥션을 1초 동안 잡고 있게 됩니다. 가상 스레드를 켜면 이런 요청이 더 많이 동시에 들어와 풀을 빠르게 소모합니다.
개선 방향:
- 결제 요청은 트랜잭션 밖에서 수행
- 결제 결과를 반영하는 DB 업데이트만 짧게 트랜잭션으로 감싼다
- 필요하면 이벤트 기반으로 분리(Outbox 패턴 등)
이 주제는 이벤트 정합성과 함께 다뤄야 하므로, 멱등키와 Outbox를 실전 관점에서 정리한 글도 함께 참고하면 설계에 도움이 됩니다.
Kafka Exactly-Once 실패? 멱등키·Outbox 실전
6-2. readOnly 트랜잭션 명시
조회 API라면 다음을 습관화합니다.
@Transactional(readOnly = true)
public List<Order> listOrders(String userId) {
return orderRepository.findAllByUserId(userId);
}
DB 및 JPA 설정에 따라 최적화 여지가 생기고, 불필요한 플러시 등을 줄이는 데 도움이 됩니다.
7) 해결 전략 4: JDBC는 "블로킹"임을 인정하고, DB 작업을 분리하라
가상 스레드는 블로킹 I/O에서 강점을 보이지만, JDBC 드라이버와 커넥션 풀은 여전히 제한된 리소스를 사용합니다. 따라서 아래 원칙이 중요합니다.
- DB 작업은 짧고 예측 가능해야 한다
- DB 작업의 동시성은 제어해야 한다
- 느린 쿼리와 N+1을 제거해 점유 시간을 줄여야 한다
N+1이 풀 고갈로 이어지는 이유
N+1은 단순히 쿼리 수가 늘어나는 문제가 아니라, 요청당 DB 왕복이 늘어나면서 커넥션 점유 시간이 길어집니다. 가상 스레드로 동시 요청이 늘어난 환경에서는 이 효과가 증폭됩니다.
8) 해결 전략 5: 타임아웃을 계층별로 정렬해 "빠르게 실패"시키기
풀 고갈 상황에서 타임아웃이 길면, 요청이 오래 대기하면서 시스템 전체가 느려지고 결국 연쇄 장애로 갑니다. 타임아웃을 짧고 일관되게 정렬해야 합니다.
권장 순서(예시):
- HTTP 요청 타임아웃
2s - 커넥션 풀 대기
1.5s - 쿼리 타임아웃
1s
JPA 쿼리 타임아웃 예:
spring:
jpa:
properties:
jakarta.persistence.query.timeout: 1000
DB 및 드라이버에 따라 동작이 다르므로 운영 DB에서 검증이 필요합니다.
9) 가상 스레드 적용 시 흔한 함정
9-1. 내부에서 별도 스레드풀을 또 만들어 DB를 때리는 경우
가상 스레드 요청 처리와 별개로, @Async나 커스텀 ExecutorService로 DB 작업을 병렬화하면 풀 고갈이 더 빨라집니다. 특히 배치성 조회를 병렬로 돌리는 코드는 주의해야 합니다.
9-2. 커넥션 누수
가상 스레드 때문이 아니라도, 누수는 풀 고갈의 치명적인 원인입니다. 누수 의심 시에는 leak-detection-threshold를 잠깐 켜고, 커넥션을 직접 다루는 코드(순수 JDBC, 템플릿 밖에서 ResultSet 처리 등)를 점검합니다.
10) 운영 관점: 장애가 났을 때 무엇을 먼저 볼까
풀 고갈은 애플리케이션 문제로 보이지만, 인프라 이벤트와 겹치면 증상이 증폭됩니다. 예를 들어 노드 OOM, 프로브 실패, 재시작이 반복되면 트래픽이 남은 인스턴스에 쏠려 풀 고갈이 더 잘 발생합니다.
Kubernetes 환경이라면 아래 글처럼 CrashLoopBackOff 원인(OOMKilled, Probe, Exit 137)을 빠르게 분리 진단해 "트래픽 쏠림"부터 제거하는 것도 중요합니다.
K8s CrashLoopBackOff - OOMKilled·Probe·Exit 137 진단
11) 추천 조합: 실전에서 가장 안정적인 패턴
정리하면, Spring Boot 3에서 가상 스레드를 켠 상태로 DB 풀 고갈을 막는 가장 현실적인 조합은 다음입니다.
- 가상 스레드 활성화
spring.threads.virtual.enabled - HikariCP 풀 크기는 DB 총량 기준으로 보수적으로 설정
connection-timeout은 짧게, 장애를 빨리 드러내기- 세마포어 벌크헤드로 DB 동시성 상한 설정
- 트랜잭션 범위 최소화(외부 호출 분리)
- 느린 쿼리, N+1 제거로 점유 시간 단축
- 메트릭 기반으로
pending증가를 조기 감지
가상 스레드는 "더 많은 요청을 동시에 받을 수 있게" 해주지만, DB는 여전히 물리적으로 제한됩니다. 따라서 목표는 동시성을 무작정 키우는 것이 아니라, DB를 보호하면서도 전체 처리량을 안정적으로 끌어올리는 것입니다.
부록: 최소 설정 예시(요약)
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 1500
leak-detection-threshold: 20000
jpa:
properties:
jakarta.persistence.query.timeout: 1000
위 설정은 출발점일 뿐이고, 실제 적정 값은 DB 스펙, 인스턴스 수, 트래픽 패턴, 쿼리 특성에 의해 결정됩니다. 중요한 것은 가상 스레드 적용 이후에는 반드시 "DB 동시성 제어"를 설계 요소로 포함시키는 것입니다.