본문 바로가기

Study

TIL - JVM Cold Start: 왜 첫 요청은 항상 느린가

728x90
반응형

한 줄 요약

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은 처음부터 컴파일하지 않나

"어차피 느릴 거면 처음부터 다 컴파일해두면 되지 않나?"

세 가지 이유:

  1. 시작 시간: 앱의 모든 클래스를 AOT(Ahead-Of-Time) 컴파일하면 시작 시간이 수십 초~수 분이 될 수 있음.
  2. 최적화 품질: JIT는 실제 실행 패턴(어떤 분기가 자주 타는지 등)을 보고 최적화. 실행 전에 컴파일하면 이런 최적화 불가.
  3. 메모리: Native 코드는 메모리를 많이 차지. 실제로 실행되는 코드만 컴파일하는 게 효율적.

→ JVM은 "자주 쓰이는 코드만 점점 빠르게" 전략을 택함. 이것이 JIT의 핵심.


GraalVM Native Image — AOT의 대안

Spring Boot 3 + GraalVM을 쓰면 앱 전체를 빌드 타임에 Native 기계어로 컴파일 가능.

쿠폰 서비스처럼 장기 실행 + 고처리량이 목적이라면 JVM + 워밍업 전략이 적합.

반응형