namalzi

← 글 목록

Tomcat 소스 파헤치기 10: 웹앱 배포와 Context 설정 — WAR 하나가 서비스되기까지

Tomcat · 게시

webapps 폴더에 WAR 파일 하나를 넣으면 Tomcat 안에서 무슨 일이 일어나는지 소스로 따라갑니다. HostConfig가 10초마다 폴더를 살펴 Context를 만들고, ContextConfig가 web.xml·conf/web.xml·어노테이션을 합치고 ServletContainerInitializer를 찾으며, StandardContext.startInternal()이 SCI·리스너·필터·서블릿을 정해진 순서로 깨웁니다. Spring의 SpringServletContainerInitializer가 불리는 자리도 Tomcat 쪽에서 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 열 번째 글입니다. 9편에서는 요청 하나가 필터 체인을 지나 서블릿의 service()에 닿는 데까지 봤습니다. 거기까지는 웹앱이 이미 "떠 있다"고 치고 이야기했습니다. 이번에는 한 걸음 물러나, 그 웹앱이 어떻게 뜨는지를 봅니다. webapps 폴더에 shop.war를 복사해 넣으면 잠시 뒤 /shop으로 요청을 받게 되는데, 그 사이에 무슨 일이 일어날까요.

답하려는 질문은 네 가지입니다.

항목 내용
대상 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가 배포하는 흐름. StandardHost가 시작할 때 START_EVENT로, 또는 백그라운드 스레드 Catalina-utility에서 Engine이 10초마다 보내는 PERIODIC_EVENT로 HostConfig.check()가 불린다. check는 autoDeploy가 true일 때만 일하며, 바뀌거나 지워진 파일이 있으면 checkResources로 reload나 undeploy를 하고, 이어서 deployApps를 부른다. deployApps는 conf/Catalina/localhost의 xml, webapps의 war, webapps의 폴더 순으로 배포한다. deployWAR는 StandardContext를 만들고 ContextConfig 리스너를 붙인 뒤 host.addChild로 Host에 붙이고, 그 안에서 context.start가 불린다. 파란 상자가 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행

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행

  1. deployDescriptors — conf/Catalina/localhost/*.xml. 파일 하나가 <Context docBase="/어딘가/myapp"> 하나입니다. 웹앱을 webapps 밖에 두고 싶을 때 쓰는 방법입니다.
  2. deployWARs — webapps/*.war.
  3. 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편의 세션 만료 검사도 돌립니다.

PERIODIC_EVENT를 보내는 길. 서버의 유틸리티 실행기(스레드 Catalina-utility-N)가 scheduleWithFixedDelay로 10초마다 작업을 돌린다. 이 주기 작업은 StandardEngine 하나에만 걸려 있다. backgroundProcessorDelay의 기본값은 -1이고 Engine만 생성자에서 10으로 바꾸기 때문이다. 작업인 ContainerBackgroundProcessor.processChildren()은 자기를 처리한 뒤 자식 가운데 지연 값이 0 이하인 것을 재귀로 돌며 Engine, Host, Context, Wrapper를 차례로 처리한다. 각 컨테이너의 backgroundProcess()는 클러스터, Realm, 파이프라인의 밸브들을 부르고 마지막 줄에서 PERIODIC_EVENT를 낸다. Host의 리스너 HostConfig는 이 이벤트를 받아 check()로 자동 배포를 한다. 같은 주기 작업이 세션 만료 검사도 돌린다(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행

  1. Host의 configClass, 곧 ContextConfig를 하나 만들어 새 Context의 리스너로 답니다. 이 순간부터 Context의 Lifecycle 이벤트는 ContextConfig에게 갑니다(3절).
  2. 이름·경로·버전을 정합니다. cn은 파일 이름을 해석한 ContextName입니다(바로 아래).
  3. docBase(웹앱 내용이 있는 곳)는 일단 WAR 파일 자체로 둡니다. 푸는 일은 나중에 ContextConfig가 합니다.
  4. 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가 배포한 적도 있습니다). 읽을 것이 네 가지입니다.

예제 1의 시간 순서. Tomcat이 켜진 뒤 비어 있던 webapps에 shop##v2.war를 복사하자, 13:42:18.748에 Catalina-utility-1 스레드가 HostConfig.deployWAR로 배포를 시작하고 13:42:20.033에 1,284밀리초 만에 끝냈다. webapps에는 shop##v2.war와 풀린 폴더 shop##v2(META-INF/war-tracker 포함)가 생겼고, 복사하고 약 9초 뒤 /shop/hello가 contextPath=/shop, realPath=/tmp/base/webapps/shop##v2/로 처음 200을 돌려줬다. WAR를 지우자 13:42:30.045에 Catalina-utility-1이 HostConfig.undeploy로 /shop##v2를 내리고 풀린 폴더도 함께 지웠으며, 지우고 약 10초 뒤 404가 됐다. 위 출력을 시간 순서로 늘어놓았습니다. 시각과 초는 이 실행의 것이라 다시 돌리면 달라집니다.

헷갈리기 쉬운 곳: 배포는 하나씩, 그리고 기동 때는 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가 시작되는 동안 불리는 순서대로 놓으면 이렇습니다.

  1. 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> 같은 줄이 있어서, 그 파일이 바뀌면 앱을 다시 읽어 들이게 됩니다.
  2. BEFORE_START_EVENT → beforeStart() (1019행): fixDocBase()로 WAR를 풉니다(바로 아래).
  3. CONFIGURE_START_EVENT → configureStart() (1034행): 이 글의 중심입니다. web.xml들을 합치고, 어노테이션을 읽고, SCI를 찾습니다. 끝나면 context.setConfigured(true)를 부릅니다.

server.xml 안에 <Context>를 직접 적은 경우에도 Digester 규칙이 같은 ContextConfig를 답니다(ContextRuleSet.java 93행). 어디서 왔든 Context를 채우는 일꾼은 하나입니다.

HostConfig.deployWAR()가 ContextConfig를 단 뒤 Context가 시작되며 ContextConfig가 받는 이벤트의 순서. deployWAR()는 context.addLifecycleListener(ContextConfig)로 리스너를 달고 docBase를 WAR 파일 그대로(shop##v2.war) 둔다. AFTER_INIT_EVENT에서 init()이 conf/context.xml, conf/Catalina/localhost/context.xml.default, 이 앱의 설정 파일(META-INF/context.xml 등)을 같은 Context 위에 차례로 읽는다. BEFORE_START_EVENT에서 beforeStart()가 fixDocBase()를 불러, unpackWARs면 ExpandWar.expand()로 WAR를 풀고 docBase를 풀린 폴더로 바꾸며 원래 값은 setOriginalDocBase()로 적어 두고, war-tracker의 수정 시각을 WAR의 수정 시각과 같게 맞춘다. CONFIGURE_START_EVENT에서 configureStart()가 web.xml 병합, 어노테이션, SCI 찾기를 하고 끝나면 context.setConfigured(true)를 부른다. 위 목록 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까지 번호가 붙어 있어 따라 읽기 좋습니다.

webConfig의 단계. 앱의 WEB-INF/web.xml을 먼저 읽고, 1·2단계에서 jar의 web-fragment.xml을 찾아 순서를 정하고, 3단계에서 META-INF/services로 SCI를 찾고, 4·5단계에서 클래스 파일을 훑어 @WebServlet, @WebFilter, @WebListener와 @HandlesTypes 짝을 찾는다. metadata-complete가 false면 6단계에서 fragment를 web.xml, fragment, 어노테이션 순의 우선순위로 병합하고, true면 건너뛴다. 7단계에서 tomcat-web.xml과 conf/web.xml 기본값을 합치고, 9단계 configureContext가 Wrapper와 매핑과 필터를 Context에 옮기고, 11단계에서 SCI와 찾은 클래스를 Context에 등록한다. 노란 상자 둘이 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

헷갈리기 쉬운 곳: 로그의 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행부터).

그다음 SCI마다 @HandlesTypes를 읽어 "이 타입 → 이 SCI들"을 typeInitializerMap에 기록합니다(1845–1877행). 4·5단계의 클래스 훑기가 이 지도를 보며 짝을 찾습니다. checkHandlesTypes()(2412행)는 클래스마다 부모 클래스와 인터페이스를 따라 올라가며(populateSCIsForCacheEntry(), 2538–2591행) 그 가운데 @HandlesTypes에 적힌 타입이 있으면, 그때서야 클래스를 로드해 initializerClassMap의 그 SCI 몫에 넣습니다. @HandlesTypes에 어노테이션 타입을 적었다면 그 어노테이션이 붙은 클래스를 찾습니다(2457–2481행).

SCI가 불리기까지. jar의 META-INF/services/jakarta.servlet.ServletContainerInitializer 파일을 WebappServiceLoader.load가 컨테이너 jar 먼저, 웹앱 jar 다음 순서로 읽는다. @HandlesTypes를 읽어 typeInitializerMap에 타입에서 SCI로 가는 지도를 만들고, 클래스 파일을 BCEL로 파싱해 부모와 인터페이스를 따라 올라가며 짝을 찾아 initializerClassMap에 SCI별 클래스 목록을 만든다. context.addServletContainerInitializer로 등록하는데 찾은 것이 없으면 null을 넘긴다. StandardContext.startInternal이 sci.onStartup(classes, servletContext)를 부르고, 예를 들어 SpringServletContainerInitializer는 WebApplicationInitializer들을 만들어 부른다. 노란 상자가 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 쪽에서 보면 이렇게 이어집니다.

  1. WebappServiceLoader가 WEB-INF/lib/spring-web-*.jar의 서비스 파일을 읽어 SpringServletContainerInitializer를 웹앱 클래스로더로 만듭니다.
  2. @HandlesTypes를 읽어 WebApplicationInitializer → 이 SCI를 지도에 올립니다.
  3. 앱의 클래스와 jar들을 BCEL로 훑어, 부모·인터페이스 사슬에 WebApplicationInitializer가 있는 클래스를 모읍니다.
  4. 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행)를 큰 단계만 남기면 그림과 같습니다.

StandardContext가 시작되는 순서. BEFORE_START_EVENT에서 ContextConfig.beforeStart가 WAR를 풀고, 리소스를 준비하고, WebappLoader를 시작해 웹앱 클래스로더를 만든다. CONFIGURE_START_EVENT에서 ContextConfig.configureStart가 web.xml을 병합하고 어노테이션과 SCI를 찾는다. 자식 Wrapper들과 파이프라인을 시작하고, mergeParameters로 context-param을 정리하고, SCI.onStartup을 부르고, listenerStart로 ServletContextListener를, 세션 Manager를 시작하고, filterStart로 Filter.init을, loadOnStartup으로 서블릿 init을 부른 뒤 STARTING 상태가 되어 요청을 받는다. 파란 상자가 웹앱의 코드가 불리거나(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

헷갈리기 쉬운 곳: 시작에 실패해도 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만 낸다면 로그를 위로 올라가 읽어야 합니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
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편과 닮았습니다.

다른 글