Spring과 Tomcat, 어떤 순서일까?
Spring Framework vs Spring Boot의 부팅 시퀀스
"Spring으로 웹 개발합니다"라는 말은 오늘날 두 가지 의미를 가진다.
- 클래식 Spring (Framework):
war파일을 만들어 이미 실행 중인 외부 톰캣(Tomcat)에 배포하는 방식. - 모던 Spring Boot:
main()메소드를 실행하면 톰캣이 내장되어jar파일 하나로 실행되는 방식.
두 방식은 서블릿 컨테이너인 톰캣과 Spring 컨테이너(ApplicationContext)의 관계가 반대이다.
두 방식의 가장 큰 차이는 부팅 주도권에 있다.
클래식 Spring의 경우에는 Tomcat이 Spring을 올리고, Spring Boot는 Spring이 내장 서버를 띄운다. (Tomcat 외에도 Netty나 Jetty 등도 선택 가능)
이 글에서는 두 방식의 부팅 시퀀스를 단계별로 비교 분석해보자.
1단계: 점화 방식의 차이 - web-xml vs main()
모든 것은 누가 먼저 시작하는가에서 갈린다.
Spring Framework: Tomcat(Servlet Container)이 모든 것을 시작한다.
클래식 Spring에서의 주도권은 WAS인 Tomcat이 가지고 있다. Tomcat이 먼저 실행되고, 그 안에서 Spring 컨테이너가 호출되어 부팅된다.
흐름은 다음과 같다.
- 관리자가 외부 톰캣 서버를 실행한다.
- 개발자가 만든
.war파일이 webapps/ 폴더에 배포된다. - 톰캣은 WAR의 압축을 해제하고 /WEB-INF/web.xml을 읽는다.
- web.xml 안에는 Spring 컨테이너를 띄우는 리스터(ContextLoaderListener)와 클라이언트의 요청을 받는 서블릿(DispatcherServlet) 정의가 포함되어 있다.
- 톰캣은 ContextLoaderListener를 먼저 실행시켜 Root ApplicationContext를 생성한다.
- 이후 DispatcherServlet을 초기화하면서 WebApplicationContext를 로딩한다. (RootContext와 WebContext는 부모-자식 관계이다.)
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
서블릿 3.0이후 web.xml이 없는 경우에는 다음과 같은 방식도 존재한다.
WebApplicationInitializer를 구현하여 코드로 서블릿을 등록할 수도 있다.
이 경우 Tomcat은 SpringServletContainerInitializer를 통해 ClassPath 상의 WebApplicationInitializer 구현체를 자동 호출한다.
public class CustomWebInitializer implements WebApplicationInitializer { @Override public void onStartup(ServletContext sc) { AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext(); context.reigster(AppConfig.class); sc.addListener(new ContextLoaderListener(context)); sc.addServlet("dispatcher", new DispatcherServlet(context)).addMapping("/"); } }
SpringBoot - Spring이 Tomcat을 자식으로 둔다
Spring Boot에서는 제어의 역전(IoC)이 서버 레벨까지 확장된다. 이제 주도권은 Spring이 쥐고있다.
흐름은 다음과 같다.
- 개발자가
java -jar app.jar를 실행하거나 IDE에서 main()을 실행한다. - SpringApplication.run(MyApp.class, args)가 호출된다.
- SpringApplication은 다음 과정을 거친다.
- ClassPath에서 spring-boot-starter-web을 감지한다.
- spring-webmvc 존재 여부를 확인해 서블릿 기반 웹 애플리케이션임을 판단한다.
- 이에 따라
AnnotationConfigServletWebServerApplicationContext를 생성한다.
@SpringBootApplication내부의@EnableAutoConfiguration이 동작한다.WebServerFactoryAutoConfiguration을 통해 Tomcat, Netty 등 중 하나를 자동 구성한다.- 기본 값은 Tomcat이며,
TomcatServletWebServerFactory빈이 등록된다.
onRefresh()시점에 Spring 컨테이너가 Tomcat 인스턴스를 생성한다. 즉,ServletWebServerFactory#getWebServber()가Tomcat.start()를 호출한다.- 컨테이너가 DispatcherServlet을 자동 생성하고, DispatcherServletRegisterationBean을 매핑한다.
@SpringBootApplication
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
2단계: @SpringBootApplication의 비밀
Spring Boot가 main() 하나로 애플리케이션을 시작하게 하는 방법은 @SpringBootApplication 어노테이션에서 나온다. 해당 어노테이션을 자세히 들여다보면 다음과 같다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration // 1. 설정 클래스
@EnableAutoConfiguration // 2. 자동 구성
@ComponentScan(...) // 3. 빈 스캔
public @interface SpringBootApplication { ... }
@ComponentScan - 해당 패키지에서부터 스캔
해당 패키지 내부에서 어노테이션이 붙은 클래스의 패키지를 루트로하여 하위 패키지를 훑는다. 결과적으로 패키지 내부에서 사용하던 @Component, @Service, @Repository, @Controller, @Configuration 등을 빈으로 등록한다.
이는 클래식 스프링에서의 <context:component-scan .../>과 동일한 역할을 수행한다.
이런 애플리케이션 루트 클래스는 보통 com.myapp과 같이 최상위 패키지에서 작성해야만 하위 모듈이 전부 스캔된다.
@SpringBootConfiguration - 설정 클래스 선언
사실상 @Configuration과 동일하다고 봐도 된다. 여기에서 정의한 @Bean 메서드도 일반 스프링 설정처럼 동작하게 된다.
차이점으로는 Boot의 라이프사이클 안에서 동작하게 된다.
좀 더 자세하게 알아보면 다음과 같다.
Spring Framework에서 @Configuration은 다음과 같이 개발자가 직접 구성한 설정 클래스이다.
@Configuration
public class CustomConfig {
@Bean
public CustomService customService() {
return new CustomServiceImpl();
}
}
이런 설정 클래스는 AnnotationConfigApplicationContext나 AnnotationConfigWebApplicationContext와 같은 Spring 컨테이너가 생성될 때 읽혀서 @Bean으로 등록한다.
즉, Spring 컨테이너가 이미 만들어지고, 그 안에서 설정이 적용되는 구조이다.
Spring Boot의 부팅 진입점은 위에서 언급했듯 SpringApplication.run(...)이다. 여기서 Spring Boot는 ApplicationContext를 직접 생성하고 관리한다.
이때 Boot는 이 프로젝트의 메인 설정 클래스가 무엇인지 찾는다. 그 기준이 되는 것이 바로 @SpringBootApplication 안의 @SpringBootConfiguration이다.
즉, Spring Boot 입장에서는 @SpringBootApplication이 붙은 클래스가 초기화할 때 기준이 되는 설정 클래스로 인식한다.
@Configuration은 단순히 Spring 컨테이너 내 설정만 담당한다. 반면 @SpringBootConfiguration은 Boot의 부팅 시퀀스(환경 설정, 자동 구성, 빈 등록 등)에 포함되어 라이프 사이클 전반에 걸쳐 영향을 미친다.
이것이 Boot의 라이프사이클 안에서 동장한다라는 말의 의미이다.
@EnableAutoConfiguration
자동 구성의 핵심은 조건부 등록이다. Boot는 ClassPath와 기존 빈 상태를 보고 필요한 설정만 골라서 활성화한다.
해당 어노테이션이 붙은 경우 Spring Boot는 spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports에 미리 정의된 수백 개의 자동 구성 후보들을 검토한다.
이러한 후보들은 조건문을 통해 활성화를 진행하게 된다.
예를 들어 @ConditionOnClass는 클래스 패스에 tomcat-embed-core가 있다면 활성화 되고,
3단계 - Spring 컨테이너의 refresh()
이 단계는 Spring Framework과 Spring Boot가 공유하는 핵심 개념이다.
ApplicationContext가 생성되고 refresh() 메소드가 호출되면, Spring 컨테이너의 실제 라이프사이클이 시작된다.
AbstractApplicationContext.refresh()는 10여 개의 복잡한 단계를 거치는 템플릿 메소드로, 컨텍스트 초기화 과정을 단계별로 정의한다.
Spring Boot 혹은 클래식 Spring 모두 컨테이너의 본질적인 초기화 루틴은 여기서 공통적으로 작동한다.
SpringApplication.run()을 호출하면 아래의 클래스에서 run 메소드를 호출하게 된다.
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) {
Startup startup = Startup.create();
if (this.properties.isRegisterShutdownHook()) {
SpringApplication.shutdownHook.enableShutdownHookAddition();
}
DefaultBootstrapContext bootstrapContext = createBootstrapContext();
ConfigurableApplicationContext context = null;
configureHeadlessProperty();
SpringApplicationRunListeners listeners = getRunListeners(args);
listeners.starting(bootstrapContext, this.mainApplicationClass);
try {
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);
Banner printedBanner = printBanner(environment);
context = createApplicationContext();
context.setApplicationStartup(this.applicationStartup);
prepareContext(bootstrapContext, context, environment, listeners, applicationArguments, printedBanner);
refreshContext(context);
afterRefresh(context, applicationArguments);
startup.started();
if (this.properties.isLogStartupInfo()) {
new StartupInfoLogger(this.mainApplicationClass, environment).logStarted(getApplicationLog(), startup);
}
listeners.started(context, startup.timeTakenToStarted());
callRunners(context, applicationArguments);
}
catch (Throwable ex) {
throw handleRunFailure(context, ex, listeners);
}
try {
if (context.isRunning()) {
listeners.ready(context, startup.ready());
}
}
catch (Throwable ex) {
throw handleRunFailure(context, ex, null);
}
return context;
}
}
우선 context = createApplicationContext(); 해당 라인에서 Spring Boot는 ApplicationContext를 생성하게 된다.
기본적으로는 웹 애플리케이션일 경우에는 AnnotationConfigServletWebServerApplicationContext가, 일반 앱일 경우에는 AnnotationConfigApplicationContext가 만들어진다.
내부에서는 ApplicationContextFactory가 역할을 하게된다.
public class SpringApplication {
protected ConfigurableApplicationContext createApplicationContext() {
return this.applicationContextFactory.create(this.properties.getWebApplicationType());
}
}
여기에서 WebApplicationType이 SERVLET이면 AnnotationConfigServletWebServerApplicationContext가 반환된다.
컨테이너를 시동시키기 위해 실제 AbstractApplicationContext.refresh()가 호출되는 부분은 어디일까?
refreshContext(context); 해당 부분이 실제로 호출되는 부분이다.
해당 메서드를 따라가면 다음처럼 정의되어 있다.
private void refreshContext(ConfigurableApplicationContext context) {
if (this.properties.isRegisterShutdownHook()) {
shutdownHook.registerApplicationContext(context);
}
refresh(context);
}
protected void refresh(ConfigurableApplicationContext applicationContext) {
applicationContext.refresh();
}
refresh(context)는 SpiringApplication의 protected 메서드이고, 결국 context.refresh()로 위임된다.
즉 해당 부분에서 AbstractApplicationContext.refresh()의 호출이 발생한다.
그런데 어떻게 AbstractApplicationContext.refresh()가 호출될 수 있었을까?
아까 언급했듯 AbstractApplicationContext는 10여개의 단계를 거치는 템플릿 메소드 패턴으로 작성된 클래스라고 했다.
해당 클래스를 직접 살펴보면 다음과 같다.
public abstract class AbstractApplicationContext extends DefaultResourceLoader implements ConfigurableApplicationContext {
// ...
@Override
public void refresh() throws BeansException, IllegalStateException {
this.startupShutdownLock.lock();
try {
this.startupShutdownThread = Thread.currentThread();
StartupStep contextRefresh = this.applicationStartup.start("spring.context.refresh");
// Prepare this context for refreshing.
prepareRefresh();
// Tell the subclass to refresh the internal bean factory.
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
// Prepare the bean factory for use in this context.
prepareBeanFactory(beanFactory);
try {
// Allows post-processing of the bean factory in context subclasses.
postProcessBeanFactory(beanFactory);
StartupStep beanPostProcess = this.applicationStartup.start("spring.context.beans.post-process");
// Invoke factory processors registered as beans in the context.
invokeBeanFactoryPostProcessors(beanFactory);
// Register bean processors that intercept bean creation.
registerBeanPostProcessors(beanFactory);
beanPostProcess.end();
// Initialize message source for this context.
initMessageSource();
// Initialize event multicaster for this context.
initApplicationEventMulticaster();
// Initialize other special beans in specific context subclasses.
onRefresh();
// Check for listener beans and register them.
registerListeners();
// Instantiate all remaining (non-lazy-init) singletons.
finishBeanFactoryInitialization(beanFactory);
// Last step: publish corresponding event.
finishRefresh();
}
catch (RuntimeException | Error ex ) {
if (logger.isWarnEnabled()) {
logger.warn("Exception encountered during context initialization - " +
"cancelling refresh attempt: " + ex);
}
// Destroy already created singletons to avoid dangling resources.
destroyBeans();
// Reset 'active' flag.
cancelRefresh(ex);
// Propagate exception to caller.
throw ex;
}
finally {
contextRefresh.end();
}
}
finally {
this.startupShutdownThread = null;
this.startupShutdownLock.unlock();
}
}
// ...
}
AbstractApplicationContext의 refresh()메소드는 템플릿 메서드로 구현되어 있기 때문에, 상속받은 하위 클래스들이 오버라이드 하지 않아도 실행 가능한 기본 구현을 이미 가진 추상 클래스이다.
어떤 상속 관계를 가지고 클래스가 구현되어있는지 살펴보자.
우선 위에서 SERVLET인 경우 AnnotationConfigServletWebServerApplicationContext를 반환한다고 했다.
상속 관계를 정리해보면 다음과 같다.

따라서, AnnotationConfigServletWebServerApplicationContext.refresh()를 실행한 경우 AbstractApplicationContext.refresh()가 실행되고, 중간 과정에서 onRefresh()만 오버라이드되어 내장 톰캣을 띄우게 되는 것이다.
prepareRefresh()
컨텍스트의 리셋과 환경 준비를 진행하는 단계이다.
가장 먼저 컨테이너의 내부 상태를 초기화하고(startupDate, active, closed 플래그 설정), 프로퍼티 소스를 구성한다.
Spring Boot의 경우 spring.profiles.active나 default와 같은 활성 프로파일이 이 시점에 확정된다.
필수 프로퍼티가 누락되지 않았는지 검증하며, 이후 발생할 이벤트를 담을 이벤트 버퍼를 준비한다.
즉, BeanFactory를 세우기 전에 토양을 다지는 단계이다.
obtainFreshBeanFactory()
이제 빈을 담을 BeanFactory를 새로 만든다.
DefaultListableBeanFactory가 생성되고, @Configuration 클래스나 XML 설정이 있다면 그 안의 빈 정의를 로드해 설계도 형태로 저장한다.
이 시점에는 실제 객체가 만들어지지 않는다. 오직 어떤 빈이 어떤 이름으로 등록될지에 대한 BeanDefinition만 존재한다. 즉, 컨테이너의 기본 뼈대를 세우는 작업이다.
prepareBeanFactory()
위에서 생성된 BeanFactory에 기본적인 설정을 주입한다.
클래스로더, SpEL, Environment, 시스템 프로퍼티 등이 이때 연결된다.
또한, ApplicationContextAware, EnvironmentAware와 같은 Aware 인터페이스를 인식할 수 있도록 후처리기가 등록된다.
이 단계가 끝나면 빈들이 Spring의 내부 기능(@Value, #{})을 자유롭게 사용할 수 있는 환경이 완성된다.
invokeBeanFactoryPostProcessors
이 단계가 Spring의 핵심이다.
BeanFactoryPostProcessor와 BeanDefinitionRegistryPostProcessor들이 호출되면서, @Configuration, @ComponentScan, @EnableAutoConfiguration 과 같은 어노테이션들이 실제로 해석된다.
Spring Boot의 Auto Configuration도 이 시점에 작동한다.
예를 들어 클래스패스에 tomcat-embed-core가 존재하면, ServletWebServerFactoryAutoConfiguration이 활성화되어 TomcatServletWebServerFactory 빈이 등록된다. DispatcherServletAutoConfiguration은 DispatcherServlet과 매핑 정보를 등록한다.
즉, 빈 설계도가 대규모로 생성되는 시점이며, Spring 의 자동 구성이 실제로 수행되는 단계이다.
registerBeanPostProcessor()
앞선 단계가 "어떤 빈을 만들까"였다면, 해당 단계는 "어떻게 만들까"를 담당한다.
모든 BeanPostProcessor들이 생성되어 BeanFactory에 등록된다.
이들은 빈의 생성 전후 과정에 개입해 의존성 주입(@Autowired), AOP 프록시 적용, @PostConstruct 실행 등을 처리한다.
대표적인 예로 AutowiredAnnotationBeanPostProcessor, CommonAnnotationBeanPostProcessor, AnnotationAwareAspectJAutoProxyCreator 등이 존재한다.
initMessageSource()
애플리케이션의 국제화(i18n) 메시지 소스를 초기화한다.
messageSource 빈이 명시적으로 정의되어 있으면 그것을 사용하고, 없으면 기본 DelegatingMessageSource를 등록한다.
이는 MessageSource.getMessage()를 통해 다국어 문구를 조회할 때 사용된다.
initApplicationEventMulticaster()
애플리케이션 이벤트를 중계하는 이벤트 브로커를 초기화한다.
사용자 정의 applicationEventMulticaster 빈이 있다면 그것을 사용하고, 없다면 기본 SimpleApplicationEventMulticaster를 생성한다.
이후 애플리케이션 전역의 이벤트 전파(publishEvent)가 가능해진다.
onRefresh()
해당 지점에서 Spring과 Spring Boot가 갈라진다.
기본 Spring에서는 아무 동작도 하지 않는다.
하지만 Spring Boot 에서는 이 메서드를 오버라이딩한 ServletWebServerApplicationContext가 등장한다.
여기서 createWebServer()를 호출하여 내장 톰캣을 생성하고, DispatcherServlet을 매핑한다.
즉, 내장 서버가 이 단계에서 실제로 띄워진다.
registerListeners()
이벤트 리스너들을 멀티캐스터에 등록하는 단계이다.
컨텍스트 초기화 이전에 버퍼링되어 있던 이벤트들을 이제 한 번에 방출하여, 애플리케이션의 나머지 구성 요소들이 알림을 받을 수 있도록 한다.
finishBeanFactoryInitialization(beanFactory)
이후 본격적으로 모든 싱글톤 빈이 생성된다.
conversionService, ${} 치환기 등이 준비되고, Lazy가 아닌 모든 싱글톤 빈이 실제로 인스턴스화된다.
의존성이 주입되고, @PostConstruct가 실행되며, SmartInitializingSingleton 인터페이스를 구현한 빈들이 호출된다.
이 단계에서 애플리케이션의 실제 객체 그래프가 완성된다.
finishRefresh()
컨테이너가 완전히 활성화되는 마지막 단계이다.
LifecycleProcessor.onRefresh()가 호출되어 start() 가능한 빈들이 시작되고, ContextRefreshedEvent가 발행되어 애플리케이션이 준비되었다는 신호가 전파된다.
Spring Boot에서는 바로 이 시점에 CommandLineRunner나 ApplicationRunner가 실행된다.
즉, 사용자가 작성한 애플리케이션 로직이 이 시점 이후부터 정상적으로 작동하기 시작한다.

