JVM 어플리케이션 웜업(Warm-up)

Brunch Kim·2025년 1월 5일

금일 포스팅은 JVM 기반 언어인 Java, Kotlin 을 이용해서 어플리케이션을 구현하는 경우,
어플리케이션 구동 시, JVM 웜업이 왜 이루어져야 되는지 그 이유들에 대해서 알아보도록 하겠습니다 :)


웜업은 왜 필요한가?

JVM에서 웜업이 반드시 필요한 이유는 JVM의 런타임 특성과 관련이 있습니다. JVM은 Just-In-Time (JIT) 컴파일러와 동적 클래스로딩 등을 통해 최적화를 수행하며, 이러한 최적화가 이루어지기 전까지 애플리케이션 성능이 저하될 수 있기 때문입니다.

그 외에도 몇가지 주된 요인들을 살펴보면 다음과 같습니다.

  1. JIT 최적화: JVM은 자주 실행되는 코드를 네이티브 코드로 컴파일하여 성능을 향상시킴. 웜업으로 최적화를 미리 유도 가능.
  2. 클래스 로딩 및 초기화: 동적 클래스로딩으로 인한 초기 지연을 줄이기 위해 필요한 클래스 파일(.class)을 미리 메모리에 로드.
  3. GC 안정화: 초기 실행 시 GC가 메모리 사용 패턴을 학습하지 못해 비효율적 동작. 웜업으로 패턴 안정화 가능.
  4. 애플리케이션 초기화: 데이터베이스, 캐시, 외부 API 등 주요 의존성을 미리 준비해 첫 요청 시 성능 문제를 방지.

위에서 예시로 든 내용을 보면 JIT 최적화클래스 로딩 및 초기화는 같은 내용으로 오해할 수도 있는데요.

클래스 로딩 및 초기화의 경우 JIT 최적화와는 별개로, JIT 최적화 이전에 이루어지는 과정입니다. 즉, JVM이 클래스를 준비하는 과정에서 발생합니다. 예를 들어 ExampleClass 가 처음 사용되었을 때 클래스가 로드되고 정적 초기화가 수행됩니다.

JIT 최적화의 경우 클래스 로딩 및 초기화 이후, 실행 중인 코드 경로를 최적화하는 작업입니다. JVM은 인터프리터 방식으로 바이트코드를 한 줄씩 실행하다가, 자주 실행되는 코드(Hotspot) 를 네이티브 코드로 컴파일하여 성능을 향상시킵니다. 즉, 자주 사용되는 코드 경로의 실행 속도를 최적화하고 런타임 성능을 극대화하는 데에 목적이 있습니다.

따라서 웜업을 하게되면 쉽게 얘기해서 아래와 같은 개선 점이 따라옵니다.

  1. 초기 요청 지연 감소: 애플리케이션이 준비되지 않은 상태에서 처리해야 하는 첫 번째 요청의 성능 문제를 해결.
  2. 안정적인 성능 제공: JVM 내부 최적화와 GC 안정화를 통해 애플리케이션 실행 중 성능 변동을 줄임.
  3. 애플리케이션의 신뢰성 향상: 모든 의존성과 초기화 작업이 완료되었음을 보장하여 실행 중 장애 발생 가능성 최소화.

샘플 코드

Case1) 웜업을 하지 않는 경우
아래 코드는 특정 계산 작업을 수행하는 클래스입니다. 초기 실행 시에는 JIT 최적화가 이루어지지 않아 상대적으로 느리게 동작합니다.

class HeavyComputation {

    fun compute(input: Int): Int {
        return (1..10000).sumOf { it * input }
    }
}

fun main() {
    val heavyComputation = HeavyComputation()

    val start = System.nanoTime()
    repeat(100) { heavyComputation.compute(it) }
    val end = System.nanoTime()

    println("실행 시간 (웜업 없이): ${(end - start) / 1_000_000} ms")
}

출력 예시

실행 시간 (웜업 없이): 150 ms

문제점

  • 초기 실행 시 JIT 최적화가 이루어지지 않아 성능이 저하됩니다.
  • 실행 시간이 안정적이지 않고, 첫 몇 번의 실행에서 느립니다.

--

Case2) 웜업을 하는 경우
아래는 동일한 클래스를 대상으로 웜업을 추가하여 JIT 최적화를 유도한 코드입니다.

class HeavyComputation {

    fun compute(input: Int): Int {
        return (1..10000).sumOf { it * input }
    }
}

fun main() {
    val heavyComputation = HeavyComputation()

    // JIT 최적화를 유도하는 웜업 단계
    println("JIT 웜업 시작")
    repeat(10_000) { heavyComputation.compute(it % 100) }
    println("JIT 웜업 완료")

    // 실제 실행
    val start = System.nanoTime()
    repeat(100) { heavyComputation.compute(it) }
    val end = System.nanoTime()

    println("실행 시간 (웜업 후): ${(end - start) / 1_000_000} ms")
}

출력 예시

JIT 웜업 시작
JIT 웜업 완료
실행 시간 (웜업 후): 20 ms

장점

  • 웜업 과정에서 JIT 최적화를 미리 유도하여 실행 시간이 대폭 단축됩니다.
  • 안정적인 성능을 보장하며, 초기 요청 지연을 줄일 수 있습니다.
특징웜업 없음웜업 존재
초기 실행 시간느림 (JIT 최적화 없음)빠름 (JIT 최적화 적용됨)
성능 안정성초기 실행 시 성능 변동이 있음웜업 후 안정적인 성능 제공
실제 사용 시 장점첫 요청에서 지연 발생 가능첫 요청부터 최적화된 성능 제공

웜업의 종류(필수/비필수)와 코드 예시

웜업의 종류로써 필수/비필수를 나누는 목적에는 이 웜업이 어플리케이션 구동하기 위해 필수적인지 아니면 그렇지 않은지 여부를 구분하는데에 있습니다.
반드시 꼭 이렇게 할 필요는 없으나 웜업을 “필수 웜업”과 “비필수 웜업”으로 나누어 관리하면 더 효율적으로 운영할 수 있습니다.

1. 필수 웜업

필수 웜업은 애플리케이션이 초기화될 때 반드시 완료되어야 하는 작업들입니다. 이 작업들이 완료되지 않으면 애플리케이션의 주요 기능이 제대로 작동하지 않을 수 있습니다.

주요 사례
• 주요 데이터 캐싱 (예: 자주 사용하는 설정값, 필수 레퍼런스 데이터).
• 외부 API 또는 DB 연결 상태 확인.
• 애플리케이션에서 필수적으로 사용하는 리소스 초기화 (예: 인증 키, API 토큰 로드 등).

코드 예시
첫째, 필수 웜업이 수행될 수 있도록 ApplicationReadyEvent 시점이 고려된 주요 로직을 구현합니다.

import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component

@Component
class EssentialWarmUp(
    private val myService: MyService,
    private val cacheService: CacheService,
    private val healthCheckService: HealthCheckService
) {

    @EventListener(ApplicationReadyEvent::class)
    fun warmUp() {
        println("필수 웜업 시작")

        try {
            // 데이터 캐싱
            cacheService.loadEssentialData()

            // 외부 API 연결 확인
            if (!myService.checkExternalService()) {
                healthCheckService.markUnhealthy("필수 외부 서비스 연결 실패")
            }

            // 모든 작업이 성공적으로 완료되면 애플리케이션 상태를 Healthy로 표시
            healthCheckService.markHealthy()
        } catch (ex: Exception) {
            healthCheckService.markUnhealthy("웜업 작업 실패: ${ex.message}")
        }

        println("필수 웜업 완료")
    }
}

둘째, 웜업 결과에 대한 응답을 보낼 수 있도록 서비스 로직을 구현합니다.

import org.springframework.stereotype.Service
import java.util.concurrent.atomic.AtomicBoolean

@Service
class HealthCheckService {
    private val isHealthy = AtomicBoolean(true)
    private val errorMessages = mutableListOf<String>()

    fun markUnhealthy(reason: String) {
        isHealthy.set(false)
        errorMessages.add(reason)
    }

    fun markHealthy() {
        isHealthy.set(true)
        errorMessages.clear()
    }

    fun getHealthStatus(): Pair<Boolean, List<String>> {
        return Pair(isHealthy.get(), errorMessages)
    }
}

마지막으로, 현재 어플리케이션의 상태를 반환할 수 있도록 엔드포인트를 제공합니다.

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/health")
class HealthCheckController(
    private val healthCheckService: HealthCheckService
) {

    @GetMapping
    fun getHealthStatus(): ResponseEntity<Map<String, Any>> {
        val (isHealthy, errorMessages) = healthCheckService.getHealthStatus()

        val response = mapOf(
            "status" to if (isHealthy) "UP" else "DOWN",
            "errors" to errorMessages
        )

        return if (isHealthy) {
            ResponseEntity.ok(response)
        } else {
            ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response)
        }
    }
}

동작 흐름

  1. 필수 웜업 작업 수행
    • EssentialWarmUp에서 캐싱과 외부 API 상태 확인을 실행.
    • 실패 시 HealthCheckService를 통해 애플리케이션 상태를 “UNHEALTHY”로 표시.
    • 성공 시 상태를 “HEALTHY”로 표시.
  2. 헬스체크 엔드포인트
    • /health 엔드포인트에서 현재 상태를 반환.
    • 상태가 정상(HEALTHY)이면 HTTP 200(UP)을 반환.
    • 상태가 비정상(UNHEALTHY)이면 HTTP 503(DOWN)을 반환.

수행 결과 예시

  • Case1) 어플리케이션 응답 준비 완료(즉, 필수 웜업 성공)
    요청 : GET /heath

    {
      "status": "UP",
      "errors": []
    }
  • Case2) 필수 웜업 실패

    {
      "status": "DOWN",
      "errors": ["필수 외부 서비스 연결 실패", "웜업 작업 실패: 데이터 로드 실패"]
    }

--

2. 비필수 웜업

비필수 웜업은 어플리케이션이 여러 기능을 수행하기에 앞서 선제적으로 필수로 하는 웜업은 아니지만 사용자 경험, 레이턴시 지연 방지 등 사용성 및 편의성의 목적으로 미리 준비하는 행위에 해당합니다.

주요 사례
어플리케이션의 특성이 raw 데이터를 다루지 않고 주로 다른 마이크로 서비스가 주는 데이터 위주로 동작하는 경우, 비필수 웜업 위주로 웜업이 구성되는 경우들이 많습니다.

코드 예시
첫째, 웜업이 수행될 수 있도록 ApplicationReadyEvent 시점이 고려된 주요 로직을 구현합니다. 이 경우에는 웜업에 필요한 시간에 제한을 두어 어플리케이션 구동이 크게 지연되지 않도록 처리합니다.

import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component

@Component
class NonEssentialWarmUp(
    private val cacheService: CacheService,
    private val healthCheckService: HealthCheckService
) {

    @EventListener(ApplicationReadyEvent::class)
    fun warmUp() = runBlocking {
        println("비필수 웜업 시작")

        try {
            // withTimeout을 사용해 타임아웃 적용
            withTimeout(10_000) { // 10초 타임아웃
                cacheService.loadNonEssentialData()
                healthCheckService.addWarning("비필수 데이터 캐싱 성공")
                println("비필수 웜업 완료")
            }
        } catch (ex: Exception) {
            healthCheckService.addWarning("비필수 데이터 캐싱 실패 또는 타임아웃 발생: ${ex.message}")
            println("비필수 웜업 작업 중 오류 또는 타임아웃 발생: ${ex.message}")
        }
    }
}

필수 작업 상태와 비필수 작업 경고를 기록합니다.

import org.springframework.stereotype.Service
import java.util.concurrent.atomic.AtomicBoolean

@Service
class HealthCheckService {
    private val isHealthy = AtomicBoolean(true)
    private val warnings = mutableListOf<String>()
    private val errors = mutableListOf<String>()

    fun markUnhealthy(reason: String) {
        isHealthy.set(false)
        errors.add(reason)
    }

    fun addWarning(warning: String) {
        warnings.add(warning)
    }

    fun getHealthStatus(): Triple<Boolean, List<String>, List<String>> {
        return Triple(isHealthy.get(), warnings, errors)
    }
}

헬스 체크 엔드포인트에서 상태와 경고 메시지를 반환합니다.

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/health")
class HealthCheckController(
    private val healthCheckService: HealthCheckService
) {

    @GetMapping
    fun getHealthStatus(): ResponseEntity<Map<String, Any>> {
        val (isHealthy, warnings, errors) = healthCheckService.getHealthStatus()

        val response = mapOf(
            "status" to if (isHealthy) "UP" else "DOWN",
            "errors" to errors,
            "warnings" to warnings
        )

        return if (isHealthy) {
            ResponseEntity.ok(response)
        } else {
            ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(response)
        }
    }
}

동작 흐름

  1. 비필수 웜업 수행
    • 캐싱 작업 시도.
    • 실패하면 경고를 기록.
    • 10초 이내에 완료되지 않으면 타임아웃 경고를 추가.
  2. 헬스 체크 엔드포인트
    • /health 엔드포인트에서 상태와 경고 메시지 반환.
    • 비필수 작업 실패는 HTTP 200 상태 코드와 함께 경고 메시지를 반환.

수행 결과 예시

  • Case1) 웜업 성공
    요청 : GET /heath

    {
      "status": "UP",
      "errors": [],
      "warnings": ["비필수 데이터 캐싱 성공"]
    }
  • Case2) 웜업 실패

    {
      "status": "UP",
      "errors": [],
      "warnings": ["비필수 데이터 캐싱 실패: 네트워크 문제", "비필수 웜업이 12초 걸려 경고 상태로 간주됨"]
    }

웜업이 필요한 상황

일반적인 경우 대고객 서비스를 하는 어플리케이션이라면 웜업을 하는 편이 좋다고 생각합니다. 아니, 오히려 필수로 해야하는 경우들이 많습니다.

  • 대규모 트래픽 처리
    애플리케이션이 초기화된 후 곧바로 높은 트래픽을 처리해야 하는 상황에서는 JIT 최적화가 이루어지지 않으면 성능 저하로 인해 요청 처리가 지연되거나 실패할 수 있습니다.

  • API 응답 시간이 중요한 애플리케이션
    API 서버의 경우 첫 번째 요청에서부터 최적화된 응답 속도를 제공해야 하는데, 웜업 없이 JVM이 초기화 과정을 거치면 초기 요청 속도가 느려질 수 있습니다.

  • 대기 시간이 중요한 배치 시스템
    배치 작업에서 실행 시작 후 즉각적인 성능이 중요한 경우, 초기화 과정에서 성능 저하가 작업 처리 시간을 늘릴 수 있습니다.


웜업 체크리스트

JVM 어플리케이션 운영 시, 웜업을 적용하기로 마음먹은 경우 다음과 같은 사항들에 대해 체크해보면 좋습니다.

  1. JIT 최적화가 필요한 코드 식별.
  2. 자주 사용되는 클래스와 메서드 미리 호출.
  3. 데이터베이스와 캐시 초기화 상태 확인.
  4. 외부 의존성(API, 메시지 브로커, 파일 스토리지 등) 상태 점검. (왜? 웜업하다가 외부 호출 디도스하는 행위를 해서는 안되므로)
  5. GC 성능 확인 및 히터 학습.

참고

https://www.baeldung.com/java-jvm-warmup

profile
브런치 즐기는 여유있는 날

0개의 댓글