한 줄 요약
JVM 애플리케이션은 시작 후 첫 요청이 느린 이유가 네트워크/DB 연결 문제가 아니라, JVM 내부의 클래스 로딩·인터프리터 실행·JIT 컴파일 지연에 있다.
배경 — 무엇을 보다 알게 됐나
쿠폰 서비스에 HikariCP Eager 워밍업을 적용해 DB TCP cold start(~220ms)를 제거했음에도,
앱 재시작 후 첫 smoke 테스트 응답이 3,616ms로 여전히 느렸다.
두 번째 실행은 592ms로 6배 빨라졌다.
TCP 연결은 이미 열려 있는데 왜 느린가? → JVM 레이어의 cold start.
JVM Cold Start란
JVM(Java Virtual Machine)은 바이트코드(.class)를 실행하는데, 처음에는 인터프리터가 한 줄씩 읽어 실행한다.
이 과정에서 세 가지 비용이 발생한다.
1. 클래스 로딩 (Class Loading)
첫 요청이 들어오면:
CouponService.class 로딩
→ CouponRepository.class 로딩
→ Hibernate 관련 수십 개 클래스 로딩
→ Spring @Transactional 프록시 클래스 생성
클래스 파일을 디스크(JAR)에서 읽어 메모리에 올리는 작업. 한 번만 발생하지만 수백 ms 소요 가능.
2. JIT 컴파일 부재 (Interpreter Mode)
JVM 실행 흐름:
최초 실행: 인터프리터 → 바이트코드 한 줄씩 실행 (느림)
↓ (일정 횟수 실행 후)
C1 컴파일: 간단한 최적화로 Native 코드 변환 (보통)
↓ (더 많이 실행 후)
C2 컴파일: 고급 최적화 Native 코드 (빠름)
JIT(Just-In-Time) 컴파일러는 **자주 실행되는 코드(핫 코드)**를 Native 기계어로 변환한다.
첫 실행 시에는 아직 핫 코드가 없으므로 인터프리터 모드로만 돈다.
3. Hibernate / Spring 내부 초기화
@Transactional 첫 호출 시:
→ TransactionInterceptor 초기화
→ Hibernate Session 첫 생성
→ JPQL → SQL 쿼리 플랜 컴파일 (쿼리 플랜 캐시 미스)
→ PreparedStatement 캐시 구성
Hibernate는 JPQL 쿼리를 처음 볼 때 SQL로 변환하고 캐시에 저장한다.
두 번째 동일 쿼리는 캐시 히트 → 즉시 실행.
실측 데이터 (쿠폰 서비스)

connection 스팬이 859ms인 이유: HikariCP 풀에서 커넥션 꺼내는 시간(~1ms)이 아니라,
Spring 트랜잭션 프록시 + Hibernate Session 생성 + 쿼리 플랜 컴파일까지 포함된 시간.
TCP Cold Start vs JVM Cold Start 비교

완전한 워밍업 전략
현재 적용: TCP 레이어만
// DataSourceWarmupConfig.java
@EventListener(ApplicationReadyEvent.class)
public void warmUpConnectionPool() {
for (int i = 0; i < POOL_SIZE; i++) {
try (Connection conn = dataSource.getConnection()) {
// TCP 소켓만 미리 생성, JPA 레이어는 건드리지 않음
}
}
}
효과: TCP cold start 제거. JVM cold start는 여전히 첫 요청에서 발생.
완전 워밍업: JPA 레이어까지
@EventListener(ApplicationReadyEvent.class)
@Transactional // Spring 트랜잭션 프록시까지 초기화
public void warmUp() {
// 1. TCP 커넥션 워밍업
for (int i = 0; i < POOL_SIZE; i++) {
try (Connection conn = dataSource.getConnection()) {}
}
// 2. JPA 레이어 워밍업: 실제 쿼리 실행으로 Hibernate 초기화 + 쿼리 플랜 캐시
couponRepository.findById(UUID.randomUUID()); // 결과 없어도 됨
log.info("[Warmup] TCP + JPA 레이어 워밍업 완료");
}
이렇게 하면 앱 시작 시:
- HikariCP 풀 10개 물리 연결 완료
- Hibernate Session Factory 첫 세션 생성 완료
- JPQL 쿼리 플랜 캐시 초기화 완료
- Spring @Transactional 프록시 초기화 완료
→ 첫 실제 요청도 2차 실행과 동일한 속도.
왜 JVM은 처음부터 컴파일하지 않나
"어차피 느릴 거면 처음부터 다 컴파일해두면 되지 않나?"
세 가지 이유:
- 시작 시간: 앱의 모든 클래스를 AOT(Ahead-Of-Time) 컴파일하면 시작 시간이 수십 초~수 분이 될 수 있음.
- 최적화 품질: JIT는 실제 실행 패턴(어떤 분기가 자주 타는지 등)을 보고 최적화. 실행 전에 컴파일하면 이런 최적화 불가.
- 메모리: Native 코드는 메모리를 많이 차지. 실제로 실행되는 코드만 컴파일하는 게 효율적.
→ JVM은 "자주 쓰이는 코드만 점점 빠르게" 전략을 택함. 이것이 JIT의 핵심.
GraalVM Native Image — AOT의 대안
Spring Boot 3 + GraalVM을 쓰면 앱 전체를 빌드 타임에 Native 기계어로 컴파일 가능.

쿠폰 서비스처럼 장기 실행 + 고처리량이 목적이라면 JVM + 워밍업 전략이 적합.
'Study' 카테고리의 다른 글
| TIL - ExecutorService와 CountDownLatch로 동시성 테스트하기 (0) | 2026.07.29 |
|---|---|
| TIL - HikariCP 커넥션 풀과 Cold Start (0) | 2026.07.24 |
| TIL - MSA에서 HTTP vs Kafka, Spring Cloud Gateway (0) | 2026.07.23 |
| TIL - Docker restart 정책 비교 (no / on-failure / always / unless-stopped) (0) | 2026.07.22 |
| TIL - Boolean.TRUE.equals()를 쓰는 이유 (0) | 2026.07.21 |