Published on

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

Authors

서버 스레드가 병목이던 시절에는 “요청 스레드 수”가 자연스럽게 동시성 상한선 역할을 했습니다. 그런데 Spring Boot 3에서 가상 스레드(virtual thread) 를 활성화하면 요청당 스레드 비용이 급격히 낮아져, 이전에는 불가능하던 수준으로 동시 요청이 한꺼번에 실행됩니다. 이때 가장 먼저 터지는 곳이 흔히 DB 커넥션 풀(HikariCP) 입니다.

증상은 단순합니다.

  • 트래픽이 조금만 올라가도 HikariPool-1 - Connection is not available, request timed out...
  • DB CPU/IO는 여유가 있는데 애플리케이션이 타임아웃
  • 스레드 덤프를 보면 수많은 가상 스레드가 커넥션 대기

이 글에서는 “가상 스레드가 DB 커넥션 고갈을 유발하는 구조적 이유”를 짚고, 풀 크기만 키우는 단순 처방을 넘어 동시성 제어, 트랜잭션 범위 축소, 관측/진단까지 포함한 해결책을 정리합니다.

왜 가상 스레드에서 커넥션 풀이 더 빨리 고갈될까?

1) 스레드 병목이 사라져 동시성이 폭증한다

플랫폼 스레드 기반 톰캣에서는 maxThreads가 사실상 “동시 요청 처리 상한”이었습니다. 예를 들어 maxThreads=200이면, 동시에 200개 요청만 애플리케이션 로직을 실행하고 나머지는 큐에서 대기합니다.

하지만 가상 스레드는 요청당 스레드를 무제한에 가깝게 생성할 수 있어, 애플리케이션이 처리할 수 있는 동시 실행 수가 DB 풀 크기보다 훨씬 커지기 쉽습니다.

결과적으로:

  • (이전) 스레드 200 vs DB pool 30 → 실제로는 스레드가 먼저 제한을 걸어줌
  • (가상 스레드) 스레드 수천 vs DB pool 30 → 커넥션이 즉시 병목

2) “커넥션 점유 시간”이 조금만 길어도 폭발한다

커넥션 풀 고갈은 보통 “풀 크기”보다 점유 시간(hold time) 문제입니다.

  • 트랜잭션 범위가 넓다
  • 외부 API 호출을 트랜잭션 안에서 한다
  • 느린 쿼리/락 대기
  • N+1 쿼리

가상 스레드 환경에서는 동시 요청이 늘어 점유 시간이 약간만 길어도 풀 고갈이 더 쉽게 발생합니다.

3) DB는 스레드처럼 무한 확장되지 않는다

DB 커넥션은 OS/DB 리소스(메모리, 세션, 버퍼, 락 구조 등)를 사용합니다. 애플리케이션이 가상 스레드로 수천 동시성을 만들었다고 해서 DB가 그만큼 늘어나는 게 아닙니다.

즉, 가상 스레드는 “애플리케이션의 동시성 비용”을 낮춘 기술이지, “DB 병목을 제거”하는 기술이 아닙니다.

1차 대응: HikariCP 풀/타임아웃을 현실적으로 조정

풀을 무작정 키우면 DB가 먼저 죽을 수 있습니다. 하지만 최소한 현재 트래픽/쿼리 특성에 맞게 Hikari 설정은 정리해야 합니다.

권장 체크 포인트

  • maximumPoolSize: DB가 감당 가능한 동시 커넥션 수(서버/인스턴스 스펙, 쿼리 특성에 따라 다름)
  • connectionTimeout: 커넥션 대기 타임아웃(짧게 잡아 빠르게 실패시키고 상위에서 보호)
  • leakDetectionThreshold: 커넥션 반환 누락 탐지

예시 설정 (application.yml)

spring:
  threads:
    virtual:
      enabled: true

  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 1000      # 1s 내 커넥션 못 구하면 실패
      validation-timeout: 500
      idle-timeout: 600000
      max-lifetime: 1800000
      leak-detection-threshold: 2000 # 2s 이상 점유 시 로그

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

connectionTimeout을 길게 두면(기본 30초) 장애 시 요청이 쌓이며 폭발 반경이 커집니다. 가상 스레드에서는 특히 대기열이 무한정 늘어날 수 있어 더 위험합니다.

핵심 해결: “DB 동시성”을 명시적으로 제한하라

가상 스레드 환경에서는 “스레드가 알아서 제한해주던 시대”가 끝났습니다. 이제는 DB 같은 유한 자원에 대해 명시적으로 동시성 제한을 걸어야 합니다.

전략 A) 세마포어로 DB 접근 구간 제한 (가장 단순/효과적)

DB 커넥션 풀 크기가 30이라면, 애플리케이션 레벨에서 DB 연산을 25~30 정도로 제한해 풀 고갈을 구조적으로 차단할 수 있습니다.

import org.springframework.stereotype.Component;

import java.util.concurrent.Semaphore;

@Component
public class DbConcurrencyLimiter {
    // 풀보다 약간 작게: 풀 내부 오버헤드/헬스체크/관리용 여유
    private final Semaphore semaphore = new Semaphore(25);

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

    @FunctionalInterface
    public interface CheckedSupplier<T> {
        T get() throws Exception;
    }
}

서비스 계층에서 DB 호출을 감싸면 됩니다.

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

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

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

    @Transactional
    public Order getOrder(long id) {
        return limiter.execute(() -> repo.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("not found")));
    }
}

이 방식의 장점:

  • 구현이 단순하고 즉시 효과
  • DB 풀 고갈 → 애플리케이션 내부 큐잉으로 흡수
  • 실패/대기 정책을 커스터마이즈 가능(tryAcquire + 타임아웃 등)

주의점:

  • 모든 DB 호출을 다 감싸기 어렵다면, 핵심 핫패스(트래픽 큰 API) 부터 적용
  • 대기 시간이 길어지면 API 레이턴시가 늘 수 있으므로, 상위에서 타임아웃/서킷브레이커도 고려

전략 B) HTTP 동시성 자체를 제한 (서버 레벨)

가상 스레드를 켠 상태에서 “요청 수”가 무제한으로 동시에 들어오면, DB뿐 아니라 캐시/외부 API도 연쇄적으로 압박합니다. 서버 레벨에서 동시성/큐를 제한하는 것도 유효합니다.

  • 톰캣 accept queue 및 커넥터 설정
  • API Gateway/Ingress에서 rate limit

Kubernetes/EKS 환경이라면 네트워크/타임아웃 이슈가 함께 보이기도 합니다. DB가 아니라 Redis/외부 서비스 타임아웃이 섞이면 진단이 어려워지는데, 이런 경우 네트워크 경로/보안그룹/네트워크폴리시를 10분 내 점검하는 체크리스트도 도움이 됩니다: EKS Pod는 뜨는데 트래픽 0 - NetPol·SG·CNI 10분 진단

트랜잭션 범위를 줄여 커넥션 점유 시간을 단축

풀 고갈의 본질은 “동시 요청 수 × 커넥션 점유 시간”입니다. 동시성 제한과 함께, 점유 시간을 줄이면 체감 효과가 큽니다.

1) 트랜잭션 안에서 외부 호출/긴 작업을 하지 말기

나쁜 예(트랜잭션이 외부 API까지 감쌈):

@Transactional
public void placeOrder(OrderRequest req) {
    Order order = repo.save(new Order(req));
    paymentClient.pay(order.getId()); // 외부 호출(지연/타임아웃 가능)
    repo.updateStatus(order.getId(), "PAID");
}

개선 예(트랜잭션을 DB 작업으로만 축소):

public void placeOrder(OrderRequest req) {
    long orderId = createOrder(req); // 짧은 트랜잭션
    paymentClient.pay(orderId);      // 트랜잭션 밖
    markPaid(orderId);               // 짧은 트랜잭션
}

@Transactional
protected long createOrder(OrderRequest req) {
    return repo.save(new Order(req)).getId();
}

@Transactional
protected void markPaid(long orderId) {
    repo.updateStatus(orderId, "PAID");
}

2) 느린 쿼리/N+1 제거는 “가상 스레드 시대”에 더 중요

가상 스레드로 요청이 더 많이 동시에 실행되면, 느린 쿼리가 동시에 더 많이 실행되어 DB가 더 빨리 포화됩니다.

  • 인덱스 점검
  • 배치성 조회는 페이징/커서
  • JPA N+1은 fetch join/entity graph로 제거

관측: “커넥션 대기”를 메트릭으로 잡아내기

장애 대응에서 중요한 건 “지금 고갈이 맞는지”와 “점유 시간이 긴지”를 수치로 보는 것입니다.

Micrometer + Hikari 메트릭

Spring Boot Actuator를 켜면 Hikari 메트릭이 노출됩니다.

  • hikaricp.connections.active
  • hikaricp.connections.pending (대기 중)
  • hikaricp.connections.max

Prometheus를 쓴다면, pending이 급증하는 순간이 곧 장애 시작점입니다.

management:
  metrics:
    tags:
      application: my-service

추가로, DB가 아니라 외부 캐시/네트워크 타임아웃이 섞여 보이면(예: Redis가 10분 단위로 타임아웃) 아래 글처럼 “타임아웃이 어디서 생겼는지”를 계층별로 분해해 보는 방식이 유용합니다: EKS Pod→ElastiCache Redis 10분 타임아웃 진단법

가상 스레드 적용 시 흔한 오해 3가지

오해 1) “가상 스레드면 blocking JDBC도 무조건 빨라진다”

가상 스레드는 blocking을 저렴하게 기다리게 해주지만, DB가 처리하는 쿼리 시간 자체를 줄여주진 않습니다. DB가 병목이면 병목은 그대로입니다.

오해 2) “풀 크기만 키우면 된다”

풀을 키우면 일시적으로 완화되지만, DB에 동시 쿼리가 더 몰려 락 경합/IO 포화로 더 큰 장애를 만들 수 있습니다. 먼저 해야 할 일은 “점유 시간 단축”과 “동시성 제한”입니다.

오해 3) “가상 스레드에서는 톰캣 튜닝이 필요 없다”

요청이 무한정 동시에 실행될 수 있으므로, 오히려 큐/레이트리밋/타임아웃 같은 보호장치가 더 중요해집니다.

실전 체크리스트: 커넥션 고갈을 재발 방지하는 순서

  1. 커넥션 타임아웃을 짧게: connectionTimeout 1~2초 수준으로 조정(서비스 특성에 맞게)
  2. leakDetectionThreshold 활성화: 반환 누락/비정상 점유 탐지
  3. 트랜잭션 범위 축소: 외부 호출, 파일 IO, 긴 계산을 트랜잭션 밖으로
  4. 동시성 제한 도입: 세마포어/레이트리밋으로 DB 접근량을 풀보다 작게 제한
  5. 슬로우 쿼리/N+1 제거: 점유 시간 단축의 왕도
  6. 메트릭 기반 알람: pending 상승, active/max 비율, DB 커넥션 수 알람

마무리

Spring Boot 3의 가상 스레드는 서버 스레드 병목을 크게 완화하지만, 그 결과로 DB 커넥션 풀이 새로운 병목으로 떠오르는 경우가 많습니다. 해결의 핵심은 “가상 스레드로 늘어난 동시성을 DB가 감당할 수 있는 수준으로 다시 제한”하고, “커넥션 점유 시간을 줄이는 구조”로 코드를 정리하는 것입니다.

풀 크기 조정은 출발점일 뿐이고, 세마포어 기반 동시성 제한 + 트랜잭션 최소화 + 메트릭 관측을 함께 적용하면 가상 스레드 환경에서도 안정적으로 처리량을 끌어올릴 수 있습니다.