- Published on
Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 스레드가 부족해 병목이 생기던 서비스를 Spring Boot 3의 가상스레드로 전환하면, CPU나 스레드가 아니라 DB 커넥션 풀이 먼저 고갈되는 현상을 자주 만나게 됩니다. 겉으로는 처리량이 늘어날 것 같은데, 실제로는 HikariPool-1 - Connection is not available 같은 오류가 늘거나 응답 지연이 급격히 커집니다.
이 글은 “가상스레드를 켰더니 왜 DB가 먼저 죽는가”를 원인부터 설명하고, Spring Boot 3 기준으로 커넥션 고갈을 막는 설정과 코드 레벨의 패턴을 정리합니다. 핵심은 간단합니다.
- 가상스레드는 동시 요청 수를 크게 늘릴 수 있다
- JDBC는 블로킹이며, DB 커넥션 수는 제한되어 있다
- 따라서 “스레드가 늘어난 만큼 DB 동시성도 늘어난다”는 착각이 커넥션 고갈로 이어진다
가상스레드가 커넥션 고갈을 더 잘 드러내는 이유
전통적인 플랫폼 스레드 기반 Tomcat에서는 요청 처리 스레드 수가 사실상 동시성 상한선이었습니다. 예를 들어 maxThreads=200이면 동시에 200개 정도의 요청만 실제로 DB까지 들어가고, 나머지는 큐에서 대기합니다.
하지만 가상스레드를 켜면(예: Spring Boot 3.2+), 요청당 스레드를 훨씬 많이 만들 수 있고, 블로킹 I/O(JDBC 포함)에서 스레드가 “가볍게” 대기합니다. 결과적으로 다음이 발생합니다.
- 애플리케이션은 더 많은 요청을 동시에 처리하려고 시도한다
- 각 요청이 JDBC 쿼리를 치면 커넥션이 필요하다
- 커넥션 풀 크기는 그대로라서 풀이 먼저 바닥난다
즉, 가상스레드는 문제를 만들었다기보다 “기존에 스레드 제한으로 숨겨졌던 DB 병목”을 노출시키는 경우가 많습니다.
증상 체크리스트: 진짜 커넥션 고갈인지 확인
아래 중 2개 이상이면 커넥션 풀이 병목일 확률이 큽니다.
- 로그에
Connection is not available, request timed out after ...ms발생 - 응답 지연이 DB 호출 구간에서 급격히 증가
- DB CPU는 낮은데 커넥션 수가 상한에 근접
- 애플리케이션 스레드는 여유가 있는데 요청이 대기
- APM에서
HikariPool대기 시간이 늘어남
추가로, 운영에서 “특정 구간에만 폭주”라면 자동 확장과 결합되어 더 심해질 수 있습니다. 오토스케일링이 흔들릴 때는 트래픽 유입이 순간적으로 커지면서 DB 커넥션이 먼저 고갈되기도 합니다. 관련해서는 EKS HPA 폭주를 KEDA 큐기반 오토스케일링으로 안정화 글의 관점(큐 기반 완충)이 서버-DB 사이에도 그대로 적용됩니다.
Spring Boot 3에서 가상스레드 활성화 방법과 주의점
Spring Boot 3.2+에서는 설정 한 줄로 가상스레드 기반 실행기를 사용할 수 있습니다.
spring:
threads:
virtual:
enabled: true
이 설정은 주로 다음에 영향을 줍니다.
@Async등에서 사용하는 TaskExecutor- (구성에 따라) 웹 요청 처리 스레드 모델
주의할 점은 “가상스레드를 켠다”가 “DB가 더 많이 처리한다”가 아니라는 것입니다. DB는 여전히 제한된 커넥션과 제한된 동시 쿼리 처리 능력을 가집니다.
해결 전략 1: 커넥션 풀 크기부터 올리면 안 되는 이유
가장 흔한 오해는 maximumPoolSize를 크게 올리면 해결된다는 것입니다. 물론 일정 범위에서는 도움이 됩니다. 하지만 무작정 올리면 아래 부작용이 생깁니다.
- DB가 동시에 처리할 수 있는 쿼리 수를 초과해 락/컨텍스트 스위칭 증가
- DB CPU 급등, 쿼리 지연 증가로 전체 처리량 감소
- 애플리케이션 인스턴스가 늘면 총 커넥션 수가 폭발
예를 들어 파드 10개에 maximumPoolSize=50이면 이론상 500 커넥션이 DB에 붙습니다. DB가 그 수를 감당하지 못하면 전체가 느려집니다.
따라서 “풀을 늘리기”는 마지막 단계로 두고, 먼저 동시성을 제어하고 커넥션 점유 시간을 줄이는 게 우선입니다.
해결 전략 2: 핵심은 DB 동시성 제한(백프레셔)
가상스레드 환경에서는 애플리케이션이 매우 많은 요청을 동시에 DB로 밀어 넣을 수 있으므로, 애플리케이션 내부에서 DB 접근 동시성을 제한하는 장치가 필요합니다.
세마포어로 DB 접근 동시성 제한
가장 단순하고 효과적인 방법 중 하나는 세마포어로 “DB에 들어갈 수 있는 동시 작업 수”를 제한하는 것입니다.
import java.util.concurrent.Semaphore;
import org.springframework.stereotype.Component;
@Component
public class DbConcurrencyLimiter {
private final Semaphore semaphore = new Semaphore(30); // DB 동시 접근 상한
public <T> T execute(DbSupplier<T> supplier) {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
return supplier.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting DB slot", e);
} finally {
if (acquired) {
semaphore.release();
}
}
}
@FunctionalInterface
public interface DbSupplier<T> {
T get();
}
}
서비스에서 DB 호출을 감싸면, 가상스레드가 수천 개 생겨도 DB로 동시에 들어가는 작업은 30개로 제한됩니다.
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final DbConcurrencyLimiter limiter;
private final OrderRepository repository;
public OrderService(DbConcurrencyLimiter limiter, OrderRepository repository) {
this.limiter = limiter;
this.repository = repository;
}
public Order findById(long id) {
return limiter.execute(() -> repository.findById(id)
.orElseThrow());
}
}
이 방식의 장점은 “커넥션 풀 타임아웃으로 실패”하는 대신 “애플리케이션에서 대기”하게 만들어 오류율을 낮추고, DB를 보호한다는 점입니다.
HikariCP 풀 크기와 함께 맞추기
세마포어 값은 보통 다음을 기준으로 잡습니다.
maximumPoolSize보다 작거나 같게- DB가 안정적으로 처리 가능한 동시 쿼리 수에 맞게
예시 설정입니다.
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 2000
validation-timeout: 1000
max-lifetime: 1800000
leak-detection-threshold: 30000
connection-timeout을 너무 길게 두면, 고갈 시 요청이 오래 매달려 장애가 커집니다.leak-detection-threshold는 커넥션 누수를 빠르게 찾는 데 도움이 됩니다.
해결 전략 3: 트랜잭션 경계를 줄여 커넥션 점유 시간을 단축
커넥션 고갈의 본질은 “커넥션을 너무 오래 쥐고 있음”입니다. 특히 다음 패턴이 위험합니다.
@Transactional메서드 안에서 외부 API 호출- 트랜잭션 안에서 대용량 데이터 가공
- 트랜잭션 범위가 웹 요청 전체를 감싸는 구조
나쁜 예: 트랜잭션 안에서 외부 호출
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final PgClient pgClient;
public PaymentService(PaymentRepository paymentRepository, PgClient pgClient) {
this.paymentRepository = paymentRepository;
this.pgClient = pgClient;
}
@Transactional
public void pay(long orderId) {
paymentRepository.markPaying(orderId);
pgClient.requestApproval(orderId); // 외부 호출로 오래 블로킹될 수 있음
paymentRepository.markPaid(orderId);
}
}
이 코드는 외부 PG 응답이 느려지면 그 시간만큼 커넥션을 점유합니다. 가상스레드에서는 이런 요청이 대량으로 동시에 들어올 수 있어 풀이 빠르게 고갈됩니다.
개선: DB 작업과 외부 호출 분리
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PaymentService {
private final PaymentRepository paymentRepository;
private final PgClient pgClient;
public PaymentService(PaymentRepository paymentRepository, PgClient pgClient) {
this.paymentRepository = paymentRepository;
this.pgClient = pgClient;
}
public void pay(long orderId) {
markPaying(orderId);
// 트랜잭션 밖에서 외부 호출
pgClient.requestApproval(orderId);
markPaid(orderId);
}
@Transactional
public void markPaying(long orderId) {
paymentRepository.markPaying(orderId);
}
@Transactional
public void markPaid(long orderId) {
paymentRepository.markPaid(orderId);
}
}
트랜잭션을 쪼개면 커넥션 점유 시간이 짧아지고, 풀 고갈 가능성이 크게 줄어듭니다.
해결 전략 4: 커넥션 누수와 지연 쿼리를 먼저 제거
가상스레드 전환 후 “갑자기” 고갈이 시작되었다면, 기존에는 드러나지 않던 누수나 느린 쿼리가 동시성 증가로 증폭되었을 가능성이 큽니다.
Hikari leak detection 켜기
앞서 설정한 leak-detection-threshold가 대표적입니다. 예를 들어 30초 이상 반환되지 않는 커넥션이 있으면 스택트레이스를 로그로 남깁니다.
spring:
datasource:
hikari:
leak-detection-threshold: 30000
느린 쿼리 로그와 지표
- DB의 slow query log 활성화
- 애플리케이션에서 쿼리 타임 분포(p95, p99) 확인
느린 쿼리를 줄이면 “커넥션 점유 시간”이 줄고, 같은 풀 크기로도 더 많은 요청을 처리할 수 있습니다.
해결 전략 5: 배치성 작업과 웹 트래픽의 풀을 분리
웹 요청과 배치가 같은 커넥션 풀을 쓰면, 배치가 풀을 잠식해 웹이 죽는 일이 흔합니다. 가상스레드는 웹 동시성을 늘리므로 충돌이 더 자주 발생합니다.
멀티 데이터소스(또는 최소한 별도 풀)로 분리하는 접근이 유효합니다.
app:
datasource:
web:
hikari:
maximum-pool-size: 20
batch:
hikari:
maximum-pool-size: 10
구현은 프로젝트마다 다르지만, 핵심은 “워크로드별로 DB 동시성 예산을 분리”하는 것입니다.
운영 관점 체크리스트: 가상스레드 적용 전후로 꼭 볼 것
가상스레드 전환은 성능 실험이 아니라 “리소스 병목 재배치”에 가깝습니다. 전환 전후에 아래를 함께 봐야 합니다.
- 애플리케이션: 요청 동시성, 응답 p95/p99, 에러율
- HikariCP: active, idle, pending threads, timeout 횟수
- DB: 현재 커넥션 수, 락 대기, CPU, IOPS
- 쿼리: p95/p99 latency, top N slow queries
특히 재시도 로직이 있는 서비스라면, 커넥션 타임아웃이 재시도를 유발해 더 큰 폭주로 이어질 수 있습니다. 외부 API 재시도 설계 관점은 OpenAI 429와 Rate Limit 헤더로 재시도 설계 글의 “재시도가 시스템을 더 망가뜨릴 수 있다”는 포인트가 그대로 적용됩니다.
권장 조합: 실전에서 가장 무난한 해법
정리하면, Spring Boot 3 가상스레드 환경에서 DB 커넥션 고갈을 안정적으로 막는 조합은 다음 순서가 실패 확률이 낮습니다.
- 트랜잭션 경계 축소로 커넥션 점유 시간 줄이기
- 세마포어 등으로 DB 접근 동시성 제한(백프레셔)
- HikariCP 풀 크기와 타임아웃을 현실적인 값으로 조정
- 누수 탐지와 느린 쿼리 제거
- 워크로드(웹/배치)별 풀 분리
가상스레드는 “스레드 부족”을 해결해주지만, DB는 여전히 가장 비싼 공유 자원입니다. 가상스레드 도입의 성패는 DB를 얼마나 정교하게 보호하고, 커넥션을 얼마나 짧게 쓰도록 만들었는지에 달려 있습니다.