Spring Boot Warmup Starter: 첫 요청 지연을 기동 단계로 당기기

SeungHyuk Shin·2026년 6월 3일

Spring Boot 애플리케이션을 운영하다 보면 배포 직후 첫 요청에서만 유독 응답 시간이 길어지는 경우가 있습니다.

대표적으로 다음과 같은 작업들이 첫 요청 시점에 몰려서 발생합니다.

  • Controller request binding 및 request body 역직렬화
  • Validation, Jackson serializer/deserializer 초기화
  • JPA metamodel 초기화
  • DB, Redis, Mongo, Kafka 등 주요 인프라 커넥션 생성
  • Kafka producer metadata 조회
  • 내부 캐시 또는 프로젝트별 초기화 로직 실행

이런 작업들은 대부분 한 번 초기화되고 나면 이후 요청에서는 훨씬 빠르게 동작합니다. 문제는 그 “첫 번째 요청”이 실제 사용자 트래픽일 수 있다는 점입니다.

spring-boot-warmup-starter는 이런 초기화 비용을 애플리케이션 기동 직후로 당기기 위한 Spring Boot starter입니다.

애플리케이션이 트래픽을 받기 전에 주요 API와 인프라 커넥션을 미리 호출해두고, warmup이 끝나기 전에는 readiness를 OUT_OF_SERVICE로 유지할 수 있도록 지원합니다.


왜 Warmup이 필요한가?

Spring Boot 애플리케이션은 ApplicationReadyEvent가 발생했다고 해서 모든 런타임 경로가 이미 준비된 것은 아닙니다.

예를 들어 특정 API가 처음 호출될 때 다음과 같은 일이 발생할 수 있습니다.

  • Controller method argument resolver 동작
  • JSON request body 역직렬화
  • Validation metadata 초기화
  • Application service 내부 lazy initialization
  • JPA metamodel 접근
  • DB connection pool의 실제 커넥션 생성
  • Redis connection 생성
  • Kafka producer metadata 조회

이 작업들은 애플리케이션 기동 이후 첫 사용자 요청에서 한꺼번에 발생할 수 있습니다.

Warmup starter의 목표는 단순합니다.

첫 요청에서 발생하기 쉬운 지연을 애플리케이션 기동 단계로 옮긴다.

이를 통해 배포 직후 사용자 요청의 latency spike를 줄이고, Kubernetes 환경에서는 warmup이 완료된 Pod만 트래픽을 받도록 구성할 수 있습니다.


주요 기능

spring-boot-warmup-starter는 다음 기능을 제공합니다.

  • @WarmUpApi annotation 기반 API warmup
  • API warmup 요청에 사용할 JSON body를 classpath resource로 등록
  • 내장 웹 서버 포트 자동 감지
  • 명시적 warmup.api.base-url 설정 지원
  • Warmup API 요청 중 outbound adapter만 mock return으로 대체
  • JPA, Mongo, Redis, Redisson, Kafka warmup task 제공
  • Actuator warmUp health indicator 제공
  • Warmup 중 readiness를 OUT_OF_SERVICE로 유지 가능
  • 프로젝트별 custom WarmUpTask 확장 가능

요구 사항

현재 starter는 다음 환경을 기준으로 합니다.

  • Spring Boot 3.x
  • JDK 21
  • Kotlin 1.9.x

설치

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

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가 호출됩니다.

따라서 다음 경로가 실제 요청과 동일하게 실행됩니다.

  • Controller
  • Request binding
  • Request body deserialization
  • Validation
  • Application service
  • 일부 outbound adapter 호출 지점

@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과 독립적으로 제어할 수 있게 합니다.


API Warmup 요청 생성 방식

WarmUpApiTask는 annotation 정보를 바탕으로 JDK HttpClient 요청을 생성합니다.

동작 방식은 다음과 같습니다.

  • pathhttp:// 또는 https://로 시작하면 absolute URL로 그대로 호출
  • path가 상대 경로이면 warmup.api.base-url과 조합
  • warmup.api.base-url이 없으면 embedded web server port를 감지해서 http://localhost:{port} 사용
  • bodyResourceResourceLoader로 읽음
  • bodyResource에 prefix가 없으면 classpath: resource로 처리
  • path, body, bodyResource, header value에는 Spring placeholder 사용 가능
  • warmup 요청에는 marker header가 자동 추가됨
  • 응답 status가 expectedStatuses에 포함되지 않으면 실패로 기록

기본 성공 status는 다음과 같습니다.

200, 201, 202, 204

필요하다면 annotation에서 API별로 성공 status를 조정할 수 있습니다.


Warmup 요청 중 Outbound Adapter만 Mock 처리하기

API warmup은 실제 endpoint를 호출합니다.

이 점은 장점이지만, 동시에 주의해야 할 부분이기도 합니다.

예를 들어 주문 생성 API를 warmup으로 호출하면 다음과 같은 부작용이 발생할 수 있습니다.

  • DB insert
  • 외부 API 호출
  • Kafka message publish
  • 결제, 쿠폰, 배차 등 외부 시스템 연동

이를 방지하기 위해 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 흐름 대부분을 실행하면서도 외부 부작용은 막을 수 있다는 점입니다.


Provider Bean 방식 Mock

도메인 객체, 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에는 다음 정보가 들어 있습니다.

  • target
  • method
  • arguments
  • returnType
  • annotation

따라서 입력 command 값을 기반으로 mock 객체를 동적으로 만들 수 있습니다.


JSON Body 방식 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)
    }
}

Outbound Mock 적용 조건

@WarmUpOutboundMock은 다음 조건을 모두 만족할 때만 적용됩니다.

  • warmup.enabled=true
  • warmup.outbound-mock.enabled=true
  • 현재 thread에 HTTP request context가 있음
  • request header가 warmup.api.marker-header-name/value와 일치함
  • outbound method에 @WarmUpOutboundMock(enabled = true)가 붙어 있음

조건이 하나라도 맞지 않으면 실제 outbound method가 그대로 실행됩니다.

즉, 일반 사용자 요청에서는 mock이 적용되지 않습니다.


Marker Header 보안 주의사항

기본 marker header는 다음과 같습니다.

X-WarmUp-Request: true

이 header는 warmup 요청인지 판단하는 기준으로 사용됩니다.

따라서 외부 사용자가 임의로 이 header를 보낼 수 있는 환경이라면 주의가 필요합니다. 일반 요청에서도 outbound mock이 적용될 수 있기 때문입니다.

운영 환경에서는 다음 중 하나를 적용하는 것을 권장합니다.

  • warmup API 호출이 내부 localhost 또는 내부 base-url에서만 일어나도록 제한
  • warmup.api.marker-header-value를 환경별 secret 값으로 설정
  • gateway 또는 WAF에서 외부 요청의 marker header 제거

예를 들어 다음처럼 환경 변수 기반 secret 값을 사용할 수 있습니다.

warmup:
  api:
    marker-header-name: X-WarmUp-Request
    marker-header-value: ${WARMUP_MARKER_VALUE}

Infrastructure Warmup

API뿐 아니라 주요 인프라 커넥션도 warmup할 수 있습니다.

현재 지원하는 task는 다음과 같습니다.

  • JPA
  • Mongo
  • Redis
  • Redisson
  • Kafka

각 task는 관련 dependency와 Bean이 있고, 해당 설정이 enable일 때만 등록됩니다.

예를 들어 warmup.jpa.enabled=true여도 EntityManager Bean이 없다면 JPA warmup task는 생성되지 않습니다.


JPA Warmup

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

Mongo warmup은 ping command를 실행하고, 설정에 따라 collection 목록을 조회합니다.

warmup:
  mongo:
    enabled: true
    ping-command: ping
    list-collections: true

동작은 다음과 같습니다.

db.runCommand({ ping: 1 })
db.listCollectionNames().first()

Redis Warmup

Redis warmup은 RedisConnectionFactory를 사용합니다.

warmup:
  redis:
    enabled: true
    key: warmup:${spring.application.name}:ping
    value: ping
    ttl: 5s
    delete-after: true

동작은 다음과 같습니다.

  • PING
  • SET key value
  • TTL 설정
  • GET key
  • delete-after=true이면 DEL key

Redisson Warmup

Redisson 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

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

Actuator Health Indicator

starter는 warmUp health indicator를 등록합니다.

Warmup 상태에 따라 health status는 다음처럼 결정됩니다.

Warmup 상태Health status의미
NOT_STARTEDOUT_OF_SERVICE아직 runner가 실행되지 않음
RUNNINGOUT_OF_SERVICEwarmup 진행 중
COMPLETEDUP모든 task 성공 또는 skipped
FAILEDDOWN하나 이상의 task 실패
DISABLEDUPwarmup.enabled=false

기본적으로 warmup 중에는 OUT_OF_SERVICE, 실패 후에는 DOWN을 반환합니다.

실패 후에도 health를 올리고 싶다면 다음처럼 설정할 수 있습니다.

warmup:
  health:
    down-on-failure: false

Kubernetes Readiness와 함께 사용하기

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로 트래픽을 보내지 않게 됩니다.


Failure Handling

각 warmup task는 독립적으로 실행되고, 실행 결과는 WarmUpState에 저장됩니다.

warmup.fail-fast=false인 경우에는 다음처럼 동작합니다.

  • 실패한 task를 기록
  • 나머지 task는 계속 실행
  • 하나라도 실패하면 최종 상태는 FAILED

반면 warmup.fail-fast=true인 경우에는 다음처럼 동작합니다.

  • 첫 번째 실패 task에서 즉시 중단
  • 실행된 task 결과만 저장
  • 최종 상태는 FAILED

운영 환경에서는 warmup 실패를 배포 실패로 볼지, 경고로만 볼지에 따라 fail-fast와 health 설정을 조합해서 사용할 수 있습니다.


Custom Warmup Task 확장

프로젝트마다 별도로 초기화해야 하는 리소스가 있을 수 있습니다.

예를 들어 내부 캐시를 미리 로딩하거나, 특정 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

현재 테스트는 다음 내용을 검증합니다.

  • warmup health 상태 전환
  • runner의 성공/실패 상태 기록
  • @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와도 자연스럽게 연동할 수 있습니다.

운영 관점에서 중요한 점은 다음 세 가지입니다.

  1. Warmup 대상 API는 실제 endpoint를 호출하므로 부작용을 반드시 고려해야 합니다.
  2. 외부 호출이나 저장 부작용은 @WarmUpOutboundMock으로 차단할 수 있습니다.
  3. Kubernetes 환경에서는 warmUp health indicator를 readiness group에 포함해 warmup 완료 전 트래픽 유입을 막는 것이 좋습니다.

첫 요청 latency를 줄이고 배포 직후 안정적인 트래픽 전환을 만들고 싶다면, warmup을 애플리케이션 기동 흐름의 일부로 명시적으로 관리해볼 만합니다.

0개의 댓글