- Published on
Spring Boot 3 가상스레드 적용 시 트랜잭션·DB풀 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 애플리케이션에서 가상 스레드(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가 느려져 타임아웃 증가
기본 원칙
- 풀 사이즈는 DB가 감당 가능한 동시 쿼리 수에 맞춘다.
- 가상 스레드는 “대기 스레드 비용”을 줄일 뿐, DB 처리량을 늘려주지 않는다.
- 풀 대기(획득) 시간을 명시적으로 관측하고, 타임아웃은 “빠른 실패”에 가깝게 설계한다.
권장 설정 예시(출발점)
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 타임아웃/재시도 정책)와 함께 조정하는 편이 안정적입니다.
풀 사이즈를 정하는 실전 접근
정답 공식은 없지만, 다음 순서가 현실적입니다.
- DB의 CPU 사용률, active sessions, 락 대기, slow query를 본다.
- 애플리케이션에서 Hikari 메트릭을 본다.
hikaricp.connections.activehikaricp.connections.pendinghikaricp.connections.timeout
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가 감당 가능한 범위”에서 튜닝하고(풀 사이즈는 만능이 아님)
- 커넥션 대기/타임아웃을 관측해 빠르게 피드백 루프를 돌리는 것
입니다.
가상 스레드를 도입하기 전, 먼저 현재 서비스의 요청당 쿼리 수 / 평균 트랜잭션 시간 / 커넥션 풀 대기 시간을 수치로 확보해두면, 전환 후 효과와 리스크를 훨씬 명확하게 판단할 수 있습니다.