배포 파이프라인이 촘촘해지고 오토스케일링이 일상이 되면서, Spring Boot 시작 시간은 더 이상 무시할 수 없는 지표가 되었다. 파드 하나가 뜨는 데 8초가 걸리면 트래픽 급증 상황에서 스케일아웃이 그만큼 늦어지고, 롤링 배포 시간도 길어진다. 그런데 막상 “느리다”는 체감만 있을 뿐, 어느 구간에서 시간이 새는지 숫자로 짚어내는 개발자는 의외로 드물다. 이번 포스팅에서는 Spring Boot 시작 시간을 ApplicationStartup API와 Java Flight Recorder로 정확히 측정하고, 지연 초기화와 CDS 같은 처방으로 실제 기동 속도를 줄이는 방법을 코드와 함께 정리하고자 한다.
Spring Boot 시작 시간, 느낌 대신 숫자로 말하려면

Spring Boot 시작 시간을 최적화하려면 추측을 걷어내고 측정부터 시작해야 한다. Spring Framework는 ApplicationStartup 인터페이스로 기동 과정의 각 단계를 StartupStep 단위로 기록한다. 컨텍스트 생성, 빈 인스턴스화, 자동 설정 적용 같은 구간이 각각 얼마나 걸렸는지 계층 구조로 남기기 때문에, “왜 느린가”를 감이 아니라 데이터로 답할 수 있다.
기본 구현은 아무 데이터도 저장하지 않는 DefaultApplicationStartup이다. 즉 스프링은 계측 훅만 열어두고, 실제로 수집할지는 개발자가 구현체를 갈아 끼워 결정한다. 프로파일링용으로는 두 가지 구현이 준비돼 있다. 하나는 수집한 단계를 메모리에 버퍼링해 액추에이터로 노출하는 BufferingApplicationStartup, 다른 하나는 JVM 이벤트와 함께 기록하는 FlightRecorderApplicationStartup이다.
측정 대상을 명확히 하는 것도 중요하다. Spring Boot 시작 시간은 크게 JVM 부팅과 클래스 로딩, 스프링 컨텍스트 초기화, 그리고 애플리케이션 자체 로직(마이그레이션, 캐시 워밍업 등)으로 나뉜다. 이 세 층 중 어디가 병목인지 모른 채 무작정 @Lazy를 붙이면 엉뚱한 곳을 최적화하게 된다. 그래서 처방보다 진단이 먼저다. 자세한 배경은 Spring Boot 공식 레퍼런스의 startup tracking 문서에 정리돼 있다.
startup 엔드포인트로 기동 단계 들여다보기

가장 손쉬운 진단 도구는 액추에이터의 startup 엔드포인트다. BufferingApplicationStartup을 SpringApplication에 등록하면, 기동 중 발생한 모든 StartupStep이 버퍼에 쌓이고 /actuator/startup에서 JSON으로 조회된다. 코드 변경은 main 메서드 몇 줄이면 끝난다.
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);
// 최대 2048개의 StartupStep을 메모리에 버퍼링한다
application.setApplicationStartup(new BufferingApplicationStartup(2048));
application.run(args);
}
}Java이 코드는 기본 구현을 BufferingApplicationStartup으로 교체하는 것이 핵심이다. 생성자 인자 2048은 버퍼가 담을 수 있는 최대 단계 수이며, 빈이 많은 대형 애플리케이션은 이 값을 넉넉히 잡아야 초기 구간이 잘리지 않는다. 버퍼는 엔드포인트를 한 번 읽으면 비워지므로, 기동 직후 한 번만 조회하면 된다.
엔드포인트를 노출하려면 액추에이터 의존성과 설정이 필요하다.
management:
endpoints:
web:
exposure:
include: startupYAML설정을 마치고 GET /actuator/startup을 호출하면 아래와 같은 응답을 받는다. 응답은 timeline 아래 각 단계를 이벤트로 나열하며, 단계마다 name, id, parentId, duration을 가진다. duration은 ISO-8601 형식 문자열(PT1.842S는 1.842초)이고, 각 단계의 parentId가 부모 단계의 id를 가리켜 어떤 하위 작업이 오래 걸렸는지 계층으로 추적할 수 있다.
{
"springBootVersion": "3.4.0",
"timeline": {
"startTime": "2026-07-29T09:00:00.000Z",
"events": [
{
"startupStep": { "name": "spring.beans.instantiate", "id": 142, "parentId": 130,
"tags": [{ "key": "beanName", "value": "entityManagerFactory" }] },
"startTime": "2026-07-29T09:00:01.100Z",
"endTime": "2026-07-29T09:00:02.942Z",
"duration": "PT1.842S"
}
]
}
}JSON응답의 duration이 긴 단계부터 정렬하면 병목 후보가 곧바로 드러난다. 위 예시처럼 entityManagerFactory 초기화가 1.8초를 잡아먹는다면, Hibernate 스키마 검증이나 커넥션 풀 워밍업을 의심해볼 수 있다. 응답 구조와 필드 정의는 액추에이터 startup 엔드포인트 문서에서 확인할 수 있다.
JVM 속사정까지 함께 보는 Flight Recorder

startup 엔드포인트가 스프링 내부 단계를 보여준다면, FlightRecorderApplicationStartup은 그 단계를 JVM 이벤트와 같은 타임라인에 겹쳐 보여준다. GC가 언제 돌았는지, 클래스 로딩과 JIT 컴파일이 기동의 어느 지점에서 몰렸는지를 스프링 단계와 나란히 놓고 볼 수 있어, “빈 생성이 느린 게 아니라 그 시점에 GC가 물렸다” 같은 진단이 가능해진다.
Java Flight Recorder는 별도 에이전트 없이 JVM 플래그만으로 켤 수 있다. 애플리케이션을 아래처럼 실행하면 기동 구간의 이벤트가 .jfr 파일로 저장된다.
java -XX:StartFlightRecording:filename=startup.jfr,duration=30s \
-jar build/libs/myapp.jarShellScript이 명령은 애플리케이션이 뜨는 30초 동안 JVM 내부 이벤트를 startup.jfr에 기록한다. 스프링 단계까지 함께 남기려면 main에서 application.setApplicationStartup(new FlightRecorderApplicationStartup())를 호출하면 된다. 생성된 파일은 JDK Mission Control이나 IntelliJ의 프로파일러로 열어 타임라인을 시각적으로 분석한다.
두 도구는 용도가 다르다. startup 엔드포인트는 CI나 스테이징에서 빠르게 순위를 확인할 때, JFR은 GC·클래스 로딩까지 파고들어야 하는 심층 분석에 적합하다. 둘을 함께 쓰면 스프링 레벨과 JVM 레벨을 모두 덮을 수 있다.
프레임워크가 모르는 내 코드 구간 계측하기
ApplicationStartup의 진짜 강점은 프레임워크가 자동으로 남기지 못하는 애플리케이션 로직까지 직접 계측할 수 있다는 데 있다. 시작 시 캐시를 채우거나 외부 설정을 당겨오는 커스텀 로직은 스프링 입장에서 그냥 빈 하나의 초기화로 보인다. 이런 구간을 StartupStep으로 감싸면 startup 엔드포인트 결과에 내 코드가 함께 잡힌다.
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.metrics.ApplicationStartup;
import org.springframework.core.metrics.StartupStep;
import org.springframework.stereotype.Component;
@Component
public class CacheWarmupRunner implements ApplicationListener<ApplicationStartedEvent> {
@Override
public void onApplicationEvent(ApplicationStartedEvent event) {
// ApplicationStartup은 기본 빈이 아니므로 컨텍스트에서 직접 얻는다
ApplicationStartup startup = event.getApplicationContext()
.getApplicationStartup();
StartupStep step = startup.start("app.cache.warmup"); // 구간 시작
step.tag("region", "kr"); // 태그로 맥락 부여
try {
warmUpProductCache();
} finally {
step.end(); // 구간 종료
}
}
}Java이 코드는 캐시 워밍업 구간을 app.cache.warmup이라는 이름의 단계로 기록한다. start()로 열고 tag()로 맥락을 붙인 뒤 end()로 닫는 구조이며, end()를 finally에 두어 예외가 나도 구간이 정상 종료되도록 한 점이 중요하다. 이렇게 남긴 단계는 프레임워크 단계와 동일한 트리에 포함되어, 내 초기화 로직이 전체 Spring Boot 시작 시간에서 차지하는 비중을 정확히 보여준다.
주의할 점은 계측 자체의 오버헤드다. 밀리초 단위의 짧은 반복 구간까지 감싸면 단계 수가 폭증해 버퍼 용량을 넘길 수 있다. 그래서 계측은 “의심되는 굵직한 구간”에 한정하는 편이 실용적이다.
측정이 끝났다면, 지연 초기화라는 첫 처방

병목이 특정 빈 무리에서 나온다면 가장 먼저 검토할 처방은 지연 초기화다. 지연 초기화를 켜면 빈이 기동 시점이 아니라 실제로 필요할 때 생성되므로, 요청 전에는 만들 필요 없는 빈들의 초기화 비용이 시작 구간에서 빠진다. 설정은 프로퍼티 한 줄이면 된다.
spring:
main:
lazy-initialization: trueYAML이 설정은 애플리케이션의 모든 빈을 기본적으로 지연 생성으로 바꾼다. 웹 애플리케이션이라면 컨트롤러, 서비스처럼 첫 HTTP 요청 전에는 쓰이지 않는 빈이 많아 체감 효과가 크다. 다만 전면 적용에는 대가가 따른다. 설정이 잘못된 빈이 기동 시점에 실패하지 않고 첫 호출 때 터지므로, 배포 직후엔 멀쩡해 보이다가 트래픽이 특정 경로로 들어오는 순간 오류가 드러날 수 있다.
특정 빈만 즉시 초기화로 되돌리려면 @Lazy(false)로 예외를 둔다.
@Lazy(false)
@Bean
public HealthCheckClient healthCheckClient() {
return new HealthCheckClient(); // 기동 시 반드시 검증돼야 하는 빈은 즉시 생성
}Java이 코드는 전역 지연 초기화가 켜진 상태에서도 healthCheckClient만 기동 시점에 생성하도록 강제한다. 헬스체크나 필수 커넥션 검증처럼 “빠른 실패”가 중요한 빈은 이렇게 예외 처리하는 것이 안전하다. 지연 초기화는 시작 시간을 줄이는 대신 오류 발견을 늦추는 트레이드오프이므로, 힙 사이즈 튜닝과 함께 신중히 적용하는 편이 좋다.
| 항목 | 지연 초기화 켜기 전 | 지연 초기화 켠 후 |
|---|---|---|
| 빈 생성 시점 | 기동 시 전부 생성 | 첫 사용 시점에 생성 |
| 시작 시간 | 상대적으로 김 | 짧아짐 |
| 설정 오류 발견 | 기동 시 즉시 실패 | 첫 호출 시 실패 |
| 첫 요청 지연 | 없음 | 초기화 비용이 첫 요청에 전가 |
클래스 로딩 비용을 통째로 줄이는 CDS

애플리케이션 코드를 건드리지 않고 Spring Boot 시작 시간을 줄이는 가장 효과가 큰 방법은 CDS(Class Data Sharing)다. CDS는 클래스 메타데이터를 미리 아카이브로 만들어 여러 실행이 공유하게 함으로써, 기동 때마다 반복되는 클래스 로딩·파싱 비용을 없앤다. Spring 공식 블로그의 벤치마크에 따르면 설정에 따라 CDS만으로 시작 시간이 최대 약 54%까지 단축되고, Spring AOT와 결합하면 약 2배까지 빨라진다.
Spring Boot 3.3부터는 실행 가능한 JAR에서 CDS 워크플로를 표준으로 지원한다. 먼저 JAR을 풀고, 트레이닝 실행으로 아카이브를 만든 뒤, 그 아카이브를 지정해 실행하는 3단계다.
# 1) 실행 가능한 JAR을 디렉터리로 추출
java -Djarmode=tools -jar myapp.jar extract --destination myapp
cd myapp
# 2) 트레이닝 실행: 컨텍스트 refresh 직후 종료하며 아카이브 생성
java -XX:ArchiveClassesAtExit=application.jsa \
-Dspring.context.exit=onRefresh -jar myapp.jar
# 3) 생성된 아카이브를 사용해 실제 기동
java -XX:SharedArchiveFile=application.jsa -jar myapp.jarShellScript핵심은 2단계의 -Dspring.context.exit=onRefresh다. 이 옵션은 스프링 컨텍스트가 refresh를 마치는 즉시 애플리케이션을 종료시켜, 실제 요청을 받지 않고도 기동에 필요한 클래스를 모두 로딩한 상태의 아카이브를 만든다. 3단계에서 이 application.jsa를 지정하면 클래스 로딩이 아카이브에서 채워져 기동이 빨라진다. 벤치마크 수치와 배경은 Spring Boot CDS 지원을 소개한 공식 블로그에 자세히 나와 있다.
컨테이너 이미지를 쓴다면 이 과정을 손으로 할 필요도 없다. Spring Boot의 bootBuildImage(Paketo Buildpacks)는 BP_JVM_CDS_ENABLED=true 환경 변수만 주면 빌드 단계에서 CDS 아카이브를 자동으로 생성해 이미지에 포함시킨다.
CDS로 충분한가, 아니면 Native까지 갈 것인가

CDS로도 목표에 못 미친다면 GraalVM Native Image가 다음 선택지다. 다만 둘은 성격이 완전히 다르다. CDS는 JVM 위에서 동작하며 코드 변경 없이 적용되는 반면, Native Image는 AOT 컴파일로 실행 파일을 만들어 기동을 수십 밀리초 수준까지 끌어내리지만 빌드 시간과 제약이 크다. 아래 표로 선택 기준을 정리한다.
| 기준 | 일반 JVM | CDS | GraalVM Native Image |
|---|---|---|---|
| 시작 시간 | 기준(예: 2~8초) | 약 30~54% 단축 | 수십 ms 수준 |
| 코드 변경 | 없음 | 없음 | 리플렉션 힌트 등 필요 |
| 빌드 시간 | 짧음 | 짧음(트레이닝 실행 추가) | 매우 김 |
| 피크 성능(런타임) | JIT로 최고 수준 | JVM과 동일 | JIT 없어 다소 불리 |
| 적합한 경우 | 일반 서비스 | 대부분의 기동 최적화 | 서버리스, 짧은 수명 워크로드 |
표에서 보듯 대부분의 웹 서비스는 CDS로 충분하다. 코드를 건드리지 않고 두 자릿수 퍼센트의 개선을 얻을 수 있기 때문이다. 반면 함수가 호출될 때마다 콜드 스타트가 문제되는 서버리스나 Kubernetes의 scale-to-zero 환경이라면, 리플렉션 설정 부담을 감수하고 Native Image로 가는 것이 합리적이다. 자세한 네이티브 이미지 가이드는 Spring Boot Native Image 공식 문서를 참고하면 된다.

정리하면 Spring Boot 시작 시간은 측정부터 하고, 지연 초기화로 구조를 손보고, CDS로 클래스 로딩 비용을 줄이고, 그래도 부족하면 Native까지 가는 순서로 접근하는 편이 안전하다. 순서를 건너뛰고 뒤부터 손대면 어디가 실제 병목인지 모른 채 비용만 치르기 쉽다.

FAQ
Spring Boot 시작 시간을 측정하는 가장 간단한 방법은 무엇인가?
액추에이터의 startup 엔드포인트가 가장 간단하다. main 메서드에서 application.setApplicationStartup(new BufferingApplicationStartup(2048))을 등록하고 management.endpoints.web.exposure.include에 startup을 추가한 뒤, 기동 직후 GET /actuator/startup을 호출하면 각 단계별 소요 시간을 확인할 수 있다. 코드 변경이 몇 줄로 끝나므로 진입 장벽이 낮다.
지연 초기화를 켜면 항상 시작 시간이 빨라지는가?
빨라지는 경향은 있지만 공짜는 아니다. 지연 초기화는 빈 생성을 첫 사용 시점으로 미룰 뿐 총 초기화 비용을 없애지 않는다. 따라서 시작은 빨라지지만 첫 요청이 느려지고, 설정 오류가 기동이 아니라 런타임에 드러난다. 헬스체크나 필수 검증 빈은 @Lazy(false)로 예외를 두는 것이 안전하다.
CDS와 GraalVM Native Image 중 무엇을 선택해야 하는가?
대부분의 서비스는 CDS가 정답에 가깝다. CDS는 코드 변경 없이 약 30~54%의 시작 시간 단축을 주고 JVM의 런타임 성능을 그대로 유지한다. 반면 콜드 스타트가 치명적인 서버리스나 scale-to-zero 환경에서는 기동을 수십 밀리초로 끌어내리는 Native Image가 유리하지만, 리플렉션 힌트 작성과 긴 빌드 시간을 감수해야 한다.
FlightRecorderApplicationStartup은 startup 엔드포인트와 무엇이 다른가?
계측 범위가 다르다. startup 엔드포인트는 스프링 내부 단계만 보여주는 반면, FlightRecorderApplicationStartup은 그 단계를 GC·클래스 로딩·JIT 같은 JVM 이벤트와 같은 타임라인에 겹쳐 기록한다. 그래서 “빈 생성이 느린 게 아니라 그 시점에 GC가 물렸다”처럼 JVM 레벨까지 파고드는 진단이 가능하다. 심층 분석에는 JFR, 빠른 순위 확인에는 startup 엔드포인트가 적합하다.
커스텀 초기화 로직도 startup 결과에 포함시킬 수 있는가?
가능하다. ApplicationStartup은 기본 빈으로 등록되지 않으므로 ConfigurableApplicationContext.getApplicationStartup()으로 얻은 뒤, start("이름")으로 구간을 열고 tag()로 맥락을 붙인 뒤 end()로 닫으면 된다. 이렇게 하면 캐시 워밍업이나 외부 설정 로딩 같은 애플리케이션 로직이 프레임워크 단계와 동일한 트리에 함께 잡힌다. end()는 예외 상황에서도 호출되도록 finally 블록에 두는 것이 안전하다.
마치며

예전에 운영하던 결제 서비스에서 파드 기동이 9초를 넘겨 오토스케일링이 트래픽을 못 따라간 적이 있다. 다들 “JPA가 무겁다”고 지목했지만, 막상 startup 엔드포인트를 열어보니 범인은 시작 시 전 카테고리 캐시를 동기로 채우던 워밍업 러너였다. 감으로 JPA를 손댔다면 며칠을 날렸을 텐데, 측정 한 번으로 방향이 정해졌다. 그 뒤로는 시작 시간이 문제가 되면 무조건 endpoint부터 열어보는 습관이 생겼다. 지금까지 Spring Boot 시작 시간을 측정하고 최적화하는 방법에 대해서 정리해 보았다.
