- Published on
Spring Boot 3 가상 스레드에서 JDBC 지연 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 스레드 모델을 가상 스레드로 바꾸면 동시성은 크게 늘지만, JDBC 지연이 같이 해결되는 건 아닙니다. 오히려 “요청은 많이 받는데 DB가 느려졌다”, “평소보다 쿼리 대기가 길다”, “HikariCP에서 커넥션을 못 구해서 대기한다” 같은 형태로 지연이 더 눈에 띄게 드러나는 경우가 많습니다.
이 글은 Spring Boot 3에서 가상 스레드를 켠 뒤 JDBC 지연이 발생하는 대표 패턴을 원인별로 쪼개고, 무엇을 어떻게 바꿔야 하는지(코드/설정/관측 포인트)를 실전 기준으로 정리합니다.
가상 스레드와 JDBC: 기대와 현실
가상 스레드(virtual thread)는 블로킹 I/O에서 플랫폼 스레드를 점유하지 않도록 스케줄링해 동시성을 높여줍니다. 하지만 JDBC는 본질적으로 “DB 커넥션이라는 희소 자원”을 필요로 하고, 다음 제약이 그대로 남습니다.
- DB 커넥션 수는 제한적이다
- DB는 CPU/IO/락/인덱스/플랜 문제로 느려질 수 있다
- 트랜잭션이 길면 커넥션 점유 시간이 길어진다
- 커넥션 풀에서 대기하는 시간은 애플리케이션 스레드 모델과 별개로 발생한다
즉, 가상 스레드는 “스레드 부족”을 완화하지만 “커넥션 부족”이나 “DB 병목”을 해결하지 않습니다. 오히려 요청을 더 많이 동시에 처리하려고 하면서 커넥션 풀 대기, 락 경합, DB CPU 포화가 더 빨리 도달할 수 있습니다.
증상별로 원인 분류하기 (가장 흔한 4가지)
1) HikariCP 커넥션 풀 대기 시간이 늘어난다
가상 스레드로 인해 동시에 더 많은 요청이 DB를 두드리면, 풀 사이즈가 같을 때 connection acquisition 대기가 급증할 수 있습니다. 이건 JDBC 지연처럼 보이지만 실제로는 “쿼리가 느린 게 아니라 커넥션을 못 얻어서 기다리는 시간”입니다.
- 로그/메트릭에서
HikariPool-... - Connection is not available, request timed out같은 메시지 - APM에서 DB span 앞단에 대기 시간이 길게 붙음
active는 풀 최대치로 고정,pending이 증가
이 경우는 아래 글에서 정리한 패턴과 거의 동일하게 접근하면 됩니다.
2) 트랜잭션이 길어져 커넥션 점유 시간이 늘어난다
가상 스레드 환경에서 동시 요청이 늘면, “조금 길었던 트랜잭션”이 전체를 막는 병목이 되기 쉽습니다.
- 외부 API 호출을 트랜잭션 안에서 수행
- 파일/네트워크/락 대기 등이 트랜잭션 범위에 포함
@Transactional이 의도와 다르게 적용되어 불필요하게 넓은 범위로 묶임
트랜잭션 경계가 예상과 다르면 커넥션 점유가 늘어나고, 결과적으로 풀 대기와 JDBC 지연이 폭발합니다.
3) JPA N+1 또는 과도한 쿼리로 DB가 포화된다
가상 스레드로 요청을 더 많이 처리하면, 기존에 숨어 있던 N+1 문제가 “DB CPU 100%” 같은 형태로 바로 튀어나옵니다. 이때 애플리케이션에서는 JDBC 지연처럼 보입니다.
동일 엔드포인트에서 쿼리 수가 비정상적으로 많음
DB CPU 상승과 함께 평균 쿼리 지연 증가
4) 드라이버/네트워크/DB 락 경합으로 “진짜 쿼리”가 느려진다
이건 가상 스레드가 직접 해결할 수 없는 영역입니다.
- 인덱스/플랜 문제
- 락 경합(동시 업데이트 증가)
- DB 연결 지연(네트워크, TLS, DNS)
이때 중요한 건 “커넥션 대기인지, 쿼리 실행이 느린지”를 분리해 측정하는 것입니다.
Spring Boot 3에서 가상 스레드 활성화: 기본 설정
Spring Boot 3.2+ 기준으로는 다음 설정으로 서블릿 기반(톰캣) 요청 처리 스레드를 가상 스레드로 전환할 수 있습니다.
spring:
threads:
virtual:
enabled: true
주의할 점은 “웹 요청 처리 스레드”만 가상 스레드로 바뀐다는 것입니다. JDBC는 여전히 커넥션 풀과 DB 성능에 의해 결정됩니다.
JDBC 지연을 줄이기 위한 핵심 전략
전략 1) 풀 사이즈를 무작정 키우지 말고, 먼저 병목을 분리한다
가상 스레드 적용 후 흔히 하는 실수가 maximumPoolSize를 크게 올리는 것입니다. DB가 감당 못 하면 오히려 더 느려집니다(락 경합, 컨텍스트 스위칭, 캐시 미스, CPU 포화).
먼저 아래 질문에 답해야 합니다.
- 지연의 대부분이
커넥션 획득 대기인가? - 아니면
쿼리 실행 시간인가?
이를 위해 Hikari 메트릭과 슬로우 쿼리 로그를 같이 봅니다.
HikariCP 메트릭 노출 (Actuator + Micrometer)
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
Prometheus를 쓴다면 hikaricp_connections_active, hikaricp_connections_pending, hikaricp_connections_timeout_total 같은 지표를 확인합니다.
pending이 늘면 풀 대기active가 항상 최대면 풀 포화timeout_total이 증가하면 이미 장애 전조
전략 2) 커넥션 점유 시간을 줄이는 게 1순위다
풀 대기는 “동시 요청 수 / 커넥션 점유 시간”의 함수입니다. 가상 스레드에서는 동시 요청 수가 늘기 쉬우니, 점유 시간을 줄이는 게 더 중요해집니다.
(1) 트랜잭션 범위를 좁혀라
아래는 흔한 안티패턴입니다. 외부 API 호출을 트랜잭션 안에 넣으면, 네트워크 지연 동안 커넥션이 묶입니다.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
@Transactional
public void placeOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
// 안티패턴: 트랜잭션 안에서 외부 호출
paymentClient.requestPayment(order.getAmount());
order.markPaid();
}
}
개선 방향은 “DB 작업과 외부 호출을 분리”하거나, 최소한 커넥션을 오래 잡지 않도록 순서를 바꿉니다(업무 규칙에 맞게 설계 필요).
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
public void placeOrder(Long orderId) {
OrderSnapshot snapshot = loadOrderForPayment(orderId);
paymentClient.requestPayment(snapshot.amount());
markPaid(orderId);
}
@Transactional(readOnly = true)
public OrderSnapshot loadOrderForPayment(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
return new OrderSnapshot(order.getId(), order.getAmount());
}
@Transactional
public void markPaid(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
}
public record OrderSnapshot(Long id, long amount) {}
}
핵심은 “트랜잭션(커넥션 점유) 시간을 짧게 쪼개는 것”입니다.
(2) 불필요한 조회/지연 로딩을 줄여라
N+1로 쿼리 수가 늘면, 커넥션 점유 시간과 DB 부하가 같이 증가합니다. fetch join이나 @EntityGraph로 쿼리 수를 줄이면 JDBC 지연이 체감상 크게 줄어듭니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"items", "member"})
Optional<Order> findWithItemsById(Long id);
}
전략 3) 풀 설정은 “지연을 숨기는” 게 아니라 “빨리 실패”하도록 잡아라
가상 스레드는 대기 스레드가 많아져도 플랫폼 스레드를 덜 쓰지만, 사용자 입장에서는 응답이 느리면 동일한 장애입니다. 풀 대기로 30초씩 기다리게 하는 것보다, 빠르게 실패하고 재시도/서킷브레이커로 보호하는 편이 낫습니다.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 1000 # 1초 내 못 얻으면 실패
validation-timeout: 1000
max-lifetime: 1800000
idle-timeout: 600000
connection-timeout을 짧게 두면 병목이 빨리 드러나고, 장애 전파 시간을 줄일 수 있습니다.- 풀 사이즈는 DB가 감당 가능한 동시 쿼리 수(코어 수, 워크로드, 락 경합)에 맞춰야 합니다.
전략 4) 동시성은 늘었으니, DB로 들어가는 동시 요청을 제한하라
가상 스레드 적용 후 “애플리케이션은 여유로운데 DB만 죽는” 상황이 흔합니다. 이때는 애플리케이션 레벨에서 DB 호출 동시성을 제한하는 것이 효과적입니다.
예를 들어, 특정 핫 엔드포인트에서만 DB 동시 접근을 제한하고 싶다면 Semaphore 같은 간단한 방식도 쓸 수 있습니다.
@Component
public class DbConcurrencyGuard {
private final Semaphore semaphore = new Semaphore(50);
public <T> T withPermit(Callable<T> action) {
boolean acquired = false;
try {
semaphore.acquire();
acquired = true;
return action.call();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (acquired) semaphore.release();
}
}
}
@Service
public class ReportService {
private final DbConcurrencyGuard guard;
private final JdbcTemplate jdbcTemplate;
public ReportService(DbConcurrencyGuard guard, JdbcTemplate jdbcTemplate) {
this.guard = guard;
this.jdbcTemplate = jdbcTemplate;
}
public List<Map<String, Object>> heavyReport() {
return guard.withPermit(() ->
jdbcTemplate.queryForList("select * from heavy_view")
);
}
}
이는 근본적으로는 캐시/리포트 테이블/비동기화 같은 설계로 가야 하지만, “가상 스레드 적용 직후 급한 불”을 끄는 데는 즉효가 있습니다.
전략 5) 관측: 커넥션 대기 vs 쿼리 실행을 분리해서 트레이싱하라
JDBC 지연을 해결하려면 “어디서 시간이 쓰이는지”가 분명해야 합니다.
- 커넥션 풀에서 기다린 시간
- DB에 실제로 쿼리 실행한 시간
APM이 없다면, 최소한 datasource-proxy 같은 라이브러리로 쿼리 시간을 로그로 남길 수 있습니다.
dependencies {
implementation "net.ttddyy:datasource-proxy:1.10"
}
@Configuration
public class DataSourceProxyConfig {
@Bean
public DataSource dataSource(DataSource original) {
return ProxyDataSourceBuilder
.create(original)
.name("DS")
.logQueryBySlf4j()
.multiline()
.countQuery()
.build();
}
}
이렇게 하면 최소한 “쿼리 자체가 느린지”는 확인할 수 있고, Hikari 메트릭과 결합해 “풀 대기인지”도 추정할 수 있습니다.
자주 하는 오해와 체크리스트
오해 1) 가상 스레드를 켰는데도 JDBC가 느리다, 가상 스레드가 실패한 것 아닌가?
가상 스레드는 스레드 대기를 줄여주지만, 커넥션/DB 병목은 그대로입니다. 오히려 처리량이 늘어 DB 병목이 더 빨리 드러나는 게 정상적인 관측일 수 있습니다.
오해 2) 풀만 키우면 해결된다
풀을 키우면 일시적으로 대기는 줄어들 수 있지만, DB가 감당 못 하면 평균 쿼리 시간이 늘고 전체 응답 시간이 악화됩니다. 먼저 트랜잭션/쿼리/N+1을 줄여 커넥션 점유 시간을 낮추는 게 우선입니다.
체크리스트
- Hikari
pending이 증가하는가? 증가한다면 풀 대기 문제 connection-timeout이 과도하게 긴가? 너무 길면 장애가 느리게 전파됨- 트랜잭션 안에 외부 호출/긴 계산/대량 직렬화가 있는가?
- 동일 요청에서 쿼리 수가 과도한가? N+1 의심
- DB 슬로우 쿼리 로그에서 상위 쿼리가 무엇인가?
- DB 락/데드락/인덱스 미스가 있는가?
마무리: 가상 스레드는 “DB 병목을 드러내는 확대경”이다
Spring Boot 3 가상 스레드는 웹 서버의 동시성 한계를 크게 완화하지만, JDBC 지연의 본질은 커넥션 풀과 DB 병목에 있습니다. 따라서 해결 순서는 보통 다음이 가장 효과적입니다.
- 풀 대기인지 쿼리 실행 지연인지 분리 관측
- 트랜잭션 범위 축소로 커넥션 점유 시간 감소
- N+1/쿼리 과다 제거로 DB 부하 감소
- 필요 시 DB 호출 동시성 제한으로 안정화
- 마지막에 풀 사이즈를 DB가 감당 가능한 범위에서 조정
가상 스레드를 켠 뒤 JDBC 지연이 보인다면, 그건 실패가 아니라 “이제 병목이 명확해졌다는 신호”일 가능성이 큽니다. 위 순서대로 분해해서 접근하면, 체감 지연을 꽤 안정적으로 낮출 수 있습니다.