[Spring Boot] 애플리케이션 Warm Up 가이드

참고 - IF(kakao) 2022

🤔 Warm Up이 필요한 주요 이유

Spring Boot 와 같은 JAVA 애플리케이션을 최초 실행하거나 재배포할 때, 첫 번째 요청이 다른 요청들에 비해 현저히 느린 현상을 경험한 적이 있을 것입니다. 이러한 현상을 Cold Start 문제라고 하며, 이를 해결하기 위해 Warm Up이 필요합니다.

1. JVM 의 JIT (Just-In-Time) 컴파일

Java 애플리케이션은 바이트코드 로 컴파일되어 실행되며, JVM은 런타임에 자주 사용되는 코드를 네이티브 코드 로 최적화합니다.

초기에는 인터프리터 모드 로 실행되어 바이트 코드를 한줄 한줄 해석하기 때문에 성능이 상대적으로 낮습니다. JIT 컴파일러가 자주 실행되는 코드 핫스팟(Hot Spot) 을 식별하고 최적화된 네이티브 코드로 전환됩니다.

때문에 애플리케이션 구동 후 JIT 컴파일러가 핫스팟(Hot Spot) 을 식별하고 최적화하는 전까지는 상대적으로 느릴 수 있습니다.

Native 코드로 변환되는 과정

2. 클래스 로딩 지연

자바 클래스가 프로그램 시작 시 전부 로딩되는 게 아니라 처음 실제로 필요해지는 순간 에 메모리에 로딩 및 초기화되면서 발생하는 지연이 발생합니다.

클래스 로딩은 로딩 > 링킹 > 초기화 순으로 3단계를 거치게 됩니다.

1. 로딩 (Loading)

로딩 단계에서는 클래스 로더가 클래스 파일 .class 을 찾아 바이트코드를 메모리로 읽어옵니다. 로딩 단계에서는 디스크 I/O 가 발생할 수 있어 시간이 더 지연될 수 있습니다.

2. 링킹 (Linking)

링크 단계에서는 바이트코드 검증, 클래스 변수, static 변수 를 위한 메모리 할당 및 기본값으로 초기화,

// 로딩 단계에서는 static 변수 x 의 값이 0 으로 세팅됩니다.
static int x = 10;

3. 초기화 (Initialization)

마지막 초기화 단계에서는 정적필드에 실제 값을 세팅합니다.

// 초기화 단계에서야 static 변수 x 의 값이 실제 10으로 세팅 됩니다. 
static int x = 10;

3. 커넥션 풀 초기화로 인한 지연

애플리케이션은 실행 초기에 minimum-idle 만큼만 커넥션을 생성하는데 이 크기가 작으면 초기에 요청이 몰렸을 시 Connection 이 부족하게 되고 부족한 DB 커넥션을 추가적으로 생성하는 동안 요청에 대한 응답 지연이 발생하게 됩니다.

# HikariCP 설정 예시
spring:
datasource:
hikari:
minimum-idle: 10
maximum-pool-size: 20
# 애플리케이션 시작 시 minimum-idle 만큼의 커넥션만 생성

4. 캐시 웜업

  • 애플리케이션 수준의 캐시(예: Caffeine, EhCache)가 비어있음
  • 첫 요청들은 캐시 미스로 인해 실제 데이터 소스에 접근
  • 캐시가 채워지기까지 성능 저하

Spring Boot에서 Warm Up 방법

1. ApplicationRunner 또는 CommandLineRunner 사용

애플리케이션 시작 후 자동으로 초기화 로직을 실행합니다.

@Component
public class WarmUpRunner implements ApplicationRunner {

private final UserService userService;
private final ProductService productService;

public WarmUpRunner(UserService userService, ProductService productService) {
this.userService = userService;
this.productService = productService;
}

@Override
public void run(ApplicationArguments args) throws Exception {
// 주요 API 엔드포인트 호출하여 Bean 초기화
warmUpServices();

// 캐시 프리로딩
preloadCache();

// JIT 컴파일 트리거
triggerJitCompilation();
}

private void warmUpServices() {
try {
// 주요 서비스 메소드 호출
userService.findById(1L);
productService.getPopularProducts();
} catch (Exception e) {
// Warm up 실패는 애플리케이션 시작을 막지 않음
log.warn("Warm up failed", e);
}
}

private void preloadCache() {
// 자주 사용되는 데이터 미리 캐시에 로드
productService.loadPopularProductsToCache();
}

private void triggerJitCompilation() {
// 반복적으로 메소드 호출하여 JIT 컴파일 유도
for (int i = 0; i < 10000; i++) {
productService.someHotMethod();
}
}
}

2. @PostConstruct를 사용한 Bean 초기화

@Service
public class CacheWarmUpService {

private final ProductRepository productRepository;
private final CacheManager cacheManager;

public CacheWarmUpService(ProductRepository productRepository,
CacheManager cacheManager) {
this.productRepository = productRepository;
this.cacheManager = cacheManager;
}

@PostConstruct
public void warmUpCache() {
List<Product> popularProducts = productRepository.findPopularProducts();
Cache productCache = cacheManager.getCache("products");

popularProducts.forEach(product ->
productCache.put(product.getId(), product)
);
}
}

3. 스케줄러를 사용한 주기적 Warm Up

@Configuration
@EnableScheduling
public class ScheduledWarmUp {

private final RestTemplate restTemplate;

public ScheduledWarmUp(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}

// 애플리케이션 시작 1분 후 실행
@Scheduled(initialDelay = 60000, fixedDelay = Long.MAX_VALUE)
public void initialWarmUp() {
// 내부 헬스체크 엔드포인트 호출
restTemplate.getForObject("http://localhost:8080/actuator/health", String.class);

// 주요 엔드포인트 호출
restTemplate.getForObject("http://localhost:8080/api/products", String.class);
}
}

4. HikariCP 커넥션 풀 사전 초기화

spring:
datasource:
hikari:
minimum-idle: 10
maximum-pool-size: 20
# 커넥션 풀을 미리 채움
initialization-fail-timeout: 0
@Configuration
public class DataSourceConfig {

@Bean
public HikariDataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
config.setUsername("user");
config.setPassword("password");
config.setMinimumIdle(10);
config.setMaximumPoolSize(20);

HikariDataSource dataSource = new HikariDataSource(config);

// 애플리케이션 시작 시 커넥션 풀 웜업
warmUpConnectionPool(dataSource);

return dataSource;
}

private void warmUpConnectionPool(HikariDataSource dataSource) {
try (Connection conn = dataSource.getConnection()) {
// 간단한 쿼리로 커넥션 검증
conn.isValid(1);
} catch (SQLException e) {
log.error("Connection pool warm up failed", e);
}
}
}

5. JVM Warm Up 옵션

JVM 옵션을 통해 시작 시 최적화를 강화할 수 있습니다.

# Tiered Compilation 활성화 (기본값)
java -XX:+TieredCompilation -jar application.jar

# C2 컴파일러 임계값 낮춤 (더 빠른 최적화)
java -XX:CompileThreshold=1000 -jar application.jar

# AOT(Ahead-of-Time) 컴파일 사용 (Java 17+)
java -XX:ArchiveClassesAtExit=app.jsa -jar application.jar
java -XX:SharedArchiveFile=app.jsa -jar application.jar

Kubernetes에서 Warm Up 방법

Kubernetes 환경에서는 Pod가 준비되기 전에 트래픽을 받지 않도록 하는 것이 중요합니다.

1. Startup Probe 사용

애플리케이션이 시작되었는지 확인합니다. Startup Probe가 성공할 때까지 Liveness와 Readiness Probe는 비활성화됩니다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-boot-app
spec:
replicas: 3
template:
spec:
containers:
- name: app
image: spring-boot-app:latest
ports:
- containerPort: 8080

# Startup Probe: 애플리케이션이 시작되었는지 확인
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 30 # 최대 150초 (30 * 5) 대기

# Readiness Probe: 트래픽을 받을 준비가 되었는지 확인
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 3

# Liveness Probe: 애플리케이션이 살아있는지 확인
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3

2. Readiness Probe와 Custom Health Indicator

Spring Boot Actuator를 사용하여 커스텀 헬스 체크를 구현합니다.

@Component
public class WarmUpHealthIndicator implements HealthIndicator {

private volatile boolean warmedUp = false;

@Override
public Health health() {
if (warmedUp) {
return Health.up()
.withDetail("status", "warmed up")
.build();
} else {
return Health.down()
.withDetail("status", "warming up")
.build();
}
}

public void markAsWarmedUp() {
this.warmedUp = true;
}
}

@Component
public class WarmUpRunner implements ApplicationRunner {

private final WarmUpHealthIndicator warmUpHealthIndicator;
private final UserService userService;

public WarmUpRunner(WarmUpHealthIndicator warmUpHealthIndicator,
UserService userService) {
this.warmUpHealthIndicator = warmUpHealthIndicator;
this.userService = userService;
}

@Override
public void run(ApplicationArguments args) throws Exception {
// Warm up 로직 실행
performWarmUp();

// Warm up 완료 표시
warmUpHealthIndicator.markAsWarmedUp();
}

private void performWarmUp() {
// 주요 서비스 호출
userService.warmUp();

// 캐시 프리로딩
// ...
}
}
# application.yml
management:
endpoint:
health:
probes:
enabled: true
group:
readiness:
include: warmUp, db, redis

3. PreStop Hook으로 Graceful Shutdown

apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-boot-app
spec:
template:
spec:
containers:
- name: app
image: spring-boot-app:latest
lifecycle:
# 종료 전 처리
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]

# 종료 유예 시간
terminationGracePeriodSeconds: 30
# application.yml
spring:
lifecycle:
timeout-per-shutdown-phase: 20s

server:
shutdown: graceful

4. Init Container로 Warm Up

별도의 Init Container에서 warm up 작업을 수행할 수 있습니다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-boot-app
spec:
template:
spec:
# Init Container: 메인 컨테이너 실행 전 warm up
initContainers:
- name: warm-up
image: spring-boot-app:latest
command: ['sh', '-c', 'java -cp /app/lib/*:/app/classes com.example.WarmUpTool']

containers:
- name: app
image: spring-boot-app:latest

5. HPA(Horizontal Pod Autoscaler)와 함께 사용

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: spring-boot-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: spring-boot-app
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # 5분간 안정화
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 30

6. Pod Disruption Budget 설정

Rolling Update 중에도 최소한의 Pod를 유지합니다.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: spring-boot-app-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: spring-boot-app
apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-boot-app
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 최대 1개 추가 Pod
maxUnavailable: 1 # 최대 1개 중단 가능

실전 Warm Up 전략

종합 예시

@Slf4j
@Component
@Order(1)
public class ComprehensiveWarmUpRunner implements ApplicationRunner {

private final DataSource dataSource;
private final CacheManager cacheManager;
private final RestTemplate restTemplate;
private final List<WarmUpTask> warmUpTasks;

@Override
public void run(ApplicationArguments args) throws Exception {
log.info("Starting comprehensive warm-up process...");

long startTime = System.currentTimeMillis();

// 1. 데이터베이스 커넥션 풀 warm up
warmUpConnectionPool();

// 2. 캐시 프리로딩
warmUpCache();

// 3. HTTP 클라이언트 warm up
warmUpHttpClient();

// 4. 커스텀 warm up 태스크 실행
executeCustomWarmUpTasks();

// 5. JIT 컴파일 트리거
triggerJitCompilation();

long duration = System.currentTimeMillis() - startTime;
log.info("Warm-up process completed in {} ms", duration);
}

private void warmUpConnectionPool() {
try (Connection conn = dataSource.getConnection()) {
conn.isValid(1);
log.info("Database connection pool warmed up");
} catch (SQLException e) {
log.error("Failed to warm up connection pool", e);
}
}

private void warmUpCache() {
warmUpTasks.stream()
.filter(task -> task.getType() == WarmUpTaskType.CACHE)
.forEach(WarmUpTask::execute);
log.info("Cache warmed up");
}

private void warmUpHttpClient() {
try {
restTemplate.getForObject("http://localhost:8080/actuator/health", String.class);
log.info("HTTP client warmed up");
} catch (Exception e) {
log.warn("Failed to warm up HTTP client", e);
}
}

private void executeCustomWarmUpTasks() {
warmUpTasks.forEach(task -> {
try {
task.execute();
} catch (Exception e) {
log.error("Warm up task failed: {}", task.getName(), e);
}
});
}

private void triggerJitCompilation() {
// 주요 비즈니스 로직을 여러 번 실행하여 JIT 최적화 유도
for (int i = 0; i < 1000; i++) {
// 핫 메소드 호출
}
log.info("JIT compilation triggered");
}
}

public interface WarmUpTask {
void execute();
String getName();
WarmUpTaskType getType();
}

public enum WarmUpTaskType {
CACHE, SERVICE, CLIENT
}

모니터링 및 측정

Warm up 효과를 측정하기 위해 메트릭을 수집합니다.

@Component
public class WarmUpMetrics {

private final MeterRegistry meterRegistry;

public WarmUpMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}

public void recordWarmUpDuration(String taskName, long durationMs) {
Timer.builder("warmup.duration")
.tag("task", taskName)
.register(meterRegistry)
.record(durationMs, TimeUnit.MILLISECONDS);
}

public void incrementWarmUpSuccess(String taskName) {
Counter.builder("warmup.success")
.tag("task", taskName)
.register(meterRegistry)
.increment();
}

public void incrementWarmUpFailure(String taskName) {
Counter.builder("warmup.failure")
.tag("task", taskName)
.register(meterRegistry)
.increment();
}
}

결론

Spring Boot 애플리케이션의 Warm Up은 초기 요청 성능을 개선하고 사용자 경험을 향상시키는 중요한 작업입니다. 특히 Kubernetes와 같은 컨테이너 오케스트레이션 환경에서는 Probe 설정과 함께 적절한 Warm Up 전략을 수립하는 것이 필수적입니다.

주요 포인트:

  • JVM의 JIT 컴파일, 클래스 로딩, 커넥션 풀 등 초기화 비용 고려
  • ApplicationRunner, @PostConstruct 등을 활용한 자동화된 Warm Up
  • Kubernetes에서는 Startup/Readiness Probe와 함께 사용
  • 실제 비즈니스 로직에 맞는 커스텀 Warm Up 전략 수립
  • 메트릭 수집을 통한 Warm Up 효과 측정 및 개선

적절한 Warm Up 전략을 통해 애플리케이션의 가용성과 성능을 크게 향상시킬 수 있습니다.

Share