Published on

Spring Boot 3 가상 스레드에서 JDBC 지연 줄이기

Authors

서버 스레드 모델을 가상 스레드로 바꾸면 동시성은 크게 늘지만, 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 지연처럼 보입니다.

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 병목에 있습니다. 따라서 해결 순서는 보통 다음이 가장 효과적입니다.

  1. 풀 대기인지 쿼리 실행 지연인지 분리 관측
  2. 트랜잭션 범위 축소로 커넥션 점유 시간 감소
  3. N+1/쿼리 과다 제거로 DB 부하 감소
  4. 필요 시 DB 호출 동시성 제한으로 안정화
  5. 마지막에 풀 사이즈를 DB가 감당 가능한 범위에서 조정

가상 스레드를 켠 뒤 JDBC 지연이 보인다면, 그건 실패가 아니라 “이제 병목이 명확해졌다는 신호”일 가능성이 큽니다. 위 순서대로 분해해서 접근하면, 체감 지연을 꽤 안정적으로 낮출 수 있습니다.