Published on

Spring Boot 3 가상스레드 적용 시 트랜잭션·DB풀 튜닝

Authors

서버 애플리케이션에서 가상 스레드(virtual thread)를 도입하는 가장 큰 이유는 동시 요청 수가 증가해도 플랫폼 스레드(커널 스레드) 고갈 없이 블로킹 I/O를 더 많이 처리하기 위해서입니다. Spring Boot 3(정확히는 Spring Framework 6 / JDK 21 조합)에서는 설정만으로도 톰캣 요청 처리 스레드를 가상 스레드로 바꿀 수 있어 진입 장벽이 낮습니다.

하지만 가상 스레드가 “성능을 자동으로 올려주는 마법”은 아닙니다. 오히려 그동안 플랫폼 스레드 수가 사실상의 동시성 상한선 역할을 하며 가려주던 문제들이, 가상 스레드 환경에서는 그대로 노출됩니다.

  • 트랜잭션을 오래 잡고 있는 코드(불필요한 @Transactional 범위)
  • DB 커넥션 풀(HikariCP) 사이즈/타임아웃 부적절
  • 외부 API 호출을 트랜잭션 내부에서 수행
  • JPA N+1로 인한 쿼리 폭증

이 글에서는 Spring Boot 3에서 가상 스레드를 켰을 때 트랜잭션 경계와 DB풀을 어떻게 튜닝해야 하는지를 실무 관점에서 정리합니다.

가상 스레드 도입 시 바뀌는 병목의 위치

가상 스레드는 “스레드 생성/대기 비용”을 크게 낮춰주지만, DB 커넥션은 여전히 비싸고 유한한 자원입니다.

기존(플랫폼 스레드) 모델에서는:

  • 요청 동시성 ≈ 톰캣 스레드 수(예: 200)
  • DB풀(예: 20~50)이 상대적으로 작아도, 요청이 스레드 큐에서 대기하며 자연스럽게 완충

가상 스레드 모델에서는:

  • 요청 동시성 상한이 크게 늘어남(수천~수만 가상 스레드)
  • DB 커넥션 풀이 즉시 병목이 됨
  • 커넥션 대기 시간이 늘고, 타임아웃/스파이크가 더 자주 발생

즉, 가상 스레드 적용의 핵심은 “스레드 튜닝”이 아니라 트랜잭션 설계 + 커넥션 풀/쿼리 튜닝으로 무게 중심이 이동한다는 점입니다.

Spring Boot 3에서 가상 스레드 활성화

가장 단순한 시작점은 다음 설정입니다.

spring:
  threads:
    virtual:
      enabled: true

이 설정은(내장 톰캣 기준) 요청 처리에 가상 스레드를 사용하도록 유도합니다. 다만, 아래를 반드시 확인하세요.

  • 실행 JDK가 21 이상인지
  • 관측(메트릭/로그)에서 플랫폼 스레드 고갈은 줄었는데 DB 대기/타임아웃이 늘지는 않는지

트랜잭션 경계: “짧게, 작게, DB 작업만”

가상 스레드 환경에서 가장 먼저 손봐야 할 것은 @Transactional 범위입니다. 커넥션 풀 병목은 대부분 “트랜잭션이 길어서 커넥션을 오래 점유”할 때 폭발합니다.

나쁜 패턴: 트랜잭션 안에서 외부 I/O 수행

@Service
@RequiredArgsConstructor
public class OrderService {
  private final OrderRepository orderRepository;
  private final PaymentClient paymentClient;

  @Transactional
  public void placeOrder(PlaceOrderCommand cmd) {
    Order order = orderRepository.save(Order.create(cmd));

    // 외부 API 호출(네트워크 I/O)을 트랜잭션 내부에서 수행
    paymentClient.requestPayment(order.getId(), cmd.amount());

    order.markPaid();
  }
}

이 코드는 가상 스레드 여부와 무관하게 좋지 않지만, 가상 스레드에서는 동시 요청이 더 많이 들어오므로 커넥션 점유 시간이 길어져 풀 고갈이 더 빨리 발생합니다.

개선 패턴 1: 트랜잭션을 DB 작업에만 사용

@Service
@RequiredArgsConstructor
public class OrderService {
  private final OrderRepository orderRepository;
  private final PaymentClient paymentClient;

  public void placeOrder(PlaceOrderCommand cmd) {
    Long orderId = createOrder(cmd);      // 짧은 트랜잭션
    paymentClient.requestPayment(orderId, cmd.amount()); // 트랜잭션 밖
    markPaid(orderId);                    // 짧은 트랜잭션
  }

  @Transactional
  protected Long createOrder(PlaceOrderCommand cmd) {
    return orderRepository.save(Order.create(cmd)).getId();
  }

  @Transactional
  protected void markPaid(Long orderId) {
    Order order = orderRepository.findById(orderId)
        .orElseThrow();
    order.markPaid();
  }
}
  • 커넥션을 잡는 시간(=트랜잭션 시간)을 최소화
  • 외부 I/O는 트랜잭션 밖으로 이동

단, 이 방식은 중간 실패에 대한 보상 로직(결제 성공했는데 markPaid 실패 등)이 필요할 수 있습니다. 이런 경우에는 다음 패턴이 더 안전합니다.

개선 패턴 2: Outbox/Saga로 정합성 확보

  • 트랜잭션 안에서는 “주문 생성 + 이벤트(outbox) 기록”만 수행
  • 별도 워커가 outbox를 읽어 결제 요청
  • 결제 결과를 다시 DB에 반영

이 패턴은 커넥션 점유 시간을 짧게 유지하면서도, 외부 시스템과의 연동을 견고하게 만듭니다.

DB 커넥션 풀(HikariCP) 튜닝: 가상 스레드라고 풀을 무작정 키우면 안 됨

가상 스레드 환경에서 흔한 오해는 “동시성이 늘었으니 DB풀도 크게 늘리자”입니다. 하지만 DB는 CPU/IO/락/버퍼 등 물리적 한계가 있어, 풀을 과도하게 키우면:

  • DB의 컨텍스트 스위칭/락 경합 증가
  • 쿼리 지연이 늘어 전체 처리량이 떨어짐
  • 애플리케이션은 커넥션은 얻었지만 DB가 느려져 타임아웃 증가

기본 원칙

  1. 풀 사이즈는 DB가 감당 가능한 동시 쿼리 수에 맞춘다.
  2. 가상 스레드는 “대기 스레드 비용”을 줄일 뿐, DB 처리량을 늘려주지 않는다.
  3. 풀 대기(획득) 시간을 명시적으로 관측하고, 타임아웃은 “빠른 실패”에 가깝게 설계한다.

권장 설정 예시(출발점)

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 30
      connection-timeout: 1000   # 커넥션 획득 대기(1s)
      validation-timeout: 500
      idle-timeout: 600000
      max-lifetime: 1800000
      leak-detection-threshold: 0
  • connection-timeout을 너무 길게(예: 30s) 두면, 가상 스레드가 대량으로 “커넥션 대기”에 들어가면서 지연이 폭발하고 장애가 길어집니다.
  • 0.5~2초 사이로 시작해, 상위 레이어(HTTP 타임아웃/재시도 정책)와 함께 조정하는 편이 안정적입니다.

풀 사이즈를 정하는 실전 접근

정답 공식은 없지만, 다음 순서가 현실적입니다.

  1. DB의 CPU 사용률, active sessions, 락 대기, slow query를 본다.
  2. 애플리케이션에서 Hikari 메트릭을 본다.
    • hikaricp.connections.active
    • hikaricp.connections.pending
    • hikaricp.connections.timeout
  3. pending이 자주 증가하고 timeout이 발생한다면
    • (a) 트랜잭션/쿼리를 줄여 커넥션 점유 시간을 먼저 줄이고
    • (b) DB가 여유가 있을 때만 pool size를 소폭 증가

가상 스레드에서는 (a)를 건너뛰고 (b)만 하면, DB가 먼저 무너집니다.

트랜잭션 격리/락: 동시성 증가가 락 경합을 키운다

가상 스레드로 동시 요청이 늘면, 동일 레코드/인덱스를 두고 경쟁하는 요청이 많아져 락 경합이 늘 수 있습니다.

  • 재고 차감, 쿠폰 사용, 포인트 차감 같은 “핫 레코드” 업데이트
  • SELECT ... FOR UPDATE 남발
  • 불필요하게 높은 격리 수준(예: SERIALIZABLE)

이 경우 풀을 늘려도 해결되지 않습니다. 오히려 동시에 더 많은 트랜잭션이 락을 잡으려 하므로 지연이 악화됩니다.

대안은 다음 중 하나입니다.

  • 낙관적 락(@Version) + 재시도
  • 업데이트를 단일 SQL로 원자화(조건부 update)
  • 핫 키를 분산(샤딩/버킷)
  • 큐 기반 직렬화(특정 키 단위로 처리)

JPA 사용 시: N+1은 가상 스레드에서 더 치명적

가상 스레드가 요청을 더 많이 동시에 처리하면, N+1 문제는 “조금 느림”이 아니라 “DB를 터뜨리는 증폭기”가 됩니다. 요청당 쿼리 수가 많아지면 커넥션 점유 시간도 길어져 풀 고갈로 이어집니다.

N+1을 빠르게 잡는 방법은 별도 글에 정리해두었습니다: Spring Boot 3+ JPA N+1 즉시 잡는 7가지

가상 스레드 전환 전/후에 반드시:

  • 엔드포인트별 쿼리 수
  • p95/p99 응답 시간
  • Hikari pending/timeout

을 같이 비교하세요.

관측 포인트: “스레드”보다 “커넥션 대기”를 봐야 한다

가상 스레드 적용 후 장애 양상은 종종 다음처럼 바뀝니다.

  • 예전: 톰캣 스레드 고갈 → 요청 대기열 증가
  • 이후: DB 커넥션 획득 대기 → 타임아웃/지연 급증

운영에서 자주 겪는 형태는 “애플리케이션은 살아있고 CPU도 널널한데 응답이 느려짐”입니다. 이때는 DB풀 대기/락/슬로우쿼리를 의심해야 합니다.

쿠버네티스 환경이라면, 지연이 길어지면서 readiness/liveness 실패 → 재시작 루프로 이어질 수 있습니다. 증상별 점검은 다음 체크리스트가 도움이 됩니다: K8s CrashLoopBackOff 원인별 진단·해결 체크리스트

실전 체크리스트: 가상 스레드 적용 전후로 이것만은 확인

1) 트랜잭션 범위 점검

  • 외부 API 호출/파일 I/O/대기 로직이 트랜잭션 내부에 있는가?
  • @Transactional(readOnly = true)가 읽기 API에 적용되어 있는가?
  • 불필요한 전파(propagation)로 트랜잭션이 넓어지지 않는가?

2) 커넥션 풀 튜닝

  • connectionTimeout을 짧게 두고 빠르게 실패하도록 했는가?
  • 풀 사이즈 증가는 DB 지표를 근거로 소폭만 했는가?
  • Hikari pending/timeout 메트릭이 대시보드에 있는가?

3) 쿼리/인덱스/락

  • 슬로우쿼리 로그와 상관관계가 있는가?
  • 핫 레코드 업데이트로 락 대기가 늘지 않는가?
  • N+1로 요청당 쿼리 수가 과도하지 않은가?

4) 타임아웃/재시도 정책

  • HTTP 서버 타임아웃 < LB 타임아웃 < 클라이언트 타임아웃 순서가 정리되어 있는가?
  • DB 커넥션 획득 타임아웃이 전체 요청 타임아웃보다 과도하게 길지 않은가?

마무리: 가상 스레드는 “DB를 더 잘 쓰게” 만들 때 효과가 난다

Spring Boot 3에서 가상 스레드를 켜면 동시성 자체는 크게 늘지만, 그 결과로 트랜잭션이 길거나 쿼리가 많은 서비스는 DB풀 병목이 더 빨리 발생합니다. 따라서 성공적인 적용의 핵심은:

  • 트랜잭션을 짧게 유지하고(커넥션 점유 시간 최소화)
  • N+1/슬로우쿼리/락 경합을 줄이며(요청당 DB 비용 절감)
  • HikariCP를 “DB가 감당 가능한 범위”에서 튜닝하고(풀 사이즈는 만능이 아님)
  • 커넥션 대기/타임아웃을 관측해 빠르게 피드백 루프를 돌리는 것

입니다.

가상 스레드를 도입하기 전, 먼저 현재 서비스의 요청당 쿼리 수 / 평균 트랜잭션 시간 / 커넥션 풀 대기 시간을 수치로 확보해두면, 전환 후 효과와 리스크를 훨씬 명확하게 판단할 수 있습니다.