- Published on
Spring Boot 3 가상스레드에서 HikariCP 교착 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 스레드 비용이 거의 0에 가까워지는 가상스레드(Virtual Thread, Project Loom) 는 Spring Boot 3에서 꽤 매력적인 옵션입니다. 하지만 가상스레드를 “그냥 켜기”만 하면, 기존에 플랫폼 스레드(커널 스레드) 기준으로 설계된 자원 제약—특히 DB 커넥션 풀(HikariCP)—과 충돌하면서 교착(deadlock)처럼 보이는 대기 상태를 만들 수 있습니다.
이 글에서는 다음을 다룹니다.
- Spring Boot 3 + Virtual Threads에서 HikariCP가 왜 막히는지(교착처럼 보이는 이유)
- 재현 가능한 전형적 패턴(트랜잭션/락/외부 호출/동시성)
- HikariCP/스레드/트랜잭션 설정으로 해결하는 방법
- 코드 레벨에서 “커넥션 점유 시간”을 줄이는 실전 팁
> DB 자체의 데드락(예: InnoDB)과는 결이 다릅니다. DB 데드락 로그로 원인 SQL을 찾는 방법은 별도로 정리해 둔 글을 참고하세요: MySQL InnoDB Deadlock 로그로 원인 SQL 찾기
1) 증상: “교착”처럼 보이는 HikariCP 대기
가상스레드를 적용한 뒤 흔히 보는 현상은 다음과 같습니다.
- API가 간헐적으로(또는 급격히) 느려짐
- 스레드 덤프를 보면 요청 처리 스레드가 엄청 많음(가상스레드라서)
- 하지만 DB 커넥션 획득에서 대기
- 로그에 다음과 같은 메시지 또는 유사 메시지
HikariPool-1 - Connection is not available, request timed out after 30000ms.
겉으로는 “교착”처럼 보이지만, 많은 경우는 (1) 커넥션 풀이 고갈되었고, (2) 커넥션을 오래 쥔 작업들이 풀을 점유해서 나머지가 줄줄이 대기하는 형태입니다. 가상스레드는 요청을 더 많이 동시에 처리하려고 시도하므로, 이 문제가 더 빨리/더 크게 드러납니다.
2) 왜 가상스레드에서 더 잘 터질까?
핵심은 동시성의 상한이 스레드에서 커넥션 풀로 이동한다는 점입니다.
- 플랫폼 스레드 기반: 톰캣 워커 스레드 수가 동시 요청 수를 제한(예: 200)
- 가상스레드 기반: 스레드는 거의 무한히 늘 수 있음 → 동시 요청 수가 더 커짐
- 하지만 DB 커넥션 풀은 여전히 유한(예: 10~30)
즉, 이전에는 “서버 스레드”가 병목이라 커넥션 풀이 보호(?)되던 상황에서, 가상스레드 적용으로 DB 풀 고갈이 전면에 등장합니다.
여기에 다음 패턴이 결합하면 “교착”에 가까운 정체가 발생합니다.
- 트랜잭션 범위가 불필요하게 큼(외부 API 호출/파일 IO/대기 포함)
- N+1/느린 쿼리로 커넥션 점유 시간이 길어짐
- 동일 요청 내에서 병렬 작업이 커넥션을 추가로 요구
- 락 경합으로 쿼리가 블로킹되며 커넥션이 반환되지 않음
N+1 때문에 커넥션 점유 시간이 늘어나는 케이스도 흔합니다. JPA를 사용 중이라면 아래 글도 함께 보면 원인 제거에 도움이 됩니다.
3) 전형적인 “가상스레드 + HikariCP 교착” 패턴
패턴 A: 트랜잭션 안에서 외부 호출(Feign/HTTP)을 해버림
아래 코드는 매우 흔한 실수입니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
@Transactional
public void placeOrder(Long orderId) {
// 1) DB 커넥션 획득 + 트랜잭션 시작
Order order = orderRepository.findById(orderId)
.orElseThrow();
// 2) 트랜잭션을 잡은 채로 외부 HTTP 호출
paymentClient.authorize(order.getUserId(), order.getAmount());
// 3) 다시 DB 작업
order.markPaid();
orderRepository.save(order);
}
}
가상스레드 환경에서는 동시 요청이 크게 늘 수 있고, 그만큼 트랜잭션(=커넥션 점유) 상태로 외부 호출을 기다리는 요청이 폭증합니다. 결과적으로 HikariCP 풀을 빠르게 고갈시켜 나머지는 커넥션 획득 대기 → 타임아웃으로 이어집니다.
Feign 타임아웃/재시도 설정이 부적절하면 이 현상은 더 악화됩니다(외부 호출이 길어지고 재시도가 겹침). 관련 체크 포인트는 아래 글이 유용합니다.
패턴 B: 요청 하나가 내부적으로 병렬 처리하며 커넥션을 여러 개 먹음
가상스레드로 바꾼 뒤 “그럼 병렬로 더 돌려보자”가 쉽게 나오는데, 아래처럼 하면 풀 고갈이 더 빨라집니다.
@Transactional(readOnly = true)
public Summary getSummary(Long userId) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var a = scope.fork(() -> repoA.findByUserId(userId));
var b = scope.fork(() -> repoB.findByUserId(userId));
var c = scope.fork(() -> repoC.findByUserId(userId));
scope.join();
scope.throwIfFailed();
return new Summary(a.get(), b.get(), c.get());
}
}
@Transactional은 보통 스레드 바인딩 기반으로 동작합니다.- fork된 작업들은 같은 트랜잭션 컨텍스트를 공유하지 못하거나(프레임워크/구성에 따라), 각자 커넥션을 잡아먹을 수 있습니다.
- 요청 하나가 커넥션을 3개 이상 소모하면, 동시 요청이 조금만 늘어도 풀은 순식간에 끝납니다.
패턴 C: DB 락/슬로우쿼리로 커넥션이 반환되지 않음
DB에서 락 대기가 걸리면 애플리케이션은 커넥션을 쥔 채로 기다립니다. 가상스레드 환경에서는 대기 요청이 더 많이 쌓여 풀 고갈이 가속됩니다.
- DB 데드락/락 경합과 애플리케이션 풀 고갈은 서로 증폭 관계일 수 있습니다.
4) 해결 전략: “풀 크기 늘리기”만으로는 부족
풀을 늘리는 것은 필요할 수 있지만, 가상스레드에서는 특히 커넥션 점유 시간을 줄이는 것이 1순위입니다. 아래 순서로 접근하는 것을 권장합니다.
- 트랜잭션 범위를 줄여 커넥션 점유 시간을 감소
- 외부 호출/IO를 트랜잭션 밖으로 이동
- 병렬 DB 호출을 제한(요청당 커넥션 소비량 상한)
- HikariCP 타임아웃/누수 탐지로 조기 감지
- 필요 시 풀 크기 조정(단, DB의 max_connections/리소스와 함께)
5) Spring Boot 3 가상스레드 설정(기본)
Spring Boot 3.2+에서는 다음 설정으로 서블릿(톰캣) 요청 처리를 가상스레드로 전환할 수 있습니다.
spring:
threads:
virtual:
enabled: true
단, 가상스레드 활성화는 동시성 상한을 올리는 스위치에 가깝습니다. DB 풀/외부 호출/락 전략이 준비되지 않으면 문제를 더 빨리 드러내는 촉매가 됩니다.
6) HikariCP 설정: “막히는 걸 빨리 드러내고” “오래 쥐지 않게”
(1) connectionTimeout을 짧게: 무한 대기 방지
spring:
datasource:
hikari:
maximum-pool-size: 20
connection-timeout: 3000
validation-timeout: 1000
connection-timeout을 30초로 두면 장애 시 요청이 30초씩 쌓이며 연쇄 타임아웃이 발생합니다.- 2~5초 정도로 줄이면 “즉시 실패 → 빠른 복구/폴백/서킷브레이커”로 전환이 쉬워집니다.
(2) leakDetectionThreshold로 커넥션 장기 점유 추적
spring:
datasource:
hikari:
leak-detection-threshold: 5000
- 5초 이상 반환되지 않은 커넥션 스택트레이스를 로그로 남겨서, 어떤 코드 경로가 커넥션을 오래 쥐는지 찾는 데 유용합니다.
- 상시 활성화는 로그 비용이 있을 수 있으니, 문제 재현 환경/기간에만 켜는 식으로 운영하는 경우가 많습니다.
(3) maxLifetime/idleTimeout은 DB/네트워크 환경에 맞추기
NAT/LB/DB 설정에 따라 유휴 커넥션이 끊기는 환경이면, Hikari의 maxLifetime을 적절히 조정하지 않으면 커넥션 재생성 폭주로 지연이 커질 수 있습니다(교착처럼 보이는 지연 유발).
7) 코드 레벨 해법: 트랜잭션을 “작게”, 외부 호출은 “밖으로”
(1) 외부 호출을 트랜잭션 밖으로 분리
아래처럼 2단계로 나눕니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
public void placeOrder(Long orderId) {
// 1) 먼저 필요한 데이터만 짧게 읽고 트랜잭션 종료
OrderSnapshot snap = loadSnapshot(orderId);
// 2) 외부 호출은 트랜잭션 밖에서 수행
paymentClient.authorize(snap.userId(), snap.amount());
// 3) 승인 결과 반영만 짧게 트랜잭션으로
markPaid(orderId);
}
@Transactional(readOnly = true)
protected OrderSnapshot loadSnapshot(Long orderId) {
Order o = orderRepository.findById(orderId).orElseThrow();
return new OrderSnapshot(o.getId(), o.getUserId(), o.getAmount());
}
@Transactional
protected void markPaid(Long orderId) {
Order o = orderRepository.findById(orderId).orElseThrow();
o.markPaid();
}
public record OrderSnapshot(Long orderId, Long userId, long amount) {}
}
핵심은 커넥션을 잡고 있는 시간을 외부 네트워크 지연과 분리하는 것입니다.
(2) 병렬 DB 호출을 제한하거나, 병렬화 대상을 DB가 아닌 것으로
정말 병렬화가 필요하다면:
- DB 호출은 합쳐서 한 번에 가져오기(조인/배치)
- 캐시/원격 호출 등 DB 커넥션을 쓰지 않는 작업만 병렬화
- 또는 요청당 동시 DB 작업 수를 제한(세마포어)
예: 요청당 DB 동시 접근을 2개로 제한
@Component
public class DbConcurrencyGuard {
private final Semaphore semaphore = new Semaphore(2);
public <T> T withPermit(Callable<T> task) throws Exception {
semaphore.acquire();
try {
return task.call();
} finally {
semaphore.release();
}
}
}
@RequiredArgsConstructor
@Service
public class SummaryService {
private final DbConcurrencyGuard guard;
private final RepoA repoA;
private final RepoB repoB;
public Pair<A, B> load(Long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var a = scope.fork(() -> guard.withPermit(() -> repoA.find(userId)));
var b = scope.fork(() -> guard.withPermit(() -> repoB.find(userId)));
scope.join();
scope.throwIfFailed();
return Pair.of(a.get(), b.get());
}
}
}
이 방식은 “가상스레드로 무한 확장”을 그대로 두면서도, DB라는 유한 자원에 대한 압력을 제어합니다.
8) 운영 관점 체크리스트(필수)
(1) 풀 고갈 vs DB 데드락을 구분
- 애플리케이션 로그:
Connection is not available...→ 풀 고갈 - DB 로그/메트릭: deadlock, lock wait timeout, slow query → DB 락/성능 이슈
둘이 함께 나타나기도 합니다. DB 락이 길어지면 커넥션이 반환되지 않아 풀 고갈이 동반됩니다.
(2) 메트릭으로 “커넥션 점유 시간”을 본다
Micrometer를 쓰면 Hikari 메트릭이 노출됩니다.
hikaricp.connections.activehikaricp.connections.pendinghikaricp.connections.max
패턴은 보통 이렇습니다.
- active가 max에 붙고
- pending이 증가하며
- API 지연이 폭증
(3) DB max_connections와 애플리케이션 인스턴스 수를 함께 계산
예:
- Hikari max=30
- 파드 10개면 최악 300 커넥션
- DB가 200 max_connections면, 단순히 풀을 늘리면 오히려 장애가 커집니다.
9) 결론: 가상스레드의 병목은 “DB 풀”로 이동한다
Spring Boot 3에서 가상스레드를 적용하면 스레드 고갈 문제는 크게 줄지만, 그만큼 DB 커넥션 풀/트랜잭션 설계가 병목의 중심이 됩니다. HikariCP 교착처럼 보이는 상황의 대부분은 다음 조합으로 설명됩니다.
- 동시 요청 증가(가상스레드)
- 커넥션 점유 시간 증가(큰 트랜잭션, 외부 호출 포함, N+1/슬로우쿼리, 락 대기)
- 결과적으로 풀 고갈 → 대기 폭증 → 타임아웃
해결의 핵심은 “풀을 키우기”가 아니라:
- 트랜잭션을 최소화하고
- 외부 호출을 트랜잭션 밖으로 빼고
- 요청당 DB 동시성을 제한하며
- 누수/대기 메트릭을 관찰해 빠르게 감지하는 것
이 4가지만 지켜도, 가상스레드 전환 후 체감하는 ‘교착’ 문제의 대부분은 안정적으로 정리됩니다.