10 minute read

배포 직후 첫 고객이 3초를 기다리는 문제를 해결한 과정을 정리했습니다.


JVM 기반 앱은 첫 요청이 느립니다

JVM은 실행 시점에 비용을 지불하는 구조입니다. 클래스 파일은 처음 참조될 때 로딩되고, 바이트코드는 인터프리터로 먼저 실행된 후 JIT 컴파일러가 점진적으로 기계어로 변환합니다. Spring Boot 기반 앱에서는 여기에 DispatcherServlet 초기화, Hibernate 쿼리 계획 생성, 커넥션 풀 확장 비용이 더해집니다.

이 비용은 “앱이 느리다”와는 다릅니다. 앱 시작 직후 단 한 번만 발생하고, 두 번째 요청부터는 사라집니다. 문제는 그 “단 한 번”이 첫 번째 고객 요청에서 터진다는 점입니다.


저희 서비스에서 확인한 문제

배포 직후 첫 요청과 두 번째 요청의 응답시간을 비교했습니다.

첫 /api/v1/main 요청: 3,447ms
두 번째 /api/v1/main 요청: 315ms

같은 API가 바로 다음 호출에서 315ms로 내려온다는 건, 비즈니스 로직 자체가 3초대라는 뜻이 아닙니다. 초기화 비용이 첫 고객 요청에 실려 나간 것입니다.

운영 관점에서 질문을 세 가지로 좁혔습니다.

  1. Spring Boot Started 로그 이후에 아직 끝나지 않은 초기화는 무엇인가?
  2. 그 비용을 고객 요청 전에 미리 소진할 수 있는가?
  3. 소진 과정에서 장애 전파를 만들지 않는가?

Started와 “첫 요청 준비 완료”는 다릅니다

Started ...는 Spring Context가 올라왔다는 신호입니다. 하지만 첫 요청이 통과하는 전체 경로의 초기화까지 끝났다는 의미는 아닙니다.

첫 요청에서 추가로 붙는 비용은 보통 아래에서 발생합니다.

구간 첫 요청에서 붙는 비용
클래스 로딩 처음 참조되는 QueryDSL/Jackson/AOP 관련 클래스 로딩
DispatcherServlet 핸들러 매핑/인터셉터 체인 초기화
Hibernate SQL 변환 계획/엔티티 매핑/프록시 준비
HikariCP 추가 커넥션 생성, 핸드셰이크
JIT 인터프리터 실행 (C1/C2 임계치 미달 시)

“배포 상태”와 “고객에게 전달 가능한 상태”를 분리해야 합니다.


저희가 선택한 방식: Warmup + Health Gate

대안은 세 가지가 있었습니다.

대안 장점 단점 최종 판단
현 상태 유지 (조치 없음) 추가 구현 비용 없음 배포마다 첫 고객이 콜드 스타트를 부담 미채택
전역 eager 초기화 기동 시 초기화 비용 완납 기동 시간 급증, 미사용 경로까지 리소스 선점, 전환 불가 항목 존재 미채택
Warmup + Health Gate 고객 유입 전 선초기화 가능, 실패 범위 통제 가능 Warmup 시나리오 관리 비용 채택

“전역 eager 초기화”란 DispatcherServlet의 load-on-startup=1, HikariCP의 minimumIdle = maximumPoolSize, Hibernate 메타데이터 선로딩 등을 기동 시점에 한꺼번에 강제하는 전략입니다. 모든 비용을 앞당기기 때문에 기동 시간이 급증하고, 실제로 호출되지 않는 API 경로의 리소스까지 선점하게 됩니다. request-scope 빈처럼 구조적으로 eager 전환이 불가능한 항목도 있어, 범용 해법이 되기 어렵습니다.

채택 이유는 “가장 빠른” 방법이라서가 아닙니다. 장애 영향 범위를 Warmup 단계에 가두고, 고객 요청 경로에서 콜드 스타트를 제거할 수 있었기 때문입니다.


동작 흐름

아래 순서로 트래픽 유입 시점을 제어했습니다.

sequenceDiagram
    participant App as App Instance
    participant Warmup as Warmup Runner
    participant LB as ALB Target Group
    participant User as User Request

    App->>Warmup: ApplicationReadyEvent
    Warmup->>App: 핵심 API 병렬 선호출
    App-->>LB: /actuator/health = 503 (in_progress)
    Warmup->>App: 완료 신호 setReady()
    App-->>LB: /actuator/health = 200 (completed)
    User->>LB: 첫 요청
    LB->>App: 이미 준비된 인스턴스로 전달

핵심은 두 가지입니다.

  1. Warmup이 끝나기 전에는 Target Group 편입을 막습니다.
  2. Warmup 실패가 전체 기동 실패로 번지지 않게 timeout/fail-open 정책을 둡니다.

구현 포인트

1) Warmup 트리거

Warmup은 ApplicationReadyEvent 직후 시작했습니다. 실제 요청 경로를 통과시키기 위해 핵심 API를 병렬 호출했습니다.

@Component
class AppWarmup(
    private val componentController: ComponentController,
    private val categoryController: CategoryController,
    private val searchController: SearchController,
    private val popupController: PopupController,
    private val productController: ProductController,
    private val warmupState: WarmupState,
) {
    @EventListener(ApplicationReadyEvent::class)
    fun onReady(event: ApplicationReadyEvent) {
        log.info("[Warmup] Starting warmup...")
        runBlocking {
            runCatching {
                withTimeout(WARMUP_TIMEOUT_MS) {
                    listOf(
                        asyncIO { componentController.getComponents(userId = null) },
                        asyncIO { componentController.getLatestProducts(page = 0, size = 20, ...) },
                        asyncIO { categoryController.getCategoriesByParent(parentCategoryId = 0) },
                        asyncIO { searchController.getAutoComplete(keyword = "a", limit = 10) },
                        asyncIO { searchController.searchProducts(...) },
                        asyncIO { popupController.getActivePopupList(page = 0, size = 10) },
                        asyncIO { productController.getRecommendedProducts(page = 0, size = 20, ...) },
                    ).awaitAll()
                }
            }.onFailure { e ->
                log.warn("[Warmup] Warmup timed out or failed, proceeding anyway", e)
            }
        }
        warmupState.setReady()
        log.info("[Warmup] Warmup completed, application is ready")
    }

    companion object {
        private const val WARMUP_TIMEOUT_MS = 30_000L
    }
}

HTTP 경로를 호출하는 대신 컨트롤러를 직접 호출하는 방식을 택했습니다. Spring의 @Component로 등록된 컨트롤러를 DI 받아 Java 메서드를 직접 실행합니다. asyncIODispatchers.IO에서 코루틴을 실행하는 확장 함수입니다.

컨트롤러 직접 호출의 트레이드오프

이 방식은 Spring의 HTTP 요청 경로(Filter → DispatcherServlet → HandlerMapping → Interceptor → Controller)를 거치지 않고, Controller 메서드를 Java 함수로 직접 호출합니다.

커버되는 비용:

  • Hibernate 쿼리 계획/프록시 생성
  • HikariCP 커넥션 사용 경로 초기화
  • QueryDSL/Jackson 등 클래스 로딩

커버되지 않는 비용:

  • DispatcherServlet init() (HandlerMapping, HandlerAdapter 초기화)
  • Spring Security Filter Chain 첫 실행
  • CommonRequestLoggingFilter 초기화

2) Health Gate

웜업 상태를 Health 응답에 직접 반영했습니다.

@GetMapping("/actuator/health")
fun actuatorHealth(): ResponseEntity<Map<String, String>> {
    return if (warmupState.isReady()) {
        ResponseEntity.ok(mapOf("status" to "UP", "warmup" to "completed"))
    } else {
        ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(mapOf("status" to "DOWN", "warmup" to "in_progress"))
    }
}

이렇게 하면 LB가 Warmup 완료 전 인스턴스를 트래픽 대상에서 제외합니다.

3) 실패 처리

Warmup은 성능 최적화 단계입니다. 배포를 막아서는 안 됩니다.

  • timeout: 30초
  • 실패 정책: 로그 남기고 진행(fail-open)
  • 상태 전환: 마지막에 setReady() 실행

즉, 완벽한 워밍업보다 서비스 지속성이 우선입니다.


Warmup 동안 JVM에서 실제로 일어난 일

A. 클래스 로딩 비용 선흡수

첫 호출 전에는 관련 클래스가 아직 메모리에 없습니다. Warmup이 먼저 호출하면 클래스 로딩/검증 비용을 Warmup이 가져갑니다.

구간 Warmup 전 첫 고객 요청 Warmup 후 첫 고객 요청
/api/v1/main 총 응답 3,447ms 249ms
메인 데이터 조회 432ms 2ms
상단 영역 조회 980ms ~17ms
하위 컴포넌트 조회 591ms 17ms

로컬 환경(macOS, Docker MySQL/Redis)에서 동일한 경로를 측정하면 Warmup 소요 약 1초, Warmup 후 첫 HTTP 응답 ~50ms 수준으로 나옵니다.

클래스 로딩 비용을 직접 확인하려면 -Xlog:class+load=info 플래그를 추가합니다.

# JVM 옵션 추가 (Gradle bootRun 기준)
jvmArgs = listOf("-Xlog:class+load=info:file=build/class-load.log:tags,time")

실제로 로컬에서 측정한 결과, 기동 과정에서 로드되는 클래스 수는 다음과 같았습니다.

구간 클래스 수 비고
기동 시 (Warmup 전) 28,911 Spring/Hibernate/Kotlin 기본 클래스
Warmup 중 (986ms) 2,086 QueryDSL 63, Jackson 86, Hibernate 346 포함
Warmup 후 (첫 HTTP~) 707 DispatcherServlet 관련 + 추가 직렬화 클래스
합계 31,704 Java 21.0.7 기준

Warmup이 없었다면, 이 2,086개 클래스는 첫 고객 요청에서 로드됩니다. class-load.log에서 실제 로드 패턴을 확인하면:

[2026-03-13T13:52:55.424][class,load] com.querydsl.core.types.dsl.BooleanExpression source: file:/.../querydsl-core-5.1.0.jar
[2026-03-13T13:52:55.518][class,load] com.fasterxml.jackson.databind.ser.BeanSerializer source: file:/.../jackson-databind-2.18.1.jar
[2026-03-13T13:52:55.692][class,load] org.hibernate.query.sqm.internal.ConcreteSqmSelectQueryPlan source: file:/.../hibernate-core-6.6.2.Final.jar

Warmup 1초 동안 이 클래스들이 한꺼번에 로드/검증되면서, 이후 고객 요청에서는 이미 메모리에 올라온 클래스를 재사용합니다.

B. DispatcherServlet 초기화

Spring MVC의 DispatcherServlet은 기본적으로 첫 HTTP 요청 시점에 init()을 실행합니다. 이 과정에서 HandlerMapping 탐색, HandlerAdapter 등록, 인터셉터 체인 구성이 일어납니다.

저희 Warmup은 컨트롤러 직접 호출 방식이라 DispatcherServlet을 통과하지 않습니다. 대신 프로덕션에서는 ALB Health Check가 Warmup 완료 후 /actuator/health를 HTTP로 호출하면서 DispatcherServlet이 초기화됩니다.

TRACE 레벨 로그(org.springframework.web.servlet.DispatcherServlet: TRACE)로 확인한 실제 init 시점입니다.

13:52:56.381 [Warmup] Warmup completed, application is ready
13:53:04.236 Initializing Servlet 'dispatcherServlet'         ← 첫 HTTP 요청 시
13:53:04.236 Detected StandardServletMultipartResolver
13:53:04.236 Detected AcceptHeaderLocaleResolver
13:53:04.236 Detected FixedThemeResolver
13:53:04.242 Detected DefaultRequestToViewNameTranslator
13:53:04.242 Detected SessionFlashMapManager
13:53:04.243 Completed initialization in 7 ms
13:53:04.313 GET "/actuator/health", parameters={} in DispatcherServlet
13:53:04.556 Completed 200 OK

init 자체는 7ms로 매우 가볍습니다. 다만 init 이후 첫 HTTP 요청(GET /actuator/health)의 전체 처리에는 Spring Security Filter Chain 통과 비용이 포함되어 약 243ms가 소요됩니다. 이 비용은 ALB Health Check가 가져가므로 고객 요청에는 영향이 없습니다.

C. Hibernate 쿼리 계획 캐시 선생성

첫 실행 시 Hibernate는 JPQL/HQL을 SQL로 변환하는 쿼리 계획을 생성하고, 엔티티 프록시를 준비합니다. Warmup은 주요 조회 경로를 한 번 실행하여 이 계획들을 캐시에 적재합니다.

로컬에서 show_sql: true 설정으로 확인한 Warmup 중 실행된 SQL입니다.

# Warmup 중 실행된 14개 SQL 쿼리 (show_sql: true 출력)
Hibernate:
    SELECT DISTINCT p.id, ...     ← 상품 목록 조회 계획 생성
Hibernate:
    SELECT COUNT(DISTINCT p.id)   ← 상품 카운트 쿼리 계획 생성
Hibernate:
    select ce1_0.id, ...          ← 카테고리 조회
Hibernate:
    select pe1_0.id, ...          ← 팝업 조회
Hibernate:
    select pgce1_0.title ...      ← 검색 자동완성

Warmup 중 14개 쿼리가 실행되면서 해당 쿼리 계획이 캐시됩니다. 이후 동일 패턴의 쿼리는 계획을 재사용하므로 SQL 변환 비용이 사라집니다.

캐시 hit/miss를 직접 확인하려면 org.hibernate.stat: DEBUGspring.jpa.properties.hibernate.generate_statistics: true를 추가합니다.

D. HikariCP 커넥션 풀 상태

HikariCP의 동작은 minimumIdle 설정에 따라 다릅니다.

# 커넥션 풀 상태를 확인하려면
logging.level:
  com.zaxxer.hikari: DEBUG
  com.zaxxer.hikari.pool.HikariPool: DEBUG

로컬(maximum-pool-size: 5, minimumIdle 미설정)에서 실측한 결과입니다.

# 기동 시: minimumIdle을 설정하지 않으면 maximumPoolSize와 같아서 풀이 즉시 만충됨
13:48:31.951 core-db-pool - Added connection ...@7d710482
13:48:32.066 core-db-pool - Pool stats (total=1, active=1, idle=0, waiting=0)
13:48:32.098 core-db-pool - Added connection ...@6bda198d
13:48:32.155 core-db-pool - Added connection ...@5e35d6a9
13:48:32.223 core-db-pool - Added connection ...@47f663d6
13:48:32.294 core-db-pool - Added connection ...@2f5f022b
13:48:32.330 core-db-pool - After adding stats (total=5, active=0, idle=5, waiting=0)

# Warmup 완료 후: 풀 상태 변화 없음 (이미 만충)
13:53:02.126 core-db-pool - Pool stats (total=5, active=0, idle=5, waiting=0)

로컬에서는 minimumIdle 미설정으로 인해 기동 시 풀이 이미 만충되어, Warmup이 추가 커넥션을 생성하지 않았습니다.

프로덕션 환경은 다릅니다. Master/Slave Replication을 사용하며, 읽기 전용 쿼리는 Slave 풀로 라우팅됩니다.

# 프로덕션 커넥션 풀 설정
Master: minimumIdle=10, maximumPoolSize=30
Slave:  minimumIdle=25, maximumPoolSize=100

# 기대되는 동작 (Warmup 쿼리는 읽기 전용 → Slave 풀 사용)
기동 직후: Slave Pool stats (total=25, idle=25)
Warmup 중: 7개 병렬 DB 조회 → active=7 → 커넥션 부족 없음 (25 ≥ 7)

핵심은 “커넥션 수”보다 “첫 커넥션 사용 경로”입니다. Warmup이 DB를 먼저 조회하면서 MySQL 핸드셰이크, 커넥션 유효성 검증 경로가 이미 활성화됩니다.

E. JIT와 Warmup의 관계

Warmup을 도입하면 “JIT 컴파일도 당겨지나요?”라는 질문을 자주 받습니다. 결론부터 말하면 효과는 미미하지만, 왜 미미한지를 설명하려면 Tiered Compilation의 동작을 이해해야 합니다.

Tiered Compilation 동작 방식

JVM은 메서드를 처음에는 인터프리터로 실행합니다. 호출이 반복되면 Tiered Compilation 파이프라인에 따라 점진적으로 최적화합니다.

인터프리터 → Tier 3(C1 프로파일링) → Tier 4(C2 최적화)

Tier 1/2(프로파일링 없는 C1)는 C2 큐가 혼잡할 때의 대체 경로이며, 일반적으로는 Tier 3으로 바로 진입합니다.

각 단계로 승격되려면 호출 횟수(invocation count)나 루프 반복 횟수(backedge count)가 임계치를 넘어야 합니다. 여기서 backedge란 루프의 끝에서 시작으로 돌아가는 분기(backward branch)를 의미하며, JVM은 이를 카운팅해서 “이 메서드 안의 루프가 얼마나 뜨거운지”를 추정합니다.

JDK 21 실측 임계치

java -XX:+PrintFlagsFinal -version 명령으로 Java 21.0.7에서 직접 확인한 값입니다.

# C1 (Tier 3) 컴파일 조건
Tier3InvocationThreshold       = 200       ← 호출만으로 트리거
Tier3MinInvocationThreshold    = 100       ← CompileThreshold와 함께 사용
Tier3CompileThreshold          = 2000      ← 호출 ≥ 100 AND 호출+백엣지 ≥ 2,000
Tier3BackEdgeThreshold         = 60000     ← 루프 반복만 60,000회 (OSR 전용)

# C2 (Tier 4) 컴파일 조건
Tier4InvocationThreshold       = 5000      ← 호출만으로 트리거
Tier4MinInvocationThreshold    = 600       ← CompileThreshold와 함께 사용
Tier4CompileThreshold          = 15000     ← 호출 ≥ 600 AND 호출+백엣지 ≥ 15,000
Tier4BackEdgeThreshold         = 40000     ← 루프 반복만 40,000회 (OSR 전용)

트리거 경로는 두 가지입니다.

일반 컴파일 (메서드 진입 시 판정):

  • 호출 ≥ InvocationThreshold → 바로 트리거
  • 호출 ≥ MinInvocationThreshold AND 호출 + 백엣지 ≥ CompileThreshold → 트리거

OSR (루프 실행 중 판정):

  • 백엣지 ≥ BackEdgeThreshold → 루프 도중 컴파일된 코드로 교체

C1 기준으로 풀어쓰면, 호출 ≥ 200이면 바로 컴파일되고, 호출이 100~199 사이라도 백엣지와 합산해서 2,000을 넘으면 컴파일됩니다. 루프가 많은 메서드는 호출 1회만으로도 BackEdge 임계치(60,000)를 넘어 OSR이 가능합니다.

실측: -XX:+PrintCompilation으로 확인

아래는 JIT 동작을 명확히 보여주기 위해 단순 메서드를 반복 호출하는 별도 데모 프로그램에서 수집한 결과입니다. add() 메서드를 50,000회 호출하면서 Java 21에서 컴파일 이벤트를 기록했습니다.

337    5       3       JitTierDemo::add (4 bytes)                     ← C1(Tier 3) 컴파일
344    6       4       JitTierDemo::add (4 bytes)                     ← C2(Tier 4) 승격
352    5       3       JitTierDemo::add (4 bytes)   made not entrant  ← 이전 C1 버전 폐기

출력 형식은 타임스탬프 컴파일ID 티어 메서드명 (바이트수)입니다. Tier 3에서 컴파일된 후 호출이 계속 쌓이면 Tier 4(C2)로 재컴파일되고, 이전 Tier 3 버전은 made not entrant로 폐기됩니다.

루프가 있는 메서드는 다른 양상을 보입니다. sumLoop(100_000)1회만 호출했을 때:

588    8 %     3       JitWarmupDemo::sumLoop @ 4 (22 bytes)  ← % = OSR

% 마커는 OSR(On-Stack Replacement)을 뜻합니다. 루프 실행 도중 인터프리터 프레임에서 컴파일된 코드로 교체하는 것으로, 메서드를 1회 호출해도 루프 반복이 BackEdge 임계치를 넘으면 발생합니다. @ 4는 바이트코드 4번 오프셋(루프 시작 지점)에서 교체가 일어났다는 의미입니다.

Warmup 1회 호출이 JIT에 주는 실질적 영향

Warmup은 각 API를 1회 호출합니다. 이때:

  • 메서드 호출 횟수: API 1회 호출 내부에서 실행되는 메서드는 대부분 1~수십 회 수준이므로, Tier3InvocationThreshold(200)에 미달합니다.
  • 루프 기반 OSR: Jackson 역직렬화, QueryDSL 조건 조합 등에서 컬렉션 순회가 발생하면, 해당 루프 메서드에 한해 BackEdge 기반 C1 OSR이 트리거될 수 있습니다.
  • C2 컴파일: Tier4InvocationThreshold(5,000)에 도달하려면 같은 메서드를 수천 번 호출해야 하므로, Warmup 1회로는 불가능합니다.

Warmup의 주된 가치는 JIT 컴파일 유도가 아니라, 앞서 설명한 A~D의 초기화 비용을 고객 요청 전에 소진하는 데 있습니다.


결과 수치와 해석

측정 조건

항목
환경 ECS + ALB Health Check
Warmup 대상 주요 조회 API 여러 개
Warmup 실행 시점 ApplicationReadyEvent 직후
트래픽 유입 제어 Warmup 완료 전 /actuator/health 503

결과

ECS Before: 첫 /api/v1/main 3,447ms
ECS After : 첫 /api/v1/main   249ms
환경 Warmup 소요 비고
ECS 프로덕션 5.2초 DB/Redis 네트워크 레이턴시 포함
로컬 (macOS, Docker) 986ms 네트워크 비용 없음, 커넥션 수립 빠름

로컬 환경에서 Warmup ON/OFF에 따른 첫 요청 응답을 비교하면 차이가 명확합니다.

조건 첫 /health HTTP 요청 비고
Warmup OFF 1,244ms DispatcherServlet init + Security Filter 초기화 + 클래스 로딩
Warmup ON 7ms 이미 초기화 완료

ECS 프로덕션 환경에서는 이 차이가 더 극대화됩니다.

환경 조건 첫 /api/v1/main 두 번째 요청
ECS Warmup OFF 3,447ms 315ms
ECS Warmup ON 249ms ~17ms

해석은 이렇습니다. 기동 전체 시간은 거의 유지하면서, 고객에게 노출되던 초기화 비용을 배포 직후 구간으로 옮겼습니다. 로컬에서는 네트워크 비용이 없어서 ON/OFF 차이가 작지만, 프로덕션에서는 DB/Redis 핸드셰이크 비용이 가산되어 차이가 극대화됩니다.


실무 체크리스트

  1. 첫 요청이 느린 API를 1개 먼저 고릅니다.
  2. 해당 경로가 실제 핫패스인지 확인합니다.
  3. ApplicationReadyEvent + timeout + fail-open으로 Warmup을 구현합니다.
  4. Health Gate를 붙여 Warmup 전 트래픽 유입을 차단합니다.
  5. Before/After를 같은 조건에서 반복 측정합니다.
  6. 효과가 확인되면 API 범위를 단계적으로 확장합니다.

정리

  • Warmup은 초기화 비용을 제거하는 기술이 아니라, 비용이 발생하는 시점을 고객 요청 이전으로 옮기는 기술입니다.
  • /api/v1/main 응답이 3,447ms에서 249ms로 개선되었고, Warmup 소요는 ECS 기준 약 5초입니다.
  • Warmup 로직 자체보다 Health Gate와 실패 정책(fail-open)을 먼저 설계하는 것이 안전합니다.
  • Warmup 대상 API는 설정 기반으로 관리하고, 주기적으로 재검토해야 합니다.

비슷한 문제를 겪고 계시거나 다른 접근 방식이 있다면 댓글로 공유 부탁드립니다!


참고 자료

  • Spring Boot Application Events: https://docs.spring.io/spring-boot/reference/features/spring-application.html#features.spring-application.application-events-and-listeners
  • Spring Boot Actuator Endpoints: https://docs.spring.io/spring-boot/reference/actuator/endpoints.html
  • Spring Boot Availability & Probes: https://docs.spring.io/spring-boot/reference/actuator/endpoints.html#actuator.endpoints.kubernetes-probes
  • ALB Target Group Health Checks: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html
  • HikariCP Configuration: https://github.com/brettwooldridge/HikariCP#configuration-knobs-baby
  • Java HotSpot VM Performance Enhancements: https://docs.oracle.com/en/java/javase/21/vm/java-hotspot-virtual-machine-performance-enhancements.html
  • JVM Tiered Compilation & PrintCompilation: https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html

Updated:

Comments