namalzi

← 글 목록

Tomcat 소스 파헤치기 3: Lifecycle과 기동 — catalina.sh start 뒤에 일어나는 일

Tomcat · 게시

catalina.sh start 한 줄 뒤에서 Bootstrap이 클래스로더를 만들고, Catalina가 Digester로 server.xml을 객체 나무로 바꾸고, 모든 부품이 같은 Lifecycle 상태 기계를 따라 init·start되는 과정을 소스로 따라갑니다. 부모가 자식을 어떤 순서로 시작시키는지, 커넥터가 실패해도 서버가 뜨는 이유, 종료 포트 8005가 하는 일을 리스너와 예제로 직접 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 세 번째 글입니다. 2편에서는 server.xml의 <Server>, <Service>, <Connector>, <Engine>, <Host>, <Context>가 각각 어떤 클래스가 되고 서로 어떻게 부모·자식으로 엮이는지 봤습니다. 이번에는 그 객체들이 언제, 누구에 의해, 어떤 순서로 만들어지고 켜지고 꺼지는지를 봅니다. catalina.sh start를 치고 "Server startup in [...] milliseconds" 로그가 찍히기까지, 그리고 catalina.sh stop으로 프로세스가 끝나기까지입니다.

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

항목 내용
대상 Apache Tomcat
버전 12.0.0-M1 (main)
커밋 17aa1769b0 (2026-10-23)
주로 읽는 클래스 startup/Bootstrap, startup/Catalina, tomcat/util/digester/Digester, Lifecycle, LifecycleState, util/LifecycleBase, core/StandardServer, core/StandardService, core/ContainerBase

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 안정판 11.0.x 브랜치와 비교해 보면, 이 글의 중심인 Bootstrap, Catalina, Lifecycle, LifecycleState, LifecycleBase, StandardServer, StandardService, ContainerBase는 주석 몇 줄을 빼면 파일이 같고, 그 밖에 인용한 클래스에서도 이 글이 다루는 부분은 달라지지 않았습니다.

Catalina.java는 1,200줄, ContainerBase.java도 1,200줄이 넘습니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

먼저 알아 둘 말

Bootstrap과 Catalina 는 Tomcat을 켜는 두 클래스입니다. Bootstrap은 main()이 있는 아주 작은 클래스로, 클래스로더를 만들고 Catalina를 불러내는 일만 합니다. Catalina가 실제 일 — server.xml을 읽고, 서버를 켜고, 종료 명령을 기다리고, 끄는 일 — 을 합니다. (Tomcat의 서블릿 컨테이너 부분 전체를 부르는 이름도 Catalina라서 헷갈리기 쉬운데, 이 글에서 Catalina라고 코드 표기로 쓰면 이 클래스 하나를 말합니다.)

CATALINA_HOME과 CATALINA_BASE 는 두 폴더입니다. CATALINA_HOME은 Tomcat을 설치한 곳(bin/, lib/), CATALINA_BASE는 이 인스턴스의 설정과 웹앱이 있는 곳(conf/, webapps/, logs/)입니다. 따로 정하지 않으면 둘은 같습니다(Bootstrap.java 110–123행). 이 글의 예제는 설치본을 건드리지 않으려고 CATALINA_BASE=/tmp/base를 따로 만들어 씁니다.

Digester는 XML 파일을 읽으면서 "이 경로의 요소가 나오면 이 일을 하라"는 규칙대로 자바 객체를 만들어 엮는 도구입니다. 원래 Apache Commons의 라이브러리였고, Tomcat은 그 사본을 org.apache.tomcat.util.digester 패키지에 두고 씁니다. server.xml이 객체 나무가 되는 곳이 여기입니다.

Lifecycle 은 Tomcat의 거의 모든 부품 — Server, Service, Connector, Engine, Host, Context, Wrapper, 밸브, 로더 … — 이 구현하는 인터페이스입니다. init(), start(), stop(), destroy() 네 메서드와 현재 상태(LifecycleState)를 가집니다. "켜고 끄는 방법을 모두 똑같이 맞춰 둔 것"이라고 보면 됩니다.

LifecycleListener 는 부품의 상태가 바뀔 때마다 알림(이벤트)을 받는 객체입니다. server.xml에서 <Listener className="..."/>로 거는 것이 바로 이것입니다. 기본 server.xml에 들어 있는 VersionLoggerListener(기동할 때 버전을 찍는 리스너)도 그중 하나입니다.

종료 포트(shutdown port)는 <Server port="8005" shutdown="SHUTDOWN">의 그 8005입니다. 켜진 Tomcat은 이 포트에서 SHUTDOWN이라는 글자가 오기를 기다리다가, 오면 스스로 꺼집니다. 6절에서 봅니다.

1. catalina.sh start에서 Bootstrap.main()까지

catalina.sh start는 셸 스크립트입니다. 환경 변수를 정리한 끝에 java를 이렇게 띄웁니다.

  eval $_NOHUP "\"$_RUNJAVA\"" "\"$CATALINA_LOGGING_CONFIG\"" $LOGGING_MANAGER "$JAVA_OPTS" "$CATALINA_OPTS" \
    -classpath "\"$CLASSPATH\"" \
    -Dcatalina.base="\"$CATALINA_BASE\"" \
    -Dcatalina.home="\"$CATALINA_HOME\"" \
    -Djava.io.tmpdir="\"$CATALINA_TMPDIR\"" \
    org.apache.catalina.startup.Bootstrap "$@" start \
    >> "$CATALINA_OUT" 2>&1 "&"

bin/catalina.sh 416–422행

세 가지를 읽을 수 있습니다. 실행하는 클래스는 org.apache.catalina.startup.Bootstrap이고 마지막 인자는 start입니다. 표준 출력과 오류는 catalina.out에 이어 붙습니다. 그리고 끝의 & 때문에 자바는 뒤에서 돌고, 스크립트는 곧바로 Tomcat started.를 찍고 끝납니다(428행).

클래스패스에는 jar가 두 개뿐입니다

CLASSPATH에 들어가는 것은 bin/bootstrap.jar(214행)와 bin/tomcat-juli.jar(225–231행)뿐입니다. Tomcat 본체인 lib/catalina.jar도, 서블릿 API인 lib/servlet-api.jar도 없습니다. 예제 컨테이너에서 실제로 떠 있는 자바 프로세스의 명령줄과 bootstrap.jar의 내용을 찍어 보면 이렇습니다(예제 1의 일부, 예제 전체는 4절에서 봅니다).

===== java command line: -classpath =====
-classpath
/opt/tomcat/bin/bootstrap.jar:/opt/tomcat/bin/tomcat-juli.jar
===== classes in bin/bootstrap.jar =====
org/apache/catalina/startup/Bootstrap.class
org/apache/catalina/startup/CatalinaProperties.class
org/apache/catalina/startup/ClassLoaderFactory$Repository.class
org/apache/catalina/startup/ClassLoaderFactory$RepositoryType.class
org/apache/catalina/startup/ClassLoaderFactory.class
org/apache/catalina/startup/Constants.class
org/apache/catalina/startup/SafeForkJoinWorkerThreadFactory$SafeForkJoinWorkerThread.class
org/apache/catalina/startup/SafeForkJoinWorkerThreadFactory.class
org/apache/catalina/startup/Tool.class
org/apache/catalina/webresources/war/Handler.class
org/apache/catalina/webresources/war/WarURLConnection.class
org/apache/tomcat/util/buf/UriUtil.class

bootstrap.jar에는 클래스 열두 개만 들어 있고, Catalina 클래스는 없습니다. 나머지 수백 개의 클래스는 Bootstrap이 직접 만든 클래스로더로 lib/에서 불러옵니다.

Bootstrap.init() — 클래스로더를 만들고 Catalina를 리플렉션으로

main()은 먼저 Bootstrap 객체를 하나 만들어 init()을 부릅니다(441–454행). init()은 이렇습니다.

    public void init() throws Exception {

        initClassLoaders();

        Thread.currentThread().setContextClassLoader(catalinaLoader);

        // Load our startup class and call its process() method
        if (log.isTraceEnabled()) {
            log.trace("Loading startup class");
        }
        Class<?> startupClass = catalinaLoader.loadClass("org.apache.catalina.startup.Catalina");
        Object startupInstance = startupClass.getConstructor().newInstance();
        // ...
        String methodName = "setParentClassLoader";
        Class<?>[] paramTypes = new Class[1];
        paramTypes[0] = Class.forName("java.lang.ClassLoader");
        Object[] paramValues = new Object[1];
        paramValues[0] = sharedLoader;
        Method method = startupInstance.getClass().getMethod(methodName, paramTypes);
        method.invoke(startupInstance, paramValues);

        catalinaDaemon = startupInstance;
    }

java/org/apache/catalina/startup/Bootstrap.java 251–277행

  1. initClassLoaders() 가 common, server, shared 세 클래스로더를 만듭니다(142–156행). 어느 폴더를 볼지는 conf/catalina.properties의 common.loader, server.loader, shared.loader 값이 정합니다. 기본 설정에서는 common.loader만 ${catalina.base}/lib, ${catalina.home}/lib/*.jar 같은 값을 갖고 나머지 둘은 비어 있습니다. 값이 비어 있으면 createClassLoader()는 새 로더를 만들지 않고 부모를 그대로 돌려주므로(161–164행), 기본 설정에서는 세 이름이 모두 같은 common 로더를 가리킵니다.
  2. catalinaLoader.loadClass(...) 로 Catalina 클래스를 그 로더에서 불러와 객체를 만듭니다.
  3. 그 객체의 메서드는 리플렉션(getMethod() + invoke())으로 부릅니다. setParentClassLoader()부터 그렇고, 이 뒤의 load(), start(), stop()도 모두 같은 방식입니다(283–357행).

왜 new Catalina()라고 쓰지 않을까요? Bootstrap을 불러온 것은 JVM의 기본 클래스로더(애플리케이션 클래스로더)이고, 그 로더의 클래스패스에는 catalina.jar가 없습니다. Bootstrap 코드에 Catalina라는 타입을 직접 적으면 JVM은 Bootstrap을 불러온 로더에서 Catalina를 찾다가 실패합니다. 그래서 Bootstrap은 Catalina를 Object로만 들고 있고, 이름(문자열)으로 메서드를 찾아 부릅니다. 이렇게 해 두면 Tomcat 본체의 클래스는 전부 lib/ 아래에 두고 catalina.properties로 바꿀 수 있습니다. 클래스로더 셋의 자세한 관계와 웹앱마다 따로 두는 로더는 4편에서 봅니다.

main()의 start 명령

init()이 끝나면 main()은 마지막 인자를 명령으로 읽습니다. start일 때 하는 일은 세 줄입니다.

                case "start":
                    daemon.setAwait(true);
                    daemon.load(args);
                    daemon.start();
                    if (null == daemon.getServer()) {
                        System.exit(1);
                    }
                    break;

java/org/apache/catalina/startup/Bootstrap.java 479–486행

setAwait(true)는 "다 켠 뒤 종료 명령이 올 때까지 기다려라"는 표시입니다(6절). load()가 Catalina.load()를(2절), start()가 Catalina.start()를 부릅니다(3·4절). 그 밖에 stop(종료 명령 보내기, 6절), configtest(load()까지만 하고 끝내기) 명령이 있습니다(469–500행). 전체 흐름을 그림으로 먼저 봅니다.

catalina.sh start에서 JVM 종료까지의 흐름. catalina.sh start가 bootstrap.jar와 tomcat-juli.jar만 클래스패스에 두고 Bootstrap start를 실행한다. Bootstrap.init이 common, server, shared 클래스로더를 만들고 리플렉션으로 Catalina 객체를 만든다. Catalina.load가 Digester로 server.xml을 읽어 객체 나무를 만들고 Server.init이 Service, Engine, Connector를 차례로 init한다. Catalina.start가 Server.start를 불러 Engine, Host, Context, 그다음 Connector를 시작하고, JVM 종료 훅을 등록한 뒤 StandardServer.await가 8005 포트에서 SHUTDOWN을 기다린다. 명령이 오면 Catalina.stop이 Server.stop과 Server.destroy를 부르고 main이 끝나 JVM이 종료된다. 회색은 스크립트와 명령줄, 파란 상자가 자바 코드입니다. 노란 상자에서 main 스레드가 서버가 사는 내내 멈춰 있습니다.

헷갈리기 쉬운 곳: "Tomcat started."는 기동이 끝났다는 뜻이 아닙니다

catalina.sh start가 찍는 Tomcat started.는 java를 뒤로 띄웠다는 뜻일 뿐입니다. 그 순간 자바는 아직 server.xml을 읽기도 전일 수 있습니다. 그래서 이 연재의 예제들은 catalina.sh start 다음에 curl이 성공할 때까지 되풀이하는 루프를 둡니다. 기동이 실패해도 스크립트는 Tomcat started.를 찍으므로, 실패는 catalina.out에서 확인해야 합니다. 앞에서 띄우고 싶으면 catalina.sh run을 씁니다. run도 같은 Bootstrap ... start를 실행하되 & 없이 띄웁니다(348–356행).

2. Catalina.load() — server.xml을 객체 나무로

Catalina.load()의 앞부분입니다.

    public void load() {

        if (loaded) {
            return;
        }
        // No load retry after a failure
        loaded = true;

        long t1 = System.nanoTime();

        // Before digester - it may be needed
        initNaming();

        // Parse main server.xml
        parseServerXml(true);
        Server s = getServer();
        if (s == null) {
            return;
        }

        getServer().setCatalina(this);
        getServer().setCatalinaHome(Bootstrap.getCatalinaHomeFile());
        getServer().setCatalinaBase(Bootstrap.getCatalinaBaseFile());

        // Stream redirection
        initStreams();

        // Start the new server
        try {
            getServer().init();

java/org/apache/catalina/startup/Catalina.java 820–849행

  1. loaded 표시로 한 번만 돕니다. 실패해도 다시 시도하지 않습니다.
  2. initNaming()이 JNDI(java:comp/env 같은 이름으로 자원을 찾는 자바 표준)를 위한 시스템 속성을 정합니다.
  3. parseServerXml(true) 가 server.xml을 읽어 Server 객체를 만듭니다. 이 절의 중심입니다.
  4. initStreams()가 System.out과 System.err를 Tomcat의 것으로 바꿔 끼웁니다(1028–1032행).
  5. getServer().init() 으로 부품들을 초기화합니다(3·4절). 끝나면 Server initialization in [...] milliseconds를 찍습니다(865–868행).

Digester의 규칙 — "이 경로가 나오면 이렇게 하라"

parseServerXml()은 createStartDigester()로 규칙을 채운 Digester를 만들고, Catalina 자신을 스택 맨 밑에 넣은 뒤 파일을 읽힙니다.

                Digester digester = start ? createStartDigester() : createStopDigester();
                InputStream inputStream = resource.getInputStream();
                InputSource inputSource = new InputSource(resource.getURI().toURL().toString());
                inputSource.setByteStream(inputStream);
                digester.push(this);

java/org/apache/catalina/startup/Catalina.java 721–725행

규칙은 이런 모양입니다. <Server>와 그 바로 아래 요소들에 대한 것만 옮깁니다.

        digester.addObjectCreate("Server", "org.apache.catalina.core.StandardServer", "className");
        digester.addSetProperties("Server");
        digester.addSetNext("Server", "setServer", "org.apache.catalina.Server");

        digester.addObjectCreate("Server/GlobalNamingResources", "org.apache.catalina.deploy.NamingResourcesImpl");
        digester.addSetProperties("Server/GlobalNamingResources");
        digester.addSetNext("Server/GlobalNamingResources", "setGlobalNamingResources",
                "org.apache.catalina.deploy.NamingResourcesImpl");

        digester.addRule("Server/Listener", new ListenerCreateRule(null, "className"));
        digester.addSetProperties("Server/Listener");
        digester.addSetNext("Server/Listener", "addLifecycleListener", "org.apache.catalina.LifecycleListener");

        digester.addObjectCreate("Server/Service", "org.apache.catalina.core.StandardService", "className");
        digester.addSetProperties("Server/Service");
        digester.addSetNext("Server/Service", "addService", "org.apache.catalina.Service");

java/org/apache/catalina/startup/Catalina.java 513–528행

요소 경로("Server/Service")마다 규칙 세 개가 짝을 이룹니다.

규칙 언제 하는 일
ObjectCreate 시작 태그 클래스를 불러 new하고 스택에 넣는다. className 속성이 있으면 그 클래스로 바꾼다 (ObjectCreateRule.java 87–107행)
SetProperties 시작 태그 속성마다 같은 이름의 setter를 부른다. port="8005" → setPort(8005) (SetPropertiesRule.java 83–135행)
SetNext 끝 태그 스택의 바로 아래 객체(부모)의 메서드에 맨 위 객체(자식)를 넘긴다. server.addService(service) (SetNextRule.java 72–96행)

끝 태그에서는 규칙을 등록한 반대 순서로 부릅니다(Digester.java 1110–1127행). 그래서 SetNext로 부모에 붙인 다음에 ObjectCreate의 end()가 스택에서 객체를 뺍니다. <Server>의 부모는 스택 맨 밑에 넣어 둔 Catalina이므로, 마지막 </Server>에서 catalina.setServer(server)가 불려 객체 나무 전체가 Catalina에 걸립니다. <Engine>, <Host>, <Context> 규칙은 EngineRuleSet, HostRuleSet, ContextRuleSet으로 묶여 있고(589–594행), <Host> 규칙에는 웹앱 배포를 맡는 HostConfig를 리스너로 붙이는 규칙도 들어 있습니다(HostRuleSet.java 64–65행).

Digester가 server.xml을 읽는 순서. 시작하기 전 스택에는 Catalina가 있다. Server 시작 태그에서 StandardServer를 만들어 스택에 넣고 setPort, setShutdown 같은 setter를 부른다. Service 시작 태그에서 StandardService를 만들어 넣는다. Connector 태그에서 Connector를 만들고 service.addConnector를 부른다. Engine과 Host 태그에서 StandardEngine과 StandardHost를 만들고 HostConfig 리스너도 붙인다. Service 끝 태그에서 server.addService를 부르고 스택에서 뺀다. Server 끝 태그에서 catalina.setServer를 부른다. 이제 Catalina.server에 객체 나무가 걸리지만 아직 아무것도 init되지 않아 상태는 NEW다. 부모에 붙는 일(SetNext)은 끝 태그에서 일어납니다. 이 단계가 끝나도 모든 부품은 아직 NEW 상태입니다.

헷갈리기 쉬운 곳: 객체를 만드는 것과 켜는 것은 따로입니다

Digester가 하는 일은 new와 setter와 addXxx() 호출뿐입니다. 포트를 여는 일도, 웹앱을 읽는 일도 아직 하지 않습니다. 예를 들어 </Host>에서 engine.addChild(host)가 불리는데, ContainerBase.addChild()는 부모가 이미 켜져 있을 때만 자식을 start()합니다(ContainerBase.java 581–584행). 지금 Engine은 NEW이므로 그냥 붙이기만 합니다. 켜는 일은 다음 절의 init()과 start()가 따로 합니다.

예제 2: server.xml을 틀리게 고쳐 보기

server.xml에 흔한 실수 세 가지를 넣고 catalina.sh configtest를 돌렸습니다. configtest는 Bootstrap이 load()까지만 하고 끝내는 명령이라(490–496행) 설정 검사에 알맞습니다. 이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. ex/u03-bad-server-xml/run.sh의 앞부분입니다.

# 이 글을 위해 쓴 예제입니다: server.xml 을 일부러 틀리게 고쳐 Tomcat 이 어디서 무엇을 말하는지 본다
# ... fresh(): 깨끗한 CATALINA_BASE 에 기본 conf/ 를 복사, show(): 로그에서 볼 만한 줄만 ...
echo "===== 1) typo in an attribute: connectionTimout (catalina.sh configtest) ====="
fresh
sed -i 's/connectionTimeout="20000"/connectionTimout="20000"/' /tmp/base/conf/server.xml
catalina.sh configtest > /tmp/base/logs/catalina.out 2>&1; echo "exit code: $?"
show 4
# ... 2) <Connector 앞에 <Conector port="8081" /> 를 끼워 넣기, 3) </Host> 를 지우기 — 같은 모양 ...

[예제 ex/u03-bad-server-xml/run.sh에서 줄임]

===== 1) typo in an attribute: connectionTimout (catalina.sh configtest) =====
exit code: 0
WARNING: Match [Server/Service/Connector] failed to set property [connectionTimout] to [20000]
INFO: Server initialization in [836] milliseconds
===== 2) typo in an element: <Conector> (catalina.sh configtest) =====
exit code: 0
WARNING: No rules found matching [Server/Service/Conector]
INFO: Server initialization in [984] milliseconds
===== 3) broken XML: </Host> removed (catalina.sh configtest) =====
exit code: 1
SEVERE: Parse fatal error at line [152] column [7]
WARNING: Unable to load server configuration from [/tmp/base/conf/server.xml]

밀리초 값은 실행마다 다릅니다.

경고에 쓰인 두 가지 말(Match [...] failed to set property, No rules found matching)을 기억해 두면 좋습니다. 둘 다 경고만 하고 기동은 계속됩니다. createStartDigester()가 켜 두는 검사(setRulesValidation(true), 495행)도 경고를 남기는 데까지만 하므로, server.xml을 고친 뒤에는 기동 로그의 WARNING 줄을 직접 확인해야 합니다.

3. Lifecycle — 모든 부품이 따르는 상태 기계

getServer().init()을 따라 들어가기 전에, 그 init()이 어떻게 생겼는지 봅니다. 상태는 LifecycleState 열거형에 열두 개 있습니다.

상태 사용 가능 (available) 이 상태가 될 때 나가는 이벤트
NEW 아니요 없음
INITIALIZING 아니요 before_init
INITIALIZED 아니요 after_init
STARTING_PREP 아니요 before_start
STARTING 예 start
STARTED 예 after_start
STOPPING_PREP 예 before_stop
STOPPING 아니요 stop
STOPPED 아니요 after_stop
DESTROYING 아니요 before_destroy
DESTROYED 아니요 after_destroy
FAILED 아니요 없음

java/org/apache/catalina/LifecycleState.java 23–71행

상태마다 두 값을 가집니다. 첫째는 available — 이 상태에서 부품을 써도 되는가 — 이고 STARTING, STARTED, STOPPING_PREP 세 개만 true입니다. 둘째는 이 상태로 바뀔 때 내보낼 이벤트 이름입니다. 상태가 STARTING_PREP이 되면 before_start, STARTING이 되면 start 이벤트가 나갑니다. NEW와 FAILED는 이벤트가 없습니다.

Lifecycle 상태 전이. NEW에서 init()을 부르면 INITIALIZING(before_init)을 거쳐 INITIALIZED(after_init)가 된다. start()를 부르면 STARTING_PREP(before_start), startInternal() 안에서 STARTING(start), 그리고 STARTED(after_start)가 된다. stop()을 부르면 STOPPING_PREP(before_stop), stopInternal() 안에서 STOPPING(stop), STOPPED(after_stop)가 된다. STOPPED에서 start()를 다시 부를 수 있고, destroy()를 부르면 DESTROYING(before_destroy)을 거쳐 DESTROYED(after_destroy)가 된다. xxxInternal()이 예외를 던지면 FAILED가 되고, stop()으로 정리될 때 STOPPING으로 간다. 상자 아래 줄이 그 상태가 될 때 나가는 이벤트입니다. Lifecycle.java 26–76행의 주석에 더 자세한 그림이 글자로 그려져 있습니다.

LifecycleBase — 규칙은 부모 클래스가, 일은 자식 클래스가

이 규칙을 부품마다 구현하지 않도록 LifecycleBase라는 추상 클래스가 네 메서드를 final로 구현해 두었습니다. start()의 가운데 토막입니다.

        if (state.equals(LifecycleState.NEW)) {
            init();
        } else if (state.equals(LifecycleState.FAILED)) {
            stop();
        } else if (!state.equals(LifecycleState.INITIALIZED) && !state.equals(LifecycleState.STOPPED)) {
            invalidTransition(BEFORE_START_EVENT);
        }

        try {
            setStateInternal(LifecycleState.STARTING_PREP, null, false);
            startInternal();
            if (state.equals(LifecycleState.FAILED)) {
                // This is a 'controlled' failure. The component put itself into the
                // FAILED state so call stop() to complete the clean-up.
                stop();
            } else if (!state.equals(LifecycleState.STARTING)) {
                // Shouldn't be necessary but acts as a check that subclasses are
                // doing what they are supposed to.
                invalidTransition(AFTER_START_EVENT);
            } else {
                setStateInternal(LifecycleState.STARTED, null, false);
            }

java/org/apache/catalina/util/LifecycleBase.java 160–181행

이 메서드는 템플릿 메서드 패턴으로 되어 있습니다. 순서와 검사는 LifecycleBase.start()가 정하고, 그 사이의 실제 일은 하위 클래스가 startInternal()로 채웁니다. init()은 initInternal(), stop()은 stopInternal(), destroy()는 destroyInternal()과 짝입니다. StandardServer, StandardService, Connector, ContainerBase는 모두 LifecycleBase를 상속하고(그 사이에 JMX 등록을 맡는 LifecycleMBeanBase가 있습니다) xxxInternal()만 구현합니다.

읽을 것이 네 가지 있습니다.

  1. NEW에서 start()를 부르면 init()부터 합니다(160–161행). 4절에서 이것이 중요해집니다.
  2. 이미 켜진 부품(STARTING_PREP, STARTING, STARTED)에 start()를 또 부르면 INFO 로그만 남기고 돌아갑니다(147–158행).
  3. 상태를 STARTING으로 바꾸는 것은 하위 클래스의 의무입니다. startInternal()이 끝났는데 STARTING이 아니면 잘못된 전이로 봅니다(175–178행).
  4. 실패는 두 가지입니다. startInternal()이 예외를 던지면 handleSubClassException()이 상태를 FAILED로 바꾸고 예외를 다시 던집니다(182–186행, 406–417행). 하위 클래스가 스스로 FAILED로 바꾸고 조용히 돌아오면 stop()으로 정리만 하고 예외는 없습니다(171–174행).

이벤트를 내보내는 곳은 setStateInternal()의 끝, 단 세 줄입니다.

        this.state = state;
        String lifecycleEvent = state.getLifecycleEvent();
        if (lifecycleEvent != null) {
            fireLifecycleEvent(lifecycleEvent, data);
        }

java/org/apache/catalina/util/LifecycleBase.java 393–397행

상태를 먼저 바꾸고 이벤트를 보냅니다. 그래서 리스너가 이벤트 안에서 getState()를 보면 이미 새 상태입니다. fireLifecycleEvent()는 등록된 리스너 목록을 차례로 돌며 lifecycleEvent()를 부릅니다(112–117행). 리스너 목록은 CopyOnWriteArrayList입니다(52행).

예제 3: LifecycleBase 하나로 상태 기계 건드려 보기

서버를 띄우지 않고 LifecycleBase를 직접 상속한 작은 부품을 만들어, 규칙을 하나씩 건드려 봤습니다. catalina.jar의 클래스 하나만 씁니다.

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... import 생략 ...
    /** xxxInternal() 이 불릴 때와 이벤트가 올 때마다 한 줄씩 찍는 부품 */
    static class Part extends LifecycleBase {
        // ... 필드와 생성자: 리스너 하나를 걸어 "event <이름> (state=<상태>)" 를 찍는다 ...

        @Override
        protected void startInternal() throws LifecycleException {
            System.out.println("    startInternal()");
            if ("throw".equals(failMode)) {
                throw new LifecycleException("boom in startInternal");
            }
            if ("setFailed".equals(failMode)) {
                setState(LifecycleState.FAILED); // 스스로 실패를 알린다 ("controlled" failure)
                return;
            }
            setState(LifecycleState.STARTING); // 하위 클래스가 해야 하는 일 — 이것이 start 이벤트를 낸다
        }

        @Override
        protected void stopInternal() throws LifecycleException {
            System.out.println("    stopInternal()");
            setState(LifecycleState.STOPPING);
        }
        // ... initInternal(), destroyInternal() 은 이름만 찍는다 ...
    }

[예제 ex/u03-state-machine/StateMachine.java에서 줄임]

main()은 부품 네 개(a~d)로 아래 출력의 -- 줄에 적힌 메서드를 차례로 부르고, 예외가 나면 !! 줄로, 끝난 뒤의 상태는 => 줄로 찍습니다. java -cp "$CATALINA_HOME/lib/*:$CATALINA_HOME/bin/*" StateMachine.java로 실행했습니다(JDK 21의 소스 파일 바로 실행). [log]로 시작하는 줄은 Tomcat이 남긴 로그입니다.

===== A: start() on a NEW part, then stop() and destroy() =====
-- a.start()
    event before_init (state=INITIALIZING)
    initInternal()
    event after_init (state=INITIALIZED)
    event before_start (state=STARTING_PREP)
    startInternal()
    event start (state=STARTING)
    event after_start (state=STARTED)
    => state STARTED
-- a.start() again
    [log] INFO: The start() method was called on component [Part[a]] after start() had already been called. The second call will be ignored.
    => state STARTED
-- a.stop()
    event before_stop (state=STOPPING_PREP)
    stopInternal()
    event stop (state=STOPPING)
    event after_stop (state=STOPPED)
    => state STOPPED
-- a.destroy()
    event before_destroy (state=DESTROYING)
    destroyInternal()
    event after_destroy (state=DESTROYED)
    => state DESTROYED
===== B: invalid transition =====
-- b.init()
    event before_init (state=INITIALIZING)
    initInternal()
    event after_init (state=INITIALIZED)
    => state INITIALIZED
-- b.init() again
    !! LifecycleException: An invalid Lifecycle transition was attempted ([before_init]) for component [Part[b]] in state [INITIALIZED]
    => state INITIALIZED
-- b.stop() while INITIALIZED
    => state INITIALIZED
===== C: startInternal() throws =====
-- c.start()
    event before_init (state=INITIALIZING)
    initInternal()
    event after_init (state=INITIALIZED)
    event before_start (state=STARTING_PREP)
    startInternal()
    !! LifecycleException: boom in startInternal
    => state FAILED
-- c.destroy()
    event before_stop (state=FAILED)
    stopInternal()
    event stop (state=STOPPING)
    event after_stop (state=STOPPED)
    event before_destroy (state=DESTROYING)
    destroyInternal()
    event after_destroy (state=DESTROYED)
    => state DESTROYED
===== D: startInternal() sets FAILED itself =====
-- d.start()
    event before_init (state=INITIALIZING)
    initInternal()
    event after_init (state=INITIALIZED)
    event before_start (state=STARTING_PREP)
    startInternal()
    event before_stop (state=FAILED)
    stopInternal()
    event stop (state=STOPPING)
    event after_stop (state=STOPPED)
    => state STOPPED

헷갈리기 쉬운 곳: start 이벤트를 내는 것은 LifecycleBase가 아닙니다

before_start와 after_start는 LifecycleBase.start()가 직접 내지만, 가운데의 start 이벤트는 하위 클래스가 startInternal() 안에서 setState(LifecycleState.STARTING)을 부를 때 나갑니다. 어디서 부르느냐는 클래스마다 다릅니다. 이것이 다음 절에서 부모와 자식의 이벤트 순서를 가릅니다.

4. 부모가 자식을 시작시키는 순서

이제 Server.init()과 Server.start()를 따라갑니다. 규칙은 단순합니다. 부모의 xxxInternal()이 자식의 xxx()를 부릅니다. 서버가 서비스를, 서비스가 엔진과 커넥터를, 엔진이 호스트를 부르는 식으로 나무를 타고 내려갑니다.

StandardServer.startInternal()의 핵심입니다.

    protected void startInternal() throws LifecycleException {

        fireLifecycleEvent(CONFIGURE_START_EVENT, null);
        setState(LifecycleState.STARTING);
        // ...
        globalNamingResources.start();

        // Start our defined Services
        for (Service service : findServices()) {
            service.start();
        }
        // ...
    }

java/org/apache/catalina/core/StandardServer.java 863–885행

StandardService.startInternal()은 순서가 눈에 띕니다.

    protected void startInternal() throws LifecycleException {

        if (log.isInfoEnabled()) {
            log.info(sm.getString("standardService.start.name", this.name));
        }
        setState(LifecycleState.STARTING);

        // Start our defined Container first
        if (engine != null) {
            engine.start();
        }

        for (Executor executor : findExecutors()) {
            executor.start();
        }

        mapperListener.start();

        // Start our defined Connectors second
        for (Connector connector : findConnectors()) {
            // If it has already failed, don't try and start it
            if (connector.getState() != LifecycleState.FAILED) {
                connector.start();
            }
        }
    }

java/org/apache/catalina/core/StandardService.java 424–449행

주석 그대로 컨테이너(Engine)가 먼저, 커넥터가 나중입니다. 요청을 받는 입구(커넥터)는 요청을 처리할 쪽(엔진과 그 아래 웹앱)이 다 준비된 뒤에 엽니다. 반대였다면 웹앱이 배포되는 동안 들어온 요청이 404를 받았을 것입니다. 끌 때는 거꾸로, 커넥터를 먼저 멈춰(pause()) 새 요청을 막은 뒤 엔진을 끕니다(459–512행). 가운데의 mapperListener는 요청 주소를 어느 웹앱으로 보낼지 정하는 지도를 채우는 부품으로 7편에서 봅니다.

Engine, Host, Context, Wrapper가 함께 쓰는 ContainerBase.startInternal()은 다릅니다.

        // Start our child containers, if any
        Container[] children = findChildren();
        List<Future<Void>> results = new ArrayList<>(children.length);
        for (Container child : children) {
            results.add(startStopExecutor.submit(new StartChild(child)));
        }
        // ... 모든 Future 의 결과를 기다리고, 실패가 있으면 모아서 LifecycleException ...

        // Start the Valves in our pipeline (including the basic), if any
        if (pipeline instanceof Lifecycle) {
            ((Lifecycle) pipeline).start();
        }

        setState(LifecycleState.STARTING);

java/org/apache/catalina/core/ContainerBase.java 728–759행

두 가지가 StandardService와 다릅니다.

  1. 자식을 실행기(executor)에 맡겨 시작시킵니다. 자식 하나를 시작하는 일은 StartChild라는 작은 작업이고(1200–1207행), 실행기는 startStopThreads 값에 따라 정해집니다. 기본값은 1이고(244행), 1이면 넘겨받은 작업을 그 자리에서 같은 스레드로 돌리는 가짜 실행기 InlineExecutorService를 씁니다(689–701행). <Host startStopThreads="4">처럼 늘리면 서버의 유틸리티 스레드 풀로 여러 웹앱을 동시에 시작합니다.
  2. 자식을 다 시작한 뒤에 setState(STARTING)을 부릅니다. 서버와 서비스는 자기 start 이벤트를 먼저 내고 자식을 부르는데, 컨테이너는 자식이 다 뜬 뒤에 자기 start 이벤트를 냅니다.

start 순서. Server.start와 Service.start는 먼저 STARTING이 되어 start 이벤트를 낸다. 그다음 Service가 ① Engine.start를 부르고, Engine은 Host.start를 부르는데 Host는 아직 NEW이므로 init부터 한다. Host가 STARTING이 되는 순간 HostConfig가 webapps를 배포해 Context.start가 불린다. 자식이 다 뜬 뒤에야 Engine이 STARTING이 되어 start 이벤트를 낸다. 그 뒤 ② Executor와 MapperListener, 마지막으로 ③ Connector.start가 불려 이제야 8080에서 요청을 받는다. 원 숫자는 StandardService.startInternal() 안의 순서입니다. 노란 상자에서 웹앱 배포가 일어납니다.

예제 1: 모든 요소에 리스너를 걸고 이벤트 순서 찍기

정말 이 순서인지, server.xml의 요소마다 같은 리스너를 걸어 이벤트를 받은 순서대로 찍었습니다.

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
package lab;
// ... import 생략 ...

/**
 * server.xml 의 여러 요소에 <Listener className="lab.EventLogger"/> 로 걸어 두고,
 * 받은 Lifecycle 이벤트를 받은 순서대로 한 줄씩 찍는다.
 */
public class EventLogger implements LifecycleListener {

    // 모든 리스너 인스턴스가 같이 쓰는 일련번호 — 요소가 달라도 전체 순서를 볼 수 있게
    private static final AtomicInteger SEQ = new AtomicInteger();

    @Override
    public void lifecycleEvent(LifecycleEvent event) {
        // periodic 이벤트는 Server 가 10초마다 보내므로 찍지 않는다
        if (Lifecycle.PERIODIC_EVENT.equals(event.getType())) {
            return;
        }
        Lifecycle source = event.getLifecycle();
        System.out.printf("EVT %02d %-22s %-15s state=%-13s thread=%s%n",
                SEQ.incrementAndGet(), name(source), event.getType(),
                source.getState(), Thread.currentThread().getName());

        // Host 가 init 되는 순간의 호출 스택을 한 번 찍는다 — 누가 Host 를 init 시키나
        if (source instanceof org.apache.catalina.Host && Lifecycle.BEFORE_INIT_EVENT.equals(event.getType())) {
            new Throwable("STACK at Host before_init").printStackTrace(System.out);
        }
    }
    // ... name(): "Server", "Service[Catalina]", "Connector[8080]", "Host[localhost]" 처럼 짧게 ...
}

[예제 ex/u03-lifecycle-events/EventLogger.java에서 줄임]

server.xml은 기본 파일 대신 리스너만 건 작은 것을 썼습니다(그래서 기본 파일의 VersionLoggerListener 같은 리스너는 없습니다).

<Server port="8005" shutdown="SHUTDOWN">
  <Listener className="lab.EventLogger" />
  <Service name="Catalina">
    <Listener className="lab.EventLogger" />
    <Connector port="8080" protocol="HTTP/1.1">
      <Listener className="lab.EventLogger" />
    </Connector>
    <Engine name="Catalina" defaultHost="localhost">
      <Listener className="lab.EventLogger" />
      <Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="false">
        <Listener className="lab.EventLogger" />
      </Host>
    </Engine>
  </Service>
</Server>

[예제 ex/u03-lifecycle-events/server.xml에서 첫 두 줄(XML 선언과 주석)을 뺌]

컴파일한 클래스는 $CATALINA_BASE/lib/lab/EventLogger.class에 두었습니다. 1절에서 본 common 로더가 ${catalina.base}/lib 폴더를 보므로 Digester가 이 클래스를 찾을 수 있습니다. 웹앱(Context)은 server.xml에 적지 않고 webapps/app/index.html 하나로 자동 배포되게 했고, 모든 웹앱에 적용되는 conf/context.xml에도 같은 리스너를 넣었습니다. catalina.sh start로 띄우고 curl로 hello가 돌아오는 것을 확인한 다음 catalina.out에서 EVT 줄을 골랐습니다. 이 예제도 같은 도커 이미지에서 실행했습니다. 먼저 init 단계입니다.

EVT 01 Server                 before_init     state=INITIALIZING  thread=main
EVT 02 Service[Catalina]      before_init     state=INITIALIZING  thread=main
EVT 03 Engine[Catalina]       before_init     state=INITIALIZING  thread=main
EVT 04 Engine[Catalina]       after_init      state=INITIALIZED   thread=main
EVT 05 Connector[8080]        before_init     state=INITIALIZING  thread=main
EVT 06 Connector[8080]        after_init      state=INITIALIZED   thread=main
EVT 07 Service[Catalina]      after_init      state=INITIALIZED   thread=main
EVT 08 Server                 after_init      state=INITIALIZED   thread=main
24-Oct-2026 13:47:28.233 INFO [main] org.apache.catalina.startup.Catalina.load Server initialization in [1980] milliseconds

init은 Server → Service → Engine → Connector까지만 내려갔습니다. Host가 없습니다. StandardService.initInternal()은 엔진과 커넥터를 init()하지만(522–545행), 엔진의 initInternal()은 렐름(Realm, 사용자 인증 정보를 담는 부품)을 준비하고 부모 클래스의 initInternal()(JMX 등록)을 부를 뿐 자식을 init()하지 않기 때문입니다(StandardEngine.java 184–189행). ContainerBase에는 initInternal()이 따로 없습니다. 커넥터가 init 단계에 포함된다는 것은 포트가 이 단계에서 열린다는 뜻이기도 합니다(5절). 이어서 start 단계입니다.

EVT 09 Server                 before_start    state=STARTING_PREP thread=main
EVT 10 Server                 configure_start state=STARTING_PREP thread=main
EVT 11 Server                 start           state=STARTING      thread=main
EVT 12 Service[Catalina]      before_start    state=STARTING_PREP thread=main
24-Oct-2026 13:47:28.263 INFO [main] org.apache.catalina.core.StandardService.startInternal Starting service [Catalina]
EVT 13 Service[Catalina]      start           state=STARTING      thread=main
EVT 14 Engine[Catalina]       before_start    state=STARTING_PREP thread=main
24-Oct-2026 13:47:28.265 INFO [main] org.apache.catalina.core.StandardEngine.startInternal Starting Servlet engine: [Apache Tomcat/12.0.0-M1-dev]
EVT 15 Host[localhost]        before_init     state=INITIALIZING  thread=main
java.lang.Throwable: STACK at Host before_init
	at lab.EventLogger.lifecycleEvent(EventLogger.java:36)
	at org.apache.catalina.util.LifecycleBase.fireLifecycleEvent(LifecycleBase.java:115)
	at org.apache.catalina.util.LifecycleBase.setStateInternal(LifecycleBase.java:396)
	at org.apache.catalina.util.LifecycleBase.init(LifecycleBase.java:127)
	at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:161)
	at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1204)
	at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1200)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
	at org.apache.tomcat.util.threads.InlineExecutorService.execute(InlineExecutorService.java:95)
	at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:145)
	at org.apache.catalina.core.ContainerBase.startInternal(ContainerBase.java:732)
	at org.apache.catalina.core.StandardEngine.startInternal(StandardEngine.java:201)
	at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:170)
	at org.apache.catalina.core.StandardService.startInternal(StandardService.java:433)
	at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:170)
	at org.apache.catalina.core.StandardServer.startInternal(StandardServer.java:878)
	at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:170)
	at org.apache.catalina.startup.Catalina.start(Catalina.java:921)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at org.apache.catalina.startup.Bootstrap.start(Bootstrap.java:345)
	at org.apache.catalina.startup.Bootstrap.main(Bootstrap.java:482)
EVT 16 Host[localhost]        after_init      state=INITIALIZED   thread=main
EVT 17 Host[localhost]        before_start    state=STARTING_PREP thread=main
24-Oct-2026 13:47:28.315 INFO [main] org.apache.catalina.startup.HostConfig.deployDirectory Deploying web application directory [/tmp/base/webapps/app]
EVT 18 Context[/app]          before_start    state=STARTING_PREP thread=main
EVT 19 Context[/app]          configure_start state=STARTING_PREP thread=main
EVT 20 Context[/app]          start           state=STARTING      thread=main
EVT 21 Context[/app]          after_start     state=STARTED       thread=main
EVT 22 Host[localhost]        start           state=STARTING      thread=main
EVT 23 Host[localhost]        after_start     state=STARTED       thread=main
EVT 24 Engine[Catalina]       start           state=STARTING      thread=main
EVT 25 Engine[Catalina]       after_start     state=STARTED       thread=main
EVT 26 Connector[8080]        before_start    state=STARTING_PREP thread=main
EVT 27 Connector[8080]        start           state=STARTING      thread=main
EVT 28 Connector[8080]        after_start     state=STARTED       thread=main
EVT 29 Service[Catalina]      after_start     state=STARTED       thread=main
EVT 30 Server                 after_start     state=STARTED       thread=main
24-Oct-2026 13:47:29.267 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [1031] milliseconds

시각과 밀리초는 실행마다 다릅니다. 읽을 것이 다섯 가지 있습니다.

헷갈리기 쉬운 곳: context.xml에 건 리스너는 init 이벤트를 못 받습니다

출력에서 Context[/app]의 첫 이벤트는 before_start(18번)입니다. before_init, after_init이 없습니다. 이 리스너는 server.xml이 아니라 conf/context.xml에 걸었는데, context.xml을 읽는 것은 ContextConfig이고, ContextConfig는 Context의 after_init 이벤트를 받았을 때 그 파일을 읽습니다(ContextConfig.java 310행, 997–1013행). 리스너가 붙는 순간이 이미 after_init을 나눠 주는 도중이라, 그 이벤트는 놓칩니다(리스너 목록이 CopyOnWriteArrayList라서 돌고 있던 목록에는 새 리스너가 들어가지 않습니다). 웹앱의 init 시점에 무언가 해야 한다면 context.xml의 <Listener>로는 안 된다는 뜻입니다.

5. 실패하면 — 커넥터는 실패해도 서버는 뜹니다

3절의 예제 C처럼 xxxInternal()이 예외를 던지면 LifecycleBase는 그 예외를 다시 던져 부모로 올려 보냅니다. 그런데 throwOnFailure라는 설정이 false이면 다시 던지지 않고 로그만 남깁니다(406–417행). 기본값은 true(61행)인데, Connector만 생성자에서 이 값을 시스템 속성으로 정합니다.

        // Default for Connector depends on this system property
        setThrowOnFailure(Boolean.getBoolean("org.apache.catalina.startup.EXIT_ON_INIT_FAILURE"));

java/org/apache/catalina/connector/Connector.java 118–119행

속성을 주지 않으면 false입니다. 커넥터 하나가 init에 실패해도 예외가 서비스까지 올라가지 않고, 커넥터만 FAILED로 남습니다. 그리고 4절에서 본 StandardService.startInternal()은 FAILED인 커넥터를 건너뜁니다(445행).

예제 2(이어서): 같은 포트에 커넥터 두 개

예제 2의 뒷부분에서 server.xml에 8080 커넥터를 하나 더 넣어 포트가 겹치게 했습니다. 한 번은 그냥, 한 번은 CATALINA_OPTS="-Dorg.apache.catalina.startup.EXIT_ON_INIT_FAILURE=true"를 주고 띄웠습니다.

===== 4) two Connectors on port 8080 (catalina.sh start) =====
curl http://127.0.0.1:8080/ -> HTTP 404
SEVERE [main] org.apache.catalina.util.LifecycleBase.handleSubClassException Failed to initialize component [Connector["http-nio-8080"]]
	org.apache.catalina.LifecycleException: Protocol handler initialization failed
	Caused by: java.net.BindException: Address already in use
INFO [main] org.apache.catalina.startup.Catalina.load Server initialization in [1789] milliseconds
INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [138] milliseconds
===== 5) the same, with -Dorg.apache.catalina.startup.EXIT_ON_INIT_FAILURE=true (catalina.sh run) =====
exit code: 1
SEVERE [main] org.apache.catalina.startup.Bootstrap.main Error running command
	java.lang.Error: org.apache.catalina.LifecycleException: Protocol handler initialization failed
	Caused by: org.apache.catalina.LifecycleException: Protocol handler initialization failed
	Caused by: java.net.BindException: Address already in use
            if (throwOnInitFailure || await) {
                throw new Error(e);
            } else {
                log.error(sm.getString("catalina.initError"), e);
            }

java/org/apache/catalina/startup/Catalina.java 858–862행

바로 위 주석(851–857행)이 이유를 적어 두었습니다. 서버가 FAILED인 채로 start()를 부르면 LifecycleBase.start()가 먼저 stop()을 부르고, 그러면 await()가 곧바로 돌아와 켜자마자 꺼지게 되므로, 기다릴 생각(await)이면 차라리 여기서 끝내는 편이 낫다는 것입니다.

커넥터 init 실패가 어디까지 올라가는지. 두 번째 Connector의 init()이 8080을 열다가 BindException을 만나면 LifecycleBase.handleSubClassException()이 커넥터를 FAILED로 바꾸고, 커넥터의 throwOnFailure 값(EXIT_ON_INIT_FAILURE 시스템 속성)을 본다. 예제 2의 (4)처럼 속성이 없어 false이면 SEVERE 로그만 남기고, StandardService.startInternal()은 FAILED 커넥터를 건너뛰며, Server startup in [138] milliseconds가 찍히고 curl에는 첫 번째 커넥터가 404로 대답한다. 예제 2의 (5)처럼 true이면 LifecycleException을 던지고, 기본값이 true인 Service와 Server가 다시 던져 올리며, Catalina.load()가 throwOnInitFailure 또는 await일 때 Error로 바꿔 던지고, Bootstrap.main()이 Error running command를 남기고 종료 코드 1로 끝난다. 같은 BindException이 설정 하나로 두 길로 갈립니다. 초록 길은 예제 2의 (4), 빨간 길은 (5)의 실제 출력입니다.

헷갈리기 쉬운 곳: "Server startup"이 찍혔다고 다 뜬 것은 아닙니다

(4)처럼 커넥터가 실패해도 Server startup in [...]은 찍힙니다. 운영에서 포트가 하나 안 열렸는데 서버는 떠 있는 상황이 이렇게 생깁니다. 실패를 기동 실패로 만들고 싶으면 EXIT_ON_INIT_FAILURE 시스템 속성을 켭니다. Spring 7편에서 본 refresh()는 정반대로 "빈이 전부 만들어지거나 하나도 없거나"를 원칙으로, 하나라도 실패하면 만든 것을 모두 치우고 예외를 던졌습니다. Tomcat은 부품마다 실패를 어디까지 올려 보낼지를 throwOnFailure로 고를 수 있게 했고, 커넥터에는 "일단 뜨는 쪽"을 기본으로 골랐습니다.

6. await()와 종료 포트 8005

Catalina.start()의 끝부분입니다.

        // Register shutdown hook
        if (useShutdownHook) {
            if (shutdownHook == null) {
                shutdownHook = new CatalinaShutdownHook();
            }
            Runtime.getRuntime().addShutdownHook(shutdownHook);
            // ... JULI 로깅의 종료 훅을 끈다 (서버보다 로그가 먼저 닫히지 않게) ...
        }

        if (await) {
            await();
            stop();
        }
    }

java/org/apache/catalina/startup/Catalina.java 942–962행

await()가 돌아오면 바로 stop()입니다. 다시 말해 main 스레드는 서버가 사는 내내 await() 안에 있습니다. 요청은 커넥터의 스레드들이 처리하고(5편), main은 종료 명령만 기다립니다. await()는 StandardServer.await()를 부릅니다.

        // Set up a server socket to wait on
        try {
            awaitSocket = new ServerSocket(getPortWithOffset(), 1, InetAddress.getByName(address));

java/org/apache/catalina/core/StandardServer.java 535–537행

포트는 <Server port>(기본 8005, 119행), 주소는 <Server address>인데 기본값이 "localhost"입니다(126행). 종료 포트는 같은 기계에서만 접속할 수 있습니다. 그 뒤 루프를 돌며 접속을 하나 받고(561행), 제어 문자가 나올 때까지 최대 1024바이트를 읽어(580–602행) 설정한 문자열과 비교합니다.

                // Decode the received bytes as UTF-8, matching the encoding
                // used by the stop command
                String command = commandBytes.toString(StandardCharsets.UTF_8);

                // Match against our command string
                boolean match = command.equals(shutdown);
                if (match) {
                    log.info(sm.getString("standardServer.shutdownViaPort"));
                    break;
                } else {
                    log.warn(sm.getString("standardServer.invalidShutdownCommand", command));
                }

java/org/apache/catalina/core/StandardServer.java 614–625행

맞으면 루프를 빠져나와 await()가 돌아오고, 틀리면 경고만 남기고 다음 접속을 기다립니다.

보내는 쪽: catalina.sh stop

catalina.sh stop은 새 JVM을 하나 더 띄워 Bootstrap ... stop을 실행합니다(466–471행). 이 JVM의 Catalina.stopServer()는 server.xml을 다시 읽되 <Server> 요소의 규칙만 있는 Digester(createStopDigester(), 639–652행)를 써서 포트와 명령 문자열만 알아내고, 그 포트로 접속해 명령을 보냅니다.

            try (Socket socket = new Socket()) {
                socket.connect(new InetSocketAddress(s.getAddress(), s.getPortWithOffset()),
                        SHUTDOWN_CONNECT_TIMEOUT_MS);
                OutputStream stream = socket.getOutputStream();
                // Send the shutdown command as UTF-8 bytes. Writing the
                // characters one at a time with OutputStream.write(int) would
                // silently truncate any character above U+00FF and the server
                // would then never match the configured shutdown string.
                stream.write(s.getShutdown().getBytes(StandardCharsets.UTF_8));
                stream.flush();

java/org/apache/catalina/startup/Catalina.java 790–799행

kill -TERM은 다른 길로 옵니다

kill -TERM(또는 Ctrl+C)을 받으면 JVM은 등록된 종료 훅(shutdown hook, JVM이 끝나기 직전에 돌려 주는 스레드)을 실행합니다. CatalinaShutdownHook이 Catalina.stop()을 부르고(1155–1172행), 그 안의 StandardServer.stopInternal()이 맨 끝에서 stopAwait()로 8005 소켓을 닫습니다(936행, 489–509행). 그러면 main 스레드의 accept()가 예외로 깨어나 await()가 돌아오고, main이 부르는 stop()은 상태가 이미 STOPPING_PREP~DESTROYED 사이이므로 아무것도 하지 않습니다(Catalina.java 990–997행).

종료의 두 길. 왼쪽은 catalina.sh stop이다. 새 JVM이 Bootstrap stop을 실행하고 Catalina.stopServer가 server.xml의 Server 요소만 다시 읽어 localhost:8005로 SHUTDOWN 바이트를 보낸다. 기동한 JVM의 main 스레드에서 StandardServer.await가 명령을 비교해 틀리면 경고만 하고 다시 기다리고, 맞으면 await가 돌아와 Catalina.start의 다음 줄인 stop이 Server.stop과 destroy를 부른다. 오른쪽은 kill -TERM이다. JVM 종료 훅 스레드에서 CatalinaShutdownHook이 Catalina.stop을 부르고, StandardServer.stopInternal의 stopAwait가 8005 소켓을 닫아 await가 돌아온다. 오른쪽 길에서는 종료 훅 스레드가 이미 서버를 멈춘 뒤라, main의 stop()은 할 일이 없습니다.

예제 1(이어서): 틀린 명령, 맞는 명령, 그리고 kill

예제 1은 기동 뒤에 bash의 /dev/tcp로 8005에 HELLO를 보내 보고, catalina.sh stop으로 끄고, 다시 띄워 kill -TERM으로 끕니다.

# 3) 종료 포트에 틀린 명령을 보내 본다
echo "===== send 'HELLO' to port 8005 ====="
exec 3<>/dev/tcp/127.0.0.1/8005; printf 'HELLO' >&3; exec 3>&-
sleep 1

# 4) 정상 종료: catalina.sh stop 은 8005 로 SHUTDOWN 을 보낸다
catalina.sh stop >/dev/null 2>&1
# ... 5) 다시 띄우고 kill -TERM $(pgrep -f Bootstrap) ...

[예제 ex/u03-lifecycle-events/run.sh에서 줄임]

24-Oct-2026 13:47:29.471 WARNING [main] org.apache.catalina.core.StandardServer.await Invalid shutdown command [HELLO] received
24-Oct-2026 13:47:31.724 INFO [main] org.apache.catalina.core.StandardServer.await A valid shutdown command was received via the shutdown port. Stopping the Server instance.
EVT 31 Server                 before_stop     state=STOPPING_PREP thread=main
EVT 32 Server                 stop            state=STOPPING      thread=main
EVT 33 Server                 configure_stop  state=STOPPING      thread=main
EVT 34 Service[Catalina]      before_stop     state=STOPPING_PREP thread=main
24-Oct-2026 13:47:31.781 INFO [main] org.apache.catalina.core.StandardService.stopInternal Stopping service [Catalina]
EVT 35 Service[Catalina]      stop            state=STOPPING      thread=main
EVT 36 Engine[Catalina]       before_stop     state=STOPPING_PREP thread=main
EVT 37 Engine[Catalina]       stop            state=STOPPING      thread=main
EVT 38 Host[localhost]        before_stop     state=STOPPING_PREP thread=main
EVT 39 Host[localhost]        stop            state=STOPPING      thread=main
EVT 40 Context[/app]          before_stop     state=STOPPING_PREP thread=main
EVT 41 Context[/app]          stop            state=STOPPING      thread=main
EVT 42 Context[/app]          configure_stop  state=STOPPING      thread=main
EVT 43 Context[/app]          after_stop      state=STOPPED       thread=main
EVT 44 Host[localhost]        after_stop      state=STOPPED       thread=main
EVT 45 Engine[Catalina]       after_stop      state=STOPPED       thread=main
EVT 46 Connector[8080]        before_stop     state=STOPPING_PREP thread=main
EVT 47 Connector[8080]        stop            state=STOPPING      thread=main
EVT 48 Connector[8080]        after_stop      state=STOPPED       thread=main
EVT 49 Service[Catalina]      after_stop      state=STOPPED       thread=main
EVT 50 Server                 after_stop      state=STOPPED       thread=main
EVT 51 Server                 before_destroy  state=DESTROYING    thread=main
EVT 52 Service[Catalina]      before_destroy  state=DESTROYING    thread=main
EVT 53 Connector[8080]        before_destroy  state=DESTROYING    thread=main
EVT 54 Connector[8080]        after_destroy   state=DESTROYED     thread=main
EVT 55 Engine[Catalina]       before_destroy  state=DESTROYING    thread=main
EVT 56 Host[localhost]        before_destroy  state=DESTROYING    thread=main
EVT 57 Context[/app]          before_destroy  state=DESTROYING    thread=main
EVT 58 Context[/app]          after_destroy   state=DESTROYED     thread=main
EVT 59 Host[localhost]        after_destroy   state=DESTROYED     thread=main
EVT 60 Engine[Catalina]       after_destroy   state=DESTROYED     thread=main
EVT 61 Service[Catalina]      after_destroy   state=DESTROYED     thread=main
EVT 62 Server                 after_destroy   state=DESTROYED     thread=main
===== after kill -TERM: Server/Service events while stopping =====
EVT 31 Server                 before_stop     state=STOPPING_PREP thread=Thread-1
EVT 32 Server                 stop            state=STOPPING      thread=Thread-1
EVT 33 Server                 configure_stop  state=STOPPING      thread=Thread-1
EVT 34 Service[Catalina]      before_stop     state=STOPPING_PREP thread=Thread-1
EVT 35 Service[Catalina]      stop            state=STOPPING      thread=Thread-1
EVT 49 Service[Catalina]      after_stop      state=STOPPED       thread=Thread-1
EVT 50 Server                 after_stop      state=STOPPED       thread=Thread-1
EVT 51 Server                 before_destroy  state=DESTROYING    thread=Thread-1
EVT 52 Service[Catalina]      before_destroy  state=DESTROYING    thread=Thread-1
EVT 61 Service[Catalina]      after_destroy   state=DESTROYED     thread=Thread-1
EVT 62 Server                 after_destroy   state=DESTROYED     thread=Thread-1

시각은 실행마다 다릅니다. 마지막 묶음은 두 번째 실행에서 서버·서비스 줄만 고른 것입니다.

헷갈리기 쉬운 곳: 내장 Tomcat에는 종료 포트가 없습니다

port="-1"로 두면 await()는 소켓을 열지 않고 10초마다 깨어나 stopAwait 표시만 확인합니다(519–533행). Spring Boot가 쓰는 내장 Tomcat의 Tomcat.getServer()는 Server를 만들 때 이 값을 -1로 정해 둡니다(Tomcat.java 641행). 내장 Tomcat에서 server.xml도, Bootstrap도, 8005도 보이지 않는 이유입니다. 그래도 Server·Service·Connector·컨테이너는 같은 Lifecycle으로 켜지고 꺼집니다. 내장 API는 16편에서 봅니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
catalina.sh start bootstrap.jar와 tomcat-juli.jar만 클래스패스에 두고 Bootstrap ... start를 뒤에서 띄운다. Tomcat started.는 기동 완료가 아니다
Bootstrap common·server·shared 로더를 만들고, Catalina를 그 로더에서 불러 리플렉션으로 부른다
Catalina.load() 한 번만. Digester로 server.xml을 객체 나무로 만들고 Server.init()
Digester 경로마다 ObjectCreate(시작 태그) · SetProperties · SetNext(끝 태그). 모르는 속성·요소는 경고만
LifecycleState 열두 상태. 상태가 바뀌면 그 상태에 붙은 이벤트가 나간다
LifecycleBase 전이 규칙은 final 메서드가, 실제 일은 xxxInternal()이. NEW에서 start()하면 init()부터
시작 순서 Server → Service → Engine → Host → Context, 그리고 마지막에 Connector. Host는 부모의 start() 때 init된다
이벤트 순서 서버·서비스는 자식보다 먼저, 컨테이너는 자식보다 나중에 start 이벤트
웹앱 배포 Host의 start 이벤트에서 HostConfig가 한다
실패 throwOnFailure. 커넥터는 기본 false라 실패해도 서버는 뜬다. EXIT_ON_INIT_FAILURE=true면 종료
await() main 스레드가 localhost:8005에서 SHUTDOWN을 기다린다. 돌아오면 stop()·destroy()
종료의 두 길 catalina.sh stop은 소켓으로 명령, kill -TERM은 종료 훅이 stop()하고 소켓을 닫는다

다음에 볼 것

이번 글에서 Bootstrap이 common·server·shared 클래스로더를 만든다고만 하고 넘어갔습니다. Tomcat은 웹앱마다 클래스로더를 하나씩 더 만들고, 그 로더는 자바의 기본 규칙(부모에게 먼저 묻기)을 일부러 뒤집습니다. 두 웹앱이 같은 이름의 다른 클래스를 쓸 수 있는 이유, 웹앱을 다시 올릴 때 메모리가 새는 이유가 4편: 클래스로더 — 웹앱마다 따로 보는 클래스에서 이어집니다.

앞 연재와 비교하면, httpd는 5편에서 본 대로 main() 안에서 설정 파일을 두 번 읽고, 재시작도 "설정을 처음부터 다시 읽는 루프 한 바퀴"로 처리했습니다. Tomcat의 Catalina.load()는 server.xml을 한 번만 읽고, 그 뒤로는 객체 나무의 부품들이 Lifecycle 상태를 오가며 켜지고 꺼집니다. 끄라는 신호도 httpd는 유닉스 신호로, Tomcat은 8005 포트의 TCP 접속으로 받습니다. Spring의 refresh()가 기동 순서를 메서드 하나의 열두 단계로 적어 둔 것과 달리, Tomcat의 순서는 한곳에 적혀 있지 않고 부모의 startInternal()마다 흩어져 있습니다. 그래서 예제 1처럼 리스너를 걸어 찍어 보는 것이 순서를 확인하는 가장 빠른 길입니다. 상태가 바뀔 때 리스너에게 알리는 방식은 Spring이 빈 생명주기에 콜백과 후처리기를 끼우는 방식(Spring 6편)과 닮았습니다.

다른 글