Spring Boot 애플리케이션을 운영하다 보면 배포 직후 첫 요청에서만 유독 응답 시간이 길어지는 경우가 있습니다.
대표적으로 다음과 같은 작업들이 첫 요청 시점에 몰려서 발생합니다.
이런 작업들은 대부분 한 번 초기화되고 나면 이후 요청에서는 훨씬 빠르게 동작합니다. 문제는 그 “첫 번째 요청”이 실제 사용자 트래픽일 수 있다는 점입니다.
spring-boot-warmup-starter는 이런 초기화 비용을 애플리케이션 기동 직후로 당기기 위한 Spring Boot starter입니다.
애플리케이션이 트래픽을 받기 전에 주요 API와 인프라 커넥션을 미리 호출해두고, warmup이 끝나기 전에는 readiness를 OUT_OF_SERVICE로 유지할 수 있도록 지원합니다.
Spring Boot 애플리케이션은 ApplicationReadyEvent가 발생했다고 해서 모든 런타임 경로가 이미 준비된 것은 아닙니다.
예를 들어 특정 API가 처음 호출될 때 다음과 같은 일이 발생할 수 있습니다.
이 작업들은 애플리케이션 기동 이후 첫 사용자 요청에서 한꺼번에 발생할 수 있습니다.
Warmup starter의 목표는 단순합니다.
첫 요청에서 발생하기 쉬운 지연을 애플리케이션 기동 단계로 옮긴다.
이를 통해 배포 직후 사용자 요청의 latency spike를 줄이고, Kubernetes 환경에서는 warmup이 완료된 Pod만 트래픽을 받도록 구성할 수 있습니다.
spring-boot-warmup-starter는 다음 기능을 제공합니다.
@WarmUpApi annotation 기반 API warmupwarmup.api.base-url 설정 지원warmUp health indicator 제공OUT_OF_SERVICE로 유지 가능WarmUpTask 확장 가능현재 starter는 다음 환경을 기준으로 합니다.
Gradle Kotlin DSL 기준으로 dependency를 추가합니다.
repositories {
mavenLocal()
mavenCentral()
}
dependencies {
implementation("com.kakaomobility:spring-boot-warmup-starter:0.1.0-SNAPSHOT")
}
로컬에서 먼저 테스트하려면 starter 프로젝트에서 publishToMavenLocal을 실행합니다.
./gradlew publishToMavenLocal
이 starter는 Spring Boot auto-configuration으로 동작합니다.
애플리케이션이 dependency를 추가하면 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports를 통해 WarmUpAutoConfiguration이 로딩됩니다.
기동 흐름은 다음과 같습니다.
sequenceDiagram
participant App as Spring Boot App
participant Auto as WarmUpAutoConfiguration
participant Server as Embedded Web Server
participant Runner as WarmUpRunner
participant Tasks as WarmUpTask List
participant Health as warmUp HealthIndicator
App->>Auto: auto-configuration load
Auto->>Health: register warmUp health indicator
Auto->>Tasks: register enabled tasks
Server->>Auto: WebServerInitializedEvent(port)
App->>Runner: ApplicationReadyEvent
Runner->>Health: state = RUNNING
Runner->>Tasks: execute by order
Tasks-->>Runner: task results
Runner->>Health: state = COMPLETED or FAILED
핵심은 warmup이 ApplicationReadyEvent 이후 실행된다는 점입니다.
즉, Spring context가 준비되고 embedded web server가 올라온 뒤, 실제 API 호출과 인프라 warmup task가 실행됩니다.
API warmup은 실제 HTTP 요청을 애플리케이션 자기 자신에게 보내는 방식으로 동작합니다.
Warmup 대상 controller method에 @WarmUpApi annotation을 붙이면 됩니다.
import com.kakaomobility.warmup.api.WarmUpApi
import com.kakaomobility.warmup.api.WarmUpHeader
import com.kakaomobility.warmup.api.WarmUpHttpMethod
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class OrderController {
@WarmUpApi(
name = "create-order",
method = WarmUpHttpMethod.POST,
path = "/u/v1/wheel/wheel_call/orders",
bodyResource = "warmup/create-order.json",
headers = [
WarmUpHeader(name = "Authorization", value = "\${warmup.token}"),
],
expectedStatuses = [200, 201],
)
@PostMapping("/u/v1/wheel/wheel_call/orders")
fun createOrder() {
// ...
}
}
요청 body는 애플리케이션의 src/main/resources 아래에 둘 수 있습니다.
{
"orderId": "warmup-order",
"userId": "warmup-user",
"dryRun": true
}
이렇게 설정하면 warmup 시점에 실제로 해당 HTTP endpoint가 호출됩니다.
따라서 다음 경로가 실제 요청과 동일하게 실행됩니다.
@WarmUpApi는 Spring MVC Mapping을 추론하지 않는다WarmUpApiScanner는 Spring bean class의 method를 스캔해서 @WarmUpApi annotation을 찾습니다.
다만 @PostMapping, @GetMapping 등의 Spring MVC mapping 정보를 자동으로 추론하지 않습니다.
따라서 실제 warmup에서 호출할 HTTP method와 path는 반드시 @WarmUpApi에 명시해야 합니다.
@WarmUpApi(
method = WarmUpHttpMethod.POST,
path = "/orders",
)
@PostMapping("/orders")
fun createOrder() {
// ...
}
이 방식은 annotation의 동작을 명시적으로 만들고, warmup 대상 API를 Spring MVC mapping과 독립적으로 제어할 수 있게 합니다.
WarmUpApiTask는 annotation 정보를 바탕으로 JDK HttpClient 요청을 생성합니다.
동작 방식은 다음과 같습니다.
path가 http:// 또는 https://로 시작하면 absolute URL로 그대로 호출path가 상대 경로이면 warmup.api.base-url과 조합warmup.api.base-url이 없으면 embedded web server port를 감지해서 http://localhost:{port} 사용bodyResource는 ResourceLoader로 읽음bodyResource에 prefix가 없으면 classpath: resource로 처리path, body, bodyResource, header value에는 Spring placeholder 사용 가능expectedStatuses에 포함되지 않으면 실패로 기록기본 성공 status는 다음과 같습니다.
200, 201, 202, 204
필요하다면 annotation에서 API별로 성공 status를 조정할 수 있습니다.
API warmup은 실제 endpoint를 호출합니다.
이 점은 장점이지만, 동시에 주의해야 할 부분이기도 합니다.
예를 들어 주문 생성 API를 warmup으로 호출하면 다음과 같은 부작용이 발생할 수 있습니다.
이를 방지하기 위해 starter는 @WarmUpOutboundMock 기능을 제공합니다.
이 기능은 controller나 application service를 mock 처리하지 않습니다. 요청은 실제 API endpoint로 들어가고 application service도 실행됩니다.
다만 outbound adapter method에 도달했을 때, 해당 method body를 실행하지 않고 mock 값을 반환합니다.
sequenceDiagram
participant Warmup as WarmUpApiTask
participant API as Controller
participant App as Application Service
participant Out as Outbound Adapter
participant DB as DB or External Service
Warmup->>API: HTTP request with X-WarmUp-Request
API->>App: normal controller flow
App->>Out: call outbound method
Out-->>App: mock return by @WarmUpOutboundMock
Note over Out,DB: real DB/external call is not executed
이 방식의 장점은 실제 API 흐름 대부분을 실행하면서도 외부 부작용은 막을 수 있다는 점입니다.
도메인 객체, sealed class, generic type처럼 JSON 변환이 까다로운 반환값은 provider bean 방식을 사용하는 것이 좋습니다.
import com.kakaomobility.warmup.outbound.WarmUpMockContext
import com.kakaomobility.warmup.outbound.WarmUpMockProvider
import com.kakaomobility.warmup.outbound.WarmUpOutboundMock
import org.springframework.stereotype.Component
class OrderSaveAdapter {
@WarmUpOutboundMock(beanName = "warmUpOrderSaveMock")
fun save(command: SaveOrderCommand): SavedOrder {
// warmup 요청에서는 실행되지 않음
// 일반 요청에서는 정상 실행
return repository.save(command.toEntity()).toDomain()
}
}
@Component("warmUpOrderSaveMock")
class WarmUpOrderSaveMock : WarmUpMockProvider {
override fun provide(context: WarmUpMockContext): Any {
val command = context.arguments[0] as SaveOrderCommand
return SavedOrder(
id = 0L,
orderNo = "warmup-${command.orderType}",
status = OrderStatus.INIT,
)
}
}
WarmUpMockContext에는 다음 정보가 들어 있습니다.
따라서 입력 command 값을 기반으로 mock 객체를 동적으로 만들 수 있습니다.
반환 타입이 Jackson으로 역직렬화 가능한 단순 DTO라면 annotation에 JSON body를 직접 넣을 수 있습니다.
class VendorClientAdapter {
@WarmUpOutboundMock(
body = """{"vendorId":0,"name":"warmup-vendor","active":true}""",
)
fun findVendor(vendorId: Long): VendorResponse {
return vendorClient.findVendor(vendorId)
}
}
JSON을 resource 파일로 분리할 수도 있습니다.
class VendorClientAdapter {
@WarmUpOutboundMock(
bodyResource = "warmup/vendor-response.json",
)
fun findVendor(vendorId: Long): VendorResponse {
return vendorClient.findVendor(vendorId)
}
}
@WarmUpOutboundMock은 다음 조건을 모두 만족할 때만 적용됩니다.
warmup.enabled=truewarmup.outbound-mock.enabled=truewarmup.api.marker-header-name/value와 일치함@WarmUpOutboundMock(enabled = true)가 붙어 있음조건이 하나라도 맞지 않으면 실제 outbound method가 그대로 실행됩니다.
즉, 일반 사용자 요청에서는 mock이 적용되지 않습니다.
기본 marker header는 다음과 같습니다.
X-WarmUp-Request: true
이 header는 warmup 요청인지 판단하는 기준으로 사용됩니다.
따라서 외부 사용자가 임의로 이 header를 보낼 수 있는 환경이라면 주의가 필요합니다. 일반 요청에서도 outbound mock이 적용될 수 있기 때문입니다.
운영 환경에서는 다음 중 하나를 적용하는 것을 권장합니다.
warmup.api.marker-header-value를 환경별 secret 값으로 설정예를 들어 다음처럼 환경 변수 기반 secret 값을 사용할 수 있습니다.
warmup:
api:
marker-header-name: X-WarmUp-Request
marker-header-value: ${WARMUP_MARKER_VALUE}
API뿐 아니라 주요 인프라 커넥션도 warmup할 수 있습니다.
현재 지원하는 task는 다음과 같습니다.
각 task는 관련 dependency와 Bean이 있고, 해당 설정이 enable일 때만 등록됩니다.
예를 들어 warmup.jpa.enabled=true여도 EntityManager Bean이 없다면 JPA warmup task는 생성되지 않습니다.
JPA warmup은 native query를 한 번 실행하고, 설정에 따라 JPA metamodel에 접근합니다.
warmup:
jpa:
enabled: true
query: SELECT 1
include-metamodel: true
동작은 다음과 같습니다.
EntityManager.createNativeQuery(query).singleResult
EntityManager.metamodel.entities.size
이를 통해 DB connection과 JPA metamodel 초기화 비용을 미리 발생시킬 수 있습니다.
Mongo warmup은 ping command를 실행하고, 설정에 따라 collection 목록을 조회합니다.
warmup:
mongo:
enabled: true
ping-command: ping
list-collections: true
동작은 다음과 같습니다.
db.runCommand({ ping: 1 })
db.listCollectionNames().first()
Redis warmup은 RedisConnectionFactory를 사용합니다.
warmup:
redis:
enabled: true
key: warmup:${spring.application.name}:ping
value: ping
ttl: 5s
delete-after: true
동작은 다음과 같습니다.
PINGSET key valueGET keydelete-after=true이면 DEL keyRedisson warmup은 RedissonClient를 사용합니다.
Redis warmup과 Redisson warmup은 별도 task입니다.
프로젝트에서 Redisson만 사용한다면 redisson.enabled=true, 일반 Spring Data Redis만 사용한다면 redis.enabled=true로 설정하면 됩니다.
warmup:
redisson:
enabled: true
key: warmup:${spring.application.name}:ping
value: ping
ttl: 5s
delete-after: true
동작은 다음과 같습니다.
redissonClient.getBucket(key)bucket.set(value, ttl)bucket.get()delete-after=true이면 bucket.delete()Kafka warmup은 KafkaTemplate을 통해 producer metadata를 조회합니다.
메시지를 실제로 발행하지는 않습니다.
warmup:
kafka:
enabled: true
topics:
- gos-callback-dobo
targets:
- name: list-order
topic: gms-order-list
template-bean-name: listOrderKafkaTemplate
동작은 다음과 같습니다.
producer.partitionsFor(topic)producer.metrics()topics만 설정하면 모든 KafkaTemplate Bean으로 해당 topic을 확인합니다.
특정 template만 사용해야 하는 프로젝트라면 targets[].template-bean-name을 지정하는 것이 좋습니다.
warmup:
enabled: true
fail-fast: false
api:
enabled: true
base-url: http://localhost:${server.port}
connect-timeout: 3s
request-timeout: 10s
marker-header-name: X-WarmUp-Request
marker-header-value: ${WARMUP_MARKER_VALUE:true}
default-headers:
Content-Type: application/json
Authorization: ${warmup.token}
outbound-mock:
enabled: true
health:
enabled: true
include-details: true
running-status: OUT_OF_SERVICE
failure-status: DOWN
down-on-failure: true
jpa:
enabled: true
query: SELECT 1
include-metamodel: true
order: 100
mongo:
enabled: true
ping-command: ping
list-collections: true
order: 110
redis:
enabled: true
key: warmup:${spring.application.name}:ping
value: ping
ttl: 5s
delete-after: true
order: 120
redisson:
enabled: false
key: warmup:${spring.application.name}:ping
value: ping
ttl: 5s
delete-after: true
order: 121
kafka:
enabled: true
order: 130
topics:
- gos-callback-dobo
targets:
- name: list-order
topic: gms-order-list
template-bean-name: listOrderKafkaTemplate
starter는 warmUp health indicator를 등록합니다.
Warmup 상태에 따라 health status는 다음처럼 결정됩니다.
| Warmup 상태 | Health status | 의미 |
|---|---|---|
NOT_STARTED | OUT_OF_SERVICE | 아직 runner가 실행되지 않음 |
RUNNING | OUT_OF_SERVICE | warmup 진행 중 |
COMPLETED | UP | 모든 task 성공 또는 skipped |
FAILED | DOWN | 하나 이상의 task 실패 |
DISABLED | UP | warmup.enabled=false |
기본적으로 warmup 중에는 OUT_OF_SERVICE, 실패 후에는 DOWN을 반환합니다.
실패 후에도 health를 올리고 싶다면 다음처럼 설정할 수 있습니다.
warmup:
health:
down-on-failure: false
Kubernetes 환경에서는 warmup이 끝나기 전까지 Pod가 트래픽을 받지 않도록 구성하는 것이 중요합니다.
Spring Boot actuator readiness group에 warmUp indicator를 포함하면 됩니다.
management:
endpoint:
health:
probes:
enabled: true
group:
readiness:
include:
- readinessState
- warmUp
이렇게 설정하면 warmup이 완료되기 전 readiness endpoint가 OUT_OF_SERVICE를 반환합니다.
그 결과 Kubernetes Service는 해당 Pod로 트래픽을 보내지 않게 됩니다.
각 warmup task는 독립적으로 실행되고, 실행 결과는 WarmUpState에 저장됩니다.
warmup.fail-fast=false인 경우에는 다음처럼 동작합니다.
FAILED반면 warmup.fail-fast=true인 경우에는 다음처럼 동작합니다.
FAILED운영 환경에서는 warmup 실패를 배포 실패로 볼지, 경고로만 볼지에 따라 fail-fast와 health 설정을 조합해서 사용할 수 있습니다.
프로젝트마다 별도로 초기화해야 하는 리소스가 있을 수 있습니다.
예를 들어 내부 캐시를 미리 로딩하거나, 특정 feature flag를 조회하거나, 외부 시스템의 lightweight API를 호출해야 할 수 있습니다.
이 경우 WarmUpTask Bean을 추가하면 됩니다.
import com.kakaomobility.warmup.core.WarmUpTask
import com.kakaomobility.warmup.core.WarmUpTaskResult
import com.kakaomobility.warmup.core.runWarmUpTask
import org.springframework.stereotype.Component
@Component
class CacheWarmUpTask : WarmUpTask {
override val name: String = "cache"
override fun getOrder(): Int = 50
override fun warmUp(): WarmUpTaskResult =
runWarmUpTask(name) {
// load cache
"loaded"
}
}
starter의 runner는 등록된 WarmUpTask Bean을 자동으로 수집하고, order 값이 낮은 순서대로 실행합니다.
Custom task가 실패하면 다른 built-in task와 동일하게 전체 warmup 상태에 반영됩니다.
개발 시 사용할 수 있는 주요 명령은 다음과 같습니다.
./gradlew build
./gradlew ktlintFormat
./gradlew publishToMavenLocal
현재 테스트는 다음 내용을 검증합니다.
@WarmUpApi annotation scan@WarmUpOutboundMock이 warmup request에서만 outbound method를 대체하는지 여부spring-boot-warmup-starter는 Spring Boot 애플리케이션에서 배포 직후 발생할 수 있는 첫 요청 지연을 줄이기 위한 도구입니다.
단순히 DB에 SELECT 1을 날리는 수준을 넘어, 실제 HTTP API를 호출하고, request binding과 application service 흐름을 실행하며, 필요한 경우 outbound adapter만 mock 처리할 수 있습니다.
또한 JPA, Mongo, Redis, Redisson, Kafka 같은 주요 인프라 warmup을 설정만으로 활성화할 수 있고, Actuator health indicator를 통해 Kubernetes readiness와도 자연스럽게 연동할 수 있습니다.
운영 관점에서 중요한 점은 다음 세 가지입니다.
@WarmUpOutboundMock으로 차단할 수 있습니다.warmUp health indicator를 readiness group에 포함해 warmup 완료 전 트래픽 유입을 막는 것이 좋습니다.첫 요청 latency를 줄이고 배포 직후 안정적인 트래픽 전환을 만들고 싶다면, warmup을 애플리케이션 기동 흐름의 일부로 명시적으로 관리해볼 만합니다.