JVM 웜업해서 API 레이턴시 개선하기
배포 직후 첫 고객이 3초를 기다리는 문제를 해결한 과정을 정리했습니다.
JVM 기반 서버는 첫 요청이 느립니다
JVM은 실행 시점에 일부 비용을 지불하는 구조입니다. 클래스 파일은 처음 참조될 때 로딩되고, Spring Boot 기반 서버에서는 여기에 대표 조회 경로의 첫 실행 비용과 첫 DB 접근 경로에서 드러나는 초기 비용이 더해집니다.
이 비용은 “서버가 느리다”와는 다릅니다. 서버 시작 직후 단 한 번만 발생하고, 두 번째 요청부터는 사라집니다. 문제는 그 “단 한 번”이 첫 번째 고객 요청에서 터진다는 점입니다.
저희 서비스에서 확인한 문제
배포 직후 첫 요청과 두 번째 요청의 응답시간을 비교했습니다.
첫 /api/v1/main 요청: 3,447ms
두 번째 /api/v1/main 요청: 315ms
같은 API가 바로 다음 호출에서 315ms로 내려온다는 건, 비즈니스 로직 자체가 3초대라는 뜻이 아닙니다. 초기화 비용이 첫 고객 요청에 실려 나간 것입니다.
운영 관점에서 질문을 세 가지로 좁혔습니다.
- Spring Boot
Started로그 이후에 아직 끝나지 않은 초기화는 무엇인가? - 그 비용을 고객 요청 전에 미리 소진할 수 있는가?
- 소진 과정에서 장애 전파를 만들지 않는가?
여기서 중요했던 점은, 애플리케이션이 기동되었다는 신호와 첫 고객 요청을 가볍게 처리할 수 있는 상태가 항상 같지는 않다는 점이었습니다. 실제로는 첫 요청 경로에서 클래스 로딩, 대표 조회 경로의 첫 실행 비용, 첫 DB/Redis 접근 경로의 초기 비용이 남아 있을 수 있었습니다.
즉, 저희는 “애플리케이션 기동 완료”와 “고객 요청 처리 준비 완료”를 분리해서 보았습니다.
저희가 선택한 방식: Warmup + Health Gate
저희는 Warmup + Health Gate 조합을 선택했습니다. 장애 영향 범위를 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: 이미 준비된 인스턴스로 전달
핵심은 두 가지입니다.
- Warmup이 끝나기 전에는 Target Group 편입을 막습니다.
- Warmup 실패가 전체 기동 실패로 번지지 않게 timeout/fail-open 정책을 둡니다.
구현 포인트
1) Warmup 트리거
Warmup은 ApplicationReadyEvent 직후 시작했습니다. 실제 요청에서 사용하는 핵심 조회 경로를 미리 실행하기 위해 핵심 API를 병렬 호출했습니다.
@Component
class AppWarmup(
private val exampleController: ExampleController,
private val warmupState: WarmupState,
) {
@EventListener(ApplicationReadyEvent::class)
fun onReady(event: ApplicationReadyEvent) {
log.info("[Warmup] Starting warmup...")
runBlocking {
runCatching {
withTimeout(WARMUP_TIMEOUT_MS) {
listOf(
asyncIO { controller.getListA(userId = null) },
asyncIO { controller.getListB(page = 0, size = 20, ...) },
asyncIO { controller.getTreeByParent(parentId = 0) },
asyncIO { controller.getSuggestions(keyword = "a", limit = 10) },
asyncIO { controller.search(...) },
asyncIO { controller.getOverlays(page = 0, size = 10) },
asyncIO { controller.getPersonalized(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 메서드를 직접 실행합니다. asyncIO는 Dispatchers.IO에서 코루틴을 실행하는 확장 함수입니다.
컨트롤러 직접 호출의 트레이드오프
이 방식은 Spring의 HTTP 요청 경로(Filter → DispatcherServlet → HandlerMapping → Interceptor → Controller)를 거치지 않고, Controller 메서드를 Java 함수로 직접 호출합니다.
앞당길 수 있는 비용:
- service/repository/query path의 첫 실행 비용
- 관련 클래스 로딩과 검증
- 첫 DB/Redis 접근 경로에서 드러나는 초기 비용
직접 커버하지 않는 비용:
DispatcherServlet초기화- Filter / Interceptor / Spring Security Filter Chain
- 실제 HTTP 요청/응답 경로의 부가 비용
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. 요청 경로의 공통 실행 경로가 먼저 로드됐다
이번에는 OpenJDK 21.0.7, macOS, Docker MySQL/Redis 환경에서 class+load*, compilation*, inlining*, codecache* 로그를 함께 수집해, 웜업 전후에 JVM 안에서 실제로 어떤 일이 일어나는지 다시 확인해봤습니다.
앱 로그 기준으로는 아래 순서로 진행됐습니다.
11:58:53.231 Started ExampleAppApplicationKt in 38.423 seconds
11:58:53.291 [Warmup] Starting warmup...
11:58:53.938 [Warmup] Warmup completed, application is ready
즉, Started 로그 이후 약 60ms 뒤에 Warmup이 시작됐고, 실제 웜업 창은 약 647ms였습니다.
| API | Warmup 전 첫 고객 요청 | Warmup 후 첫 고객 요청 |
|---|---|---|
| 메인 페이지 API | 3,447ms | 249ms |
이번 계측에서 먼저 확인한 것은, 실제 서비스 구현체 상당수가 웜업 시점보다 앞선 startup 과정에서 이미 로드되어 있었다는 점이었습니다. 실제 로그에서도 웜업 시작 시각인 11:58:53.291보다 앞선 시각에 구현체와 프록시가 먼저 나타났습니다.
[2026-03-21T11:58:17.049+0900][info ][class,load] com.example.app.category.CategoryRepositoryImpl
[2026-03-21T11:58:17.053+0900][info ][class,load] com.example.app.component.service.ComponentServiceImpl
[2026-03-21T11:58:29.995+0900][info ][class,load] com.example.app.component.service.ComponentServiceImpl$$SpringCGLIB$$0
여기서 중요한 점은, 클래스가 startup 과정에서 이미 로드되는 것과 실제 요청 경로가 한 번 실행되어 준비되는 것은 다르다는 것입니다. Spring Boot는 기동 과정에서 component scanning, bean 생성, proxy 생성, 의존성 주입 같은 작업을 먼저 수행하기 때문에 핵심 Service/Repository 구현체 상당수는 Warmup 전에도 이미 JVM에 올라와 있을 수 있습니다. 하지만 그 이후에도 실제 쿼리 실행, transaction/jdbc/cache 경로 사용, Redis/Lettuce 접근, serializer와 DTO 보조 클래스 로딩처럼 요청을 한 번 태워봐야 드러나는 비용은 남아 있습니다.
즉 이번 웜업의 효과를 “우리 구현체 본체를 처음 로드해서 빨라졌다”라고 설명하는 것은 정확하지 않았습니다. 대신 웜업이 실제로 추가로 당겨온 것은 요청 경로에서 공통으로 재사용되는 Spring, RDB, Redis, Cache 실행 경로와, 그 위에서 함께 필요한 DTO·람다·코루틴 보조 클래스들이었습니다.
실제로 로컬에서 측정한 결과는 아래와 같았습니다.
| 구간 | 클래스 수 | 비고 |
|---|---|---|
| 기동 시 (Warmup 전) | 25,395 | 핵심 구현체와 프록시 상당수는 startup에서 이미 로드됨 |
| Warmup 중 (647ms) | 1,351 | 요청 경로의 공통 실행 경로가 추가로 로드됨 |
| Warmup 후 | 140 | 계측 스크립트 종료 과정에서 생긴 후처리/teardown 위주 |
Warmup 중 추가로 로드된 클래스의 대표 범주를 보면 아래와 같습니다.
| 분류 | 웜업 중 추가 class load 수 |
|---|---|
| Hibernate / QueryDSL | 311 |
| Redis / Lettuce / serializer | 273 |
| Spring transaction / jdbc / cache | 40 |
| 구현 코드 | 65 |
| 기타 (JDK / Spring core / DTO / 코루틴 보조 클래스 등) | 662 |
블로그 본문에도 이번 실험에서 나온 로그를 그대로 남겨두겠습니다. 아래는 웜업 구간에서 실제로 찍힌 class,load 로그입니다.
[2026-03-21T11:58:53.351+0900][info ][class,load ] org.springframework.orm.jpa.EntityManagerHolder source: spring-orm-6.2.0.jar
[2026-03-21T11:58:53.354+0900][info ][class,load ] org.springframework.jdbc.datasource.DataSourceUtils source: spring-jdbc-6.2.0.jar
[2026-03-21T11:58:53.373+0900][info ][class,load ] io.lettuce.core.protocol.DefaultEndpoint source: lettuce-core-6.4.1.RELEASE.jar
[2026-03-21T11:58:53.391+0900][info ][class,load ] com.example.app.component.service.ComponentServiceImpl$getHome$1 source: app classes
처음 보면 길지만 의미는 단순합니다.
[2026-03-21T11:58:53.351+0900]- 이 시각에
[info ][class,load]- JVM이 클래스를 메모리에 올렸고
org.springframework.orm.jpa.EntityManagerHolder- 실제로 올라온 클래스는 이것이며
source: spring-orm-6.2.0.jar- 이 클래스가 어느 라이브러리 JAR에서 왔는지를 보여줍니다.
즉 이 로그는 웜업 도중 Spring ORM/JDBC, Redis/Lettuce, 구현 코드 주변 클래스가 실제로 이 시점에 JVM에 올라왔다는 뜻입니다. 여기서는 경로 자체보다 어떤 라이브러리 클래스가 웜업되고 있는지를 읽는 편이 더 중요합니다.
이 결과를 보면, 이번 웜업은 몇 개의 비즈니스 클래스만 미리 올리는 작업이 아니라, 요청 경로에서 공통으로 사용하는 Spring transaction/jdbc/cache, Hibernate/QueryDSL, Redis/Lettuce 경로를 먼저 실행해 두는 작업에 가까웠습니다. 또한 일부 @Cacheable 경로를 실제로 호출하기 때문에 Redis와 Lettuce, 그리고 Redis 직렬화에 필요한 serializer 경로도 함께 웜업됩니다.
B. 웜업 중 JIT compilation과 inlining도 함께 진행되었다
여기서 JIT compilation은 JVM이 자주 실행되는 메서드를 실행 중에 더 빠른 기계어로 다시 컴파일하는 것을 뜻합니다. 그리고 inlining은 그 과정에서 작은 메서드 호출을 호출한 쪽에 코드로 삽입하는 최적화입니다.
이번 측정에서는 웜업 중 클래스 로딩만 일어난 것이 아니었습니다. 같은 구간에서 JIT compilation 712건과 inlining 관련 로그 5,053건도 함께 관찰되었습니다.
| 구간 | 관찰 내용 | 해석 |
|---|---|---|
| 웜업 전 | class load 25,395 |
핵심 구현체와 프록시 상당수는 startup에서 이미 로드됨 |
| 웜업 중 | class load 1,351 |
공통 실행 경로가 추가로 웜업됨 |
| 웜업 중 | JIT compilation 712 |
공통 프레임워크 경로에서 JIT 활동이 함께 관찰됨 |
| 웜업 중 | inlining 관련 로그 5,053 |
Spring/JDK 공통 경로의 인라인 관련 판단이 함께 관찰됨 |
| 웜업 후 | class load 140 |
주요 초기 비용은 대부분 웜업 창 안에서 소진됨 |
JIT 로그도 가공하지 않은 로그를 그대로 보면 아래와 같습니다. 앞서 본 class,load와 달리, 여기서는 compile id, tier, bytecode 크기, 인라인 판단 이유 같은 정보가 같이 붙습니다.
[2026-03-21T11:58:53.386+0900][debug][jit,compilation ] 20849 2 com.querydsl.core.types.Templates::add (48 bytes)
[2026-03-21T11:58:53.387+0900][debug][jit,inlining ] @ 11 com.querydsl.core.util.CollectionUtils::unmodifiableList (71 bytes) callee is too large
[2026-03-21T11:58:53.626+0900][debug][jit,compilation ] 20790 4 org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource::getTransactionAttribute (173 bytes)
[2026-03-21T11:58:53.637+0900][debug][jit,inlining ] @ 12 org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource::getCacheKey (10 bytes) inline (hot)
[2026-03-21T11:58:53.664+0900][debug][jit,compilation ] 20789 4 org.springframework.transaction.interceptor.TransactionAttributeSourcePointcut::matches (27 bytes)
[2026-03-21T11:58:53.664+0900][debug][jit,inlining ] @ 4 org.springframework.transaction.interceptor.AbstractFallbackTransactionAttributeSource::getTransactionAttribute (173 bytes) already compiled into a big method
[2026-03-21T11:58:53.854+0900][debug][jit,compilation ] 21206 1 com.fasterxml.jackson.databind.JavaType::getRawClass (5 bytes)
이 줄들은 아래처럼 읽으면 됩니다.
[debug][jit,compilation]- JVM이 어떤 메서드를 실제로 JIT 컴파일했다는 뜻입니다.
20849,20790,21206- JVM 내부 compile id입니다. 같은 메서드가 다시 컴파일되면 다른 id가 붙을 수 있습니다.
2,4,1- tier level입니다. 높을수록 더 강한 최적화 단계로 올라간 경우가 많습니다.
(48 bytes),(173 bytes)- 그 메서드의 바이트코드 크기입니다.
[jit,inlining]- 작은 메서드 호출을 호출한 쪽에 코드로 삽입할지 판단한 로그입니다.
@ 11,@ 12,@ 4- 호출한 메서드 안에서 어느 위치의 호출인지 나타내는 바이트코드 오프셋입니다.
callee is too large- 인라인 후보였지만 호출 대상 메서드가 너무 커서 넣지 않았다는 뜻입니다.
inline (hot)- 자주 실행되는 경로라서 실제로 인라인에 성공했다는 뜻입니다.
already compiled into a big method- 이 로직이 이미 더 큰 컴파일된 메서드 흐름 안에 들어가 있어, 같은 자리에서 다시 별도로 인라인할 의미가 크지 않다는 뜻입니다.
즉 이 로그는 웜업 중 JIT가 실제로 동작했고, 그 대상이 우리 비즈니스 메서드보다는 Spring transaction, QueryDSL, Jackson 같은 공통 경로였다는 점을 그대로 보여줍니다.
즉, 이번 웜업에서는 클래스 로딩과 JIT 활동이 실제로 함께 관찰되었지만, 그 직접적인 수혜자는 우리 비즈니스 메서드 자체보다 요청 경로에서 공통으로 재사용되는 Spring, Hibernate, QueryDSL, Redis/Lettuce, serializer 경로에 더 가까웠습니다.
결과 수치와 해석
결과
Warmup OFF: 첫 /api/v1/main 3,447ms
Warmup ON : 첫 /api/v1/main 249ms
같은 웜업 창 안에서는 아래 변화가 함께 관찰됐습니다.
| 항목 | 값 | 의미 |
|---|---|---|
| Warmup 길이 | 647ms |
Started 직후 요청 경로를 미리 실행한 구간 |
| Warmup 중 class load | 1,351 |
공통 실행 경로가 추가로 로드된 수 |
| Warmup 중 JIT compilation | 712 |
공통 프레임워크 경로에서 JIT 활동이 함께 관찰됨 |
| Warmup 중 inlining 관련 로그 | 5,053 |
Spring/JDK 공통 경로의 인라인 관련 판단이 함께 관찰됨 |
| 조건 | 첫 /api/v1/main | 두 번째 요청 |
|---|---|---|
| Warmup OFF | 3,447ms | 315ms |
| Warmup ON | 249ms | ~17ms |
해석은 이렇습니다. 기동 전체 시간을 크게 늘리지 않으면서, 고객에게 노출되던 초기화 비용을 배포 완료 이전 구간으로 옮겼습니다. 그 과정에서 요청 경로에서 공통으로 사용하는 Spring, RDB, Redis, Cache 경로가 먼저 웜업되었고, 같은 구간에서 클래스 로딩과 JIT 활동도 함께 관찰되었습니다.
즉, 이번 개선의 핵심은 고객에게 노출되던 초기화 비용을 배포 완료 이전 구간으로 옮기고, 요청 경로에서 공통으로 사용하는 실행 경로를 미리 웜업한 데 있었습니다.
MSA에서 웜업 시 주의할 점
웜업은 내부 초기화 비용을 배포 완료 이전 구간으로 옮기는 데 유효합니다. 다만 MSA처럼 여러 서비스가 서로 다른 외부 서비스를 호출하는 구조에서는, 웜업에 외부 API 호출을 그대로 넣는 것은 주의해야 합니다. Kubernetes Pod나 ECS Task가 여러 개 동시에 올라간 상태에서 각 인스턴스가 여러 외부 서비스를 함께 호출하면 호출 수가 N x M 형태로 늘어날 수 있고, 그만큼 외부 서비스 트래픽을 한꺼번에 밀어 넣는 상황이 생길 수 있습니다.
그래서 웜업 범위는 내부에서 제어 가능한 경로 위주로 잡는 편이 안전합니다. 예를 들면 Spring 내부 라이브러리 초기화, RDB 경로, Cache 경로처럼 서비스 내부에서 직접 통제 가능한 부분만 웜업하고, 외부 HTTP API 호출은 웜업 대상에서 제외하는 방식입니다. 현재 저희 서비스도 외부 API 호출은 웜업에 포함하지 않고, RDB와 Cache 같은 내부 경로 위주로만 웜업하고 있습니다.
참고 자료
- 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
- Oracle Java HotSpot VM Performance Enhancements: https://docs.oracle.com/en/java/javase/21/vm/java-hotspot-virtual-machine-performance-enhancements.html
- JVM Spec - Loading, Linking, and Initializing: https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-5.html
- ALB Target Group Health Checks: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/target-group-health-checks.html
- NAVER D2 - 웜업 시 주의할 점 참고: https://d2.naver.com/helloworld/1580651
Comments