이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 열 번째 글입니다. 9편에서는 요청 하나가 필터 체인을 지나 서블릿의 service()에 닿는 데까지 봤습니다. 거기까지는 웹앱이 이미 "떠 있다"고 치고 이야기했습니다. 이번에는 한 걸음 물러나, 그 웹앱이 어떻게 뜨는지를 봅니다. webapps 폴더에 shop.war를 복사해 넣으면 잠시 뒤 /shop으로 요청을 받게 되는데, 그 사이에 무슨 일이 일어날까요.
답하려는 질문은 네 가지입니다.
- 실행 중인 Tomcat은
webapps에 새 WAR가 생긴 것을 누가, 언제 알아채는가 - 파일 이름
shop##v2.war는 어떻게 주소/shop이 되고, WAR는 언제 풀리는가 WEB-INF/web.xml,conf/web.xml,@WebServlet어노테이션, jar 안의web-fragment.xml이 함께 있으면 무엇이 이기는가ServletContainerInitializer(SCI)는 어떻게 찾아지고, 리스너·필터·서블릿과 비교해 언제 불리는가 — Spring의SpringServletContainerInitializer가 바로 이 길로 불립니다
| 항목 | 내용 |
|---|---|
| 대상 | Apache Tomcat |
| 버전 | 12.0.0-M1 (main) |
| 커밋 | 17aa1769b0 (2026-10-23) |
| 주로 읽는 클래스 | HostConfig, ContextConfig, StandardContext, WebappServiceLoader, WebXml, ExpandWar, ContextName |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
ContextConfig.java는 3,000줄, StandardContext.java는 6,000줄이 넘습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
Host와 Context. server.xml의 <Host name="localhost" appBase="webapps">가 StandardHost 객체, 웹앱 하나가 StandardContext 객체입니다. Host는 Context들을 자식으로 거느립니다. 컨테이너 넷(Engine → Host → Context → Wrapper)의 부모·자식 관계는 2편에서 봤습니다.
배포(deploy)는 "파일로 있는 웹앱을 보고 StandardContext를 만들어 Host의 자식으로 붙인 뒤 시작시키는 일"입니다. 반대로 배포 해제(undeploy)는 Context를 멈추고 Host에서 떼어 내는 일입니다. 자동 배포(auto deploy)는 Tomcat이 켜져 있는 동안에도 appBase 폴더를 주기적으로 살펴 새 파일은 배포하고 사라진 파일은 배포 해제하는 기능입니다.
WAR(Web application ARchive)는 웹앱 폴더(WEB-INF/classes, WEB-INF/lib, WEB-INF/web.xml, 정적 파일들)를 통째로 묶은 zip 파일입니다. 확장자만 .war입니다.
Lifecycle 리스너와 이벤트. Tomcat의 구성 요소는 init → start → stop → destroy 상태를 거치고, 상태가 바뀔 때마다 before_start, configure_start 같은 이벤트를 붙어 있는 리스너들에게 알립니다(3편). 이 글의 두 주인공 HostConfig와 ContextConfig는 둘 다 리스너입니다. 각각 Host와 Context에 붙어 이벤트를 받을 때 일을 합니다. 이름에 "Config"가 붙었지만 설정값을 담는 객체가 아니라 "설정하는 일꾼"입니다.
배포 서술자(deployment descriptor)는 서블릿 규격이 정한 웹앱 설정 파일 WEB-INF/web.xml을 말합니다. jar 안에 들어가는 조각 META-INF/web-fragment.xml도 있고, Tomcat 전용으로 conf/web.xml(모든 웹앱의 기본값)과 WEB-INF/tomcat-web.xml이 더 있습니다. 소스에서는 이것들을 모두 WebXml 객체 하나씩으로 읽어 들입니다.
ServletContainerInitializer(SCI)는 서블릿 규격의 인터페이스로, 메서드가 onStartup(Set<Class<?>> classes, ServletContext ctx) 하나뿐입니다. jar 안의 META-INF/services/jakarta.servlet.ServletContainerInitializer 파일에 구현 클래스 이름을 적어 두면 컨테이너가 웹앱을 시작할 때 불러 줍니다. 클래스에 @HandlesTypes(어떤타입.class)를 붙이면 "그 타입을 구현하거나 상속한 클래스들"을 컨테이너가 찾아 첫 인자로 넘겨 줍니다. web.xml 없이 코드로 서블릿·필터를 등록하는 프레임워크들의 출입구입니다.
1. 큰 그림: 두 리스너와 하나의 시작 메서드
배포는 세 클래스가 나눠 맡습니다.
| 누가 | 어디에 붙나 | 하는 일 |
|---|---|---|
HostConfig |
Host의 리스너 | appBase를 훑어 Context를 만들고 Host에 붙인다. 주기적으로 다시 훑는다 |
ContextConfig |
Context의 리스너 | context.xml, web.xml들, 어노테이션, SCI를 읽어 Context를 채운다 |
StandardContext.startInternal() |
Context 자신 | 채워진 내용을 정해진 순서로 깨운다(SCI → 리스너 → 필터 → 서블릿) |
server.xml에서 보던 <Host ... unpackWARs="true" autoDeploy="true">의 두 속성이 이 글의 스위치입니다. 둘 다 StandardHost에서 기본값이 true입니다(StandardHost.java 109행, 152행). Host에 붙는 리스너 클래스 이름도 Host가 들고 있습니다. configClass의 기본값이 "org.apache.catalina.startup.ContextConfig"입니다(115행).
파란 상자가 HostConfig의 일, 노란 상자가 그 일을 깨우는 주기 스레드입니다. WAR마다 deployWAR()가 한 번씩 불립니다.
2. HostConfig — 누가 webapps 폴더를 보나
이벤트 네 개에 반응한다
HostConfig가 Host로부터 받는 이벤트와 하는 일은 lifecycleEvent()의 switch 하나에 다 있습니다.
switch (event.getType()) {
case Lifecycle.PERIODIC_EVENT -> check();
case Lifecycle.BEFORE_START_EVENT -> beforeStart();
case Lifecycle.START_EVENT -> start();
case Lifecycle.STOP_EVENT -> stop();
}
java/org/apache/catalina/startup/HostConfig.java 283–288행
BEFORE_START_EVENT→beforeStart():createDirs가 켜져 있으면appBase와 설정 폴더(conf/Catalina/localhost)를 만들어 둡니다(1629–1638행).START_EVENT→start(): Host가 시작되면deployOnStartup(기본true,StandardHost.java127행)일 때deployApps()로 한 번 다 배포합니다(1644–1667행).catalina.sh start로 켤 때webapps의 앱들이 배포되는 길이 이것입니다.PERIODIC_EVENT→check(): 켜져 있는 동안 주기적으로 불립니다. 자동 배포가 이것입니다.
deployApps() — 세 종류를 정해진 순서로
protected void deployApps() {
// Migrate legacy Java EE apps from legacyAppBase
migrateLegacyApps();
File appBase = host.getAppBaseFile();
File configBase = host.getConfigBaseFile();
String[] filteredAppPaths = filterAppPaths(appBase.list());
// Deploy XML descriptors from configBase
deployDescriptors(configBase, configBase.list());
// Deploy WARs
deployWARs(appBase, filteredAppPaths);
// Deploy expanded folders
deployDirectories(appBase, filteredAppPaths);
}
java/org/apache/catalina/startup/HostConfig.java 401–413행
deployDescriptors—conf/Catalina/localhost/*.xml. 파일 하나가<Context docBase="/어딘가/myapp">하나입니다. 웹앱을webapps밖에 두고 싶을 때 쓰는 방법입니다.deployWARs—webapps/*.war.deployDirectories—webapps안의 폴더. 이미 풀린 WAR 폴더도 여기에 해당하지만, 같은 이름의 Context가 이미 배포되어 있으면 건너뜁니다(deploymentExists()).
맨 앞의 migrateLegacyApps()는 webapps-javaee 폴더(legacyAppBase, StandardHost.java 92행)에 javax.* 시절 앱을 넣으면 jakarta.*로 바꿔 webapps로 옮겨 주는 기능입니다(1138행부터). 11.0.x 브랜치에도 같은 호출이 있습니다.
10초마다 — 누가 PERIODIC_EVENT를 보내나
PERIODIC_EVENT는 ContainerBase.backgroundProcess()의 마지막 줄이 보냅니다(ContainerBase.java 956행). 그 앞에서는 클러스터, Realm, 파이프라인의 밸브들에게도 차례로 backgroundProcess()를 부릅니다. 이 메서드를 주기적으로 부르는 작업은 threadStart()가 서버의 유틸리티 실행기에 걸어 둡니다.
backgroundProcessorFuture = Container.getService(this).getServer().getUtilityExecutor()
.scheduleWithFixedDelay(new ContainerBackgroundProcessor(), backgroundProcessorDelay,
backgroundProcessorDelay, TimeUnit.SECONDS);
java/org/apache/catalina/core/ContainerBase.java 1090–1092행
backgroundProcessorDelay의 기본값은 -1(끔)인데(142행), StandardEngine만 생성자에서 10으로 바꿉니다(StandardEngine.java 62행). 그래서 주기 작업은 Engine에 하나만 생기고, 그 작업(ContainerBackgroundProcessor.processChildren())이 자기 아래의 Host·Context·Wrapper 가운데 지연 값이 0 이하인 것들을 재귀로 돌며 backgroundProcess()를 불러 줍니다(1164–1194행). Host가 PERIODIC_EVENT를 받는 간격이 기본 10초인 이유입니다. 이 실행기의 스레드 이름은 Catalina-utility-로 시작합니다(StandardServer.java 406행). 같은 주기 작업이 11편의 세션 만료 검사도 돌립니다.
위 두 문단을 한 줄로 이었습니다. 주기 작업은 Engine에 하나뿐이고, 아래 컨테이너들은 그 작업이 재귀로 돌아 줍니다.
check()는 이렇습니다.
protected void check() {
if (host.getAutoDeploy()) {
// Check for resources modification to trigger redeployment
DeployedApplication[] apps = deployed.values().toArray(new DeployedApplication[0]);
for (DeployedApplication app : apps) {
if (tryAddServiced(app.name)) {
try {
checkResources(app, false);
} finally {
removeServiced(app.name);
}
}
}
// Check for old versions of applications that can now be undeployed
if (host.getUndeployOldVersions()) {
checkUndeploy();
}
// Hotdeploy applications
deployApps();
}
java/org/apache/catalina/startup/HostConfig.java 1693–1715행
이미 배포한 앱마다 지켜보는 파일 목록(redeployResources, reloadResources)과 그때의 수정 시각을 기억해 두었다가, 시각이 바뀌었거나 파일이 사라졌으면 다시 배포하거나 배포를 해제합니다(checkResources(), 1369행부터). 그다음 deployApps()를 다시 불러, 그사이 새로 생긴 파일을 배포합니다. "새 WAR 감지"가 따로 있는 게 아니라 매번 처음부터 다시 훑고, 이미 배포된 이름은 건너뛰는 방식입니다.
tryAddServiced()는 같은 앱을 두 스레드가 동시에 만지지 않게 하는 표시입니다. 관리 웹앱(Manager)이 앱을 올리는 도중에 주기 검사가 같은 앱을 건드리면 안 되기 때문입니다.
deployWAR() — Context를 만들어 Host에 붙인다
WAR 하나를 배포하는 deployWAR()(783행부터)는 먼저 WAR 안에 META-INF/context.xml이 있는지 봅니다. 있으면 그것을 Digester로 읽어 Context 객체를 만들고, 없으면 contextClass(기본 StandardContext)를 그냥 만듭니다(817–865행). 끝부분이 핵심입니다.
Class<?> clazz = Class.forName(host.getConfigClass());
LifecycleListener listener = (LifecycleListener) clazz.getConstructor().newInstance();
context.addLifecycleListener(listener);
context.setName(cn.getName());
context.setPath(cn.getPath());
context.setWebappVersion(cn.getVersion());
context.setDocBase(cn.getBaseName() + ".war");
host.addChild(context);
java/org/apache/catalina/startup/HostConfig.java 916–924행
- Host의
configClass, 곧ContextConfig를 하나 만들어 새 Context의 리스너로 답니다. 이 순간부터 Context의 Lifecycle 이벤트는ContextConfig에게 갑니다(3절). - 이름·경로·버전을 정합니다.
cn은 파일 이름을 해석한ContextName입니다(바로 아래). docBase(웹앱 내용이 있는 곳)는 일단 WAR 파일 자체로 둡니다. 푸는 일은 나중에ContextConfig가 합니다.host.addChild(context). 이 한 줄이 Context를 시작시킵니다.
마지막이 잘 안 보입니다. addChild는 자식을 목록에 넣고 끝나는 게 아니라, 부모가 이미 실행 중이면 그 자리에서 start()를 부릅니다.
try {
if ((getState().isAvailable() || LifecycleState.STARTING_PREP.equals(getState())) && startChildren) {
child.start();
}
} catch (LifecycleException e) {
throw new IllegalStateException(sm.getString("containerBase.child.start"), e);
}
java/org/apache/catalina/core/ContainerBase.java 581–587행
그래서 6절에서 볼 StandardContext.startInternal()은 HostConfig.deployWAR()의 호출 스택 안에서 돕니다. 예제 3의 스택 트레이스로 확인합니다. 한편 addChild가 보낸 ADD_CHILD_EVENT를 7편의 MapperListener가 듣고 새 Context에 자기를 리스너로 붙입니다. 그리고 Context의 시작이 끝나는 순간(AFTER_START_EVENT) Mapper에 /shop을 등록합니다(MapperListener.java 141–143행, 446–459행). 시작이 다 끝나기 전에는 요청이 이 앱으로 오지 않는 이유입니다.
파일 이름이 주소가 되는 규칙 — ContextName
파일 이름과 Context 경로 사이의 변환은 ContextName 한 곳에 모여 있습니다(ContextName.java 49–98행). .war·.xml 확장자를 떼고, ## 뒤를 버전으로 떼어 내고, #을 /로 바꾸고, ROOT는 빈 경로로 둡니다.
| 파일 이름 | Context 경로(getPath()) |
버전 | Context 이름(getName()) |
|---|---|---|---|
shop.war |
/shop |
(없음) | /shop |
ROOT.war |
"" (루트) |
(없음) | "" |
api#v1.war |
/api/v1 |
(없음) | /api/v1 |
shop##v2.war |
/shop |
v2 |
/shop##v2 |
## 버전은 병렬 배포(parallel deployment)에 씁니다. 같은 경로 /shop에 shop##v1과 shop##v2를 함께 올려 두면, 기존 세션은 옛 버전에 남고 새 세션은 새 버전으로 갑니다(요청을 어느 버전으로 보낼지는 Mapper 쪽 일이라 이 글에서 소스로 따라가지는 않았습니다). 옛 버전의 세션이 다 사라지면 undeployOldVersions가 켜져 있을 때 checkUndeploy()가 지웁니다(1751행부터). 이 글에서는 이름 규칙만 봅니다.
예제 1: 실행 중에 WAR 넣고 빼기
정말 10초 주기로 알아채는지 봤습니다. 서블릿 하나를 shop##v2.war로 묶어, 비어 있는 webapps를 가진 Tomcat이 켜진 뒤에 복사해 넣고, /shop/hello가 200을 줄 때까지 1초마다 물어봤습니다. 그다음 WAR를 지우고 404가 될 때까지 물어봤습니다.
이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. ex/u10-autodeploy/run.sh의 핵심은 이렇습니다.
# WAR 는 zip 이다. JDK 의 jar 도구로 묶는다.
(cd $B/app && jar cf $B/shop##v2.war .)
# ... Tomcat 을 켜고 기동을 기다린 뒤 ...
cp "$B/shop##v2.war" /tmp/base/webapps/
for i in $(seq 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/shop/hello)
[ "$code" = 200 ] && break
sleep 1
done
# ... 같은 방식으로, WAR 를 지우고 404 가 될 때까지 기다린다 ...
[예제 ex/u10-autodeploy/run.sh에서 줄임]
===== webapps before copy =====
===== first 200 after about 9 s =====
hello from contextPath=/shop realPath=/tmp/base/webapps/shop##v2/
===== webapps after deploy =====
shop##v2
shop##v2.war
MANIFEST.MF
war-tracker
===== catalina log (deploy) =====
24-Oct-2026 13:42:18.748 INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [/tmp/base/webapps/shop##v2.war]
24-Oct-2026 13:42:20.033 INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [/tmp/base/webapps/shop##v2.war] has finished in [1,284] ms
===== after removing the WAR: HTTP 404 after about 10 s =====
===== catalina log (undeploy) =====
24-Oct-2026 13:42:30.045 INFO [Catalina-utility-1] org.apache.catalina.startup.HostConfig.undeploy Undeploying context [/shop##v2]
시각, 걸린 초, 배포에 걸린 밀리초, 스레드 이름 끝의 번호는 실행마다 다릅니다(다른 실행에서는 배포까지 8초, 10초, 12초가 나왔고 Catalina-utility-2가 배포한 적도 있습니다). 읽을 것이 네 가지입니다.
- 복사하고 응답까지 9초, 지우고 404까지 10초. 주기 검사가 다음에 돌 때까지 기다렸기 때문입니다. 검사 간격이 10초이고 배포 자체에도 1초 남짓 걸리므로, 실행마다 몇 초에서 10초 남짓까지 오갑니다.
- 로그의 스레드 이름이
Catalina-utility-1입니다. 요청을 처리하는http-nio-8080-exec-N(5편)이 아니라 유틸리티 실행기의 스레드가 배포를 했습니다. - 경로는
/shop, 풀린 폴더 이름은shop##v2입니다. 위 표의 규칙 그대로입니다. 풀린 폴더에는META-INF/war-tracker라는 빈 파일이 생겼습니다(3절). - WAR를 지우자 풀린 폴더도 함께 사라졌습니다(
ls결과가 비어 있습니다). 풀린 폴더가 WAR에 딸린 "지켜보는 파일"로 등록되어 있어서, WAR가 사라지면 배포 해제와 함께 지웁니다(deleteRedeployResources()).
위 출력을 시간 순서로 늘어놓았습니다. 시각과 초는 이 실행의 것이라 다시 돌리면 달라집니다.
헷갈리기 쉬운 곳: 배포는 하나씩, 그리고 기동 때는 main 스레드에서
deployWARs()는 WAR마다 host.getStartStopExecutor()에 작업을 넘깁니다(714행, 755행). 이름만 보면 여러 앱을 병렬로 배포할 것 같지만, Host의 startStopThreads 기본값은 1이고(ContainerBase.java 244행), 1일 때는 넘겨받은 작업을 부른 스레드에서 바로 실행하는 InlineExecutorService를 씁니다(689–701행). 그래서 기본 설정에서는 앱이 하나씩 차례로 배포되고, 예제 1처럼 주기 검사에서는 Catalina-utility-1이, 기동 때는 main 스레드가 직접 배포합니다(예제 3의 스택에서 보입니다). 큰 앱이 여러 개라 기동이 느리면 <Host startStopThreads="4">처럼 늘릴 수 있습니다.
3. ContextConfig — Context를 채우는 리스너
HostConfig가 달아 준 ContextConfig는 Context의 이벤트 여섯 개에 반응합니다.
switch (event.getType()) {
case Lifecycle.CONFIGURE_START_EVENT -> configureStart();
case Lifecycle.BEFORE_START_EVENT -> beforeStart();
case Lifecycle.AFTER_START_EVENT -> {
// Restore docBase for management tools
if (originalDocBase != null) {
context.setDocBase(originalDocBase);
}
}
case Lifecycle.CONFIGURE_STOP_EVENT -> configureStop();
case Lifecycle.AFTER_INIT_EVENT -> init();
case Lifecycle.AFTER_DESTROY_EVENT -> destroy();
}
java/org/apache/catalina/startup/ContextConfig.java 300–312행
Context가 시작되는 동안 불리는 순서대로 놓으면 이렇습니다.
AFTER_INIT_EVENT→init()(997행):context.xml들을 읽습니다.conf/context.xml(모든 앱 공통),conf/Catalina/localhost/context.xml.default(그 Host의 앱 공통), 그리고 이 앱의 설정 파일(context.getConfigFile()— 앱의META-INF/context.xml이나conf/Catalina/localhost/<앱 이름>.xml)을 차례로 같은 Context 위에 읽습니다(contextConfig(), 608행부터). 기본conf/context.xml에는<WatchedResource>WEB-INF/web.xml</WatchedResource>같은 줄이 있어서, 그 파일이 바뀌면 앱을 다시 읽어 들이게 됩니다.BEFORE_START_EVENT→beforeStart()(1019행):fixDocBase()로 WAR를 풉니다(바로 아래).CONFIGURE_START_EVENT→configureStart()(1034행): 이 글의 중심입니다.web.xml들을 합치고, 어노테이션을 읽고, SCI를 찾습니다. 끝나면context.setConfigured(true)를 부릅니다.
server.xml 안에 <Context>를 직접 적은 경우에도 Digester 규칙이 같은 ContextConfig를 답니다(ContextRuleSet.java 93행). 어디서 왔든 Context를 채우는 일꾼은 하나입니다.
위 목록 1~3을 deployWAR() 다음에 이어 붙였습니다. 파란 칸 둘이 이 절, 초록 칸이 4·5절의 이야기입니다.
WAR는 시작 직전에 풀린다
fixDocBase()는 docBase가 .war 파일이고 unpackWARs가 켜져 있으면 ExpandWar.expand()로 풉니다.
if (docBaseAbsolute.toLowerCase(Locale.ENGLISH).endsWith(".war") && !docBaseAbsoluteFile.isDirectory()) {
URL war = UriUtil.buildJarUrl(docBaseAbsoluteFile);
if (unpackWARs) {
docBaseAbsolute = ExpandWar.expand(host, war, pathName);
docBaseAbsoluteFile = new File(docBaseAbsolute);
if (context instanceof StandardContext) {
((StandardContext) context).setOriginalDocBase(originalDocBase);
}
} else {
ExpandWar.validate(host, war, pathName);
}
java/org/apache/catalina/startup/ContextConfig.java 883–893행
풀고 나면 docBase를 풀린 폴더로 바꾸고(940행), 원래 값(WAR 경로)은 StandardContext.setOriginalDocBase()로 따로 적어 둡니다. 예제 1에서 getRealPath("/")가 풀린 폴더를 가리킨 이유입니다. unpackWARs="false"면 풀지 않고 WAR를 그대로 읽어 서비스합니다.
예제 1에서 본 META-INF/war-tracker는 ExpandWar가 풀기를 마친 뒤 만드는 빈 파일입니다. 이 파일의 수정 시각을 WAR의 수정 시각과 똑같이 맞춰 둡니다(ExpandWar.java 168–175행). 다음에 Tomcat이 켜질 때 풀린 폴더가 이미 있으면, 두 시각이 같으면(또는 war-tracker가 아예 없으면) 그대로 쓰고, 다르면 폴더를 지우고 새로 풉니다(97–112행). Tomcat이 꺼져 있는 동안 WAR를 바꿔 넣어도 알아채는 장치입니다.
헷갈리기 쉬운 곳: 풀린 폴더를 고쳐도 WAR가 이깁니다
webapps/shop.war와 webapps/shop/이 함께 있으면, 배포는 WAR 기준입니다. 풀린 폴더의 JSP를 직접 고쳐도, WAR의 수정 시각이 바뀌는 순간 폴더는 지워지고 다시 풀립니다. 위의 war-tracker 비교 때문입니다. unpackWARs="false"인데 같은 이름의 폴더가 있으면 HostConfig는 "WAR가 우선이라 폴더는 무시한다"는 경고(hostConfig.deployWar.hiddenDir)를 남깁니다(736–749행).
4. web.xml 병합 — 누가 이기나
configureStart()는 먼저 webConfig()를 부릅니다(1288행부터). 소스 주석에 Step 1부터 11까지 번호가 붙어 있어 따라 읽기 좋습니다.
노란 상자 둘이 SCI의 몫입니다(5절). 8단계(<jsp-file>로 적은 JSP를 서블릿으로 바꾸기)와 10단계(jar 안의 정적 파일)는 그림에서 뺐습니다.
가운데 토막을 그대로 옮깁니다.
// Step 3. Look for ServletContainerInitializer implementations
if (ok) {
processServletContainerInitializers();
}
if (!webXml.isMetadataComplete() || !typeInitializerMap.isEmpty()) {
// Steps 4 & 5.
processClasses(webXml, orderedFragments);
}
if (!webXml.isMetadataComplete()) {
// Step 6. Merge web-fragment.xml files into the main web.xml
// file.
if (ok) {
ok = webXml.merge(orderedFragments);
}
// Step 7a
// merge tomcat-web.xml
webXml.merge(tomcatWebXml);
// Step 7b. Apply global defaults
// Have to merge defaults before JSP conversion since defaults
// provide JSP servlet definition.
webXml.merge(defaults);
// Step 8. Convert explicitly mentioned jsps to servlets
if (ok) {
convertJsps(webXml);
}
// Step 9. Apply merged web.xml to Context
if (ok) {
configureContext(webXml);
}
} else {
webXml.merge(tomcatWebXml);
webXml.merge(defaults);
java/org/apache/catalina/startup/ContextConfig.java 1343–1380행
우선순위: 앱의 web.xml → 앱의 어노테이션 → fragment → 기본값
순서는 소스 곳곳에 흩어져 있어 표로 모읍니다.
| 출처 | 어디서 읽나 | 우선순위 |
|---|---|---|
WEB-INF/web.xml |
getContextWebXmlSource() (1324–1328행에서 맨 먼저 파싱) |
가장 높다 |
WEB-INF/classes의 어노테이션 |
processClasses() 4단계 — 앱의 webXml에 직접 더한다 |
web.xml에 같은 이름이 있으면 web.xml을 따른다 |
jar의 web-fragment.xml과 jar 안 클래스의 어노테이션 |
1·2·5단계 → 6단계 merge() |
web.xml이 정한 것은 건너뛴다 |
WEB-INF/tomcat-web.xml |
getTomcatWebXmlFragment() (1654행) |
7a, 덮어쓸 수 있는 기본값 |
conf/web.xml + conf/Catalina/localhost/web.xml.default |
getDefaultWebXmlFragment() (1683행) |
7b, 가장 낮다 |
6단계의 WebXml.merge() 안에도 같은 원칙이 주석으로 적혀 있습니다.
// As per 'clarification' from the Servlet EG, servlet definitions and
// mappings in the main web.xml override those in fragments and those in
// fragments override those in annotations
// Skip servlet definitions and mappings from fragments that are
// defined in web.xml
java/org/apache/tomcat/util/descriptor/web/WebXml.java 2281–2285행
conf/web.xml과 tomcat-web.xml은 setOverridable(true)로 읽어서(1657행, 1738행), 거기서 온 서블릿은 "앱이 같은 이름을 정의하면 비켜 준다"는 표시를 달고 들어갑니다. 모든 웹앱에 default 서블릿(/)과 jsp 서블릿(*.jsp)이 있는 이유가 이 7b단계이고, 앱이 /를 직접 매핑하면 기본값의 /는 들어오지 않는 이유도 여기에 있습니다.
@WebServlet은 web.xml에 무엇을 양보하나
어노테이션은 클래스를 로드해서 읽지 않습니다. 4·5단계는 .class 파일의 바이트를 Tomcat에 들어 있는 BCEL(org.apache.tomcat.util.bcel, 클래스 파일 파서)로 직접 해석합니다.
protected void processAnnotationsStream(InputStream is, WebXml fragment, boolean handlesTypesOnly,
Map<String,JavaClassCacheEntry> javaClassCache) throws ClassFormatException, IOException {
ClassParser parser = new ClassParser(is);
JavaClass clazz = parser.parse();
checkHandlesTypes(clazz, javaClassCache);
if (handlesTypesOnly) {
return;
}
processClass(fragment, clazz);
}
java/org/apache/catalina/startup/ContextConfig.java 2363–2375행
수천 개의 클래스를 전부 클래스로더에 올리면 느리고, 그 클래스의 static 초기화 코드가 원치 않게 돌 수도 있기 때문입니다. processClass()가 @WebServlet·@WebFilter·@WebListener 셋을 골라 WebXml에 항목으로 더합니다(2384–2402행).
@WebServlet을 처리하는 processAnnotationWebServlet()(2622행부터)은 먼저 이름을 정합니다. name이 없으면 클래스 이름(패키지까지 붙은 전체 이름)이 서블릿 이름입니다. 그 이름이 web.xml에 이미 있으면 새로 만들지 않고 web.xml의 정의에 덧붙이는데, loadOnStartup이나 초기 파라미터는 web.xml에 없는 것만 채웁니다. 주소(URL 패턴)는 끝부분이 정합니다.
if (!isWebXMLservletDef && urlPatterns != null) {
fragment.addServlet(servletDef);
}
if (urlPatterns != null) {
if (!fragment.getServletMappings().containsValue(servletName)) {
for (String urlPattern : urlPatterns) {
fragment.addServletMapping(urlPattern, servletName);
}
}
}
java/org/apache/catalina/startup/ContextConfig.java 2702–2711행
web.xml이 그 서블릿에 매핑을 하나라도 주었으면 어노테이션의 주소는 통째로 무시됩니다. 합쳐지지 않습니다.
metadata-complete="true"
<web-app metadata-complete="true">는 "이 web.xml이 전부다, 어노테이션은 보지 마라"는 뜻입니다. 위 코드에서 이 값이 true면 6~9단계 대신 else 쪽으로 가서 fragment 병합을 건너뜁니다. 그런데 4·5단계의 조건은 !webXml.isMetadataComplete() || !typeInitializerMap.isEmpty()입니다. @HandlesTypes를 단 SCI가 하나라도 있으면 metadata-complete여도 클래스를 훑습니다. 다만 그때는 handlesTypesOnly가 true라서 @HandlesTypes 짝만 찾고 processClass()(어노테이션 등록)는 하지 않습니다. 소스 머리 주석에도 "metadata-complete여도 SCI를 위해 JAR는 처리한다"고 적혀 있습니다(1297–1312행).
예제 2: web.xml과 @WebServlet이 같은 서블릿을 말할 때
같은 클래스와 같은 web.xml로 앱을 두 개 만들고, 하나만 metadata-complete="true"로 바꿨습니다. 서블릿 Hello에는 이렇게 어노테이션을 달았습니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// 어노테이션은 이름 "hello", 주소 /anno, 초기 파라미터 greeting=from-annotation 을 말한다.
// web.xml 에도 같은 이름 "hello" 를 적어 두고 둘이 어떻게 합쳐지는지 본다.
@WebServlet(name = "hello", urlPatterns = "/anno",
initParams = @WebInitParam(name = "greeting", value = "from-annotation"))
public class Hello extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain");
resp.getWriter().println("servlet=" + getServletName()
+ " greeting=" + getInitParameter("greeting"));
}
}
[예제 ex/u10-webxml-annotation/Hello.java에서 import 줄임]
web.xml은 같은 이름 hello에 greeting=from-web-xml과 주소 /xml을 줍니다. 그리고 web.xml에는 전혀 나오지 않는 @WebServlet("/only-anno") public class OnlyAnno가 하나 더 있습니다. /merge 앱에는 META-INF/context.xml로 <Context logEffectiveWebXml="true"/>를 주어, 합쳐진 최종 web.xml을 로그에 찍게 했습니다(1385–1387행).
===== /merge (metadata-complete=false) =====
GET /merge/xml servlet=hello greeting=from-web-xml [HTTP 200]
GET /merge/anno [HTTP 404]
GET /merge/only-anno servlet=OnlyAnno [HTTP 200]
===== /complete (metadata-complete=true) =====
GET /complete/xml servlet=hello greeting=from-web-xml [HTTP 200]
GET /complete/anno [HTTP 404]
GET /complete/only-anno [HTTP 404]
===== effective web.xml of /merge: servlet-mapping (url-pattern -> servlet-name) =====
*.jspx -> jsp
*.jsp -> jsp
/only-anno -> OnlyAnno
/xml -> hello
/ -> default
/merge/anno가 404입니다. web.xml이hello에/xml을 주었으므로 어노테이션의/anno는 버려졌습니다(2706행의 조건). 초기 파라미터도 web.xml의from-web-xml입니다./merge/only-anno는 200이고, 서블릿 이름이OnlyAnno입니다.name을 주지 않아 클래스 이름이 이름이 되었습니다./complete/only-anno는 404입니다.metadata-complete="true"라 어노테이션 등록을 하지 않았습니다.- 최종 매핑에
default(/)와jsp(*.jsp,*.jspx)가 있습니다. 앱은 적은 적이 없는데conf/web.xml에서 7b단계로 들어왔습니다.
헷갈리기 쉬운 곳: 로그의 metadata-complete="true"
logEffectiveWebXml로 찍힌 /merge의 최종 web.xml 머리에는 metadata-complete="true"가 적혀 있었습니다. 원래 web.xml은 false였는데도 그렇습니다(/complete에도 같은 설정을 주고 따로 돌려 보면 그쪽도 true로 찍힙니다). WebXml.toXml()이 버전이 2.4가 아니면 이 값을 언제나 true로 쓰기 때문입니다(WebXml.java 1422–1426행). 찍힌 것은 어노테이션까지 다 합친 결과이므로, 그것을 다시 읽을 때는 어노테이션을 볼 필요가 없다는 뜻으로 보입니다. 원래 설정이 true였다는 뜻으로 읽으면 안 됩니다.
5. ServletContainerInitializer — Spring이 들어오는 문
찾기: WebappServiceLoader
3단계 processServletContainerInitializers()는 WebappServiceLoader로 SCI들을 찾습니다.
protected void processServletContainerInitializers() {
List<ServletContainerInitializer> detectedScis;
try {
WebappServiceLoader<ServletContainerInitializer> loader = new WebappServiceLoader<>(context);
detectedScis = loader.load(ServletContainerInitializer.class);
} catch (IOException ioe) {
log.error(sm.getString("contextConfig.servletContainerInitializerFail", context.getName()), ioe);
ok = false;
return;
}
java/org/apache/catalina/startup/ContextConfig.java 1833–1843행
JDK의 java.util.ServiceLoader와 같은 파일(META-INF/services/<인터페이스 이름>)을 읽지만, 웹앱에 맞춘 규칙이 더 있습니다(WebappServiceLoader.load(), 98행부터).
- 컨테이너 것 먼저, 웹앱 것 나중. 부모 클래스로더(Tomcat의
lib/)에서 찾은 것을 먼저 모으고, 웹앱 클래스로더에서 새로 보이는 것을 그 뒤에 붙입니다(195행). 웹앱이 컨테이너의 SCI에 기댈 수 있게 하려는 순서입니다. containerSciFilter로 컨테이너 쪽 SCI를 정규식으로 끌 수 있습니다(77–82행, 122–125행).- web.xml에
<absolute-ordering>이 있으면 거기 이름이 오른 jar에서만 찾습니다(ORDERED_LIBS). - 찾은 클래스는 웹앱 클래스로더로 로드해 인자 없는 생성자로 만듭니다(231–232행).
그다음 SCI마다 @HandlesTypes를 읽어 "이 타입 → 이 SCI들"을 typeInitializerMap에 기록합니다(1845–1877행). 4·5단계의 클래스 훑기가 이 지도를 보며 짝을 찾습니다. checkHandlesTypes()(2412행)는 클래스마다 부모 클래스와 인터페이스를 따라 올라가며(populateSCIsForCacheEntry(), 2538–2591행) 그 가운데 @HandlesTypes에 적힌 타입이 있으면, 그때서야 클래스를 로드해 initializerClassMap의 그 SCI 몫에 넣습니다. @HandlesTypes에 어노테이션 타입을 적었다면 그 어노테이션이 붙은 클래스를 찾습니다(2457–2481행).
노란 상자가 SCI와 클래스를 잇는 두 지도입니다. 회색 상자는 Spring 쪽입니다.
등록과 호출은 다른 곳에서
ContextConfig는 SCI를 부르지 않습니다. 11단계에서 Context에 등록만 합니다.
if (ok) {
for (Map.Entry<ServletContainerInitializer,Set<Class<?>>> entry : initializerClassMap.entrySet()) {
if (entry.getValue().isEmpty()) {
context.addServletContainerInitializer(entry.getKey(), null);
} else {
context.addServletContainerInitializer(entry.getKey(), entry.getValue());
}
}
}
java/org/apache/catalina/startup/ContextConfig.java 1407–1415행
찾은 클래스가 하나도 없으면 빈 집합이 아니라 null 을 넘깁니다. StandardContext는 이것을 LinkedHashMap에 넣어 두었다가(StandardContext.java 229행, 1329–1331행) 6절의 startInternal()에서 등록한 순서대로 onStartup()을 부릅니다.
Spring과 만나는 곳
Spring 20편에서 본 대로, spring-web jar에는 META-INF/services/jakarta.servlet.ServletContainerInitializer 파일이 있고 내용은 org.springframework.web.SpringServletContainerInitializer 한 줄이며, 이 클래스에는 @HandlesTypes(WebApplicationInitializer.class)가 붙어 있습니다. Spring 20편은 "WebApplicationInitializer 구현 클래스를 찾아 넘겨 주는 탐색은 서블릿 컨테이너가 하는 일이라 소스로 확인하지 않았다"고 적었습니다. 그 탐색이 바로 이 절의 내용입니다. Tomcat 쪽에서 보면 이렇게 이어집니다.
WebappServiceLoader가WEB-INF/lib/spring-web-*.jar의 서비스 파일을 읽어SpringServletContainerInitializer를 웹앱 클래스로더로 만듭니다.@HandlesTypes를 읽어WebApplicationInitializer→ 이 SCI를 지도에 올립니다.- 앱의 클래스와 jar들을 BCEL로 훑어, 부모·인터페이스 사슬에
WebApplicationInitializer가 있는 클래스를 모읍니다. StandardContext.startInternal()이onStartup(그 클래스들, servletContext)를 부릅니다. 그다음부터는 Spring 20편이 소스로 본 길입니다 — 클래스들을 걸러 객체로 만들고 차례로onStartup(servletContext)를 부릅니다.
Spring의 onStartup이 인터페이스와 추상 클래스를 걸러 내는 이유도 Tomcat 쪽에서 보입니다. Tomcat은 상속 사슬에 그 타입이 있으면 추상 클래스든 무엇이든 다 넘겨 줍니다. 예제 3에서 확인합니다. 한편 Spring 20편의 예제처럼 내장 Tomcat의 tomcat.addContext()를 쓰면 ContextConfig 대신 FixContextListener가 붙습니다(Tomcat.java 680행). 이 리스너는 configured를 true로 바꾸고 어노테이션 몇 가지만 처리할 뿐 web.xml도 SCI도 찾지 않습니다(1112–1141행). 그 예제가 addServletContainerInitializer()로 SCI를 손수 넣은 이유가 이것입니다. 내장 Tomcat은 16편에서 봅니다.
6. StandardContext.startInternal() — 깨우는 순서
이제 Context가 실제로 시작되는 메서드입니다. 300줄이 넘는 메서드(4390–4701행)를 큰 단계만 남기면 그림과 같습니다.
파란 상자가 웹앱의 코드가 불리거나(SCI, 리스너, 필터, 서블릿) ContextConfig가 일하는 자리입니다. 회색 상자는 다른 편에서 다룹니다.
| 순서 | 무엇 | 위치 (StandardContext.java) |
|---|---|---|
| 0 | BEFORE_START_EVENT → ContextConfig.beforeStart() — WAR 풀기 |
LifecycleBase.start()가 STARTING_PREP으로 바꿀 때 |
| 1 | 리소스(StandardRoot) 준비 |
4429행 |
| 2 | WebappLoader 시작 — 웹앱 클래스로더(4편) |
4476행 |
| 3 | CONFIGURE_START_EVENT → ContextConfig.configureStart() |
4526행 |
| 4 | 자식 Wrapper들, 파이프라인 시작 | 4529–4539행 |
| 5 | configured가 아니면 실패 처리 |
4576–4579행 |
| 6 | mergeParameters() — context-param |
4603행 |
| 7 | SCI의 onStartup() |
4606–4614행 |
| 8 | listenerStart() — ServletContextListener.contextInitialized() |
4617–4622행 |
| 9 | 세션 Manager 시작(11편) |
4635행 |
| 10 | filterStart() — Filter.init() |
4644행 |
| 11 | loadOnStartup() — load-on-startup 서블릿의 init()(9편) |
4652행 |
2번이 3번보다 먼저인 것이 중요합니다. ContextConfig가 @HandlesTypes 짝을 찾으면 그 클래스를 로드해야 하는데, 그러려면 웹앱 클래스로더가 이미 있어야 합니다. 7~11번은 이렇게 생겼습니다.
// Call ServletContainerInitializers
for (Map.Entry<ServletContainerInitializer,Set<Class<?>>> entry : initializers.entrySet()) {
try {
entry.getKey().onStartup(entry.getValue(), getServletContext());
} catch (ServletException e) {
log.error(sm.getString("standardContext.sciFail"), e);
ok = false;
break;
}
}
// Configure and call application event listeners
if (ok) {
if (!listenerStart()) {
log.error(sm.getString("standardContext.listenerFail"));
ok = false;
}
}
// ...
// Load and initialize all "load on startup" servlets
if (ok) {
if (!loadOnStartup(findChildren())) {
log.error(sm.getString("standardContext.servletFail"));
ok = false;
}
}
java/org/apache/catalina/core/StandardContext.java 4605–4656행
SCI가 맨 앞인 데는 이유가 있습니다. SCI의 onStartup()은 ctx.addServlet(), ctx.addFilter(), ctx.addListener()로 새 서블릿·필터·리스너를 등록할 수 있는 자리입니다. 등록이 다 끝난 뒤에 리스너를 부르고 필터와 서블릿을 초기화해야, SCI가 등록한 것들도 똑같이 대접받습니다. Spring 20편의 예제에서 onStartup에 등록한 ContextLoaderListener가 바로 다음 단계(8번)에서 불려 루트 컨텍스트를 refresh()한 것이 이 순서 덕분입니다.
예제 3: 직접 쓴 SCI와 시작 순서
MySci를 jar로 묶어 WEB-INF/lib/my-sci.jar에 넣었습니다. jar 안의 META-INF/services/jakarta.servlet.ServletContainerInitializer 파일에는 MySci 한 줄을 적었습니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// WEB-INF/lib 의 jar 에 들어가는 SCI. META-INF/services 파일로 Tomcat 에 알린다.
// @HandlesTypes 에 적은 Plugin 을 구현한 클래스들을 Tomcat 이 찾아 첫 인자로 넘겨 준다.
@HandlesTypes(Plugin.class)
public class MySci implements ServletContainerInitializer {
@Override
public void onStartup(Set<Class<?>> classes, ServletContext ctx) {
Set<String> names = new TreeSet<>();
if (classes != null) {
classes.forEach(c -> names.add(c.getName()));
}
System.out.println("1. SCI onStartup, @HandlesTypes classes = " + names);
// 누가 나를 불렀나: org.apache.catalina 의 호출만 골라 찍는다
for (StackTraceElement e : Thread.currentThread().getStackTrace()) {
if (e.getClassName().startsWith("org.apache.catalina")) {
System.out.println(" at " + e.getClassName() + "." + e.getMethodName());
}
}
// web.xml 없이 코드로 서블릿을 등록한다 (Spring 의 WebApplicationInitializer 가 하는 일과 같은 API)
var reg = ctx.addServlet("fromSci", new HelloServlet());
reg.addMapping("/hello");
reg.setLoadOnStartup(1);
}
}
[예제 ex/u10-sci/sci/MySci.java에서 import 줄임]
Plugin은 빈 인터페이스입니다(jar 안). 웹앱의 WEB-INF/classes에는 PluginA implements Plugin, abstract class AbstractPlugin implements Plugin, PluginB extends AbstractPlugin, 그리고 아무 관계 없는 NotAPlugin을 두었습니다. @WebListener가 붙은 리스너와 @WebFilter("/*") 필터도 하나씩 두어 각각 "2.", "3."을 찍게 하고, MySci가 등록한 서블릿은 init()에서 "4."를 찍습니다. run.sh는 먼저 Tomcat의 lib/*.jar에 들어 있는 SCI 서비스 파일도 찍어 봅니다.
===== SCIs that ship with Tomcat itself (lib/*.jar) =====
jasper.jar: org.apache.jasper.servlet.JasperInitializer
tomcat-websocket.jar: org.apache.tomcat.websocket.server.WsSci
hello from a servlet registered by an SCI
===== catalina.out (System.out of the web app) =====
1. SCI onStartup, @HandlesTypes classes = [AbstractPlugin, PluginA, PluginB]
at org.apache.catalina.core.StandardContext.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.core.ContainerBase.addChild
at org.apache.catalina.core.StandardHost.addChild
at org.apache.catalina.startup.HostConfig.deployDirectory
at org.apache.catalina.startup.HostConfig$DeployDirectory.run
at org.apache.catalina.startup.HostConfig.deployDirectories
at org.apache.catalina.startup.HostConfig.deployApps
at org.apache.catalina.startup.HostConfig.start
at org.apache.catalina.startup.HostConfig.lifecycleEvent
at org.apache.catalina.util.LifecycleBase.fireLifecycleEvent
at org.apache.catalina.util.LifecycleBase.setStateInternal
at org.apache.catalina.util.LifecycleBase.setState
at org.apache.catalina.core.ContainerBase.startInternal
at org.apache.catalina.core.StandardHost.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.core.ContainerBase$StartChild.call
at org.apache.catalina.core.ContainerBase$StartChild.call
at org.apache.catalina.core.ContainerBase.startInternal
at org.apache.catalina.core.StandardEngine.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.core.StandardService.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.core.StandardServer.startInternal
at org.apache.catalina.util.LifecycleBase.start
at org.apache.catalina.startup.Catalina.start
at org.apache.catalina.startup.Bootstrap.start
at org.apache.catalina.startup.Bootstrap.main
2. ServletContextListener.contextInitialized
3. Filter.init: LogFilter
4. servlet init (load-on-startup): fromSci
- SCI → 리스너 → 필터 → 서블릿. 위 표의 7, 8, 10, 11번 그대로입니다. "4."는
MySci가 코드로 등록한 서블릿인데도load-on-startup처리를 똑같이 받았습니다. - 넘겨받은 클래스는
AbstractPlugin,PluginA,PluginB입니다.PluginB는Plugin을 직접 구현하지 않았지만 부모를 따라 올라가 찾았습니다. 추상 클래스도 들어 있습니다.Plugin자신과NotAPlugin은 없습니다. 짝은 "부모와 인터페이스 중에 그 타입이 있는가"로만 보기 때문에(2538–2591행) 타입 자신은 짝이 아닙니다. - Tomcat의
lib/에도 SCI 서비스 파일을 가진 jar가 둘 있습니다.JasperInitializer(JSP, 13편)와WsSci(WebSocket, 15편)입니다. 둘 다 이WebappServiceLoader길로, 웹앱의 SCI보다 먼저 찾아집니다. - 스택을 아래에서 위로 읽으면
Bootstrap.main→ Server → Service → Engine → Host 시작(3편) → Host의START_EVENT를 받은HostConfig.start()→deployApps()→deployDirectories()(이번에는 WAR가 아니라 폴더로 넣었습니다) →StandardHost.addChild()→ContainerBase.addChild()가 부른start()→StandardContext.startInternal()→MySci.onStartup()입니다. 2절에서 말한 "addChild()안에서 시작된다"와 "기동 때는main스레드에서 배포한다"가 둘 다 보입니다.deployDirectories와DeployDirectory.run사이의 실행기 호출은org.apache.catalina패키지가 아니라서(InlineExecutorService는org.apache.tomcat.util.threads에 있습니다) 이 예제의 필터에 걸리지 않았습니다.
헷갈리기 쉬운 곳: 시작에 실패해도 Host는 계속 돕니다
SCI가 ServletException을 던지거나 리스너가 실패하면 ok가 false가 되고, 마지막에 Context가 FAILED 상태가 됩니다(4690–4700행). LifecycleBase.start()는 이것을 "스스로 실패를 알린 경우"로 보고 예외를 던지지 않은 채 stop()으로 정리만 합니다(LifecycleBase.java 170–173행). 그래서 이 앱은 요청을 받지 않지만, HostConfig는 다음 앱의 배포를 그대로 이어 갑니다. 로그에는 Context [/shop##v2] startup failed due to previous errors 같은 줄(standardContext.startFailed)이 남는데, 진짜 원인은 그 위쪽에 찍힌 오류에 있습니다. 앱이 404만 낸다면 로그를 위로 올라가 읽어야 합니다.
이 글에서 뺀 것
conf/Catalina/localhost/*.xml배포(deployDescriptor())와copyXML·deployXML의 보안 의미:HostConfig.java545행부터- 다시 읽기(reload)와 다시 배포(redeploy)의 구분,
WatchedResource:checkResources()1369행부터 web-fragment.xml의 순서 정하기(<ordering>,<absolute-ordering>):WebXml.orderWebFragments()2653행부터- 훑을 jar를 줄이는
JarScanner와catalina.properties의jarsToSkip:java/org/apache/tomcat/util/scan/StandardJarScanFilter.java - 병렬 어노테이션 스캔(
parallelAnnotationScanning):ContextConfig.java2202행부터 context.xml을 자바 코드로 미리 만들어 두는 기능(getGenerateCode(),getUseGeneratedCode()): 439행부터- 병합된
WebXml을 Context 객체에 옮기는configureContext(): 1463행부터 javax.*앱을 옮기는migrateLegacyApps(): 1138행부터- Windows에서 파일 잠김을 피하려 웹앱을 임시 폴더로 복사하는
antiResourceLocking:antiLocking()947행부터
정리
| 개념 | 한 줄 요약 |
|---|---|
HostConfig |
Host의 리스너. 시작 때와 PERIODIC_EVENT마다 xml → WAR → 폴더 순으로 배포한다 |
| 자동 배포 주기 | Engine만 backgroundProcessorDelay=10. Catalina-utility 스레드가 10초마다 자식들을 돈다 |
deployWAR() |
Context를 만들고 ContextConfig를 달고 host.addChild() — 그 안에서 start()가 불린다 |
ContextName |
# → /, ## 뒤는 버전, ROOT는 루트 |
| WAR 풀기 | BEFORE_START의 fixDocBase() → ExpandWar. war-tracker의 시각으로 바뀐 WAR를 알아챈다 |
ContextConfig |
Context의 리스너. context.xml → WAR 풀기 → webConfig() |
| web.xml 우선순위 | 앱의 web.xml > WEB-INF/classes의 어노테이션 > jar의 fragment(와 그 jar의 어노테이션) > tomcat-web.xml > conf/web.xml |
| 어노테이션 스캔 | BCEL로 클래스 파일을 읽는다. web.xml이 매핑을 주면 @WebServlet의 주소는 버린다 |
metadata-complete |
어노테이션 등록은 끄지만, @HandlesTypes 짝 찾기는 계속한다 |
| SCI 찾기 | WebappServiceLoader — 컨테이너 jar 먼저, 웹앱 jar 다음 |
@HandlesTypes |
부모·인터페이스 사슬로 짝을 찾는다. 추상 클래스도 넘기고, 없으면 null |
startInternal() 순서 |
클래스로더 → configureStart → SCI → 리스너 → 필터 → load-on-startup 서블릿 |
다음에 볼 것
이제 웹앱이 떠서 요청을 받습니다. 요청이 오면 많은 앱이 가장 먼저 찾는 것이 세션입니다. request.getSession() 한 줄이 JSESSIONID를 어떻게 만들고, 6절의 표에서 9번으로 시작된 세션 Manager가 같은 10초 주기 작업에서 만료된 세션을 어떻게 지우는지 11편: 세션 — JSESSIONID는 어떻게 만들고 지우나에서 이어집니다.
Spring 쪽에서 보면 이 글은 Spring 20편의 바로 앞 장면입니다. Spring 20편이 SpringServletContainerInitializer.onStartup()부터 시작했다면, 이 글은 Tomcat이 그 SCI를 jar에서 찾아 WebApplicationInitializer 구현 클래스를 모아 넘기기까지를 봤습니다. 그렇게 등록된 DispatcherServlet이 요청을 받아 처리하는 길은 Spring 21편에 있습니다. conf/web.xml 기본값 위에 앱의 설정을 덮어쓰는 병합 방식은, 서버 전체 설정 위에 디렉터리별 설정을 병합하던 httpd 7편과 닮았습니다.