이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 세 번째 글입니다. 2편에서는 server.xml의 <Server>, <Service>, <Connector>, <Engine>, <Host>, <Context>가 각각 어떤 클래스가 되고 서로 어떻게 부모·자식으로 엮이는지 봤습니다. 이번에는 그 객체들이 언제, 누구에 의해, 어떤 순서로 만들어지고 켜지고 꺼지는지를 봅니다. catalina.sh start를 치고 "Server startup in [...] milliseconds" 로그가 찍히기까지, 그리고 catalina.sh stop으로 프로세스가 끝나기까지입니다.
답하려는 질문은 네 가지입니다.
catalina.sh start다음에 처음 실행되는 자바 코드는 무엇이고, 왜Catalina를 리플렉션으로 부르는가server.xml은 어떻게 객체 나무가 되고, 오타가 있으면 어디서 무엇이 나오는가- 모든 부품이 따르는
Lifecycle상태 기계는 어떻게 생겼고, 부모는 자식을 어떤 순서로 시작시키는가 - 종료 포트 8005는 무엇을 기다리고,
catalina.sh stop과kill은 어떻게 다른가
| 항목 | 내용 |
|---|---|
| 대상 | 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 "&"
세 가지를 읽을 수 있습니다. 실행하는 클래스는 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행
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로더를 가리킵니다.catalinaLoader.loadClass(...)로Catalina클래스를 그 로더에서 불러와 객체를 만듭니다.- 그 객체의 메서드는 리플렉션(
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행). 전체 흐름을 그림으로 먼저 봅니다.
회색은 스크립트와 명령줄, 파란 상자가 자바 코드입니다. 노란 상자에서 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행
loaded표시로 한 번만 돕니다. 실패해도 다시 시도하지 않습니다.initNaming()이 JNDI(java:comp/env같은 이름으로 자원을 찾는 자바 표준)를 위한 시스템 속성을 정합니다.parseServerXml(true)가server.xml을 읽어Server객체를 만듭니다. 이 절의 중심입니다.initStreams()가System.out과System.err를 Tomcat의 것으로 바꿔 끼웁니다(1028–1032행).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행).
부모에 붙는 일(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]
밀리초 값은 실행마다 다릅니다.
- 속성 이름 오타(1)는 경고 한 줄로 끝나고 종료 코드는 0입니다.
SetPropertiesRule이 setter를 찾지 못하면 경고만 남기기 때문입니다(113–116행). 그Connector는connectionTimeout을 기본값으로 쓴 채 그대로 뜹니다. - 요소 이름 오타(2)도 경고 한 줄입니다. 그 경로에 맞는 규칙이 하나도 없으면 Digester는 경고만 남기고(
Digester.java1102–1104행) 요소를 통째로 무시합니다.<Conector port="8081">은 만들어지지 않았고 8081 포트는 열리지 않습니다. - XML 문법 오류(3)만 진짜 실패입니다.
</Host>를 지웠더니</Engine>이 있는 152행에서 파서가 멈췄습니다.parseServerXml()은 예외를 경고 로그로 바꾸고(740–745행)Server가 없는 채로 돌아가며,configtest는getServer()가null이므로 종료 코드 1로 끝납니다.start였다면Catalina.start()가Cannot start server, server instance is not configured를 남기고(912–915행)Bootstrap이System.exit(1)합니다.
경고에 쓰인 두 가지 말(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.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()만 구현합니다.
읽을 것이 네 가지 있습니다.
- NEW에서
start()를 부르면init()부터 합니다(160–161행). 4절에서 이것이 중요해집니다. - 이미 켜진 부품(
STARTING_PREP,STARTING,STARTED)에start()를 또 부르면 INFO 로그만 남기고 돌아갑니다(147–158행). - 상태를
STARTING으로 바꾸는 것은 하위 클래스의 의무입니다.startInternal()이 끝났는데STARTING이 아니면 잘못된 전이로 봅니다(175–178행). - 실패는 두 가지입니다.
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
- A: NEW에서
start()한 번으로init의 두 이벤트와start의 세 이벤트가 모두 나왔습니다.start이벤트는 내startInternal()이setState(STARTING)을 부른 순간에 나갔습니다. 두 번째start()는 로그 한 줄로 끝났습니다. - B:
init()을 두 번 부르면LifecycleException입니다(122–124행). 반면 INITIALIZED에서stop()은 아무 일도 하지 않습니다(218–220행) — 시작한 적 없는 부품은 멈출 것도 없기 때문입니다. - C:
startInternal()의 예외는 호출한 쪽으로 다시 던져지고 상태는FAILED로 남습니다.FAILED에서destroy()를 부르면 먼저stop()으로 정리합니다(274–282행). 이때는STOPPING_PREP을 건너뛰므로before_stop이벤트의 상태가FAILED로 찍혔습니다(232–236행의 주석이 "잠깐이라도 사용 가능으로 보이지 않게"라고 이유를 적어 두었습니다). - D: 스스로
FAILED가 되면 예외 없이stop()까지 불려STOPPED로 끝납니다. 이 차이가 5절의 "커넥터는 실패해도 서버는 뜬다"로 이어집니다.
헷갈리기 쉬운 곳: 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와 다릅니다.
- 자식을 실행기(executor)에 맡겨 시작시킵니다. 자식 하나를 시작하는 일은
StartChild라는 작은 작업이고(1200–1207행), 실행기는startStopThreads값에 따라 정해집니다. 기본값은 1이고(244행), 1이면 넘겨받은 작업을 그 자리에서 같은 스레드로 돌리는 가짜 실행기InlineExecutorService를 씁니다(689–701행).<Host startStopThreads="4">처럼 늘리면 서버의 유틸리티 스레드 풀로 여러 웹앱을 동시에 시작합니다. - 자식을 다 시작한 뒤에
setState(STARTING)을 부릅니다. 서버와 서비스는 자기start이벤트를 먼저 내고 자식을 부르는데, 컨테이너는 자식이 다 뜬 뒤에 자기start이벤트를 냅니다.
원 숫자는 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
시각과 밀리초는 실행마다 다릅니다. 읽을 것이 다섯 가지 있습니다.
- 스택을 아래에서 위로 읽으면 1절부터 지금까지의 길이 그대로 있습니다.
Bootstrap.main()→Bootstrap.start()→ (리플렉션Method.invoke) →Catalina.start()→StandardServer→StandardService→StandardEngine→ContainerBase.startInternal()이InlineExecutorService에StartChild를 맡기고 → Host의start()가 NEW이므로init()을 부릅니다(161행). Host는init단계가 아니라 부모가start()할 때 init됩니다. - 서버와 서비스는
start이벤트(11, 13)가 자식보다 먼저, 엔진과 호스트는start이벤트(22, 24)가 자식보다 나중입니다. 3절 끝에서 말한setState(STARTING)의 자리 차이입니다. - 웹앱 배포는 Host의
start이벤트 안에서 일어났습니다. 17번과 22번 사이에Deploying web application directory로그와 Context의 이벤트가 끼어 있습니다.HostConfig는start이벤트를 받으면deployApps()로webapps/를 훑고(HostConfig.java283–288행, 1644–1667행), 만든Context를host.addChild()로 붙입니다(1092행). 이때 Host는 이미STARTING(사용 가능)이므로 2절에서 본addChild()가 그 자리에서context.start()를 부릅니다. 22번이 그 뒤에 찍힌 것은, 같은start이벤트를HostConfig가 내 리스너보다 먼저 받았기 때문입니다(HostConfig는<Host>시작 태그에서, 내 리스너는 그 안의<Listener>에서 붙었습니다). 배포의 자세한 과정은 10편에서 봅니다. - 커넥터는 맨 마지막(26~28) 입니다. 웹앱이 다 뜬 뒤에야 8080이 요청을 받기 시작합니다.
- 모두
main스레드입니다.startStopThreads가 1이라 자식도 같은 스레드에서 시작됐습니다.
헷갈리기 쉬운 곳: 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
- (4) 두 번째 커넥터는
init단계에서 포트를 열다가BindException으로 실패했지만, 로그만 남고Server startup까지 갔습니다.curl에 대답한 것은 먼저 포트를 잡은 첫 번째 커넥터입니다(webapps/가 비어 있어 404). 포트 충돌이start가 아니라init단계의 로그로 나온다는 점도 눈여겨볼 만합니다. 커넥터는 기본적으로init에서 포트를 엽니다(bindOnInit기본값true,AbstractEndpoint.java1317행). - (5) 속성을 켜면 커넥터의 예외가 서비스와 서버로 올라가고,
Catalina.load()가 그것을Error로 바꿔 던집니다.Bootstrap.main()이 받아Error running command를 남기고 종료 코드 1로 끝납니다.
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)이면 차라리 여기서 끝내는 편이 낫다는 것입니다.
같은 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행).
오른쪽 길에서는 종료 훅 스레드가 이미 서버를 멈춘 뒤라, 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
시각은 실행마다 다릅니다. 마지막 묶음은 두 번째 실행에서 서버·서비스 줄만 고른 것입니다.
HELLO에는 경고 한 줄,SHUTDOWN에는 INFO 한 줄과 함께 멈추기가 시작됩니다. 둘 다[main]— 1절부터 기다리던 그main스레드입니다.stop은 위에서 아래로 내려가며 Engine → Host → Context를 멈춘 다음(36~45), 커넥터를 그 뒤에 멈춥니다(46~48). 4절에서 본 대로 커넥터는 먼저pause()로 새 요청을 막아 두고, 실제stop()은 엔진이 멈춘 뒤에 부릅니다.destroy는 서비스가 커넥터 → 실행기 → 엔진 순으로 부릅니다(StandardService.java549–567행).kill -TERM으로 끄면 같은 이벤트가Thread-1에서 나옵니다.CatalinaShutdownHook은 이름을 주지 않은Thread라 JVM이 붙인 이름이고, 번호는 실행 환경에 따라 다를 수 있습니다.
헷갈리기 쉬운 곳: 내장 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편에서 봅니다.
이 글에서 뺀 것
Bootstrap의 클래스로더 만들기(ClassLoaderFactory,catalina.properties의 경로 해석):Bootstrap.java159–243행 — 4편server.xml을 자바 코드로 미리 바꿔 두는 기능(-generateCode,-useGeneratedCode):Catalina.java660–747행server.xml안의${...}치환(Digester.updateBodyText(),PropertySource):java/org/apache/tomcat/util/digester/Digester.javaoptional="true"인<Listener>(ListenerCreateRule):ListenerCreateRule.java56–82행- 기본
server.xml의 리스너들(VersionLoggerListener,AprLifecycleListener,JreMemoryLeakPreventionListener…):java/org/apache/catalina/startup/,java/org/apache/catalina/core/ - 10초마다의
periodic이벤트와 컨테이너의 백그라운드 처리(backgroundProcess()):StandardServer.java881–902행,ContainerBase.ContainerBackgroundProcessor - 부품을 JMX에 등록하는
LifecycleMBeanBase:LifecycleMBeanBase.java51–63행 — 16편 - 한 번 쓰고 버리는 부품을 위한
Lifecycle.SingleUse:Lifecycle.java284–289행
정리
| 개념 | 한 줄 요약 |
|---|---|
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편)과 닮았습니다.