SpringApplication.run()은 한 줄인데 내부에서 무슨 일이 벌어지는가
웹 타입 감지부터 내장 서버 포트 바인딩까지, Spring Boot 시작 과정의 설계 철학과 각 단계의 역할을 추적한다.
- 01 SpringApplication.run()은 한 줄인데 내부에서 무슨 일이 벌어지는가
- 02 Spring Boot Auto-configuration은 어떻게 스스로를 조립하는가
- 03 Spring Boot 설정은 어떻게 주입되는가
- 04 Spring Boot Actuator는 어떻게 작동하는가
- 05 Spring Boot 내장 서버는 어떻게 뜨는가
- 06 Spring Boot DevTools는 어떻게 개발 루프를 단축하는가
- 07 Spring Boot 앱은 어떻게 실행되는가
SpringApplication.run(App.class, args) 한 줄이 실행되면 Spring Boot는 15단계 이상의 과정을 밟는다. 대부분의 개발자는 이 과정을 블랙박스로 다루지만, 내부를 모르면 커스터마이징 지점을 찾지 못하고, @ConditionalOnMissingBean이 왜 때로는 기대와 다르게 동작하는지 설명할 수 없다. 이 설계가 어떤 문제를 풀기 위해 만들어졌는가?
두 단계로 나뉘는 시작 과정
SpringApplication.run()은 사실 두 단계다. 첫 번째는 SpringApplication 객체 생성, 두 번째는 run() 메서드 실행이다.
객체 생성 시점에 세 가지 핵심 결정이 이루어진다. 웹 애플리케이션 타입 감지, spring.factories(또는 Boot 3.x의 .imports 파일)에서 ApplicationContextInitializer와 ApplicationListener 로딩, 그리고 스택 트레이스를 분석해 main 클래스 추정이다.
웹 타입 감지는 클래스패스 기반이다. DispatcherHandler가 있고 DispatcherServlet이 없으면 REACTIVE, 서블릿 관련 클래스가 존재하면 SERVLET, 그 외는 NONE이다. 이 결과가 이후 ApplicationContext 타입을 결정한다.
SERVLET → AnnotationConfigServletWebServerApplicationContext
REACTIVE → AnnotationConfigReactiveWebServerApplicationContext
NONE → AnnotationConfigApplicationContext
객체 생성 단계에서 이 결정이 끝나기 때문에, app.setWebApplicationType(WebApplicationType.NONE)으로 오버라이드하면 내장 서버 없이 순수 Bean 컨테이너만 시작할 수 있다. 배치 처리나 CLI 도구에서 유용한 패턴이다.
@SpringBootApplication은 세 어노테이션의 합성이다
run() 단계로 넘어오면 가장 먼저 Environment를 구성하고, 배너를 출력한 뒤, ApplicationContext를 생성한다. 이 Context가 준비되는 과정에서 @SpringBootApplication이 처음 해석된다.
@SpringBootApplication은 세 어노테이션의 합성 메타 어노테이션이다.
@SpringBootConfiguration:@Configuration과 기능은 동일하지만@SpringBootTest가 시작점을 탐색할 때 이 어노테이션을 마커로 사용한다.@EnableAutoConfiguration:@Import(AutoConfigurationImportSelector.class)를 통해 Auto-configuration 후보 목록을 로딩하는 핵심 스위치다.@ComponentScan: 어노테이션이 붙은 클래스의 패키지를 기준으로 하위 패키지 전체를 스캔한다.com.example.config.App에 배치하면com.example.service는 스캔 대상에서 빠진다.
exclude로 Auto-configuration을 끄는 방법은 세 가지다. 어노테이션 속성(exclude = DataSourceAutoConfiguration.class)은 컴파일 타임에 클래스 존재를 검사한다. excludeName 속성은 클래스가 클래스패스에 없어도 오류 없이 동작한다. spring.autoconfigure.exclude 프로퍼티는 코드 변경 없이 환경별로 제어할 수 있다. 각각 다른 시점에 다른 방식으로 동작한다.
Auto-configuration이 사용자 Bean에 밀리는 이유
@EnableAutoConfiguration의 핵심은 AutoConfigurationImportSelector가 DeferredImportSelector를 구현한다는 점이다. 일반 @Import와 달리, DeferredImportSelector는 모든 @Configuration 클래스 파싱이 끝난 후에 실행된다.
처리 순서는 다음과 같다.
- 사용자
@Configuration클래스 파싱 완료 → 사용자DataSourceBean 등록 DeferredImportSelector실행 → Auto-configuration 후보 처리DataSourceAutoConfiguration의@ConditionalOnMissingBean(DataSource.class)평가- 이미
DataSource가 등록되어 있으므로 조건false→ 스킵
이 순서가 보장되기 때문에 사용자 Bean이 항상 Auto-configuration보다 우선한다. DeferredImportSelector가 없었다면 처리 순서가 섞여 @ConditionalOnMissingBean이 부정확하게 동작했을 것이다.
Auto-configuration 후보는 수백 개지만 대부분은 1차 필터링에서 탈락한다. spring-boot-autoconfigure-processor가 컴파일 타임에 @ConditionalOnClass 조건을 메타데이터 파일로 추출해두고, 런타임에는 클래스를 로딩하지 않고 클래스명 문자열 비교만으로 즉시 제외한다. 실제로 클래스 로딩과 @Conditional 평가가 발생하는 것은 1차 필터링을 통과한 소수다.
Boot 3.x에서는 Auto-configuration 후보 목록 파일도 분리됐다. spring.factories의 EnableAutoConfiguration 키 대신 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 파일을 사용한다. 한 줄에 하나의 클래스명을 담는 구조라 Git diff가 명확하고 GraalVM Native Image 분석에도 유리하다.
refresh()에서 내장 서버가 두 번 등장하는 이유
ApplicationContext가 생성되고 나면 prepareContext() → refreshContext() 순서로 진행된다. refresh()는 12단계로 구성되는데, 내장 서버와 관련해 두 단계가 중요하다.
onRefresh() 단계에서 내장 Tomcat 객체가 생성되고 DispatcherServlet이 등록된다. 그러나 아직 포트 바인딩은 일어나지 않는다. finishBeanFactoryInitialization() 단계에서 모든 싱글턴 Bean이 생성되고, 그 후 finishRefresh()에서 비로소 포트 바인딩이 완료된다.
onRefresh() → Tomcat 객체 생성, DispatcherServlet 등록 (포트 닫힘)
finishBeanFactoryInitialization() → 모든 Bean 생성 완료
finishRefresh() → 포트 바인딩 (HTTP 요청 수신 시작)
이 순서가 보장하는 것은 “포트가 열리는 시점에 모든 Bean이 준비 완료 상태”다. DispatcherServlet, @Controller, @Service 전부 초기화된 이후에 트래픽을 받는다.
@Conditional 평가는 Bean 생성이 아니라 등록 시점에 일어난다
흔한 오해 중 하나는 @Conditional이 getBean() 호출 시 평가된다는 것이다. 실제로는 BeanDefinition 등록 시점에 평가된다. 조건이 false이면 BeanDefinition 자체가 등록되지 않는다.
평가 시점은 두 단계로 나뉜다. PARSE_CONFIGURATION Phase는 @Configuration 클래스 파싱 중 클래스 레벨 조건을 평가한다. 조건이 false이면 해당 클래스 전체가 스킵된다. REGISTER_BEAN Phase는 @Bean 메서드 등록 중 메서드 레벨 조건을 평가한다.
@ConditionalOnClass는 PARSE_CONFIGURATION Phase에서 가장 높은 우선순위로 실행된다. 클래스가 없으면 나머지 조건 평가가 생략된다. @ConditionalOnMissingBean은 REGISTER_BEAN Phase에서 낮은 우선순위로 실행되어 사용자 Bean이 모두 등록된 후에 평가된다. 이 두 Phase의 분리와 순서가 Auto-configuration의 신뢰성을 만든다.
정리
SpringApplication생성 시점에 웹 타입이 감지되고, 이것이ApplicationContext타입을 결정한다.@SpringBootApplication은 세 어노테이션의 합성이다.@ComponentScan기본 범위는 클래스 위치 기준이므로 루트 패키지에 배치해야 한다.- Auto-configuration이 사용자 Bean에 밀리는 것은
DeferredImportSelector와@ConditionalOnMissingBean의REGISTER_BEANPhase 덕분이다. - 내장 서버는
onRefresh()에서 생성되고finishRefresh()에서 포트를 열어 “준비 완료 후 트래픽 수신”을 보장한다. - Boot 3.x에서
spring.factories의 Auto-configuration 목록은.imports파일로 분리됐다.ApplicationContextInitializer,ApplicationListener등은 여전히spring.factories를 사용한다.
다음 글에서는 @ConditionalOnClass의 1차 필터링이 클래스 로딩 없이 어떻게 동작하는지, spring-boot-autoconfigure-processor가 컴파일 타임에 무엇을 생성하는지 추적한다.