- Published on
Spring Boot 3 가상스레드와 JDBC 커넥션 병목
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 스레드가 부족해서 느린 줄 알고 Spring Boot 3에서 가상스레드(virtual threads)를 켰는데, TPS가 기대만큼 오르지 않거나 오히려 지연이 튀는 경우가 많습니다. 원인은 의외로 단순한 편인데, 대부분의 웹 요청은 결국 DB I/O(JDBC)에서 막히고, JDBC는 커넥션 수라는 물리적 자원에 의해 상한이 결정되기 때문입니다.
이 글은 Spring Boot 3에서 가상스레드를 적용할 때 JDBC 커넥션 풀이 어떤 방식으로 병목을 만들고, 이를 어떻게 관측하고(증상), 어떻게 설계로 풀어야 하는지(해결)까지 한 번에 정리합니다.
관련해서 JPA를 쓴다면 N+1이 커넥션 점유 시간을 늘려 병목을 더 악화시키기도 합니다. 필요하면 다음 글도 함께 보면 좋습니다.
가상스레드가 "DB 성능"을 올려주지 않는 이유
가상스레드는 블로킹 호출이 많은 서버에서 플랫폼 스레드(platform thread) 수를 적게 유지하면서도 동시 요청을 많이 처리하도록 돕습니다. 즉, "스레드 부족" 문제를 완화합니다.
하지만 JDBC는 다음 특성이 있어, 가상스레드를 켠다고 DB 처리량이 자동으로 늘지 않습니다.
- JDBC 호출은 본질적으로 블로킹 I/O이며, 각 쿼리는 커넥션을 점유합니다.
- 커넥션 풀의 최대 커넥션 수가 동시 DB 작업의 상한입니다.
- 가상스레드로 동시 요청 수가 늘면, 커넥션 풀 대기열에서 대기가 폭증할 수 있습니다.
정리하면, 가상스레드는 "대기하는 스레드를 싸게" 만들어주지만, "DB 커넥션이라는 희소 자원"을 늘려주지는 못합니다.
전형적인 증상: 처리량 정체, 지연 급증, 커넥션 대기
가상스레드 적용 후 JDBC 병목이 터질 때 자주 보이는 신호는 아래와 같습니다.
- 평균 응답시간은 비슷한데 P95/P99 지연이 크게 증가
- DB CPU나 IOPS는 여유가 있는데도 애플리케이션 지연이 증가
- HikariCP의 커넥션 획득 대기 시간이 늘어남
- 애플리케이션 스레드 덤프에서 커넥션 획득 대기 상태가 많이 보임
특히 "DB는 안 바쁜데 앱이 느리다"는 느낌이 들면, 커넥션 풀 대기 또는 트랜잭션 범위 과대가 1순위로 의심됩니다.
핵심 메커니즘: 요청 수가 늘면 커넥션 풀이 큐가 된다
웹 요청 하나가 DB 쿼리를 1번만 날린다고 가정해도, 동시 요청 수가 커넥션 풀 크기보다 커지면 나머지는 대기합니다.
- 동시 요청 2,000
- Hikari 최대 커넥션 30
- 평균 쿼리 시간 50ms
이 경우 이론적으로 DB 작업은 한 번에 30개만 진행되고, 나머지는 "커넥션이 반환될 때까지" 대기합니다. 가상스레드는 이 대기를 플랫폼 스레드를 많이 쓰지 않고도 감당할 수 있게 해주지만, 사용자 입장에서는 그냥 느린 것입니다.
또 한 가지 중요한 점은 "쿼리 시간"이 아니라 "커넥션 점유 시간"이 병목을 결정한다는 것입니다.
- 쿼리는 10ms인데
- 트랜잭션이 200ms 동안 열려 있고
- 그 동안 커넥션을 잡고 있다면
커넥션 풀 관점에서 200ms짜리 작업입니다.
Spring Boot 3에서 가상스레드 활성화
Spring Boot 3.2+ 기준으로 가상스레드는 설정 한 줄로 활성화할 수 있습니다.
spring:
threads:
virtual:
enabled: true
이 설정은 주로 요청 처리 스레드(서블릿 컨테이너 또는 일부 실행기)에 영향을 줍니다. 다만 "가상스레드를 켰으니 DB도 빨라지겠지"는 성립하지 않습니다. 이제부터는 커넥션 풀과 트랜잭션 경계를 같이 봐야 합니다.
HikariCP 커넥션 풀: 무엇을, 어디까지 늘려야 하나
1) 무작정 maximumPoolSize를 키우면 안 되는 이유
가상스레드로 동시 요청이 늘면, 직감적으로 커넥션 풀도 크게 늘리고 싶어집니다. 하지만 커넥션은 공짜가 아닙니다.
- DB 서버의 동시 세션/커넥션 처리 한계
- 커넥션 수 증가에 따른 컨텍스트 스위칭 및 락 경합
- 쿼리 플랜 캐시/버퍼 캐시 효율 저하 가능성
- DB 리소스 고갈로 전체 성능 붕괴
따라서 커넥션 풀 튜닝은 "애플리케이션 요청 동시성"이 아니라 "DB가 건강하게 처리 가능한 동시 쿼리 수"에서 출발해야 합니다.
2) 현실적인 접근: 풀 크기보다 먼저 "점유 시간"을 줄여라
풀을 늘리기 전에 먼저 해야 할 일은 다음입니다.
- 트랜잭션 범위 최소화
- N+1 제거 및 쿼리 수 감소
- 느린 쿼리 인덱스/플랜 개선
- 불필요한
@Transactional제거
커넥션 점유 시간이 줄면 같은 풀 크기에서도 처리량이 올라가고 대기열이 줄어듭니다.
3) Hikari 설정 예시
아래는 예시입니다. 숫자는 서비스 특성과 DB 스펙에 따라 달라야 합니다.
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 30
connection-timeout: 2000
validation-timeout: 1000
max-lifetime: 1800000
leak-detection-threshold: 20000
connection-timeout을 너무 길게 두면, 커넥션 풀 병목이 "느린 응답"으로만 보이고 장애 감지가 늦어집니다.leak-detection-threshold는 커넥션 누수를 잡는 데 도움이 되지만, 정상적으로 오래 걸리는 트랜잭션이 많은 서비스에서는 경보가 과해질 수 있습니다.
가상스레드 환경에서 더 중요한 것: "동시성 제한"을 앱에서 걸어라
가상스레드는 동시 요청을 매우 쉽게 늘립니다. 문제는 DB는 그대로라는 점입니다. 그래서 DB로 들어가는 동시 쿼리 수를 애플리케이션에서 제한하는 전략이 효과적입니다.
세마포어로 DB 동시 접근 제한 (간단하고 강력)
아래 예시는 "DB에 들어가는 구간"에 세마포어를 걸어 커넥션 풀 대기 폭증을 완화합니다. 핵심은 "요청은 많이 받아도 DB로 들어가는 수는 제한"하는 것입니다.
import java.util.concurrent.Semaphore;
import org.springframework.stereotype.Component;
@Component
public class DbConcurrencyLimiter {
// DB가 감당 가능한 동시 작업 수로 설정 (예: 풀 크기와 비슷하거나 더 작게)
private final Semaphore semaphore = new Semaphore(30);
public <T> T execute(CheckedSupplier<T> supplier) throws Exception {
semaphore.acquire();
try {
return supplier.get();
} finally {
semaphore.release();
}
}
@FunctionalInterface
public interface CheckedSupplier<T> {
T get() throws Exception;
}
}
사용 예:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
private final DbConcurrencyLimiter limiter;
private final UserService userService;
public UserController(DbConcurrencyLimiter limiter, UserService userService) {
this.limiter = limiter;
this.userService = userService;
}
@GetMapping("/users")
public Object users() throws Exception {
return limiter.execute(() -> userService.findUsers());
}
}
이 방식은 특히 다음 상황에서 효과가 큽니다.
- 트래픽 스파이크 때 커넥션 풀 대기열이 폭증
- DB는 보호해야 하고, 앱은 빠르게 실패하거나 큐잉을 제어해야 함
주의할 점은 세마포어 제한 값이 너무 작으면 앱이 과도하게 직렬화되어 처리량이 떨어질 수 있다는 것입니다. 반대로 너무 크면 커넥션 풀 대기와 다를 바가 없어집니다.
트랜잭션 경계가 커넥션 점유 시간을 결정한다
가상스레드 적용 후 "커넥션은 늘렸는데도" 여전히 지연이 크다면, 트랜잭션이 필요 이상으로 길어 커넥션을 오래 잡고 있을 가능성이 큽니다.
흔한 실수 1: 외부 API 호출을 트랜잭션 안에서 수행
예를 들어 결제 승인 API 호출, 파일 업로드, 메시지 발행 등을 트랜잭션 안에서 처리하면 그 시간만큼 커넥션이 묶입니다.
나쁜 예(개념적으로):
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
public OrderService(PaymentClient paymentClient, OrderRepository orderRepository) {
this.paymentClient = paymentClient;
this.orderRepository = orderRepository;
}
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
// 외부 호출이 느리면 커넥션이 그만큼 점유된다
paymentClient.approve(order.getId());
}
}
개선 방향은 보통 다음 중 하나입니다.
- 트랜잭션을 쪼개서 DB 작업만 짧게 묶기
- 아웃박스 패턴으로 트랜잭션 안에서는 이벤트만 기록하고 비동기로 외부 호출
- 최소한 읽기 전용 조회는
@Transactional(readOnly = true)로 구분
흔한 실수 2: "조회"에도 불필요한 트랜잭션을 크게 잡음
조회 API에 광범위하게 @Transactional이 붙어 있고, 그 안에서 여러 쿼리 및 매핑 로직이 길게 실행되면 커넥션 점유 시간이 늘어납니다. 특히 JPA에서 지연 로딩이 섞이면 예상보다 많은 쿼리가 트랜잭션 내에서 실행될 수 있습니다.
관측 포인트: 무엇을 측정해야 병목이 보이나
가상스레드 적용 시에는 "스레드"보다 "커넥션 풀"과 "DB 시간"을 먼저 봐야 합니다.
1) HikariCP 메트릭
Micrometer를 쓰면 Hikari 메트릭을 쉽게 볼 수 있습니다. 봐야 할 대표 지표는 다음입니다.
- 사용 중 커넥션 수
- 대기 중 스레드 수
- 커넥션 획득 시간
대기 중 스레드 수가 계속 증가하거나, 커넥션 획득 시간이 튀면 병목이 커넥션 풀에 있다는 뜻입니다.
2) 애플리케이션 관점 분해
응답시간을 다음처럼 분해해보면 원인이 선명해집니다.
- 컨트롤러 진입부터 종료까지 총 시간
- DB 커넥션 획득 대기 시간
- 실제 쿼리 실행 시간
- 트랜잭션 오픈부터 커밋까지 시간
"쿼리 실행 시간"보다 "커넥션 획득 대기"나 "트랜잭션 시간"이 길다면, 가상스레드로 동시성이 늘어난 것이 오히려 대기열을 키웠을 가능성이 큽니다.
JPA를 쓴다면: N+1이 커넥션 병목을 증폭시킨다
가상스레드로 동시 요청이 늘어난 상황에서 N+1이 있으면 문제가 더 커집니다.
- 요청 1개당 쿼리 수가 증가
- 커넥션 점유 시간이 증가
- 풀 대기열이 기하급수로 늘어남
결과적으로 "가상스레드 적용 전에는 어찌어찌 버티던" 구조가 적용 후 더 빨리 무너질 수 있습니다. N+1을 제거하는 것만으로도 커넥션 점유 시간이 크게 줄어드는 경우가 많습니다.
위에서 소개한 내부 글 2개는 N+1을 실전에서 빠르게 줄이는 방법을 상세히 다룹니다.
실전 체크리스트: 가상스레드 + JDBC를 함께 성공시키는 방법
아래 순서로 접근하면 시행착오가 줄어듭니다.
- 가상스레드 활성화는 "스레드 부족" 문제 해결로만 기대치 설정
- Hikari 메트릭으로 커넥션 획득 대기와 대기 스레드 수를 관측
- 풀 크기 조정 전에 트랜잭션 범위 축소, 느린 쿼리 개선, N+1 제거
- 트래픽 스파이크를 대비해 앱 레벨 동시성 제한(세마포어 등) 도입 검토
connection-timeout을 적절히 줄여 "느린 성공" 대신 "빠른 실패"로 장애를 표면화- DB가 감당 가능한 동시 쿼리 수를 기준으로 풀 크기 상한을 설정
마무리
Spring Boot 3의 가상스레드는 서버의 동시성 처리 모델을 바꿔주는 강력한 도구지만, JDBC 기반 서비스에서 성능의 상한은 여전히 커넥션 풀과 DB가 결정합니다. 가상스레드 적용의 성패는 결국 "DB로 들어가는 동시성"과 "커넥션 점유 시간"을 어떻게 설계하고 관측하느냐에 달려 있습니다.
가상스레드를 켠 뒤 성능이 기대만큼 오르지 않는다면, 스레드 덤프보다 먼저 Hikari 대기와 트랜잭션 길이부터 확인해보세요. 대부분의 답은 그 안에 있습니다.