Published on

Spring Boot 3 가상스레드에서 DB풀 병목 진단법

Authors

서버에 Spring Boot 3 가상스레드(virtual thread)를 적용하면 동시성(특히 블로킹 I/O 대기)을 훨씬 저렴하게 확장할 수 있습니다. 그런데 실서비스에서는 기대한 만큼 TPS가 오르지 않거나, p95/p99 지연이 오히려 튀는 케이스가 자주 나옵니다. 이때 가장 흔한 원인은 애플리케이션 스레드가 아니라 DB 커넥션 풀(HikariCP) 입니다.

가상스레드는 “스레드 비용”을 낮춰줄 뿐, DB가 처리할 수 있는 동시 쿼리 수나 커넥션 수를 마법처럼 늘려주지 않습니다. 오히려 가상스레드로 요청 동시성이 늘어나면서, 기존에 숨겨져 있던 풀 대기 시간(connection acquisition wait) 이 지연의 주범으로 드러납니다.

이 글은 다음을 목표로 합니다.

  • 가상스레드 적용 후 DB풀 병목이 생겼는지 빠르게 판별
  • HikariCP 지표와 로그로 “대기”를 수치화
  • 스레드 덤프/JFR로 병목 위치를 증명
  • 풀 크기 조정만으로 해결이 안 되는 근본 원인(트랜잭션/쿼리/락)을 분리

참고로, 운영에서 재현·관측 자동화가 중요합니다. CI/배포 파이프라인에서 성능 회귀를 빠르게 잡는 방법은 GitHub Actions 매트릭스로 CI 시간 50% 줄이기 같은 글도 함께 참고하면 좋습니다.

1) 증상: 가상스레드인데 왜 느려지나

가상스레드 적용 후 아래 증상이 보이면 DB풀 병목을 의심하세요.

  • CPU 사용률은 낮은데 응답 시간이 증가
  • 요청 수가 늘면 p95/p99가 급격히 상승(계단형)
  • 애플리케이션 로그에 간헐적인 타임아웃(특히 DB 커넥션 획득 타임아웃)
  • DB 서버의 active connection 또는 DB CPU는 이미 한계치 근처

핵심은 이겁니다.

  • 가상스레드가 많아지면 “DB에 동시에 접근하려는 시도”가 늘어남
  • 그러나 풀의 maximumPoolSize 는 그대로면, 더 많은 요청이 커넥션을 기다리는 대기열 로 들어감
  • 이 대기는 사용자 지연으로 그대로 전가됨

2) 기본기: 가상스레드와 JDBC의 관계(오해 정리)

  • JDBC는 블로킹 I/O입니다.
  • 가상스레드는 블로킹 시 플랫폼 스레드를 점유하지 않도록 스케줄링해줍니다.
  • 하지만 DB 커넥션 자체는 제한된 자원입니다. 커넥션 풀은 “동시 실행 쿼리 수를 제한”하는 역할도 합니다.

즉, 가상스레드는 “대기 비용”을 줄여주지만 “자원 한계”를 없애지 않습니다. DB풀 병목은 가상스레드에서 더 잘 드러납니다.

3) 가장 빠른 판별법: HikariCP 지표 3종 세트

DB풀 병목은 HikariCP 지표만 제대로 봐도 1차 판별이 가능합니다.

3.1 Actuator + Micrometer로 풀 지표 노출

Spring Boot에서 Actuator와 Micrometer를 켜고, Hikari 지표를 확인합니다.

build.gradle 예시:

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-actuator'
  implementation 'io.micrometer:micrometer-registry-prometheus'
}

application.yml 예시:

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

Prometheus로 보면 보통 아래 지표가 핵심입니다.

  • hikaricp_connections_active
  • hikaricp_connections_idle
  • hikaricp_connections_pending

판단 기준(경험칙):

  • activemaximumPoolSize 근처에 오래 붙어있다
  • 동시에 pending 이 0보다 커지고, 트래픽 증가 시 함께 증가한다

이 조합이면 거의 확정적으로 “커넥션 획득 대기”가 지연을 만들고 있습니다.

3.2 connection timeout 로그로 확정

Hikari에서 커넥션 획득 실패가 나면 보통 이런 형태가 뜹니다.

  • Connection is not available, request timed out after ...

이 로그가 보이면 병목은 이미 사용자 영향 단계입니다.

3.3 풀 대기 시간을 직접 측정(커스텀 메트릭)

기본 지표만으로는 “얼마나 기다렸는지”가 부족할 수 있습니다. DataSource 를 감싸서 커넥션 획득 시간을 측정하면 확실해집니다.

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Duration;

public final class TimingDataSource extends javax.sql.DataSourceWrapper {
  private final Timer acquireTimer;

  public TimingDataSource(DataSource delegate, MeterRegistry registry) {
    super(delegate);
    this.acquireTimer = Timer.builder("db.connection.acquire")
        .description("Time to acquire a DB connection from the pool")
        .publishPercentileHistogram()
        .sla(Duration.ofMillis(5), Duration.ofMillis(20), Duration.ofMillis(50), Duration.ofMillis(200))
        .register(registry);
  }

  @Override
  public Connection getConnection() throws SQLException {
    return acquireTimer.record(() -> super.getConnection());
  }
}

위처럼 db.connection.acquire 의 p95/p99가 튀면, 애플리케이션 내부 병목이 아니라 “풀 대기”가 사용자 지연으로 직결되고 있다는 뜻입니다.

주의: 위 코드는 예시이며, 실제 적용 시 DataSourceWrapper 를 직접 구현하거나 스프링 빈 구성에서 DataSource 를 데코레이트하는 방식으로 맞추면 됩니다.

4) 가상스레드 적용 설정: 어디가 바뀌고 무엇이 안 바뀌나

Spring Boot 3.2+ 기준으로 가상스레드를 켜는 대표 방법은 다음과 같습니다.

application.yml:

spring:
  threads:
    virtual:
      enabled: true

이 설정은 주로 웹 요청 처리 스레드(서블릿 컨테이너/웹 레이어)에 영향을 줍니다. 하지만 아래는 자동으로 해결되지 않습니다.

  • HikariCP 풀 크기/획득 타임아웃
  • DB 서버의 최대 커넥션, 워커 수, CPU/IO
  • 트랜잭션 경계가 넓어서 커넥션을 오래 쥐는 문제

그래서 “가상스레드 적용 후 병목이 DB풀로 이동”하는 현상이 흔합니다.

5) 스레드 덤프로 병목을 증명하기

지표가 의심이라면, 스레드 덤프는 증거입니다. 병목 상황에서 스레드 덤프를 떠보면, 많은 요청이 커넥션 획득에서 대기 중인 패턴이 보입니다.

가상스레드 환경에서는 덤프가 길어질 수 있으니, 특정 상태를 필터링하는 접근이 좋습니다.

  • 대기 스택에 Hikari 관련 클래스가 반복적으로 등장
  • 예: com.zaxxer.hikari.pool.HikariPool.getConnection 같은 프레임

리눅스에서 덤프를 뜨는 예:

jcmd $(pgrep -f your-app.jar) Thread.print > thread-dump.txt

덤프에서 “많은 스레드가 동일 지점에서 대기”하면, 그 지점이 병목입니다. 가상스레드라도 동일합니다. 다만 가상스레드는 수가 많아 덤프가 방대하므로, 병목 재현 시점에 짧게 여러 번 떠서 비교하는 방식이 실전에서 유용합니다.

6) JFR로 풀 대기와 DB 호출을 한 번에 보기

운영/스테이징에서 JFR(Java Flight Recorder)은 강력합니다.

  • 가상스레드 스케줄링, 블로킹, 락 대기
  • JDBC 호출 시간
  • 스레드 상태 전환

JFR 시작 예:

jcmd $(pgrep -f your-app.jar) JFR.start name=profile settings=profile duration=120s filename=recording.jfr

JFR에서 확인 포인트:

  • JDBC 관련 이벤트에서 특정 쿼리가 길게 잡히는지
  • 애플리케이션이 DB 커넥션을 획득하기까지 대기하는 시간이 관측되는지
  • 락/모니터 대기가 늘어나는지(풀 내부 락 경합, 애플리케이션 동기화 등)

JFR은 “풀 대기인지, 쿼리 자체가 느린지, 트랜잭션이 길어서 커넥션이 안 돌아오는지”를 분해하는 데 도움이 됩니다.

7) 병목 원인 분해: 풀 크기만 늘리면 해결될까

결론부터 말하면, 풀 크기 증가는 임시 처방일 때가 많습니다. 아래 체크리스트로 원인을 분해하세요.

7.1 커넥션을 오래 쥐는 트랜잭션(가장 흔함)

  • 트랜잭션 범위가 넓어서 커넥션을 오래 점유
  • 외부 API 호출/파일 I/O/복잡한 계산을 트랜잭션 안에서 수행

안티패턴 예:

@Transactional
public void placeOrder(OrderRequest req) {
  // DB 커넥션을 잡은 상태에서
  paymentClient.charge(req); // 외부 호출이 길어지면 커넥션 점유가 길어짐
  orderRepository.save(...);
}

개선 방향:

  • 외부 호출을 트랜잭션 밖으로 빼거나
  • 트랜잭션을 더 작은 단위로 분리
  • 꼭 필요한 구간만 트랜잭션 적용

7.2 느린 쿼리/인덱스 미스

풀을 늘려도 쿼리가 느리면 DB가 더 빨리 포화됩니다.

  • p95가 아니라 p99가 튀는 경우, 특정 쿼리/플랜 흔들림 가능
  • 락 경합(특히 업데이트)으로 대기 시간이 늘어날 수 있음

이때는 DB 관측(슬로우 쿼리 로그, 실행 계획, 락 대기)을 같이 봐야 합니다.

7.3 풀 크기와 DB의 처리 능력 불일치

풀을 늘리면 좋아 보이지만, DB의 CPU/IO가 받쳐주지 않으면 오히려 악화됩니다.

  • DB CPU가 이미 높다면 커넥션을 늘려도 컨텍스트 스위칭/락 경합만 증가
  • Postgres/MySQL의 설정(최대 커넥션, 워커 수, 버퍼)과 함께 봐야 함

실무 팁:

  • 풀 크기 조정은 “DB가 처리 가능한 동시 쿼리 수”를 기준으로 잡습니다.
  • 애플리케이션 인스턴스 수가 늘면, 인스턴스당 풀 크기는 더 보수적으로 잡아야 합니다.

8) 진단을 재현 가능하게 만드는 부하 테스트 시나리오

가상스레드 적용 전후를 비교하려면, 부하 테스트가 “DB풀 병목을 드러내는 형태”여야 합니다.

  • 단순한 GET 캐시 응답이 아니라 DB 접근이 포함된 엔드포인트
  • 읽기/쓰기 비율이 실제와 유사
  • 커넥션 점유 시간이 길어지는 케이스(조인/정렬/락)도 포함

k6 예시:

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  scenarios: {
    load: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 50 },
        { duration: '2m', target: 200 },
        { duration: '1m', target: 400 },
      ],
    },
  },
};

export default function () {
  http.get('https://your-service/api/orders?userId=123');
  sleep(0.2);
}

부하를 올릴 때 hikaricp_connections_pendingdb.connection.acquire(커스텀 타이머) p95/p99를 같이 보면, “언제부터 풀 대기가 시작되는지” 임계점을 찾을 수 있습니다.

9) 해결 전략: 우선순위대로 정리

9.1 1순위: 트랜잭션 경계 축소

  • 커넥션 점유 시간을 줄이면, 풀 크기를 늘리지 않아도 처리량이 오릅니다.
  • 가상스레드 환경에서는 특히 효과가 큽니다(요청 동시성이 높아질수록 점유 시간의 영향이 커짐).

9.2 2순위: 느린 쿼리 제거

  • 인덱스 추가/쿼리 재작성/불필요한 N+1 제거
  • 쓰기 락 경합이 있으면 업데이트 패턴(순서, 배치, 키 설계)을 재검토

9.3 3순위: 풀 설정 튜닝(과신 금지)

대표 파라미터:

  • maximumPoolSize
  • connectionTimeout
  • maxLifetime, keepaliveTime(네트워크/방화벽 환경에 따라)

application.yml 예시:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 2000
      max-lifetime: 1800000

주의:

  • connectionTimeout 을 너무 길게 두면, 병목이 “타임아웃 실패” 대신 “긴 대기”로 바뀌어 장애 감지가 늦어질 수 있습니다.
  • 풀을 키울수록 DB에 동시 부하가 커지므로, DB 지표(CPU, 락 대기, IOPS)와 같이 조정해야 합니다.

9.4 4순위: 요청 레벨에서 동시성 제한(벌크헤드)

가상스레드로 인해 웹 레이어 동시성이 과도하게 커졌다면, DB 접근 구간에 세마포어로 상한을 두는 것도 방법입니다.

import java.util.concurrent.Semaphore;

public class DbBulkhead {
  private final Semaphore semaphore = new Semaphore(50);

  public <T> T call(java.util.concurrent.Callable<T> action) throws Exception {
    semaphore.acquire();
    try {
      return action.call();
    } finally {
      semaphore.release();
    }
  }
}

이 방식은 “DB가 감당 가능한 동시 요청 수”를 애플리케이션에서 강제해, 풀 대기 폭발과 꼬리 지연을 줄이는 데 유용합니다.

10) 운영 체크리스트(한 장 요약)

  • hikaricp_connections_active 가 상한에 붙는가
  • hikaricp_connections_pending 이 트래픽과 함께 증가하는가
  • 커넥션 획득 시간 메트릭(db.connection.acquire) p95/p99가 튀는가
  • 스레드 덤프에서 Hikari 커넥션 획득 대기가 반복되는가
  • JFR에서 JDBC 호출이 긴지, 커넥션 대기가 긴지 구분되는가
  • 트랜잭션 안에 외부 호출/긴 작업이 섞여 있지 않은가
  • 풀을 늘리기 전에 느린 쿼리/락 경합을 먼저 제거했는가

관측과 재현을 자동화해두면, 성능 회귀를 배포 전에 잡을 수 있습니다. 배포 파이프라인에서 인증/권한 이슈로 관측이 막히는 경우가 많은데, AWS 연동을 쓴다면 GitHub Actions OIDC로 AWS AssumeRole 403 해결 같은 글이 운영 효율에 도움됩니다.

마무리

Spring Boot 3 가상스레드는 “동시성의 비용”을 낮춰주지만, DB 커넥션 풀은 여전히 제한 자원입니다. 가상스레드 적용 후 성능이 기대만큼 나오지 않는다면, 가장 먼저 HikariCP의 active/pending 지표와 커넥션 획득 시간을 확인하세요. 그 다음 스레드 덤프와 JFR로 병목을 증명하고, 풀 크기 조정에 앞서 트랜잭션 경계와 쿼리 성능을 개선하는 것이 정석입니다.