Published on

Spring Boot 3 가상스레드로 DB 병목 완화하기

Authors

서버가 느려졌을 때 가장 흔한 증상은 CPU가 남는데도 응답 시간이 늘어나는 상황입니다. 대개 원인은 DB I/O 대기인데, 이때 전통적인 플랫폼 스레드 기반 요청 처리 모델은 스레드가 DB 응답을 기다리며 묶이는 동안 동시 처리량이 급격히 떨어집니다. Spring Boot 3는 JDK 21 기반에서 가상스레드(virtual thread)를 비교적 쉽게 도입할 수 있어, "대기하는 동안 스레드를 아껴서" 동시성을 끌어올리는 데 도움을 줍니다.

다만 가상스레드는 DB 자체를 빠르게 만들지 않습니다. 쿼리가 느리거나 커넥션 풀이 너무 작으면 병목은 그대로입니다. 가상스레드는 주로 다음 문제를 완화합니다.

  • 요청 스레드가 DB 호출에서 블로킹되어 스레드 풀이 고갈되는 문제
  • 트래픽 스파이크에서 스레드 생성 및 컨텍스트 스위칭 비용이 커지는 문제
  • 타임아웃이 꼬리를 물며 연쇄 장애로 번지는 문제

아래에서는 Spring Boot 3에서 가상스레드를 적용하는 방법, DB 병목의 본질을 해치지 않으면서도 체감 성능을 올리는 튜닝 포인트, 그리고 흔한 함정을 정리합니다.

DB 병목이 "스레드 병목"으로 번지는 구조

전형적인 MVC(서블릿) 기반 서버에서 요청 하나는 보통 플랫폼 스레드 하나가 처리합니다. 요청 처리 중 DB 호출이 발생하면 JDBC 호출은 블로킹이고, 해당 스레드는 DB 응답이 올 때까지 대기합니다.

  • 동시 요청 수가 증가
  • 스레드가 DB 대기 상태로 누적
  • 톰캣 워커 스레드가 고갈
  • 큐잉 지연이 증가하고 타임아웃이 늘어남

여기서 중요한 점은 "DB가 느려서"가 아니라 "DB를 기다리는 동안 플랫폼 스레드를 비싼 자원으로 묶어두는 구조"가 문제를 증폭시킨다는 것입니다. 가상스레드는 블로킹 호출을 만나면 캐리어 스레드에서 언마운트되어, 캐리어 스레드가 다른 가상스레드를 실행할 수 있게 합니다. 즉, 같은 하드웨어에서 더 많은 동시 요청을 "대기 상태로" 안전하게 수용할 수 있습니다.

가상스레드가 해결해주는 것과 못해주는 것

가상스레드 도입 전 기대치를 정확히 잡아야 합니다.

가상스레드가 잘하는 것

  • 블로킹 I/O가 많은 서버에서 동시 처리량과 tail latency 개선
  • 스레드 고갈로 인한 대기열 증가 완화
  • 코드 구조를 비동기 스타일로 바꾸지 않고도 높은 동시성 확보

가상스레드가 못하는 것

  • 느린 쿼리 자체를 빠르게 만들기
  • 부족한 DB 커넥션 수를 마법처럼 늘리기
  • 락 경합, 인덱스 부재, N+1 같은 근본 문제 해결

특히 JPA를 쓰는 서비스라면 N+1이 숨어 있으면 가상스레드로 동시성만 올려서 DB를 더 세게 두드리는 결과가 나올 수 있습니다. 먼저 쿼리 수를 줄이는 최적화가 우선입니다. 관련해서는 Spring Boot JPA N+1 해결 - fetch join·EntityGraph도 함께 점검하는 것을 권합니다.

Spring Boot 3에서 가상스레드 켜기

전제는 JDK 21 이상입니다. Spring Boot 3.2 이상에서는 설정만으로도 서블릿 기반 요청 처리를 가상스레드로 전환할 수 있습니다.

1) Gradle 설정 예시

build.gradle에서 툴체인 또는 소스/타깃을 21로 맞춥니다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.6'
    id 'io.spring.dependency-management' version '1.1.6'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'org.postgresql:postgresql'
}

2) application.yml에서 가상스레드 활성화

spring:
  threads:
    virtual:
      enabled: true

이 설정은 Spring MVC(서블릿) 요청 처리에 가상스레드를 사용하도록 전환합니다. 즉, 요청당 스레드 모델을 유지하되, 그 스레드가 가상스레드가 됩니다.

3) 동작 확인용 간단한 컨트롤러

가상스레드가 적용되면 요청 처리 스레드 이름에서 힌트를 얻을 수 있습니다.

@RestController
public class ThreadCheckController {

    @GetMapping("/thread")
    public String thread() {
        Thread t = Thread.currentThread();
        return "name=" + t.getName() + ", virtual=" + t.isVirtual();
    }
}

운영에서는 이름만으로 단정하지 말고, t.isVirtual() 같은 방식으로 확실히 확인하는 편이 좋습니다.

DB 병목 완화의 핵심: 커넥션 풀과 동시성의 균형

가상스레드를 켜면 동시 요청을 더 많이 수용할 수 있습니다. 하지만 JDBC는 결국 커넥션 풀(HikariCP)에서 커넥션을 빌려야 쿼리를 실행합니다. 따라서 다음이 중요합니다.

  • 가상스레드 수가 늘어도 DB 커넥션 수는 제한되어 있음
  • 커넥션 풀이 작으면 "커넥션 대기"가 새 병목이 됨
  • 커넥션 풀이 지나치게 크면 DB가 스로틀링되거나 락 경합이 커짐

HikariCP 기본 튜닝 포인트

아래는 출발점으로 볼 만한 설정입니다. 숫자는 서비스/DB 스펙에 따라 달라집니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 2000
      validation-timeout: 1000
      max-lifetime: 1800000
      keepalive-time: 300000
  • connection-timeout을 너무 길게 두면, 커넥션 고갈 시 요청이 오래 매달리며 지연이 전파됩니다.
  • 가상스레드 환경에서는 "빨리 실패" 전략이 더 중요해집니다. 대기열이 무한히 쌓이기 쉬워서입니다.

풀 사이즈 결정의 현실적인 가이드

  • DB가 처리 가능한 동시 쿼리 수를 먼저 파악합니다.
  • 쿼리 평균 시간, p95 시간, 락 경합을 함께 봅니다.
  • 애플리케이션 인스턴스 수가 늘어날수록 총 커넥션 수는 선형으로 증가합니다.

예를 들어 인스턴스 10대에 maximum-pool-size 30이면 DB는 최대 300 커넥션을 받습니다. 이 숫자를 DB가 감당 가능한지부터 확인해야 합니다.

"가상스레드 켰는데 더 느려졌다"의 흔한 원인

가상스레드는 스레드 비용을 낮춰주지만, 병목을 숨기지는 못합니다. 오히려 병목을 더 빨리 드러내기도 합니다.

1) N+1, 과도한 쿼리 발행

동시성이 올라가면 같은 시간에 더 많은 쿼리가 쏟아져 DB가 먼저 무너질 수 있습니다. 특히 목록 API에서 연관 엔티티를 지연 로딩하면 요청당 쿼리 수가 폭증합니다.

  • fetch join
  • @EntityGraph
  • 배치 사이즈

같은 기법으로 쿼리 수를 줄여야 합니다. 자세한 내용은 앞서 언급한 N+1 글을 참고하세요.

2) 트랜잭션 범위가 너무 넓음

트랜잭션이 길면 커넥션을 오래 점유합니다. 가상스레드로 요청을 더 많이 받으면 커넥션 점유 시간이 누적되어 풀 고갈이 빨라집니다.

  • 트랜잭션은 DB 작업 구간으로만 좁히기
  • 외부 API 호출을 트랜잭션 안에서 하지 않기
  • 불필요한 readOnly=false 제거

3) 타임아웃 계층이 정리되지 않음

클라이언트, ALB, 애플리케이션, JDBC, DB 각각의 타임아웃이 뒤엉키면 장애 시나리오에서 리소스가 오래 붙잡힙니다. 특히 ALB 계층의 타임아웃과 앱 타임아웃이 어긋나면 "사용자는 끊었는데 서버는 계속 처리" 같은 낭비가 생깁니다. 인프라 쪽 증상이 있다면 AWS ALB 502/504 10분 타임아웃 진단 가이드도 같이 점검하세요.

@Async, 스케줄러, 커스텀 Executor에서의 가상스레드 적용

spring.threads.virtual.enabled은 주로 웹 요청 처리에 적용됩니다. 하지만 서비스 내부의 비동기 작업이나 배치가 여전히 플랫폼 스레드 풀을 쓴다면, 병목 구간이 남을 수 있습니다.

가상스레드 기반 Executor 빈 등록

@Configuration
public class VirtualThreadExecutorConfig {

    @Bean
    public Executor virtualThreadExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

@Async에 적용

@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

    private final Executor virtualThreadExecutor;

    public AsyncConfig(Executor virtualThreadExecutor) {
        this.virtualThreadExecutor = virtualThreadExecutor;
    }

    @Override
    public Executor getAsyncExecutor() {
        return virtualThreadExecutor;
    }
}

주의할 점은, DB 작업을 @Async로 무작정 병렬화하면 커넥션 풀을 더 빨리 고갈시킬 수 있다는 것입니다. 가상스레드는 "병렬 처리량"을 무한히 늘리는 도구가 아니라, "블로킹 대기 비용"을 줄이는 도구에 가깝습니다.

관측 가능성: 병목이 DB인지 커넥션 풀인지 구분하기

가상스레드를 켠 뒤에는 "스레드 대기"가 줄어드는 대신 "커넥션 대기"가 눈에 띄는 병목으로 바뀌는 경우가 많습니다. 따라서 다음 지표를 반드시 같이 봐야 합니다.

  • HikariCP 풀 메트릭: active, idle, pending(대기)
  • DB 메트릭: 커넥션 수, 락 대기, slow query, CPU/IO
  • 애플리케이션 메트릭: 요청 p95/p99, 타임아웃 비율, 에러율

Spring Boot Actuator와 Micrometer를 쓰면 Hikari 메트릭을 쉽게 수집할 수 있습니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
}
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

실전 적용 체크리스트

가상스레드로 "DB 병목을 해결"한다고 말하려면, 정확히는 "DB 대기로 인한 스레드 병목을 완화"하는 접근이어야 합니다. 아래 순서로 적용하면 실패 확률이 낮습니다.

  1. 느린 쿼리, N+1, 불필요한 트랜잭션 범위부터 제거
  2. HikariCP 풀 사이즈와 타임아웃을 현실적으로 재설계
  3. Spring Boot에서 가상스레드 활성화 후, 부하 테스트로 p95/p99 확인
  4. 커넥션 대기(pending)가 늘면 풀 조정 또는 쿼리/락 개선
  5. 타임아웃 계층(ALB, 앱, JDBC)을 정합성 있게 맞춤

마무리

Spring Boot 3의 가상스레드는 블로킹 I/O 중심의 전통적인 서버 코드에 큰 구조 변경 없이도 동시성을 높일 수 있는 강력한 옵션입니다. 하지만 효과가 나는 지점은 "DB를 더 빠르게"가 아니라 "DB를 기다리는 동안 스레드를 효율적으로"입니다.

가상스레드를 켜기 전에 쿼리 수와 트랜잭션 범위를 먼저 정리하고, 켠 이후에는 커넥션 풀 대기와 타임아웃 전파를 관측하면서 조정하면, 트래픽 스파이크에서도 더 안정적인 응답 시간을 만들 수 있습니다.