티스토리 뷰
[Spring] SpringBoot 소스 코드 분석하기, SpringBootApplication의 생성과 초기화 과정 - (4)
망나니개발자 2022. 2. 16. 10:00이번에는 SpringBoot의 실행 과정을 소스 코드로 직접 살펴보려고 합니다. 지난 포스팅에서는 SpringBoot의 핵심 어노테이션인 @SpringBootApplication를 먼저 자세히 살펴보았습니다. 이번에는 애플리케이션이 초기화되고 실행되는 run 메소드를 꼼꼼히 살펴볼 계획입니다. 어떠한 작업들이 진행되는지 살펴보도록 하겠습니다.
아래의 내용은 SpringBoot 2.6.3를 기준으로 작성되었습니다.
1. SpringBootApplication의 생성과 초기화 과정
[ SpringBootApplication run 메소드 호출 ]
SpringBoot 프로젝트를 생성하면 메인 클래스와 메인 메소드가 만들어진다. 우리는 다음과 같이 SpringApplication의 run이라는 정적 메소드를 통해 애플리케이션을 실행시킬 수 있다.
@SpringBootApplication
public class TestingApplication {
public static void main(String[] args) {
SpringApplication.run(TestingApplication.class, args);
}
}
SpringApplication의 run 메소드는 ConfigurableApplicationContext을 반환하고 있는데, SpringApplication 클래스가 어떠한 부모 클래스나 인터페이스를 가지고 있지 않으므로, run 내부에서 ApplicaitonContext를 만들어 실행하고 반환함을 추측할 수 있다.
public class SpringApplication {
...
public static ConfigurableApplicationContext run(Class<?> primarySource, String... args) {
return run(new Class<?>[] { primarySource }, args);
}
public static ConfigurableApplicationContext run(Class<?>[] primarySources, String[] args) {
return new SpringApplication(primarySources).run(args);
}
public ConfigurableApplicationContext run(String... args) {
// 실제 run 로직 존재
}
...
}
그 다음으로는 SpringApplication의 생성자를 살펴보도록 하자.
[ SpringBootApplication 초기화 및 실행 과정 ]
SpringApplication 객체를 생성하는 생성자 코드는 다음과 같이 작성되어 있다. 여기서 primarySources는 애플리케이션의 메인 클래스이며 전달된 메인 클래스가 null이면 에러를 반환하도록 되어있다.
public SpringApplication(Class<?>... primarySources) {
this(null, primarySources);
}
public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) {
this.resourceLoader = resourceLoader;
Assert.notNull(primarySources, "PrimarySources must not be null");
this.primarySources = new LinkedHashSet<>(Arrays.asList(primarySources));
this.webApplicationType = WebApplicationType.deduceFromClasspath();
this.bootstrapRegistryInitializers = getBootstrapRegistryInitializersFromSpringFactories();
setInitializers((Collection) getSpringFactoriesInstances(ApplicationContextInitializer.class));
setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));
this.mainApplicationClass = deduceMainApplicationClass();
}
메인 클래스가 null인지 검사한 후에 이어지는 과정은 크게 5가지 단계로 구성된다. 각각의 단계에 대해 자세히 살펴보도록 하자.
- 클래스 패스로부터 애플리케이션 타입을 추론함
- BootstrapRegistryInitializer를 불러오고 셋해줌
- ApplicationContextInitializer를 찾아서 셋해줌
- ApplicationListener를 찾아서 셋해줌
- 메인클래스를 추론함
1. 클래스 패스로부터 애플리케이션 타입을 추론함
SpringBoot는 애플리케이션 실행 매우 초기에 현재 애플리케이션 타입이 무엇인지를 판별한다. Spring 5.0부터는 리액티브 앱이 추가되어 현재 다음 3가지 타입의 앱이 존재한다. (앞선 애플리케이션 컨텍스트 포스팅에서 살펴보았다.)
- 웹이 아닌 애플리케이션: AnnotationConfigApplicationContext
- 서블릿 기반의 웹 애플리케이션: AnnotationConfigServletWebServerApplicationContext
- 리액티브 웹 애플리케이션: AnnotationConfigReactiveWebServerApplicationContext
그리고 앱 타입을 판단하는 기준을 볼 차례인데, 앞선 포스팅을 읽었다면 클래스 로더를 통해 클래스 패스에 해당 클래스가 존재하는지를 기준으로 함을 예상할 수 있었을 것이다.
static WebApplicationType deduceFromClasspath() {
if (ClassUtils.isPresent(WEBFLUX_INDICATOR_CLASS, null)
&& !ClassUtils.isPresent(WEBMVC_INDICATOR_CLASS, null)
&& !ClassUtils.isPresent(JERSEY_INDICATOR_CLASS, null)) {
return WebApplicationType.REACTIVE;
}
for (String className : SERVLET_INDICATOR_CLASSES) {
if (!ClassUtils.isPresent(className, null)) {
return WebApplicationType.NONE;
}
}
return WebApplicationType.SERVLET;
}
이렇게 실행 매우 초기에 결정된 애플리케이션 타입은 이후에 ApplicationContext나 Environment의 구현체를 만드는데 사용된다. 위의 코드를 보면 한가지 사실을 파악할 수 있는데, 만약 web(servlet)과 webflux 의존성이 모두 존재하는 경우라면 web(servlet) 애플리케이션이 만들어진다는 것이다. 또한 이 애플리케이션 타입은 AutoConfig를 위한 이넘으로 매핑되어 AutoConfig의 컨디셔널 조건으로도 활용된다. 대표적으로 서블릿 웹 애플리케이션을 위한 AutoConfig 클래스인 WebMvcAutoConfiguration에는 다음과 같이 ConditionalOnWebApplication이 서블릿일 경우로 설정되어 있다.
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
@AutoConfigureAfter({ DispatcherServletAutoConfiguration.class, TaskExecutionAutoConfiguration.class,
ValidationAutoConfiguration.class })
public class WebMvcAutoConfiguration {
...
}
대표적으로 API 요청을 하는 부분에 RestTemplate이 아닌 WebClient를 사용하는 경우에 이러한 상황이 될 수 있다.
2. BootstrapRegistryInitializer를 불러오고 셋해줌
BootstrapRegistry(BootstrapContext)는 실제 구동되는 애플리케이션 컨텍스트가 준비되고 환경 변수들을 관리하는 스프링의 Environment 객체가 후처리되는 동안에 이용되는 임시 컨텍스트 객체이다.
즉, 애플리케이션이 준비되는 동안에는 BootstrapRegistry를 사용해야 하는데, 해당 객체를 초기화하는데 사용되는 Initializer들을 SpringFactory에서 가져오고 객체로 만들어 셋하는 작업을 진행하는 것이다. (앞선 @SpringBootApplication 어노테이션 포스팅에서 이미 살펴본 내용이다.)
private <T> Collection<T> getSpringFactoriesInstances(Class<T> type, Class<?>[] parameterTypes, Object... args) {
ClassLoader classLoader = getClassLoader();
// Use names and ensure unique to protect against duplicates
Set<String> names = new LinkedHashSet<>(SpringFactoriesLoader.loadFactoryNames(type, classLoader));
List<T> instances = createSpringFactoriesInstances(type, parameterTypes, classLoader, args, names);
AnnotationAwareOrderComparator.sort(instances);
return instances;
}
객체로 만들 클래스를 SpringFactory에서 조회하는 부분은 loadFactoryNames 메소드이다. SpringFactory는 jar 안에 META-INF/spring.factories에 존재하는 텍스트 파일로써 설정을 위해 필요한 클래스 정보들이 다음과 같이 들어있다.
해당 값은 key=value 형태인데, (key, value)는 jar 파일에 따라 (인터페이스의 이름, 구현체 목록) 또는 (설정 클래스 이름, 필요한 클래스 목록) 등으로 구성된다. 구현체가 여러 개인 경우에는 컴마로 구현체들을 나열한다. 이러한 형태의 SpringFactory 정보들을 불러오는 loadFactoryNames 코드는 다음과 같다.
private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
// 캐싱이 적용되어 있음
MultiValueMap<String, String> result = (MultiValueMap)cache.get(classLoader);
if (result != null) {
return result;
} else {
try {
//Get the spring.factories File path for
Enumeration<URL> urls = classLoader != null ? classLoader.getResources("META-INF/spring.factories") : ClassLoader.getSystemResources("META-INF/spring.factories");
LinkedMultiValueMap result = new LinkedMultiValueMap();
while(urls.hasMoreElements()) {
//Load one of them spring.factories file
URL url = (URL)urls.nextElement();
UrlResource resource = new UrlResource(url);
Properties properties = PropertiesLoaderUtils.loadProperties(resource);
//The spring.factories All key value pairs in the file
Iterator var6 = properties.entrySet().iterator();
while(var6.hasNext()) {
//A collection of implementation classes for one of the interfaces
Entry<?, ?> entry = (Entry)var6.next();
List<String> factoryClassNames = Arrays.asList(StringUtils.commaDelimitedListToStringArray((String)entry.getValue()));
result.addAll((String)entry.getKey(), factoryClassNames);
}
}
cache.put(classLoader, result);
return result;
} catch (IOException var9) {
throw new IllegalArgumentException("Unable to load factories from location [META-INF/spring.factories]", var9);
}
}
}
Spring은 다운받은 모든 jar 파일 안에서 META-INF 하위에 spring.factories가 존재하는지 스캔한다. 그리고 존재한다면 해당 값을 모두 불러들인다. spring.factories가 존재하는 프로젝트로는 크게 spring-boot, spring-boot-autoconfigure, spring-data-jpa가 있다.
스캔한 값들은 이후에 ApplicationContextInitializer나 ApplicationListener 및 AutoConfig에서도 동일하게 사용되므로 연속적인 File I/O로 인한 성능 문제를 방지하기 위해 캐싱을 적용한다. 이 단계에서는 캐싱된 내역이 없어 파일로부터 내용을 읽어왔다. (이후에 ApplicationContextInitializer나 ApplicationListener 및 AutoConfig에서는 캐싱되어 있으므로 파일에서 값을 불러오지 않는다.)
그리고 불러온 클래스 목록들은 createSpringFactoriesInstances를 통해 객체로 생성되어 반환된다.
3. ApplicationContextInitializer를 찾아서 셋해줌
이 단계에서는 실제 사용되는 ApplicationContext를 위한 Initializer들을 로딩하는데, 해당 내용은 위의 BootStrapRegistryInltializer를 불러오는 과정과 거의 동일하다. 다만 Initializer들을 로딩하는 과정에서 이미 BootStrap 단계에서 spring.factories를 스캔했으므로 파일에서 찾는 것이 아닌 캐싱된 값으로부터 찾는다는 점과 spring.factories에서 읽은 값들의 key로 ApplicationContextInitializer에 해당하는 Value들만을 조회해 객체로 가져온다는 점이 다르다. SpringBoot는 해당 구현체들을 생성한 다음에 Initializer값을 셋해준다.
public void setInitializers(Collection<? extends ApplicationContextInitializer<?>> initializers) {
this.initializers = new ArrayList<>(initializers);
}
4. ApplicationListener를 찾아서 셋해줌
그 다음은 ApplicationListener들을 불러오고 listener 값을 셋해주는 부분이다. 이 부분은 ApplicationContextInitializer과 거의 동일하며 ApplicationListener 클래스를 Key타입으로 준다는 것만 다르다. 참고로 ApplicationListener는 옵저버 패턴을 기반으로 애플리케이션을 구독하는 리스너이다.
5. 메인클래스를 추론함
드디어 SpringApplication을 생성하는 마지막 단계인 메인 클래스를 찾는 부분이다. 메인 클래스를 찾는 과정은 다음과 같다.
private Class<?> deduceMainApplicationClass() {
try {
StackTraceElement[] stackTrace = new RuntimeException().getStackTrace();
for (StackTraceElement stackTraceElement : stackTrace) {
if ("main".equals(stackTraceElement.getMethodName())) {
return Class.forName(stackTraceElement.getClassName());
}
}
}
catch (ClassNotFoundException ex) {
// Swallow and continue
}
return null;
}
SpringBoot는 먼저 의도적으로 RuntimeException을 발생시킨 다음에 해당 StackTrace를 가져온다. 그리고 "main"이 붙어있는 메소드를 찾으면 Main 클래스라고 판단하고 해당 클래스의 이름을 찾는다. 로직이 꽤나 허술해보이는데, 실제로 애플리케이션을 run하는 클래스를 옮기면 잘못된 클래스를 main으로 잡는 것을 테스트해볼 수 있다. 그래도 의도적으로 변경하지 않는 이상은 올바르게 찾으므로 크게 문제가 될 것 같지는 않다.
이렇게 찾아낸 메인 애플리케이션 클래스는 이후에 Logger를 만들어 로그를 남기거나 리스너를 등록하는 등에 사용될 예정이다.
우리는 위의 단계들을 살펴봄으로써 SpringApplication 객체를 만드는 과정에서 어느 애플리케이션 타입인지 판단하고, 객체들을 준비하는 등의 작업이 진행되는 것을 파악할 수 있었다. 그 다음에는 실제 run 메소드의 호출을 살펴보도록 하자.
이제부터 내용이 조금 복잡해진다. 그런 만큼 살펴볼만한 디자인 패턴들도 등장하니 관련 내용도 짚고 넘어가도록 하자.
위의 내용들은 개인적으로 공부를 하면서 작성을 한 내용이라 충분히 틀리거나 잘못된 내용들이 있을 수 있습니다. 혹시 수정 또는 추가할 내용들을 발견하셨다면 댓글 남겨주세요! 반영해서 수정하도록 하겠습니다:)
관련 포스팅
- SpringBoot 소스 코드 분석하기, SpringBoot의 장점과 특징 - (1)
- SpringBoot 소스 코드 분석하기, 애플리케이션 컨텍스트(Application Context)와 빈팩토리(BeanFactory) - (2)
- SpringBoot 소스 코드 분석하기, @SpringBootApplication 어노테이션의 속성과 구성요소 - (3)
- SpringBoot 소스 코드 분석하기, SpringBootApplication의 생성과 초기화 과정 - (4)
- SpringBoot 소스 코드 분석하기, SpringBootApplication의 run 메소드와 실행과정 - (5)
- SpringBoot 소스 코드 분석하기, 애플리케이션 컨텍스트(ApplicationContext)의 refreshContext 동작 과정 - (6)
- SpringBoot 소스 코드 분석하기, DispatcherServlet(디스패처 서블릿) 동작 과정 - (7)