Spring Boot application의 시작 시간이 길어지면 CPU request나 limit부터 올리고 싶어진다. CPU가 부족해 실제로 느려지는 경우도 있다. 하지만 “core를 늘리면 bean이 병렬로 초기화돼 시작이 빨라진다”는 식으로 일반화하면 원인을 놓치기 쉽다.
시작 과정에는 CPU를 쓰는 작업과 외부 응답을 기다리는 작업이 섞여 있다. 어느 구간이 느린지 측정한 뒤 CPU와의 관계를 확인해야 한다.
먼저 시작 시간의 끝점을 정한다
하나의 application에도 여러 시점이 있다.
process 시작
→ JVM·class loading
→ Spring context 준비와 refresh
→ singleton bean 생성
→ runner 실행
→ ApplicationReadyEvent
→ readiness가 traffic 허용
→ 첫 요청이 정상 응답
Spring Boot는 ApplicationStartedEvent 뒤 runner를 실행하고, runner까지 끝난 뒤 ApplicationReadyEvent를 보낸다. readiness가 traffic을 받는 시점도 별도 상태다.
따라서 “부팅 20초”라는 숫자만으로는 부족하다.
- process 시작부터
ApplicationReadyEvent까지인가? - Kubernetes readiness probe가 성공할 때까지인가?
- 첫 실제 요청의 dependency 호출까지 끝난 시간인가?
- lazy bean이 만들어지는 첫 요청 latency도 포함하는가?
측정 구간을 고정해야 변경 전후를 비교할 수 있다.
Spring Boot 시작 중 CPU를 쓰는 부분
application마다 비중은 다르지만 다음 작업은 CPU 영향을 받을 수 있다.
- class loading, bytecode verification과 JIT compilation
- classpath scanning과 annotation metadata 처리
- auto-configuration condition 평가
- bean 생성, dependency injection과 proxy 생성
- serialization metadata나 framework cache 준비
- 압축 해제, configuration parsing, cryptographic 초기화
반면 다음은 CPU를 늘려도 그대로일 수 있다.
- DNS와 network connection 대기
- database connection·migration·lock 대기
- remote configuration과 secret provider 응답
- disk·container image layer I/O
- 잘못된 retry와 timeout
- dependency graph상 순차로 기다려야 하는 초기화
Spring의 singleton pre-instantiation은 dependency 관계를 만족시키며 진행된다. CPU core가 많다는 이유만으로 모든 bean 초기화가 자동 병렬화되는 것은 아니다.
CPU request와 limit을 다르게 읽어야 한다
Kubernetes에서 CPU request는 scheduler가 node 배치를 결정할 때 사용한다. Linux cgroup 환경에서는 여러 workload가 동시에 CPU를 원할 때 상대적인 CPU weight에도 영향을 준다. request가 곧 언제나 전용 core를 보장한다는 뜻은 아니다.
CPU limit은 kernel이 CPU 사용 시간을 throttling하는 상한이다. 짧은 시작 구간에 CPU를 몰아서 쓰는 application은 평균 CPU가 낮아 보여도 limit 때문에 반복적으로 멈출 수 있다.
resources:
requests:
cpu: "500m"
limits:
cpu: "1"
이 설정을 “최소 0.5 core, 최대 1 core가 물리적으로 고정 배정된다”라고 읽으면 안 된다. 일반적인 shared CPU 환경에서는 scheduling과 contention, cgroup policy를 함께 봐야 한다. integer CPU request를 가진 Guaranteed Pod에 static CPU Manager policy를 쓰는 특수한 경우처럼 placement 보장이 달라지는 환경도 있다.
첫 번째 측정: Spring startup step
Spring Framework의 ApplicationStartup은 context와 bean lifecycle의 startup step을 기록한다. Spring Boot의 BufferingApplicationStartup을 연결하면 Actuator startup endpoint에서 timeline을 볼 수 있다.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.metrics.buffering.BufferingApplicationStartup;
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(MyApplication.class);
application.setApplicationStartup(new BufferingApplicationStartup(2048));
application.run(args);
}
}
/actuator/startup은 startup sequence와 bean step을 확인하는 진단 endpoint다. production에서 인터넷에 공개하지 말고 management network와 authentication·authorization으로 제한한다. buffer capacity가 충분한지도 확인해야 한다.
Spring Boot가 제공하는 application.started.time과 application.ready.time metric도 시작 완료 시점을 비교하는 데 쓸 수 있다.
두 번째 측정: JVM과 OS event
Spring step만으로 class loading, allocation, GC, JIT와 thread 상태를 모두 설명할 수는 없다. Java Flight Recorder를 함께 보면 JVM event와 Spring timeline을 맞춰 볼 수 있다.
java \
-XX:StartFlightRecording=filename=startup.jfr,duration=60s,settings=profile \
-jar app.jar
jfr summary startup.jfr
Kubernetes에서는 같은 image와 JVM option, 같은 node class, 같은 dependency 상태에서 cold start를 여러 번 반복한다. 한 번의 결과만 보면 image cache나 DNS, database 상태의 우연을 CPU 효과로 착각할 수 있다.
같은 시각에 다음 증거를 모은다.
- container CPU usage와 throttling 증가량
- node contention과 run queue
- JFR의 class loading, compilation, allocation, GC, socket I/O
- Spring startup step의 긴 bean·phase
- dependency connect·migration log의 시작과 종료 시각
- readiness probe가 성공한 시점
kubectl top의 짧은 평균값만으로 throttling 유무를 결론 내리지는 않는다. cgroup version과 runtime에 맞는 cpu.stat나 monitoring metric에서 throttled period·time을 확인한다.
CPU가 원인인지 확인하는 실험
다음처럼 한 번에 한 변수만 바꾼다.
| 실험 | 해석 |
|---|---|
| limit만 완화했더니 throttling과 시작 시간이 함께 감소 | CPU 상한이 병목일 가능성 |
| CPU를 늘려도 특정 bean 시간이 그대로 | I/O·lock·순차 dependency 가능성 |
| network를 stub 처리하자 크게 감소 | 외부 dependency가 지배적 |
| warm start만 빠름 | class·JIT·image·filesystem cache 영향 |
| readiness만 늦고 ReadyEvent는 빠름 | probe나 필수 dependency 준비 경로 점검 |
CPU allocation과 시작 시간의 correlation만 보고 causality를 단정하지 않는다. 동일 조건의 반복 측정과 throttling·profile evidence가 함께 움직여야 한다.
lazy initialization의 실제 tradeoff
spring.main.lazy-initialization=true는 bean을 시작 때 모두 만들지 않고 필요할 때 생성하도록 미룬다.
spring:
main:
lazy-initialization: true
표면적인 startup time은 줄 수 있지만 비용이 사라지는 것은 아니다.
- 잘못된 bean configuration을 시작 때 발견하지 못할 수 있다.
- 첫 요청 latency가 커질 수 있다.
- 시작 시 보이지 않던 memory 사용량이 traffic 뒤 늘어난다.
- non-lazy singleton의 dependency라면 lazy bean도 시작 중 생성된다.
Spring Boot도 이 이유로 lazy initialization을 default로 켜지 않는다. 특정 optional path에 제한적으로 적용하고 first-request latency와 failure detection을 함께 검증한다.
@Async로 initialization을 감싸는 것도 일반적인 부팅 최적화가 아니다. 필수 작업이 끝나기 전에 readiness가 성공하면 요청이 미완성 상태를 볼 수 있고, dependency order와 error propagation이 불명확해질 수 있다. traffic 전에 꼭 끝나야 하는 작업은 readiness 경계 안에서 완료돼야 한다.
원인별 개선 방향
classpath와 auto-configuration이 크다
- 쓰지 않는 starter와 library를 제거한다.
- condition report와 startup step으로 실제 비용을 확인한다.
- auto-configuration exclusion은 side effect를 검토한 뒤 측정으로 판단한다.
database와 외부 service가 느리다
- connection timeout과 retry budget을 명시한다.
- migration과 application start의 책임을 분리할지 검토한다.
- 필수 dependency와 선택 dependency를 구분한다.
- optional warm-up은 실패해도 readiness를 속이지 않는 별도 lifecycle로 둔다.
CPU throttling이 확인됐다
- request가 실제 peak와 scheduling 요구를 반영하는지 본다.
- limit 정책을 조직의 noisy-neighbor 기준과 함께 조정한다.
- node contention, JVM processor 인식과 thread pool size도 확인한다.
- 변경 전후를 같은 cold-start 실험으로 다시 잰다.
bean 하나가 오래 걸린다
- constructor나
@PostConstruct에서 network I/O를 하는지 확인한다. - 반드시 시작 중 필요한 작업인지 다시 나눈다.
- cache warm-up과 preload의 완료 조건, timeout, fallback을 명시한다.
Spring Boot 시작 문제는 CPU 숫자 하나로 설명되지 않는다. 시작의 끝점을 정하고, Spring step·JFR·cgroup·dependency 시간을 같은 timeline에 놓는 것이 첫 단계다.
Kubernetes QoS와 CPU request·limit 정리에서 resource semantics를 더 확인하고, Spring application의 memory 할당과 Spring 부팅 속도 최적화 방안을 원인별로 이어서 볼 수 있다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| Spring Boot 시작 시간 줄이기: 측정부터 Lazy·AOT 판단까지 (0) | 2024.09.23 |
|---|---|
| Spring Boot 메모리 설정 기준: Heap·RSS·컨테이너 limit 함께 보기 (2) | 2024.09.22 |
| BigQuery 기본 구조와 비용 관리: dataset·partition·bytes processed (0) | 2024.08.16 |
| 미들웨어란: reverse proxy·message broker·ORM·웹 middleware 구분 (0) | 2024.08.16 |
| JPA 엔티티에 protected 기본 생성자가 필요한 이유 (0) | 2023.01.30 |
댓글