
금일 포스팅은 JVM 기반 언어인 Java, Kotlin 을 이용해서 어플리케이션을 구현하는 경우,
어플리케이션 구동 시, JVM 웜업이 왜 이루어져야 되는지 그 이유들에 대해서 알아보도록 하겠습니다 :)
JVM에서 웜업이 반드시 필요한 이유는 JVM의 런타임 특성과 관련이 있습니다. JVM은 Just-In-Time (JIT) 컴파일러와 동적 클래스로딩 등을 통해 최적화를 수행하며, 이러한 최적화가 이루어지기 전까지 애플리케이션 성능이 저하될 수 있기 때문입니다.
그 외에도 몇가지 주된 요인들을 살펴보면 다음과 같습니다.
위에서 예시로 든 내용을 보면 JIT 최적화와 클래스 로딩 및 초기화는 같은 내용으로 오해할 수도 있는데요.
클래스 로딩 및 초기화의 경우 JIT 최적화와는 별개로, JIT 최적화 이전에 이루어지는 과정입니다. 즉, JVM이 클래스를 준비하는 과정에서 발생합니다. 예를 들어 ExampleClass 가 처음 사용되었을 때 클래스가 로드되고 정적 초기화가 수행됩니다.
JIT 최적화의 경우 클래스 로딩 및 초기화 이후, 실행 중인 코드 경로를 최적화하는 작업입니다. JVM은 인터프리터 방식으로 바이트코드를 한 줄씩 실행하다가, 자주 실행되는 코드(Hotspot) 를 네이티브 코드로 컴파일하여 성능을 향상시킵니다. 즉, 자주 사용되는 코드 경로의 실행 속도를 최적화하고 런타임 성능을 극대화하는 데에 목적이 있습니다.
따라서 웜업을 하게되면 쉽게 얘기해서 아래와 같은 개선 점이 따라옵니다.
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
문제점
--
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 최적화 적용됨) |
| 성능 안정성 | 초기 실행 시 성능 변동이 있음 | 웜업 후 안정적인 성능 제공 |
| 실제 사용 시 장점 | 첫 요청에서 지연 발생 가능 | 첫 요청부터 최적화된 성능 제공 |
웜업의 종류로써 필수/비필수를 나누는 목적에는 이 웜업이 어플리케이션 구동하기 위해 필수적인지 아니면 그렇지 않은지 여부를 구분하는데에 있습니다.
반드시 꼭 이렇게 할 필요는 없으나 웜업을 “필수 웜업”과 “비필수 웜업”으로 나누어 관리하면 더 효율적으로 운영할 수 있습니다.
필수 웜업은 애플리케이션이 초기화될 때 반드시 완료되어야 하는 작업들입니다. 이 작업들이 완료되지 않으면 애플리케이션의 주요 기능이 제대로 작동하지 않을 수 있습니다.
주요 사례
• 주요 데이터 캐싱 (예: 자주 사용하는 설정값, 필수 레퍼런스 데이터).
• 외부 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)
}
}
}
동작 흐름
수행 결과 예시
Case1) 어플리케이션 응답 준비 완료(즉, 필수 웜업 성공)
요청 : GET /heath
{
"status": "UP",
"errors": []
}
Case2) 필수 웜업 실패
{
"status": "DOWN",
"errors": ["필수 외부 서비스 연결 실패", "웜업 작업 실패: 데이터 로드 실패"]
}
--
비필수 웜업은 어플리케이션이 여러 기능을 수행하기에 앞서 선제적으로 필수로 하는 웜업은 아니지만 사용자 경험, 레이턴시 지연 방지 등 사용성 및 편의성의 목적으로 미리 준비하는 행위에 해당합니다.
주요 사례
어플리케이션의 특성이 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)
}
}
}
동작 흐름
수행 결과 예시
Case1) 웜업 성공
요청 : GET /heath
{
"status": "UP",
"errors": [],
"warnings": ["비필수 데이터 캐싱 성공"]
}
Case2) 웜업 실패
{
"status": "UP",
"errors": [],
"warnings": ["비필수 데이터 캐싱 실패: 네트워크 문제", "비필수 웜업이 12초 걸려 경고 상태로 간주됨"]
}
일반적인 경우 대고객 서비스를 하는 어플리케이션이라면 웜업을 하는 편이 좋다고 생각합니다. 아니, 오히려 필수로 해야하는 경우들이 많습니다.
대규모 트래픽 처리
애플리케이션이 초기화된 후 곧바로 높은 트래픽을 처리해야 하는 상황에서는 JIT 최적화가 이루어지지 않으면 성능 저하로 인해 요청 처리가 지연되거나 실패할 수 있습니다.
API 응답 시간이 중요한 애플리케이션
API 서버의 경우 첫 번째 요청에서부터 최적화된 응답 속도를 제공해야 하는데, 웜업 없이 JVM이 초기화 과정을 거치면 초기 요청 속도가 느려질 수 있습니다.
대기 시간이 중요한 배치 시스템
배치 작업에서 실행 시작 후 즉각적인 성능이 중요한 경우, 초기화 과정에서 성능 저하가 작업 처리 시간을 늘릴 수 있습니다.
JVM 어플리케이션 운영 시, 웜업을 적용하기로 마음먹은 경우 다음과 같은 사항들에 대해 체크해보면 좋습니다.