Tomcat의 컴포넌트나 구조에 대해 살펴보고, 실제 스프링 부트에 사용되는 구현체나 세팅 과정 등에 대한 흐름에 대해 살펴보고자 합니다.
내용이 길기 때문에, 본론에 앞서 글을 간단히 요약했습니다.
Tomcat의 컴포넌트 중 핵심은 다음과 같습니다.
Embed Tomcat의 경우 Tomcat의 기능을 최적화해 스프링 부트 애플리케이션에 적합하게 만들기 위해 Tomcat의 컴포넌트를 분리해 별도의 라이브러리로 만든 것입니다.
MVC 기반의 스프링 부트 애플리케이션을 사용하는 경우 다음과 같은 라이브러리를 의존합니다.
Tomcat의 구조는 위와 같습니다.
특이 사항은 다음과 같습니다.
위 과정을 통해 요청 데이터를 파싱합니다.
위 과정을 통해 Servlet.service()를 호출해 클라이언트의 요청을 처리할 수 있는 비즈니스 로직을 수행합니다.
톰캣의 경우 위와 같이 7개의 컴포넌트를 가지고 있습니다.
추가적으로, Tomcat의 패키지에는 포함되어 있지 않지만 웹 소켓과 관련된 패키지도 존재합니다.
Embed Tomcat의 경우 Tomcat의 기능을 최적화해 스프링 부트 애플리케이션에 적합하게 만들기 위해 Tomcat의 컴포넌트를 분리해 별도의 라이브러리로 만들었습니다.
다음과 같은 라이브러리가 존재합니다.
Spring Boot Starter Web을 의존하는 경우, Spring Boot Starter Tomcat에 대한 의존성이 추가되어 결과적으로는 Tomcat Embed 라이브러리 중 Core, EL, Websocket에 대한 의존성이 추가됩니다.
Tomcat Embed Core는 이름 그대로 Tomcat의 핵심 기능을 제공하는 라이브러리입니다.
즉, Tomcat이 WAS의 역할에 충실할 수 있도록 하는 라이브러리입니다.
그렇기 때문에 jakarta 패키지에서는 security와 servlet에 대한 의존성만이 추가되었고, apache 패키지에서는 서블릿 컨테이너인 catalina, TCP 기반 프로토콜을 지원하는 coyote, 로그 기능을 제공하는 juli, JNDI를 통해 외부 리소스를 바인딩할 수 있는 naming, 웹 서버인 tomcat이 포함되어 있습니다.
Tomcat Embed EL은 이름 그대로 EL(Expression Language)에 대한 내용만이 존재합니다.
스프링 부트 버전 2.5부터 스프링에서 사용하는 EL의 구현체를 톰캣의 EL로 통일하기로 결정했기 때문에, 어떠한 WAS를 사용하더라도 무조건 Tomcat Embed EL을 의존하고 있음을 확인할 수 있습니다.
(해당 내용은 Switch to Apache EL implementation by default이라는 스프링 부트 깃허브 레포지토리 이슈에서 확인할 수 있습니다.)
실제 maven repository에서 Spring Boot Starter Netty를 확인해보면 의존성으로 Tomcat EL을 가지고 있는 것을 확인할 수 있습니다.
Tomcat Embed WebSocket 또한 이름 그대로 웹 소켓에 대한 내용만이 존재합니다.
Tomcat 구조에 대해 살펴본 뒤, 실제 사용되는 구현체나 스프링 부트에서 초기화되는 과정에 대해 살펴보겠습니다.
Tomcat의 구조는 위와 같습니다.
하나씩 살펴보도록 하겠습니다.
이제 실제 구현체가 무엇인지, 구현체가 어떤 과정을 통해 초기화되고 세팅되는지 확인해보겠습니다.
Embed Tomcat Core에 위치한 org.apache.catalina.startup.Tomcat 입니다.
이는 Embed Tomcat을 실행하기 위한 최소한의 기능을 가진 Tomcat Starter입니다.
가장 큰 차이점은 WEB-INF 디렉토리에 존재하는 web.xml을 사용하지 않고, 스프링 부트 Auto Configuration 과정 중에 추가된 ServletContextIntializer에 의해 설정이 진행된다는 점입니다.
물론 스프링 부트에 resources/WEB-INF/web.xml이 존재하고, 내용에 문제가 있지 않다면 동시에 적용 가능합니다.
동시에 적용할 때 다음과 같은 규칙에 따라 우선 순위가 결정됩니다.
사실상 스프링 부트를 사용하면서 resources/WEB-INF/web.xml을 사용할 일은 없다고 생각하기 때문에, 무시해도 좋을 것 같습니다.
다음으로 Tomcat 인스턴스가 어떤 타이밍에, 어디서 초기화되는지 확인해보겠습니다.
스프링 부트의 ApplicationContext Refresh 과정 중 ServletWebServerApplicationContext.onRefresh()에서 private의 createWebServer()를 호출하게 되면, TomcatServletWebServerFactory.getWebServer()를 호출하게 됩니다.
TomcatServletWebServerFactory.getWebServer()에서는 new Tomcat()을 통해 생성되고 있음을 확인할 수 있습니다.
기본 구현체는 StandardServer 입니다.
TomcatServletWebServerFactory.getWebServer()을 보면 서블릿 생명 주기와 관련된 리스너를 등록하는 과정에서 Tomcat.getServer()를 호출하고 있는 것을 확인할 수 있습니다.
메서드를 타고 들어가다 보면 StandardServer 인스턴스를 생성하고 있는 것을 확인할 수 있습니다.
기본 구현체는 StandardService 입니다.
getServer() 호출 시 StandardServer를 생성한 뒤, StandardService를 생성해 StandardServer에 등록합니다.
TomcatServletWebServerFactory.getWebServer() 호출 시 내부적으로 Connector를 세팅하고 있음을 확인할 수 있습니다.
Connector는 내부적으로 가지고 있는 ProtocolHandler에 의해 용도가 결정되기 때문에 스프링 부트에서 지정한 기본 값을 활용합니다.
TomcatServletWebServerFactory에서는 기본적으로 http11NioProtocol을 ProtocolHandler로 사용합니다.
기본 구현체는 StandardEngine 입니다.
TomcatServletWebServerFactory.getWebServer() 호출 시 내부적으로 Tomcat.getEngine()을 호출하고 관련된 설정을 세팅합니다.
내부적으로 StandardEngine을 생성하고, Service.setContainer()를 통해 StandardService에 StandardEngine을 세팅합니다.
StandardService.setContainer()의 경우 위와 같이 기존의 Engine을 삭제한 뒤, 지정한 Engine을 새롭게 등록합니다.
스프링 부트에서 사용하는 Realm은 SimpleRealm 입니다.
Tomcat은 여러 Realm을 가지고 있지만, 사실상 웹 애플리케이션을 구현할 때 인증/인가 과정은 매우 중요한 로직이기 때문에 별도의 아키텍처를 만들거나, 스프링 시큐리티를 커스터마이징해서 사용하는 것이 대부분입니다.
그렇다보니 Tomcat의 Realm은 사용하지 않으므로, Tomcat의 inner class로 선언된 SimpleRealm 만을 사용합니다.
TomcatServletWebServerFactory.getWebServer() 호출 시 내부적으로 Tomcat.getEngine()을 호출하고 관련된 설정을 세팅하게 되는데 getEngine() 내부적으로 createDefaultRealm()을 호출해 SimpleRealm을 Engine에 세팅합니다.
매우 간단하게 구현되어 있습니다.
사실상 의미가 없으니 무시해도 무방하다고 생각합니다.
기본 구현체는 StandardPipeline 입니다.
Tomcat에서 Container라고 취급되는 것들은 모두 ConatinerBase를 확장하게 됩니다.
이에 속하는 것들로는 Engine, Host, Context가 있습니다.
예시로 StandardEngine의 생성자를 보면, Pipeline에 StandardEngineValve를 생성해 기본 Valve로 세팅하고 있음을 확인할 수 있습니다.
ContainerBase를 확인해보면, 기본적으로 StandardPipeline으로 초기화하고 있음을 확인할 수 있습니다.
StandardHost, StandardContext 모두 동일하게 StandardPipeline에 Valve를 세팅하고 있습니다.
Valve는 Embed Tomcat 기준으로 34개의 구현체를 가지고 있습니다.
전부를 살펴보기에는 너무 많기 때문에 스프링 부트에서 사용하는 것들만 살펴보도록 하겠습니다.
다음과 같은 Valve가 등록됩니다.
자주 언급된 TomcatServletWebServerFactory.getWebServer()에서 세팅되는 Valve는 Engine, Host, Context 내부적으로 기본적인 Valve를 세팅합니다.
각각 StandardEngineValve, StandardHostValve, StandardContextValve를 Pipeline의 기본 Valve로 세팅합니다.
TomcatServletWebServerFactory.getWebServer()에서 수행하는 configureEngine()은 Valve를 세팅합니다.
디버깅으로 확인해보면 아무런 Valve도 없다는 것을 확인할 수 있습니다.
해당 메서드는 개발자가 직접 커스터마이징한 Vavle나 resources/WEB-INF/web.xml에 등록된 Valve를 등록하는 용도이기 때문입니다.
사실상 web.xml이나 Valve를 커스터마이징할 일은 없기 때문에 크게 신경쓰지 않아도 된다고 생각합니다.
TomcatServletWebServerFactory이 Tomcat을 생성한 뒤 TomcatWebServer를 반환한 이후, TomcatWebServerFactoryCustomizer에서 추가적인 설정을 수행합니다.
customizeRemoteIpValue()에서는 RemoteIpValue를 초기화합니다.
customizeErrorReportValve()에서는 ErrorReportValue를 초기화합니다.
application.properties 혹은 application.yml에서 명시한 로그 관련 설정을 기반으로 AccessLogValve를 초기화합니다.
기본 구현체는 DirectJDKLog 입니다.
Engine, Host, Context 모두 ContainerBase를 상속하고 있으며, ContainerBase는 필드로 org.apache.juli.logging.Log를 필드로 가지고 있습니다.
ContainerBase 타입에서 생성되는 Log의 경우, DirectJDKLog.getInstnace()로 초기화됩니다.
하지만 스프링 부트가 사용하는 기본적인 Log는 Slf4j의 구현체인 Logback을 기본적으로 사용하기 때문에 jul-to-slf4j에 의해 Slf4j Bridge로 라우팅됩니다.
실제로 LogbackLoggingSystem에서 Tomcat Juli 관련 설정 대신 SLF4JBridgeHandler를 세팅하고 있음을 확인할 수 있습니다.
즉, Embed Tomcat의 Log는 스프링 부트에서 기본적으로 사용하지 않습니다.
기본 구현체는 StandardHost 입니다.
TomcatServletWebServerFactory.getWebServer()에서 auto deploy 설정을 false로 세팅하기 위해 Tomcat.getHost()를 호출합니다.
(auto deploy는 자동 배포 설정으로, Context를 추가/삭제하거나 Context에 등록된 웹 애플리케이션의 코드를 수정할 경우 재배포하는 기능입니다.)
Tomcat.getHost()에서는 Engine이 가지고 있는 Host가 없는 경우 StandardHost를 초기화합니다.
기본 구현체는 StandardContext 입니다.
스프링 부트에서 사용하는 Context는 StandardContext를 확장한 TomcatEmbeddedContext 입니다.
TomcatServletWebServerFactory.getWebServer()에서 prepareContext()를 호출합니다.
prepareContext() 내부적으로 TomcatEmbeddedContext를 생성하고 있음을 확인할 수 있습니다.
기본 구현체는 StandardWrapper 입니다.
addServlet()에서는 TomcatEmbeddedContext.createWrapper()를 호출합니다.
TomcatEmbeddedContext 내에 스프링 부트 애플리케이션의 FrontController인 DispatcherServlet이 어떤 과정을 통해 등록되는 지 확인해보겠습니다.
TomcatServletWebServerFactory.prepareContext() 하단부에서 configureContext()를 호출하는 것을 확인할 수 있습니다.
configureContext()는 내부적으로 TomcatStarter를 생성하고, 이를 TomcatEmbeddedContext.setStarter()를 통해 세팅합니다.
이렇게 세팅한 TomcatStarter는 StandardContext.startInternal()에 의해 호출됩니다.
TomcatStarter.onStartup()은 ServletContextInitializer를 모두 순회하며 필요한 설정을 세팅합니다.
DispatcherServlet은 ServletWebServerApplicationContext에서 등록됩니다.
ServletWebServerApplicationContext에서 DispatcherServletRegistrationBean으로 인해 DispatcherServlet이 세팅됩니다.
DispatcherServletRegistrationBean은 초기화 시 DispatcherServletAutoConfiguration.DispatcherServletRegistrationConfiguration에 의해 DispatcherServlet을 주입 받습니다.
DispatcherServletRegistrationBean.addRegistration()을 통해 org.apache.catalina.core.ApplicationContextFacade에 Servlet을 추가합니다.
org.apache.catalina.core.ApplicationContextFacade는 org.apache.catalina.core.ApplicationContext에 Servlet을 추가합니다.
org.apache.catalina.core.ApplicationContext.addServlet()은 오버로딩이 적용되어 있으며 결과적으로는 private 접근 제어자의 addServlet()을 호출하게 됩니다.
addServlet()에서는 TomcatEmbeddedContext.createWrapper()를 호출합니다.
StandardWrapper를 생성하고, 생성자에서는 실제 클라이언트의 요청을 Servlet으로 전달하는 StandardWrapperValve를 생성합니다.
Embed Tomcat에서 클라이언트의 요청을 받기 위해 이전에 살펴본 구성 요소들의 초기화 과정이 필요합니다.
초기화 과정을 위해 Tomcat은 Tomcat.start()를 호출합니다.
이를 위해 Tomcat의 생명 주기(LifecycleBase)를 시작하고, Connector, Container를 초기화하며, HostConfig 등의 설정을 세팅합니다.
Tomcat.start()에서 많은 작업을 수행하므로 요청 처리 과정에서 필수적으로 사용되는 ContextVersion에 대해서만 살펴보도록 하겠습니다.
위와 같이 TomcatServletWebServerFactory부터 시작해 MapperListner를 거쳐 Mapper에 도달하게 됩니다.
MapperListener에서 Host와 Context 등의 과정을 거치고, 요청을 라우팅하기 위해 필요한 ContextVersion를 생성합니다.
ContextVersion은 Mapper의 static inner class로, MapElement를 상속한 클래스입니다.
필드에서 확인할 수 있듯이, path나 WebResourceRoot, Context 등 특정 요청과 Context를 매핑하는 용도입니다.
MapperListener.registerContext()의 경우 지정한 Context, 경로, Host, WebResourceRoot 등 필요한 내용을 파라미터로 전달합니다.
Tomcat.start() 호출 시 초기화가 되지 않았으므로 MappedContext는 존재하지 않습니다.
그러므로 ContextVersion, MappedContext를 생성하고 등록하게 됩니다.
이 과정은 Tomcat.start() 호출 시 한 번만 동작하게 됩니다.
이 과정부터는 요청이 올 때 마다 매번 동작하는 과정입니다.
Tomcat 스레드 풀에서 Worker 스레드가 동작하게 된 이후, 위와 같은 과정을 통해 CoyoteAdapter에 요청이 전달됩니다.
CoyoteAdapter는 Connector와 Container 사이의 통신을 담당합니다.
Connector에서 전달된 클라이언트 요청을 Container가 이해할 수 있는 Request로 변환하고, 변환한 Request를 적절한 Container에게 전달합니다.
매우 중요한 역할을 수행하기 때문에, CoyoteAdapter부터 살펴보도록 하겠습니다.
CoyoteAdapter.service()를 확인해보면 Request를 생성하고 초기화하는 것을 확인할 수 있습니다.
이후 파싱 작업을 수행하는 postParesRequest()를 호출합니다.
postParseRequest() 메서드는 요청 URI 파싱, 매핑 데이터 설정, 인코딩 설정, 세션 관리, 요청 속성 설정, 프로토콜 설정, 비동기 지원 설정 등 다양한 작업을 수행합니다.
여기서는 클라이언트의 요청을 라우팅하기 위한 과정만 확인하겠습니다.
중간에 Connector에서 Mapper를 가져와 map을 수행하는 것을 확인할 수 있습니다.
이 때 요청에 세팅된 MappingData 객체를 전달하게 됩니다.
MappingData는 Context와 요청을 매핑하기 위해 필요한 데이터들을 관리하는 용도입니다.
Mapper.map()은 internalMap()을 호출합니다.
해당 메서드는 MappingData가 가지고 있는 Host, Context 등을 초기화하게 됩니다.
Context의 경우 이전 초기화 과정에서 등록한 ContextVersion을 활용해 세팅하게 됩니다.
CoyoteAdapter.service()에서 postParseRequest()를 통해 클라이언트 요청을 파싱해 Request를 초기화한 이후, Pipeline의 Valve를 통해 Request를 라우팅하게 됩니다.
postParseRequest()가 성공했다면 Connector -> Service -> Engine의 Pipeline의 첫 번째 Valve를 가져와 invoke()를 호출합니다.
즉, StandardEngineValve부터 시작해 다음과 같은 Valve를 거치게 됩니다.
간단하게 하나씩 살펴보도록 하겠습니다.
StandardEngineValve는 비동기 처리 지원 여부를 체크합니다.
Engine에서 Host의 Pipeline을 조회하기 위해 Request 객체에서 Host를 꺼내고, 그 Host의 Pipeline에서 첫 번째 Valve를 꺼내는 것을 확인할 수 있습니다.
ErrorReportValve는 예외/에러가 발생한 경우 예외 처리 및 예외 페이지를 렌더링하는 기능을 제공합니다.
이 때의 예외 페이지는 스프링 부트에서 예외 처리를 하지 않은 예외 발생 시, 브라우저에서 확인할 수 있는 그 예외 페이지입니다.
예외 처리 및 예외 페이지 렌더링을 하다 보니 우선 다음 Vavle.invoke()를 호출합니다.
invoke()의 report()를 타고 들어가다 보면 findErrorPage() 메서드가 호출되고, 이를 통해 예외 페이지를 찾아서 렌더링하게 됩니다.
HTTP 요청/응답을 처리하는 Host Valve 입니다.
Context를 매핑하고 Context의 Pipeline을 호출합니다.
Host에서 Context를 조회하기 위해 Request에서 이를 조회하고, Context를 매핑합니다.
이 과정까지 에러/예외가 발생하지 않았다면 Context의 Pipeline을 가져와 첫 번째 Valve.invoke()를 호출합니다.
AuthenticatorBase는 Tomcat의 인증 과정을 처리하는 Valve입니다.
인증/인가 처리를 하지 않으므로, 인증 없이 모든 요청을 허용하는 Authenticator인 NonLoginAuthenticator가 무조건 동작하게 됩니다.
사실상 Tomcat에서 인증/인가 처리를 하지 않으므로 무조건 위 코드를 통해 다음 Valve를 호출하게 됩니다.
StandardHostValve에서 NonLoginAuthenticator를 호출하고 있음을 확인할 수 있습니다.
StandardContextValve는 Request URI를 분석해 알맞은 Wrapper에게 전달하는 역할을 수행합니다.
Resource Directory 검증, Request Wrapper 검증, 응답에 ACK 설정을 수행한 뒤 Wrapper에 세팅된 Pipeline을 조회해 첫 번째 Valve.invoke()를 호출합니다.
StandardWrapperValve는 FilterChain을 통해 요청의 전/후처리를 수행하도록 요청을 전달합니다.
ApplicationFilterChain을 생성한 뒤, doFilter()를 호출합니다.
ApplicationFilterChain은 요청의 전/후처리를 수행하는, 등록된 모든 Filter를 순회한 뒤 Servlet을 실제로 호출합니다.
FilterChain에 등록된 Filter를 순회하면서 doFilter()를 호출합니다.
이후 Servlet.service()를 호출합니다.
스프링 부트에서 application.properties/application.yml을 통해 Embed Tomcat과 관련된 설정을 진행할 수 있습니다.
# Tomcat
server:
tomcat:
accept-count: 5
max-connections: 150
threads:
max: 50
min-spare: 20
이러한 설정은 ServerProperties와 매핑됩니다.
ServerProperties$Tomcat 클래스에 Tomcat 관련 설정 값들에 대한 필드가 존재하는 것을 확인할 수 있습니다.
이러한 ServerProperties는 TomcatWebServerFactoryCustomizer에 의해 호출되어 설정한 값들이 적용됩니다.