배포만 하면 느려지는 API? JVM 첫 요청 Warmup 개선기
공통 초기화 경로로 학습한 JDK 25 AOTCache만으로는 실제 비즈니스 API의 첫 요청 지연이 사라지지 않았습니다. 비즈니스 경로를 readiness 이전에 실행해 첫 요청 중앙값을 112.7ms에서 47.9ms로 줄인 과정과 JVM warmup의 원리를 정리했습니다.
Sample code“지금 API가 조금 느린 것 같은데, 혹시 방금 배포하셨나요?”
우리 서비스는 새 instance를 먼저 띄우고 health check를 통과하면 traffic을 전환하는 rolling 방식으로 배포하고 있었습니다. 기존 instance를 유지한 채 새 버전을 확인하므로 사용자는 배포를 알아차리지 않을 것이라고 생각했습니다. 그런데 응답 속도만으로 배포 시점을 짐작한 메시지를 받았습니다.
배포 때마다 CPUUtilization이 100%까지 오르는 모습도 이미 보고 있었습니다. ECS의 CPUUtilization 100%는 장애가 아니라 task에 예약된 CPU를 JVM이 모두 사용하고 있다는 뜻입니다. class loading과 Spring context 초기화, JIT compilation이 몰리는 기동 구간이라면 자연스러운 현상일 수 있습니다. 하지만 CPU가 바쁜 시점과 새 instance로 traffic이 넘어가는 시점이 겹치고, 사용자가 실제 지연까지 느꼈다면 단순한 기동 특성으로 넘길 수 없었습니다.
그래서 배포 구간의 지표를 다시 확인하고 JVM warmup을 원인 후보로 잡았습니다. 운영의 aggregate CPU와 p99만으로는 특정 instance의 첫 요청을 분리할 수 없었기 때문에, 먼저 로컬에서 CPU·memory·AOT cache 조건을 고정하고 컨테이너를 반복 재기동했습니다. 이미 적용한 JDK 25 AOTCache가 왜 실제 비즈니스 요청의 지연을 전부 없애지 못하는지도 함께 확인했습니다.
결론부터 말하면 AOTCache가 실패한 것은 아니었습니다. 다만 AOTCache를 만들 때 실행한 공통 초기화 경로와 사용자가 실제로 호출하는 경로가 달랐습니다. 실제 traffic은 조회·DB mapping·JSON serialization을 수행하는 비즈니스 API로 들어왔습니다. cache는 학습 실행에서 본 것만 미리 준비할 수 있습니다.
조건을 통제해 다섯 번씩 재기동한 결과, 실제 비즈니스 경로를 readiness 전에 한 번 호출했을 때 첫 요청 중앙값은 112.7ms에서 47.9ms로 57.5% 감소했습니다. 첫 요청과 이후 요청 p50의 차이인 cold penalty는 95.6ms에서 31.7ms로 66.8% 감소했습니다. 완전히 0이 되지는 않았습니다. 이 글에서는 그 남은 31.7ms까지 설명합니다.
배포는 사용자가 눈치채지 못해야 했습니다
처음 받은 카카오톡은 단순한 성능 문의가 아니었습니다. 서버가 오류 없이 교체됐더라도 사용자가 응답시간으로 배포를 알아차렸다면, 운영 관점에서는 투명한 배포라고 보기 어려웠습니다.
새 instance가 readiness를 통과하면 load balancer가 요청을 전달합니다. 이때 process와 health check만 준비됐을 뿐 주요 비즈니스 경로가 데워지지 않았다면, 새 instance의 첫 사용자가 class loading, serializer 생성, DB 경로 초기화, JIT compilation 비용을 대신 지불합니다. instance가 여러 개 교체되면 이 비용도 새 instance마다 반복됩니다.
이 문제는 평균 응답시간만 보면 잘 드러나지 않습니다. 대부분의 요청은 이미 데워진 instance가 처리하고, 일부 요청만 막 투입된 instance에 도착하기 때문입니다. 전체 평균은 안정적으로 보여도 배포 구간의 p95와 p99는 튈 수 있습니다. 사용자 입장에서는 같은 화면이 어떤 때는 빠르고 어떤 때는 느린 간헐적인 문제로 보입니다. 어느 instance가 처리했는지 모르면 개발자가 같은 현상을 재현하기도 어렵습니다.
첫 요청 지연이 client나 gateway의 timeout 예산을 넘으면 영향은 더 커집니다. client의 retry가 새 요청을 만들고, 느린 요청은 그동안 application thread와 DB connection을 더 오래 점유합니다. JIT compilation이 사용하는 CPU까지 겹치면 새 instance가 traffic을 받자마자 추가 부하를 떠안게 됩니다. 정상 배포를 성능 회귀나 장애로 오판해 rollback할 가능성도 생깁니다.
이번 로컬 실험에서 측정한 95.6ms의 cold penalty가 곧바로 production timeout을 만든다는 뜻은 아닙니다. 모든 요청이 95.6ms씩 느려진다는 의미도 아닙니다. 다만 배포 때마다 새 instance의 소수 요청에 비용이 집중되고, 그 결과가 tail latency로 나타나는 구조는 확인할 수 있었습니다.
그래서 목표를 단순히 “5xx 없이 배포하기”로 두지 않았습니다. 사용자가 배포 사실을 응답 지연으로도 눈치채지 못하게 하는 것을 목표로 삼았습니다. readiness는 프로세스가 실행 중이라는 표시를 넘어, 최소한의 대표 비즈니스 경로를 사용자에게 넘겨도 되는 시점이어야 했습니다. 첫 사용자가 내던 warmup 비용을 readiness 이전 내부 요청으로 옮긴 이유입니다.
먼저, JVM warmup이란 무엇인가
Java 소스 코드는 바로 CPU가 실행하는 기계어가 되지 않습니다. javac가 .java를 JVM 명령어인 bytecode가 들어 있는 .class로 컴파일하고, JVM이 실행 시점에 그 bytecode를 현재 CPU에 맞게 처리합니다. 같은 JAR를 macOS ARM64와 Linux x86_64에서 실행할 수 있는 이유가 이 중간 계층에 있습니다.
HotSpot JVM의 실행을 단순화하면 다음과 같습니다.
- 필요한 class를 찾아 bytecode를 읽고 검증합니다.
- class를 JVM 내부 자료구조로 만들고 linking합니다.
- 처음에는 interpreter 또는 낮은 최적화 단계에서 method를 실행합니다.
- 실행 횟수, 실제 타입, 자주 선택되는 분기 같은 profile을 모읍니다.
- 자주 실행되는 hot method를 JIT compiler가 native code로 컴파일합니다.
- 더 많은 profile이 쌓이면 더 공격적으로 최적화하거나, 가정이 깨졌을 때 deoptimization합니다.
HotSpot의 tiered compilation은 interpreter와 C1/C2 compiler를 함께 사용합니다. 시작부터 모든 method를 최고 수준으로 컴파일하지 않는 이유는 간단합니다. 전체 컴파일은 시작 시간을 늘리고, 실제로 한 번도 실행하지 않을 코드에도 CPU와 메모리를 씁니다. 대신 실행 중에 발견한 hot spot에 비용을 집중합니다. Oracle JVM 기술 개요와 java의 tiered compilation 옵션은 이 적응형 실행 방식을 설명합니다.
그 때문에 “프로세스가 떴다”와 “주요 요청 경로가 충분히 준비됐다”는 같은 상태가 아닙니다. Spring context와 HTTP server가 준비되어도 아래 작업은 특정 요청이 들어와야 처음 실행될 수 있습니다.
- 해당 controller와 filter chain의 실제 분기
- DTO를 JSON으로 바꾸는 serializer와 metadata
- DB connection pool의 첫 대여, query 실행, row mapping
- proxy와 reflection 경로
- 특정 타입과 분기를 관찰한 뒤 진행되는 JIT compilation
- 운영체제 page cache, DB buffer처럼 JVM 밖에 있는 계층
이 준비 비용이 초반 요청에 몰리는 현상을 넓게 JVM warmup이라고 부릅니다. JIT만의 문제가 아니라 class loading, framework의 지연 초기화, 애플리케이션과 인프라 cache가 함께 만드는 현상입니다.
AOTCache는 어떤 캐시인가
여기서 말하는 AOTCache는 JDK 25의 HotSpot AOT cache입니다. Redis나 HTTP response cache처럼 요청 결과를 저장하는 캐시가 아닙니다. 애플리케이션을 한 번 학습 실행하고, 그 실행에서 관찰한 JVM 상태를 파일로 남겨 다음 JVM이 시작할 때 재사용하는 방식입니다.
JDK 25에서는 다음처럼 cache를 만들고 사용할 수 있습니다.
# 학습 실행에서 관찰한 JVM 상태를 app.aot에 기록
java -XX:AOTCacheOutput=app.aot -jar app.jar
# 다음 실행에서 cache를 로드
java -XX:AOTCache=app.aot -jar app.jar
명령이 짧아 보여도 cache 안에는 서로 다른 두 종류의 정보가 들어갑니다.
1. class loading과 linking 결과
JEP 483: Ahead-of-Time Class Loading & Linking은 학습 실행에서 읽고, 파싱하고, 로드하고, linking한 class를 AOT cache에 담습니다. 다음 실행은 같은 class를 처음부터 다시 처리하는 비용을 줄일 수 있습니다. Spring처럼 시작 과정에서 많은 class와 framework metadata를 다루는 애플리케이션에 특히 유용합니다.
다만 학습할 때 등장하지 않은 class까지 예측해서 넣는 기능은 아닙니다. training workload가 실행하지 않은 경로의 class는 다음 실행에서도 평소처럼 runtime에 로드하고 linking해야 합니다.
2. method 실행 profile
JEP 515: Ahead-of-Time Method Profiling은 학습 실행에서 수집한 method profile을 cache에 저장합니다. 호출 빈도, 관찰한 receiver type, 분기 방향 같은 정보입니다. 다음 JVM의 JIT compiler는 빈 profile에서 시작할 때보다 일찍 최적화 판단을 내립니다.
여기서 가장 헷갈리기 쉬운 점이 있습니다. AOTCache는 최종 native code를 저장하는 code cache가 아닙니다. 다음 JVM도 native code를 만들기 위한 JIT compilation을 수행합니다. AOT method profile은 JIT가 출발할 위치를 앞당기지만, compilation CPU 비용 자체를 없애지는 않습니다. 이것이 한 번의 runtime warmup 뒤에도 작은 cold penalty가 남을 수 있는 이유 중 하나입니다.
JEP 514: Ahead-of-Time Command-Line Ergonomics는 이 기능을 -XX:AOTCacheOutput과 -XX:AOTCache로 쓰기 쉽게 정리했습니다. 새로운 최적화 알고리즘을 추가했다기보다 JEP 483과 JEP 515로 쌓인 AOT 기능의 사용 절차를 단순화한 변화입니다.
이름이 비슷한 기술과 구분하기
“AOT”라는 이름이 여러 기술에 붙어 있어 처음 보면 거의 반드시 헷갈립니다.
| 기술 | 미리 준비하는 것 | 실행 형태 | 이 글의 문제와의 관계 |
|---|---|---|---|
| CDS / AppCDS | 공유 가능한 class metadata | 일반 JVM | class loading 비용을 줄이는 기반 기술 |
| JDK 25 AOTCache | 학습된 class loading/linking 상태와 method profile | 일반 JVM + JIT | 이미지 시작과 JIT의 출발점을 앞당김 |
| Spring AOT | bean 구성을 바탕으로 생성한 source/bytecode와 runtime hint | JVM AOT mode 또는 Native Image 준비 | Spring의 동적 구성을 build-time 산출물로 바꿈 |
| GraalVM Native Image | 정적 분석을 거친 platform별 native executable | JVM 없이 native 실행 | 빠른 시작과 작은 메모리를 노리지만 build와 호환성 모델이 다름 |
Spring AOT는 JDK AOTCache와 별개입니다. Spring Boot의 processAot는 runtime에 기여할 bean의 persistent view와 생성 코드를 만듭니다. 일반 JVM에서 이 코드를 시험하려면 spring.aot.enabled=true가 필요합니다. Spring Boot AOT Gradle 문서와 JVM에서 AOT 처리 결과를 시험하는 방법에 이 차이가 정리되어 있습니다.
GraalVM Native Image는 더 멀리 갑니다. 애플리케이션을 platform별 실행 파일로 만들고 일반적인 JVM/JIT 없이 실행합니다. 대신 closed-world 가정과 reflection/resource/proxy hint 같은 제약을 받아들여야 합니다. Spring Boot Native Image 문서도 JVM 배포와 다른 실행 모델임을 명확히 구분합니다.
이번 선택은 “JDK AOTCache 또는 runtime warmup”이 아니었습니다. 둘은 서로 다른 구간을 줄입니다. AOTCache는 프로세스 시작의 공통 기반을 앞당기고, business-path warmup은 실제 사용자 경로에 남은 runtime 비용을 traffic 이전에 실행합니다.
readiness 전에 실제 경로를 호출하기
해결 방법은 단순했습니다. Spring ApplicationRunner에서 loopback 주소로 실제 read-only 비즈니스 API를 호출하고, 모든 warmup 요청이 성공한 뒤에만 health를 UP으로 바꿨습니다.
class StartupWarmup(
private val paths: List<String>,
private val requestExecutor: StartupWarmupRequestExecutor,
) : ApplicationRunner, HealthIndicator {
private val warmedUp = AtomicBoolean(false)
override fun run(args: ApplicationArguments) {
paths.forEach(requestExecutor::execute)
warmedUp.set(true)
}
override fun health(): Health =
if (warmedUp.get()) Health.up().build()
else Health.outOfService().build()
}
loopback HTTP를 택한 이유는 controller method만 직접 호출하는 것보다 실제 경로에 가깝기 때문입니다. HTTP server, routing, filter, argument binding, controller, service, repository, response serialization을 함께 통과합니다. 외부 URL로 빠지지 않도록 /로 시작하는 application-relative path만 허용했고, 2xx가 아니거나 timeout이 나면 startup을 실패하게 했습니다.
Spring Boot는 ApplicationRunner와 CommandLineRunner가 끝난 뒤 readiness를 traffic 수용 상태로 바꿉니다. startup runner에서 warmup을 수행하면 준비되지 않은 instance가 먼저 요청을 받는 일을 피할 수 있습니다. Spring Boot application availability 문서가 runner와 readiness 상태의 관계를 설명합니다.
함께 공개한 실행 가능한 sample code에는 다음 조건을 test로 고정했습니다.
- warmup 전 health는
OUT_OF_SERVICE - 등록한 경로를 모두 호출한 뒤에만
UP - 한 경로라도 실패하면 계속
OUT_OF_SERVICE - loopback이 아닌 외부 URL은 거부
- 2xx가 아닌 응답은 warmup 실패로 처리
JVM과 warmup 흐름을 한 화면으로 보면
아래 이미지는 이 글의 설명을 실제 로컬 Grafana text panel로 구성해 캡처한 화면입니다. 첫 번째 줄은 AOT cache 생성, 두 번째는 runtime warmup이 없는 경우, 세 번째는 비즈니스 경로를 readiness 전에 실행한 경우입니다.

핵심은 비용의 위치입니다. warmup이 비용을 삭제한 것은 아닙니다. 사용자 첫 요청이 내던 비용의 일부를 readiness 이전 내부 요청으로 옮겼습니다.
측정 방법
서로 다른 조건을 한 프로세스에서 순서대로 비교하면 두 번째 조건이 이미 데워진 JVM의 이득을 봅니다. 그래서 각 측정마다 컨테이너를 완전히 재기동했습니다.
| 항목 | 조건 |
|---|---|
| Runtime | Eclipse Temurin JRE 25.0.3, JDK 25 AOTCache |
| Container | Docker Desktop ARM64, 1 CPU, 768MiB |
| Heap | -Xms128m -Xmx352m |
| Dataset | 공개 범위의 합성 프로필 20개 |
| 비교 조건 | runtime warmup 없음 / 실제 비즈니스 경로 warmup |
| 반복 | 조건별 5회, 실행 순서를 교차 배치 |
| 첫 요청 이후 | 같은 API 20회 호출해 warm p50/p95 계산 |
| AOT cache | 두 조건 모두 같은 cache 사용, 동일한 공통 경로로 학습 |
schema와 filesystem cache를 안정시키기 위한 pilot 1회는 기록에서 제외했습니다. 다섯 번은 큰 표본은 아니지만, 각 round에서 두 조건의 순서를 번갈아 배치했고 모든 첫 요청은 HTTP 200과 같은 20개 response를 반환했습니다.
첫 요청은 얼마나 줄었나
| 지표 중앙값 | Before: runtime warmup 없음 | After: business-path warmup | 변화 |
|---|---|---|---|
| 첫 비즈니스 요청 | 112.7ms | 47.9ms | -57.5% |
| 이후 요청 p50 | 17.1ms | 16.2ms | -0.9ms |
| Cold penalty | 95.6ms | 31.7ms | -66.8% |
| Readiness 도달 | 6.83s | 7.54s | +0.71s |
| 요청 후 container memory | 443.5MiB | 473.0MiB | +29.5MiB |
여기서 cold penalty는 첫 요청 - 이후 요청 p50으로 계산했습니다. steady-state p50은 거의 같지만 첫 요청과의 간격이 크게 줄었습니다. 전체 API가 영구적으로 두 배 빨라진 것이 아니라, 배포 직후 첫 요청에 몰렸던 비용이 줄었다는 뜻입니다.
warmup 내부 요청 자체는 중앙값 490.4ms가 걸렸습니다. readiness가 중앙값 기준 0.71초 늦어진 이유입니다. 한 번의 측정에서는 readiness polling과 runtime 변동이 겹쳐 11.57초까지 올라갔습니다. 그래서 startup 시간이 정확히 0.71초 늘어난다고 일반화하기보다, rollout 동안 instance가 traffic을 받기 전 준비 시간이 늘어난다는 방향으로 해석해야 합니다.
실제 부하를 흘리고 Grafana로 확인하기
다섯 개 점만 예쁘게 그리면 운영 그래프처럼 보일 수는 있지만, 실제 traffic 아래에서 안정적인지는 알 수 없습니다. 별도 실행에서 같은 read-only API에 60초 동안 2,864건을 실제로 보냈습니다. 부하는 12→45→110→25→90→35 RPS로 바꿔 두 번의 burst를 만들었습니다.
- 성공: 2,864 / 2,864, HTTP 2xx 100%
- 평균: 47.7 RPS
- peak: 111 requests/s
- latency p50: 7.2ms
- latency p95: 14.7ms
- latency p99: 35.7ms
- max: 61.6ms
아래 이미지는 Grafana 13.1.3에 raw CSV와 restart 결과를 data source로 넣어 dashboard를 렌더링한 뒤 캡처한 화면입니다. 상단의 빨간 Before와 초록 After가 첫 요청과 cold penalty의 전후 값이고, 하단은 2,864건의 실제 로컬 요청과 restart별 결과입니다.

이 부하 결과는 production traffic이 아닙니다. 운영처럼 밀도가 있는 시계열을 보기 위해 만든 local load test이며, 합성 데이터만 사용했습니다. dashboard JSON과 측정 CSV도 sample repository에 함께 공개했습니다.
그런데 왜 47.9ms가 16.2ms까지 내려오지 않았나
한 번의 business-path warmup으로 모든 runtime 상태를 완성할 수는 없습니다.
JIT compilation은 “method를 한 번 실행하면 즉시 최고 단계 native code가 된다”는 구조가 아닙니다. invocation threshold와 profile 축적이 필요하고 compiler thread가 비동기로 작업합니다. 내부 warmup 요청이 compilation을 촉발했더라도 다음 사용자 요청 시점에 모든 compilation이 끝났다고 보장할 수 없습니다.
한 번의 요청은 한 가지 branch와 type 조합만 보여 줍니다. query parameter, 데이터 분포, nullable field, collection 크기가 달라지면 다른 코드와 serializer 분기가 실행됩니다. warmup endpoint를 많이 추가한다고 모두 해결되는 것은 아닙니다. 실제 주요 traffic을 대표하는 소수의 안전한 경로를 골라야 합니다.
JVM 밖의 변동도 남습니다. DB buffer, connection 상태, container CPU scheduling, filesystem page cache는 요청마다 달라질 수 있습니다. AOTCache에는 DB result나 HTTP response가 들어 있지 않습니다. AOT method profile 역시 최종 native code는 아닙니다. profile을 이용하면 JIT가 더 빨리 좋은 code를 만들 수 있지만, JIT compilation 자체는 다음 실행에서도 일어납니다.
그래서 이번 결과는 “warmup 문제를 완전히 제거했다”보다 첫 사용자에게 노출되던 cold penalty의 약 3분의 2를 readiness 이전으로 이동했다고 표현하는 편이 정확합니다.
운영 적용 기준 수치
로컬 재기동 실험에서 확인한 값을 운영 적용의 기준으로 삼았습니다. 같은 business-path warmup을 새 instance의 readiness 이전에 넣으면 실행 순서는 AOT cache load → Spring Boot 시작 → 내부 비즈니스 API 호출 → readiness UP → load balancer traffic 전환이 됩니다. 사용자가 처음 호출하던 코드를 내부 요청이 먼저 실행하는 구조입니다.
아래 표는 조건별로 다섯 번씩 컨테이너를 재기동해 얻은 중앙값입니다. 운영 배포에서는 이 값을 첫 요청 지연, readiness, memory에 대한 적용 기준으로 사용합니다.
| 운영 적용 기준 | 적용 전 | 적용 후 기준 | 변화 |
|---|---|---|---|
| 첫 외부 비즈니스 요청 | 112.7ms | 47.9ms | -57.5% |
| 첫 요청 cold penalty | 95.6ms | 31.7ms | -66.8% |
| 이후 요청 p50 | 17.1ms | 16.2ms | -5.3% |
| 이후 요청 p95 | 73.4ms | 63.4ms | -13.6% |
| Readiness 도달 | 6.83s | 7.54s | +10.4% |
| 요청 후 container memory | 443.5MiB | 473.0MiB | +6.7% |
이 기준은 steady-state 성능을 크게 바꾸기보다 새 instance의 첫 사용자에게 집중되던 지연을 줄이는 데 초점을 둡니다. readiness가 약 0.71초 늦어지고 memory가 29.5MiB 늘어나는 대신, 첫 요청 중앙값과 cold penalty를 각각 57.5%, 66.8% 줄이는 교환입니다. 60초 동안 2,864건을 보낸 부하 실험에서도 2xx 100%, p95 14.7ms, p99 35.7ms를 유지했습니다.
CPU peak를 같은 비율로 낮추는 것은 적용 기준에 포함하지 않았습니다. warmup은 class loading과 JIT compilation 비용을 없애는 것이 아니라 traffic을 받기 전에 실행합니다. 따라서 핵심 효과는 CPU 최대값 감소가 아니라, JVM이 기동 비용을 치르는 동안 외부 요청을 받지 않는 것입니다.
운영 적용 뒤에는 ALB 전체 p99만 보지 않고 다음 항목을 함께 수집해야 합니다.
- deployment revision 또는 task ID
- request path와 handler
- instance가 ready가 된 뒤 몇 번째 요청인지
- warmup duration과 성공 여부
- 첫 N개 요청의 latency histogram
이 정보가 있으면 task 시작 → warmup 완료 → readiness UP → 첫 외부 요청 순서를 재구성하고, 위 기준을 운영에서도 충족했는지 확인할 수 있습니다.
적용할 때의 체크리스트
runtime warmup endpoint는 아무 API나 고르면 안 됩니다.
- GET처럼 idempotent하고 side effect가 없어야 합니다.
- 외부 결제, 알림, 메시지 발행을 호출하지 않아야 합니다.
- production data를 변경하거나 불필요하게 노출하지 않아야 합니다.
- timeout과 전체 startup 예산이 있어야 합니다.
- 실패했는데 readiness를 열어 버리지 않도록 fail-closed로 처리해야 합니다.
- rollout 중 동시에 뜨는 instance 수를 고려해 DB burst를 제한해야 합니다.
- warmup 경로가 실제 주요 traffic을 대표하는지 주기적으로 다시 봐야 합니다.
특히 warmup은 공짜가 아닙니다. 이번 실험에서도 readiness는 느려졌고 memory는 늘었습니다. 첫 요청 latency, rollout 시간, 추가 memory 사이의 교환입니다. autoscaling과 rolling deployment가 이 준비 시간을 감당할 수 있는지 함께 봐야 합니다.
마치며
처음에는 “AOT를 켰는데 왜 아직 첫 요청이 느리지?”라는 질문이었습니다. 측정해 보니 AOTCache와 runtime warmup은 경쟁하는 해법이 아니었습니다.
AOTCache는 학습 실행에서 본 class loading/linking 상태와 method profile을 다음 JVM에 넘깁니다. 하지만 최종 native code, DB result, 실제 비즈니스 경로의 모든 분기까지 저장하지는 않습니다. 학습하지 않은 경로에는 runtime 비용이 남습니다.
그 남은 비용을 실제 business path로 한 번 실행하고 readiness 뒤로 숨겼을 때 첫 요청은 112.7ms에서 47.9ms로 줄었습니다. 대신 시작 시간과 memory 비용을 지불했습니다. 결국 중요한 것은 “AOT를 켰는가”가 아니라 무엇을 학습했고, 어떤 비용을 사용자 전과 후 중 어디에서 지불하도록 설계했는가였습니다.