@SpringBootApplication 한 줄 뜯어보기
메인 클래스의 그 한 줄
Spring Boot 프로젝트를 만들면 항상 이 코드가 있습니다.
1
2
3
4
5
6
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
너무 당연하게 써왔지만, 이 한 줄은 세 개의 애너테이션을 합친 메타 애너테이션이고 run()은 부트 시퀀스 전체를 오케스트레이션합니다. 이걸 모르면 “왜 내 @Service가 안 잡히지”, “왜 @Bean 내부 호출이 새 인스턴스를 만들지”, “왜 부팅이 느리지”를 추측으로만 때우게 됩니다. 이 글은 그 한 줄을 끝까지 분해합니다.
먼저, run()이 실제로 도는 순서
SpringApplication.run()은 “컨텍스트 하나 만들고 끝”이 아니라, 정해진 단계를 순서대로 밟습니다. 각 단계가 켜지는 흐름을 먼저 눈으로 보세요.
이 단계들은 SpringApplication.run() 내부에서 거의 그대로 호출됩니다.
1
2
3
4
5
6
7
8
run()
├─ getRunListeners() // SpringApplicationRunListeners (이벤트 발행)
├─ prepareEnvironment() // ① 프로퍼티/프로파일 결정 → environmentPrepared 이벤트
├─ createApplicationContext() // ② 웹이면 Servlet/Reactive 컨텍스트 선택
├─ prepareContext() // ApplicationContextInitializer 적용, 소스 등록
├─ refreshContext() // ③ AbstractApplicationContext.refresh() → 스캔·자동구성·서버
├─ afterRefresh()
└─ callRunners() // ⑤ ApplicationRunner / CommandLineRunner
④ 서버 기동이 ③ refresh 안에 포함된다는 게 핵심입니다. 내장 톰캣은 ServletWebServerApplicationContext.onRefresh()에서 떠서, “Bean 다 만들고 → 서버 오픈” 순서가 보장됩니다(서버가 트래픽을 받기 전에 모든 Bean이 준비됨).
세 애너테이션의 합성
flowchart TD
SBA["@SpringBootApplication"]
SBA --> A["@SpringBootConfiguration<br/>(= @Configuration)"]
SBA --> B["@EnableAutoConfiguration"]
SBA --> C["@ComponentScan<br/>(+ 2개의 excludeFilter)"]
A --> A1["이 클래스도 @Bean 정의 가능<br/>+ 테스트가 찾는 설정 진입점"]
B --> B1["조건부 자동 구성 스위치"]
C --> C1["@Component 계열 스캔 →<br/>Bean 등록"]
실제 정의는 이렇게 생겼습니다. @AliasFor로 속성을 하위 애너테이션에 위임하는 부분까지 봐야 절반을 본 겁니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = {
@Filter(type = CUSTOM, classes = TypeExcludeFilter.class),
@Filter(type = CUSTOM, classes = AutoConfigurationExcludeFilter.class)
})
public @interface SpringBootApplication {
@AliasFor(annotation = ComponentScan.class, attribute = "basePackages")
String[] scanBasePackages() default {};
@AliasFor(annotation = EnableAutoConfiguration.class, attribute = "exclude")
Class<?>[] exclude() default {};
boolean proxyBeanMethods() default true;
}
@SpringBootApplication(scanBasePackages = "...")이 동작하는 이유가 바로@AliasFor입니다. 내가 준 값이 합성 과정에서@ComponentScan의basePackages로 그대로 위임됩니다. 마찬가지로exclude는@EnableAutoConfiguration으로 흘러갑니다.
숨은 디테일: 두 개의 excludeFilter
@ComponentScan에 AutoConfigurationExcludeFilter가 끼워져 있는 게 중요합니다. 자동 구성 클래스도 결국 @Configuration이라, 컴포넌트 스캔이 그냥 두면 조건 평가 없이 주워서 등록해 버립니다. 이 필터가 “이미 자동 구성 목록(imports)에 있는 클래스는 스캔에서 제외”해, 자동 구성이 @Conditional을 거쳐 등록되도록 보장합니다. TypeExcludeFilter는 테스트 슬라이스(@WebMvcTest 등)가 스캔 범위를 좁힐 때 쓰는 확장점입니다.
각 애너테이션을 한 겹 더
① @SpringBootConfiguration — 그냥 @Configuration이지만 “이 앱의 기본 설정 진입점“이라는 의미가 추가됩니다. @SpringBootTest는 테스트 클래스에서 패키지 트리를 거슬러 올라가며 @SpringBootConfiguration이 붙은 클래스를 찾아 설정으로 씁니다. 그래서 한 컨텍스트에 @SpringBootConfiguration은 하나여야 합니다(둘이면 테스트가 Found multiple @SpringBootConfiguration로 실패).
② @EnableAutoConfiguration — 자동 구성의 스위치. 동작 원리는 자동 구성 글에서 끝까지 파헤쳤습니다.
③ @ComponentScan — 별도 설정이 없으면 메인 클래스가 위치한 패키지와 그 하위를 스캔합니다. 이 기본값이 다음 함정의 근원입니다.
프로덕션 함정 1: “Bean이 안 잡혀요”의 90%는 패키지 위치
1
2
3
4
5
6
com.example.demo ← DemoApplication (여기 두면 하위 전부 스캔)
├── controller
├── service
└── repository
com.another.pkg ← 스캔 안 됨! @Service 붙여도 Bean 등록 실패
@Service를 분명히 붙였는데 주입이 안 된다면, 십중팔구 그 컴포넌트가 메인 클래스 패키지 바깥에 있습니다. 해결은 메인 클래스를 루트 패키지로 올리거나 범위를 명시하는 것.
1
2
@SpringBootApplication(scanBasePackages = {"com.example.demo", "com.another.pkg"})
public class DemoApplication { }
라이브러리/모듈을 만들 땐 스캔에 의존하지 말고 자동 구성으로 Bean을 공급하는 게 정석입니다(스캔은 애플리케이션의 것).
프로덕션 함정 2: proxyBeanMethods와 @Bean 내부 호출
@Configuration(기본 proxyBeanMethods = true)은 CGLIB로 프록시되어, @Bean 메서드를 내부에서 호출해도 항상 같은 싱글턴을 돌려줍니다.
1
2
3
4
5
@Configuration // proxyBeanMethods = true (기본)
class AppConfig {
@Bean A a() { return new A(b()); } // b()는 프록시를 거쳐 "그" 싱글턴 b 반환
@Bean B b() { return new B(); }
}
proxyBeanMethods = false(Lite 모드)로 바꾸면 b()는 매번 새 인스턴스를 만듭니다. 자동 구성 클래스가 @Configuration(proxyBeanMethods = false)인 이유가 이것(메서드 간 호출을 안 하니 프록시 비용을 없애 시작을 빠르게). 즉 이 옵션은 “성능 ↔ 메서드 호출 시맨틱”의 트레이드오프입니다.
부팅을 관측하기 — 추측 대신 데이터
- 무슨 Bean이 떴나 →
/actuator/beans또는 컨텍스트에서getBeanDefinitionNames(). - 부팅의 어느 단계가 느린가 →
ApplicationStartup을BufferingApplicationStartup으로 교체하고/actuator/startup조회. 각 단계(spring.beans.instantiate등) 소요가 step 단위로 찍힙니다.
1
2
3
4
5
public static void main(String[] args) {
SpringApplication app = new SpringApplication(DemoApplication.class);
app.setApplicationStartup(new BufferingApplicationStartup(2048)); // 부팅 프로파일링
app.run(args);
}
“부팅이 느리다”를 느낌으로 말하지 말고, 이 step 데이터로 어느 단계가 느린지 짚는 게 시작입니다.
면접/리뷰 단골 질문
- Q.
@SpringBootApplication을 세 애너테이션으로 풀어 쓸 수 있나? → 가능.@SpringBootConfiguration+@EnableAutoConfiguration+@ComponentScan. 합성 애너테이션일 뿐이다. - Q. 메인 클래스의
@ComponentScan이 왜 자동 구성 클래스를 중복 등록하지 않나? →AutoConfigurationExcludeFilter가imports목록의 클래스를 스캔에서 제외하기 때문. - Q.
@Bean메서드를 다른@Bean에서 호출하면 매번 새 객체인가? →proxyBeanMethods에 달렸다. 기본(true)이면 CGLIB 프록시로 같은 싱글턴, false면 새 객체.
정리
@SpringBootApplication=@SpringBootConfiguration+@EnableAutoConfiguration+@ComponentScan, 그리고@AliasFor로scanBasePackages·exclude를 하위에 위임.@ComponentScan엔AutoConfigurationExcludeFilter가 들어 있어 자동 구성이 조건 평가를 거쳐 등록되도록 보장한다.- 스캔 기준은 메인 클래스 패키지 하위 → “Bean이 안 잡힌다”면 패키지 위치부터 의심.
proxyBeanMethods(기본 true)가@Bean내부 호출의 싱글턴 보장을 좌우한다.run()은 환경 준비 → 컨텍스트 생성 → refresh(스캔·자동구성·서버) → 러너의 순서. 느리면/actuator/startup으로 단계를 측정하자.
관련 글: 합성된 자동 구성의 내부 동작과, 스캔으로 등록된 Bean들이 거치는 IoC/DI와 Bean 생명주기를 함께 보면 부팅의 전체 그림이 완성됩니다.
