Published on

Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기

Authors

서버 스레드가 부족해 병목이 생기던 서비스를 Spring Boot 3의 가상스레드로 전환하면, CPU나 스레드가 아니라 DB 커넥션 풀이 먼저 고갈되는 현상을 자주 만나게 됩니다. 겉으로는 처리량이 늘어날 것 같은데, 실제로는 HikariPool-1 - Connection is not available 같은 오류가 늘거나 응답 지연이 급격히 커집니다.

이 글은 “가상스레드를 켰더니 왜 DB가 먼저 죽는가”를 원인부터 설명하고, Spring Boot 3 기준으로 커넥션 고갈을 막는 설정과 코드 레벨의 패턴을 정리합니다. 핵심은 간단합니다.

  • 가상스레드는 동시 요청 수를 크게 늘릴 수 있다
  • JDBC는 블로킹이며, DB 커넥션 수는 제한되어 있다
  • 따라서 “스레드가 늘어난 만큼 DB 동시성도 늘어난다”는 착각이 커넥션 고갈로 이어진다

가상스레드가 커넥션 고갈을 더 잘 드러내는 이유

전통적인 플랫폼 스레드 기반 Tomcat에서는 요청 처리 스레드 수가 사실상 동시성 상한선이었습니다. 예를 들어 maxThreads=200이면 동시에 200개 정도의 요청만 실제로 DB까지 들어가고, 나머지는 큐에서 대기합니다.

하지만 가상스레드를 켜면(예: Spring Boot 3.2+), 요청당 스레드를 훨씬 많이 만들 수 있고, 블로킹 I/O(JDBC 포함)에서 스레드가 “가볍게” 대기합니다. 결과적으로 다음이 발생합니다.

  • 애플리케이션은 더 많은 요청을 동시에 처리하려고 시도한다
  • 각 요청이 JDBC 쿼리를 치면 커넥션이 필요하다
  • 커넥션 풀 크기는 그대로라서 풀이 먼저 바닥난다

즉, 가상스레드는 문제를 만들었다기보다 “기존에 스레드 제한으로 숨겨졌던 DB 병목”을 노출시키는 경우가 많습니다.

증상 체크리스트: 진짜 커넥션 고갈인지 확인

아래 중 2개 이상이면 커넥션 풀이 병목일 확률이 큽니다.

  • 로그에 Connection is not available, request timed out after ...ms 발생
  • 응답 지연이 DB 호출 구간에서 급격히 증가
  • DB CPU는 낮은데 커넥션 수가 상한에 근접
  • 애플리케이션 스레드는 여유가 있는데 요청이 대기
  • APM에서 HikariPool 대기 시간이 늘어남

추가로, 운영에서 “특정 구간에만 폭주”라면 자동 확장과 결합되어 더 심해질 수 있습니다. 오토스케일링이 흔들릴 때는 트래픽 유입이 순간적으로 커지면서 DB 커넥션이 먼저 고갈되기도 합니다. 관련해서는 EKS HPA 폭주를 KEDA 큐기반 오토스케일링으로 안정화 글의 관점(큐 기반 완충)이 서버-DB 사이에도 그대로 적용됩니다.

Spring Boot 3에서 가상스레드 활성화 방법과 주의점

Spring Boot 3.2+에서는 설정 한 줄로 가상스레드 기반 실행기를 사용할 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

이 설정은 주로 다음에 영향을 줍니다.

  • @Async 등에서 사용하는 TaskExecutor
  • (구성에 따라) 웹 요청 처리 스레드 모델

주의할 점은 “가상스레드를 켠다”가 “DB가 더 많이 처리한다”가 아니라는 것입니다. DB는 여전히 제한된 커넥션과 제한된 동시 쿼리 처리 능력을 가집니다.

해결 전략 1: 커넥션 풀 크기부터 올리면 안 되는 이유

가장 흔한 오해는 maximumPoolSize를 크게 올리면 해결된다는 것입니다. 물론 일정 범위에서는 도움이 됩니다. 하지만 무작정 올리면 아래 부작용이 생깁니다.

  • DB가 동시에 처리할 수 있는 쿼리 수를 초과해 락/컨텍스트 스위칭 증가
  • DB CPU 급등, 쿼리 지연 증가로 전체 처리량 감소
  • 애플리케이션 인스턴스가 늘면 총 커넥션 수가 폭발

예를 들어 파드 10개에 maximumPoolSize=50이면 이론상 500 커넥션이 DB에 붙습니다. DB가 그 수를 감당하지 못하면 전체가 느려집니다.

따라서 “풀을 늘리기”는 마지막 단계로 두고, 먼저 동시성을 제어하고 커넥션 점유 시간을 줄이는 게 우선입니다.

해결 전략 2: 핵심은 DB 동시성 제한(백프레셔)

가상스레드 환경에서는 애플리케이션이 매우 많은 요청을 동시에 DB로 밀어 넣을 수 있으므로, 애플리케이션 내부에서 DB 접근 동시성을 제한하는 장치가 필요합니다.

세마포어로 DB 접근 동시성 제한

가장 단순하고 효과적인 방법 중 하나는 세마포어로 “DB에 들어갈 수 있는 동시 작업 수”를 제한하는 것입니다.

import java.util.concurrent.Semaphore;

import org.springframework.stereotype.Component;

@Component
public class DbConcurrencyLimiter {
    private final Semaphore semaphore = new Semaphore(30); // DB 동시 접근 상한

    public <T> T execute(DbSupplier<T> supplier) {
        boolean acquired = false;
        try {
            semaphore.acquire();
            acquired = true;
            return supplier.get();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Interrupted while waiting DB slot", e);
        } finally {
            if (acquired) {
                semaphore.release();
            }
        }
    }

    @FunctionalInterface
    public interface DbSupplier<T> {
        T get();
    }
}

서비스에서 DB 호출을 감싸면, 가상스레드가 수천 개 생겨도 DB로 동시에 들어가는 작업은 30개로 제한됩니다.

import org.springframework.stereotype.Service;

@Service
public class OrderService {
    private final DbConcurrencyLimiter limiter;
    private final OrderRepository repository;

    public OrderService(DbConcurrencyLimiter limiter, OrderRepository repository) {
        this.limiter = limiter;
        this.repository = repository;
    }

    public Order findById(long id) {
        return limiter.execute(() -> repository.findById(id)
            .orElseThrow());
    }
}

이 방식의 장점은 “커넥션 풀 타임아웃으로 실패”하는 대신 “애플리케이션에서 대기”하게 만들어 오류율을 낮추고, DB를 보호한다는 점입니다.

HikariCP 풀 크기와 함께 맞추기

세마포어 값은 보통 다음을 기준으로 잡습니다.

  • maximumPoolSize보다 작거나 같게
  • DB가 안정적으로 처리 가능한 동시 쿼리 수에 맞게

예시 설정입니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 2000
      validation-timeout: 1000
      max-lifetime: 1800000
      leak-detection-threshold: 30000
  • connection-timeout을 너무 길게 두면, 고갈 시 요청이 오래 매달려 장애가 커집니다.
  • leak-detection-threshold는 커넥션 누수를 빠르게 찾는 데 도움이 됩니다.

해결 전략 3: 트랜잭션 경계를 줄여 커넥션 점유 시간을 단축

커넥션 고갈의 본질은 “커넥션을 너무 오래 쥐고 있음”입니다. 특히 다음 패턴이 위험합니다.

  • @Transactional 메서드 안에서 외부 API 호출
  • 트랜잭션 안에서 대용량 데이터 가공
  • 트랜잭션 범위가 웹 요청 전체를 감싸는 구조

나쁜 예: 트랜잭션 안에서 외부 호출

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PaymentService {
    private final PaymentRepository paymentRepository;
    private final PgClient pgClient;

    public PaymentService(PaymentRepository paymentRepository, PgClient pgClient) {
        this.paymentRepository = paymentRepository;
        this.pgClient = pgClient;
    }

    @Transactional
    public void pay(long orderId) {
        paymentRepository.markPaying(orderId);
        pgClient.requestApproval(orderId); // 외부 호출로 오래 블로킹될 수 있음
        paymentRepository.markPaid(orderId);
    }
}

이 코드는 외부 PG 응답이 느려지면 그 시간만큼 커넥션을 점유합니다. 가상스레드에서는 이런 요청이 대량으로 동시에 들어올 수 있어 풀이 빠르게 고갈됩니다.

개선: DB 작업과 외부 호출 분리

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PaymentService {
    private final PaymentRepository paymentRepository;
    private final PgClient pgClient;

    public PaymentService(PaymentRepository paymentRepository, PgClient pgClient) {
        this.paymentRepository = paymentRepository;
        this.pgClient = pgClient;
    }

    public void pay(long orderId) {
        markPaying(orderId);

        // 트랜잭션 밖에서 외부 호출
        pgClient.requestApproval(orderId);

        markPaid(orderId);
    }

    @Transactional
    public void markPaying(long orderId) {
        paymentRepository.markPaying(orderId);
    }

    @Transactional
    public void markPaid(long orderId) {
        paymentRepository.markPaid(orderId);
    }
}

트랜잭션을 쪼개면 커넥션 점유 시간이 짧아지고, 풀 고갈 가능성이 크게 줄어듭니다.

해결 전략 4: 커넥션 누수와 지연 쿼리를 먼저 제거

가상스레드 전환 후 “갑자기” 고갈이 시작되었다면, 기존에는 드러나지 않던 누수나 느린 쿼리가 동시성 증가로 증폭되었을 가능성이 큽니다.

Hikari leak detection 켜기

앞서 설정한 leak-detection-threshold가 대표적입니다. 예를 들어 30초 이상 반환되지 않는 커넥션이 있으면 스택트레이스를 로그로 남깁니다.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 30000

느린 쿼리 로그와 지표

  • DB의 slow query log 활성화
  • 애플리케이션에서 쿼리 타임 분포(p95, p99) 확인

느린 쿼리를 줄이면 “커넥션 점유 시간”이 줄고, 같은 풀 크기로도 더 많은 요청을 처리할 수 있습니다.

해결 전략 5: 배치성 작업과 웹 트래픽의 풀을 분리

웹 요청과 배치가 같은 커넥션 풀을 쓰면, 배치가 풀을 잠식해 웹이 죽는 일이 흔합니다. 가상스레드는 웹 동시성을 늘리므로 충돌이 더 자주 발생합니다.

멀티 데이터소스(또는 최소한 별도 풀)로 분리하는 접근이 유효합니다.

app:
  datasource:
    web:
      hikari:
        maximum-pool-size: 20
    batch:
      hikari:
        maximum-pool-size: 10

구현은 프로젝트마다 다르지만, 핵심은 “워크로드별로 DB 동시성 예산을 분리”하는 것입니다.

운영 관점 체크리스트: 가상스레드 적용 전후로 꼭 볼 것

가상스레드 전환은 성능 실험이 아니라 “리소스 병목 재배치”에 가깝습니다. 전환 전후에 아래를 함께 봐야 합니다.

  • 애플리케이션: 요청 동시성, 응답 p95/p99, 에러율
  • HikariCP: active, idle, pending threads, timeout 횟수
  • DB: 현재 커넥션 수, 락 대기, CPU, IOPS
  • 쿼리: p95/p99 latency, top N slow queries

특히 재시도 로직이 있는 서비스라면, 커넥션 타임아웃이 재시도를 유발해 더 큰 폭주로 이어질 수 있습니다. 외부 API 재시도 설계 관점은 OpenAI 429와 Rate Limit 헤더로 재시도 설계 글의 “재시도가 시스템을 더 망가뜨릴 수 있다”는 포인트가 그대로 적용됩니다.

권장 조합: 실전에서 가장 무난한 해법

정리하면, Spring Boot 3 가상스레드 환경에서 DB 커넥션 고갈을 안정적으로 막는 조합은 다음 순서가 실패 확률이 낮습니다.

  1. 트랜잭션 경계 축소로 커넥션 점유 시간 줄이기
  2. 세마포어 등으로 DB 접근 동시성 제한(백프레셔)
  3. HikariCP 풀 크기와 타임아웃을 현실적인 값으로 조정
  4. 누수 탐지와 느린 쿼리 제거
  5. 워크로드(웹/배치)별 풀 분리

가상스레드는 “스레드 부족”을 해결해주지만, DB는 여전히 가장 비싼 공유 자원입니다. 가상스레드 도입의 성패는 DB를 얼마나 정교하게 보호하고, 커넥션을 얼마나 짧게 쓰도록 만들었는지에 달려 있습니다.