이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 두 번째 글입니다. 1편에서 소스를 받아 빌드하고, 폴더 지도와 요청 하나가 지나는 큰 길을 펴 보았습니다. 이번 글은 코드를 한 줄씩 읽기 전에 펴 놓는 전체 지도입니다.
Tomcat을 써 본 사람이라면 conf/server.xml을 열어 <Connector port="8080">의 포트를 바꿔 본 적이 있을 것입니다. 그 파일에 적힌 <Server>, <Service>, <Engine>, <Host>는 소스에서 무엇일까요. 이 글은 그 설정 파일을 다리 삼아 다음 질문에 답합니다.
- server.xml의 요소 하나하나는 어떤 클래스의 객체가 되고, 누가 그 객체들을 만들어 서로 잇는가
- Engine·Host·Context·Wrapper "컨테이너 넷"은 무엇이고, 부모·자식 관계는 소스에서 어떻게 지켜지는가
- 연결을 받는 쪽(Coyote)과 서블릿을 부르는 쪽(Catalina)은 왜 나뉘어 있고, 둘을 잇는
Adapter는 어떻게 생겼는가 - 서블릿 API(
jakarta.*)와 구현, 그리고 Jasper·EL·JULI·Naming은 저장소와 jar의 어디에 있는가
| 항목 | 내용 |
|---|---|
| 대상 | Apache Tomcat |
| 버전 | 12.0.0-M1 (main) |
| 커밋 | 17aa1769b0 (2026-10-23) |
| 주로 읽는 클래스 | startup/Catalina, core/StandardServer·StandardService, Container, core/ContainerBase·StandardEngine, connector/Connector·CoyoteAdapter, coyote/Adapter·ProtocolHandler |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다. 경로는 java/org/apache/catalina/ 처럼 저장소 루트에서 시작하고, 본문에서 core/StandardEngine 처럼 짧게 쓰면 org/apache/catalina/ 아래를 뜻합니다.
지도를 그리는 글이라 이 글은 큰 줄기만 따라갑니다. 각 개념을 자세히 읽는 글은 절마다 "N편에서 봅니다"로 이어 두었습니다.
먼저 알아 둘 말
서블릿 컨테이너(servlet container)는 서블릿(HttpServlet을 상속해 doGet()을 채운 그 클래스)을 담아 두었다가, HTTP 요청이 오면 알맞은 서블릿을 골라 불러 주는 프로그램입니다. 서블릿을 만들고(init()), 부르고(service()), 치우는(destroy()) 일을 컨테이너가 맡습니다. Tomcat이 바로 이것입니다.
Catalina(카탈리나)는 Tomcat 안에서 서블릿 컨테이너 부분의 이름입니다. 패키지 org.apache.catalina가 그것이고, catalina.sh·catalina.out·CATALINA_HOME에도 같은 이름이 붙어 있습니다.
Coyote(코요테)는 커넥터(connector) 부분의 이름입니다. 소켓에서 바이트를 읽어 HTTP 요청으로 해석하고 응답을 바이트로 써 보내는 일을 합니다. 패키지는 org.apache.coyote입니다. 서블릿은 모릅니다(4절).
컨테이너(Container)는 이 글에서 두 가지 뜻으로 쓰입니다. 넓게는 위의 "서블릿 컨테이너" 전체이고, 좁게는 org.apache.catalina.Container라는 인터페이스입니다. Engine·Host·Context·Wrapper 넷이 이 인터페이스를 구현하므로 "컨테이너 넷"이라고 부릅니다. 이 글에서 Container처럼 코드 표기로 쓰면 좁은 뜻입니다.
Lifecycle(생명 주기)은 init()·start()·stop()·destroy() 네 메서드를 가진 인터페이스입니다. Server부터 Wrapper까지 이 글에 나오는 부품은 모두 이것을 구현하고, 부모가 start()되면 자식도 차례로 start()됩니다. 상태가 어떻게 바뀌는지는 3편에서 봅니다.
파이프라인과 밸브(Pipeline, Valve)는 컨테이너마다 하나씩 걸린 처리 사슬입니다. 요청은 컨테이너에 들어갈 때 그 컨테이너의 밸브들을 차례로 지나고, 맨 끝의 기본 밸브(basic valve)가 다음 컨테이너로 넘깁니다. server.xml의 <Valve>가 여기에 끼는 것입니다. 8편에서 자세히 봅니다.
1. server.xml 한 장이 곧 객체 나무다
기본 server.xml
저장소의 conf/server.xml에서 주석을 걷어 내면 뼈대는 이렇습니다.
<Server port="8005" shutdown="SHUTDOWN">
<Listener className="org.apache.catalina.startup.VersionLoggerListener" />
<!-- ... (리스너 몇 개) -->
<GlobalNamingResources>
<Resource name="UserDatabase" auth="Container"
type="org.apache.catalina.UserDatabase"
description="User database that can be updated and saved"
factory="org.apache.catalina.users.MemoryUserDatabaseFactory"
pathname="conf/tomcat-users.xml" />
</GlobalNamingResources>
<!-- ... -->
<Service name="Catalina">
<!-- ... -->
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
<!-- ... -->
<Engine name="Catalina" defaultHost="localhost">
<!-- ... -->
<Realm className="org.apache.catalina.realm.LockOutRealm">
<!-- ... -->
</Realm>
<Host name="localhost" appBase="webapps"
unpackWARs="true" autoDeploy="true">
<!-- ... -->
<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
prefix="localhost_access_log" suffix=".txt"
pattern="%h %l %u %t "%r" %s %b" />
</Host>
</Engine>
</Service>
</Server>
요소가 안으로 겹겹이 들어가 있습니다. Server 하나 안에 Service, Service 안에 Connector와 Engine, Engine 안에 Host. 이 겹침이 그대로 자바 객체의 부모·자식 관계가 됩니다.
XML을 객체로 바꾸는 규칙: Catalina.createStartDigester()
catalina.sh start를 하면 결국 org.apache.catalina.startup.Catalina 클래스가 server.xml을 읽습니다(어떤 길로 여기까지 오는지는 3편). 읽는 도구는 Digester(다이제스터)라는 Tomcat 자체의 XML → 객체 변환기이고, 변환 규칙은 이 메서드 하나에 모여 있습니다.
digester.addObjectCreate("Server", "org.apache.catalina.core.StandardServer", "className");
digester.addSetProperties("Server");
digester.addSetNext("Server", "setServer", "org.apache.catalina.Server");
// ...
digester.addObjectCreate("Server/Service", "org.apache.catalina.core.StandardService", "className");
digester.addSetProperties("Server/Service");
digester.addSetNext("Server/Service", "addService", "org.apache.catalina.Service");
// ...
digester.addRule("Server/Service/Connector", new ConnectorCreateRule());
digester.addSetProperties("Server/Service/Connector",
new String[] { "executor", "sslImplementationName", "protocol" });
digester.addSetNext("Server/Service/Connector", "addConnector", "org.apache.catalina.connector.Connector");
// ...
// Add RuleSets for nested elements
digester.addRuleSet(new NamingRuleSet("Server/GlobalNamingResources/"));
digester.addRuleSet(new EngineRuleSet("Server/Service/"));
digester.addRuleSet(new HostRuleSet("Server/Service/Engine/"));
digester.addRuleSet(new ContextRuleSet("Server/Service/Engine/Host/"));
java/org/apache/catalina/startup/Catalina.java 513–592행
한 요소마다 규칙이 세 줄씩 붙습니다.
addObjectCreate(경로, 기본 클래스, "className")— XML에서 그 경로(Server/Service)의 시작 태그를 만나면 객체를 하나 만듭니다. 요소에className속성이 있으면 그 클래스로, 없으면 기본 클래스로 만듭니다.addSetProperties(경로)— 요소의 속성을 같은 이름의 setter로 넣습니다.<Server port="8005">는setPort(8005)가 됩니다.addSetNext(경로, 메서드 이름, 타입)— 끝 태그를 만나면, 방금 만든 객체를 바깥 요소의 객체의 그 메서드에 넘깁니다. Service는 Server의addService()로, Connector는 Service의addConnector()로 들어갑니다.
Engine·Host·Context는 같은 일을 하는 규칙 묶음(RuleSet)으로 떼어 놓았습니다. Host의 것을 보면 똑같은 세 줄에 몇 줄이 더 붙어 있습니다.
digester.addObjectCreate(prefix + "Host", "org.apache.catalina.core.StandardHost", "className");
digester.addSetProperties(prefix + "Host");
digester.addRule(prefix + "Host", new CopyParentClassLoaderRule());
digester.addRule(prefix + "Host",
new LifecycleListenerRule("org.apache.catalina.startup.HostConfig", "hostConfigClass"));
digester.addSetNext(prefix + "Host", "addChild", "org.apache.catalina.Container");
java/org/apache/catalina/startup/HostRuleSet.java 61–66행
HostConfig라는 리스너를 Host마다 하나씩 붙이는 줄이 눈에 띕니다. 이 리스너가 나중에 webapps/ 폴더를 보고 웹앱을 배포합니다(아래 "헷갈리기 쉬운 곳"과 10편).
규칙을 표로 모으면 server.xml과 소스가 한 줄씩 이어집니다.
| server.xml 요소 | 만들어지는 객체(기본 클래스) | 바깥 객체에 붙는 메서드 | 규칙이 있는 곳 |
|---|---|---|---|
<Server> |
core/StandardServer |
Catalina.setServer() |
Catalina.java 513–515행 |
<Service> |
core/StandardService |
Server.addService() |
Catalina.java 526–528행 |
<Connector> |
connector/Connector |
Service.addConnector() |
Catalina.java 542–545행, ConnectorCreateRule |
<Engine> |
core/StandardEngine |
Service.setContainer() |
EngineRuleSet.java 61–65행 |
<Host> |
core/StandardHost |
Engine.addChild() |
HostRuleSet.java 61–66행 |
<Context>(적었을 때만) |
core/StandardContext |
Host.addChild() |
ContextRuleSet.java 84–94행 |
<Valve> |
className에 적은 클래스 |
그 컨테이너의 addValve() |
각 RuleSet |
<Connector>만 addObjectCreate가 아니라 ConnectorCreateRule을 씁니다. protocol 속성을 먼저 읽어 new Connector(protocolName)으로 만들어야 하기 때문입니다(ConnectorCreateRule.java 63–64행). 이 생성자 안은 4절에서 봅니다. Digester 자체(tomcat/util/digester)가 규칙을 어떻게 돌리는지는 3편에서 봅니다.
파란 상자가 server.xml에 적힌 요소이고, 회색 상자는 server.xml 밖에서 만들어지는 것입니다.
예제 1: server.xml을 읽은 직후와 시작한 직후의 나무
정말 이런 나무가 생기는지, catalina.sh가 쓰는 것과 같은 Catalina 클래스로 기본 server.xml을 읽어 나무를 찍어 보았습니다. load()는 server.xml을 읽고 init()까지 하고, start()는 서버를 시작합니다. 웹앱은 ROOT와 docs 둘만 두었습니다.
이 글의 예제는 모두 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. 실행 스크립트는 ex/u02-architecture/run.sh 하나이고, 출력의 ### 줄은 스크립트가 찍은 제목입니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// catalina.sh 와 같은 Catalina 클래스로 conf/server.xml 을 읽어 객체 나무를 만들고,
// 시작 전(load 직후)과 시작 후(start 직후)의 나무를 찍는다.
// ... (import 생략)
public class ServerXmlTree {
public static void main(String[] args) throws Exception {
Catalina catalina = new Catalina();
catalina.setAwait(false); // 종료 포트를 기다리지 않는다
catalina.setUseShutdownHook(false);
catalina.load(); // server.xml -> 객체, 그리고 init()
System.out.println("=== after load() (server.xml parsed, not started) ===");
print(catalina.getServer(), false);
catalina.start(); // start(): 이때 webapps/ 를 배포한다
System.out.println("=== after start() ===");
print(catalina.getServer(), true);
catalina.stop();
}
// ... print(): Server → Service → Connector·Engine → Host → Context → Wrapper 를 차례로 찍는다
}
### 1. server.xml -> objects (Catalina.load / start)
=== after load() (server.xml parsed, not started) ===
Server org.apache.catalina.core.StandardServer port=8005
Service org.apache.catalina.core.StandardService name=Catalina
Connector org.apache.catalina.connector.Connector port=8080
protocolHandler = org.apache.coyote.http11.Http11NioProtocol
adapter = org.apache.catalina.connector.CoyoteAdapter
Engine org.apache.catalina.core.StandardEngine name=Catalina parent=null
Host org.apache.catalina.core.StandardHost name=localhost contexts=0
=== after start() ===
Server org.apache.catalina.core.StandardServer port=8005
Service org.apache.catalina.core.StandardService name=Catalina
Connector org.apache.catalina.connector.Connector port=8080
protocolHandler = org.apache.coyote.http11.Http11NioProtocol
adapter = org.apache.catalina.connector.CoyoteAdapter
Engine org.apache.catalina.core.StandardEngine name=Catalina parent=null
Host org.apache.catalina.core.StandardHost name=localhost contexts=2
Context org.apache.catalina.core.StandardContext path='' wrappers=2
Wrapper org.apache.catalina.core.StandardWrapper name=default
Wrapper org.apache.catalina.core.StandardWrapper name=jsp
Context org.apache.catalina.core.StandardContext path='/docs' wrappers=2
- 표의 기본 클래스가 그대로 나옵니다.
<Connector protocol="HTTP/1.1">은Http11NioProtocol이 되었습니다. load()직후 이미 커넥터에CoyoteAdapter가 꽂혀 있습니다. 커넥터의init()에서 꽂기 때문입니다(4절).load()직후 Host의 자식(Context)은 0개,start()뒤에는 2개입니다.
헷갈리기 쉬운 곳: server.xml에는 Context도 Wrapper도 없다
기본 server.xml에는 <Context>가 없습니다. Context는 Host가 시작될 때 Host에 붙은 HostConfig 리스너가 webapps/ 폴더를 훑어 만듭니다. HostConfig는 START_EVENT를 받으면 start()를 부르고(HostConfig.java 286행), 그 안에서 deployApps()를 부릅니다(1665행). 예제에서 load() 직후 Context가 0개였던 이유입니다. server.xml에 <Context>를 직접 적을 수도 있지만(그래서 ContextRuleSet이 있습니다), 보통은 이렇게 폴더나 conf/Catalina/localhost/*.xml로 둡니다.
Wrapper는 더 안쪽에서 생깁니다. Context가 시작될 때 ContextConfig가 web.xml을 읽어 <servlet> 하나마다 Wrapper를 하나 만들어 Context에 붙입니다.
for (ServletDef servlet : webxml.getServlets().values()) {
Wrapper wrapper = context.createWrapper();
// ...
context.addChild(wrapper);
java/org/apache/catalina/startup/ContextConfig.java 1544–1592행
ROOT 웹앱의 WEB-INF/web.xml에는 <servlet>이 하나도 없는데 Wrapper가 2개(default, jsp)인 것은, 모든 웹앱에 합쳐지는 conf/web.xml에 이 둘이 정의되어 있기 때문입니다. web.xml이 어떻게 합쳐지는지는 10편에서 봅니다.
헷갈리기 쉬운 곳: className으로 클래스를 바꿀 수 있다
addObjectCreate의 셋째 인자 "className" 덕분에 <Host className="com.example.MyHost">처럼 적으면 StandardHost 대신 다른 클래스가 만들어집니다. 그래서 소스는 늘 Host·Engine 같은 인터페이스로 부품을 다루고, Standard로 시작하는 클래스는 "기본 구현"일 뿐입니다. server.xml의 <Realm className=…>과 <Valve className=…>이 클래스 이름을 꼭 적어야 하는 것도 같은 규칙에서 기본 클래스가 null(반드시 적어라)이기 때문입니다(EngineRuleSet.java 76–77행).
2. Server와 Service — 컨테이너가 아닌 두 상자
server.xml 맨 위의 주석이 먼저 경고합니다.
A "Server" is not itself a "Container", so you may not define subcomponents such as "Valves" at this level.
(conf/server.xml 18–19행) Service 위의 주석(51–52행)도 같은 말을 합니다. Server와 Service는 Container를 구현하지 않습니다. Lifecycle만 구현합니다(public interface Server extends Lifecycle, Server.java 38행). 그래서 파이프라인도 밸브도 없습니다. 요청을 처리하는 상자가 아니라 부품을 묶어 두는 상자입니다.
Server: Tomcat 하나
Server 인터페이스의 설명은 "Catalina 서블릿 컨테이너 전체"를 나타내고, Service를 여럿 담고, port 속성의 포트로 종료 명령을 받는다고 적고 있습니다(Server.java 25–37행). 포트의 기본값 8005는 StandardServer의 필드에 있습니다(private int port = 8005;, StandardServer.java 119행). 구현인 StandardServer가 가진 것도 그대로입니다.
/**
* The set of Services associated with this Server.
*/
private Service[] services = new Service[0];
// ...
/**
* The shutdown command string we are looking for.
*/
private String shutdown = "SHUTDOWN";
java/org/apache/catalina/core/StandardServer.java 136–148행
catalina.sh stop이 8005번 포트에 SHUTDOWN이라는 글자를 보내면 Tomcat이 꺼지는 장치가 여기 있습니다. StandardServer.await()가 그 포트를 지키는 과정은 3편에서 봅니다. server.xml의 <GlobalNamingResources>(전역 JNDI 자원)도 Server에 붙습니다(5절).
Service: 커넥터 여럿 + 엔진 하나
Service의 설명은 이 상자가 왜 있는지를 한 문장으로 말합니다.
A Service is a group of one or more Connectors that share a single Container to process their incoming requests. This arrangement allows, for example, a non-SSL and SSL connector to share the same population of web apps.
(Service.java 22–29행) 8080(HTTP)과 8443(HTTPS) 두 커넥터가 같은 웹앱들을 보여 주려면, 커넥터 둘이 엔진 하나를 함께 써야 합니다. 그 "둘 이상과 하나"를 묶는 것이 Service입니다. StandardService의 필드가 그 모양입니다.
/**
* The set of Connectors associated with this Service.
*/
protected Connector[] connectors = new Connector[0];
// ...
/**
* The list of executors held by the service.
*/
protected final ArrayList<Executor> executors = new ArrayList<>();
// ...
private Engine engine = null;
// ...
/**
* Mapper.
*/
protected final Mapper mapper = new Mapper();
/**
* Mapper listener.
*/
protected final MapperListener mapperListener = new MapperListener(this);
java/org/apache/catalina/core/StandardService.java 84–109행
| 필드 | 무엇 | 자세히 |
|---|---|---|
connectors |
배열. 커넥터는 여럿 | 4절, 5편 |
executors |
server.xml의 <Executor>(커넥터들이 함께 쓸 스레드 풀) |
5편 |
engine |
필드 하나. 엔진은 하나뿐 | 3절 |
mapper, mapperListener |
URL을 보고 Host·Context·Wrapper를 고르는 표와, 그 표를 컨테이너 나무에 맞춰 고치는 리스너 | 7편 |
Mapper가 Engine이 아니라 Service에 있다는 점을 기억해 두면 좋습니다. 커넥터는 요청을 받으면 "내 Service의 Mapper"로 목적지를 고르고 "내 Service의 Engine"에 넘깁니다(4절의 CoyoteAdapter).
헷갈리기 쉬운 곳: Engine의 부모는 Service가 아니다
Service는 setContainer(engine)으로 Engine을 갖고, 그때 Engine에 engine.setService(this)로 자신을 알려 줍니다(StandardService.java 150–158행). 하지만 이것은 getParent()로 가는 부모·자식 관계가 아닙니다. Service는 Container가 아니므로 Container.getParent()가 Service를 돌려줄 수 없습니다. 실제로 Engine의 getParent()는 늘 null입니다(예제 1의 parent=null, 3절). 컨테이너에서 Service를 찾고 싶으면 Engine까지 올라간 뒤 getService()를 부릅니다. Container에 그 일을 하는 도우미가 있습니다.
static Service getService(Container container) {
while (container != null && !(container instanceof Engine)) {
container = container.getParent();
}
if (container == null) {
return null;
}
return ((Engine) container).getService();
}
java/org/apache/catalina/Container.java 307–315행
위 두 필드 목록과 Container.getService() 를 한 장에 모은 그림입니다. 실선은 요청이 지나는 길, 점선은 Service와 Engine이 서로를 아는 방법입니다. 8080·8443은 Service 설명에 나오는 예입니다.
3. 컨테이너 넷: Engine, Host, Context, Wrapper
Container 인터페이스가 말하는 네 층
Container 인터페이스의 설명이 네 층을 직접 나열합니다.
- Engine - Representation of the entire Catalina servlet engine, most likely containing one or more subcontainers that are either Host or Context implementations, or other custom groups.
- Host - Representation of a virtual host containing a number of Contexts.
- Context - Representation of a single ServletContext, which will typically contain one or more Wrappers for the supported servlets.
- Wrapper - Representation of an individual servlet definition.
(Container.java 36–41행) 우리말로, 그리고 설정 파일과 이어서 정리하면 이렇습니다.
| 층 | 기본 구현 | 하나가 나타내는 것 | 어디서 오나 | 자식 |
|---|---|---|---|---|
| Engine | StandardEngine |
서블릿 엔진 전체. Service마다 하나 | server.xml <Engine> |
Host |
| Host | StandardHost |
가상 호스트 하나(localhost, www.example.com) |
server.xml <Host> |
Context |
| Context | StandardContext |
웹앱 하나 = ServletContext 하나(/, /docs) |
webapps/, conf/Catalina/localhost/*.xml, (드물게) server.xml |
Wrapper |
| Wrapper | StandardWrapper |
서블릿 정의 하나(web.xml의 <servlet> 하나) |
web.xml, @WebServlet |
없음 |
Host는 httpd의 <VirtualHost>와 같은 개념입니다. httpd에서는 가상 호스트마다 server_rec이 하나씩 생겼습니다(httpd 3편). Tomcat에서는 Host 객체 하나입니다.
공통 뼈대: 부모, 자식, 파이프라인
Container가 네 층에 요구하는 메서드 가운데 지도를 그리는 데 필요한 것만 남기면 이렇습니다.
public interface Container extends Lifecycle {
// ...
Pipeline getPipeline();
// ...
String getName();
// ...
Container getParent();
// ...
void setParent(Container container);
// ...
void addChild(Container child);
// ...
Container findChild(String name);
// ...
Container[] findChildren();
java/org/apache/catalina/Container.java 64–377행
부모 하나(getParent), 이름으로 찾는 자식 여럿(findChild), 그리고 파이프라인 하나(getPipeline). 네 층이 모두 같은 모양이라서, 요청을 처리하는 코드도 "내 파이프라인을 돌리고, 끝에서 자식의 파이프라인을 부른다"는 같은 모양이 됩니다(6절).
이 공통 부분을 실제로 구현한 것은 추상 클래스 ContainerBase입니다. 네 Standard… 클래스가 모두 이것을 상속합니다. 자식을 붙이는 addChild()는 이렇습니다.
@Override
public void addChild(Container child) {
// ...
childrenLock.writeLock().lock();
try {
if (children.get(child.getName()) != null) {
throw new IllegalArgumentException(sm.getString("containerBase.child.notUnique", child.getName()));
}
child.setParent(this); // May throw IAE
children.put(child.getName(), child);
} finally {
childrenLock.writeLock().unlock();
}
fireContainerEvent(ADD_CHILD_EVENT, child);
// Start child
// Don't do this inside sync block - start can be a slow process and
// locking the children object can cause problems elsewhere
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 559–588행
- 자식은 이름을 열쇠로 하는
HashMap(children, 135행)에 들어갑니다. 같은 이름이 있으면 거절합니다. Host의 이름은localhost, Context의 이름은 경로(/docs), Wrapper의 이름은 서블릿 이름입니다. - 자식의
setParent(this)를 불러 위로 가는 길을 놓습니다. - 부모가 이미 돌고 있으면 자식을 바로
start()합니다. 실행 중에 WAR를 넣으면 그 웹앱이 곧바로 살아나는 것은 이 줄 덕분입니다(10편).
파란 상자가 컨테이너 넷과 그 공통 부모입니다. Server·Service·Connector는 같은 생명 주기 뿌리를 공유하지만 Container는 아닙니다.
그림의 관계는 소스의 클래스 선언 그대로입니다. public abstract class ContainerBase extends LifecycleMBeanBase implements Container(ContainerBase.java 119행), public class StandardEngine extends ContainerBase implements Engine(StandardEngine.java 49행), public final class StandardServer extends LifecycleMBeanBase implements Server(StandardServer.java 67행), public class Connector extends LifecycleMBeanBase(Connector.java 56행)입니다. LifecycleMBeanBase의 "MBean"은 JMX(자바의 원격 관리 장치)에 자신을 등록한다는 뜻입니다(16편).
층의 순서는 누가 지키나
Container 인터페이스 자체는 "Engine 밑에는 Host만"이라고 강제하지 않습니다. 순서를 지키는 것은 각 구현의 addChild()와 setParent()입니다.
public void addChild(Container child) {
if (!(child instanceof Host)) {
throw new IllegalArgumentException(sm.getString("standardEngine.notHost"));
}
super.addChild(child);
}
// ...
@Override
public void setParent(Container container) {
throw new IllegalArgumentException(sm.getString("standardEngine.notParent"));
}
java/org/apache/catalina/core/StandardEngine.java 159–180행
| 검사 | 어디 | 어기면 |
|---|---|---|
| Engine의 자식은 Host | StandardEngine.addChild() 161행 |
IllegalArgumentException |
| Engine은 부모를 가질 수 없다 | StandardEngine.setParent() 178행 |
IllegalArgumentException |
| Host의 자식은 Context | StandardHost.addChild() 670행 |
IllegalArgumentException |
| Context의 자식은 Wrapper | StandardContext.addChild() 2830행 |
IllegalArgumentException |
| Wrapper의 부모는 Context | StandardWrapper.setParent() 337행 |
IllegalArgumentException |
| Wrapper는 자식을 가질 수 없다 | StandardWrapper.addChild() 525행 |
IllegalStateException |
예제 2: 내장 Tomcat으로 나무를 만들고 getParent()로 올라가기
Spring Boot가 쓰는 방식, 곧 server.xml 없이 코드로 Tomcat을 띄우는 내장 Tomcat(org.apache.catalina.startup.Tomcat)으로 나무를 만들어 보았습니다. Tomcat 클래스는 getServer()를 처음 부를 때 StandardServer와 StandardService를 만들고(Tomcat.java 626–647행), getEngine()·getHost()가 필요할 때 StandardEngine·StandardHost를 만들어 붙입니다(591–619행). server.xml의 Digester가 하던 일을 코드가 대신하는 셈입니다. 내장 API 전체는 16편에서 봅니다.
Tomcat tomcat = new Tomcat();
tomcat.setBaseDir("/tmp/emb");
tomcat.setPort(8080);
tomcat.getConnector(); // 커넥터를 만들게 한다
Context ctx = tomcat.addContext("/app", null); // Host 아래에 Context 하나
Wrapper wrapper = Tomcat.addServlet(ctx, "stack", new StackServlet()); // Context 아래에 Wrapper 하나
ctx.addServletMapping("/stack", "stack"); // 11.0.x 에서는 addServletMappingDecoded 도 있었다
System.out.println("=== 1. getParent() chain from the Wrapper ===");
Container c = wrapper;
while (c != null) {
System.out.println(c.getClass().getSimpleName() + " name='" + c.getName() + "'");
c = c.getParent();
}
Engine engine = tomcat.getEngine();
System.out.println("Engine.getService() = " + engine.getService().getClass().getSimpleName()
+ " name='" + engine.getService().getName() + "'");
System.out.println("Service.getServer() = " + engine.getService().getServer().getClass().getSimpleName());
System.out.println("=== 2. adding a child in the wrong place ===");
try {
engine.addChild(new StandardContext());
} catch (RuntimeException e) {
System.out.println("engine.addChild(Context) -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
// ... wrapper.addChild(new StandardContext()), engine.setParent(tomcat.getHost()) 도 같은 모양으로
ex/u02-architecture/EmbeddedTree.java의 main() 앞부분입니다.
### 2. embedded Tomcat
=== 1. getParent() chain from the Wrapper ===
ExistingStandardWrapper name='stack'
StandardContext name='/app'
StandardHost name='localhost'
StandardEngine name='Tomcat'
Engine.getService() = StandardService name='Tomcat'
Service.getServer() = StandardServer
=== 2. adding a child in the wrong place ===
engine.addChild(Context) -> IllegalArgumentException: Child of an Engine must be a Host
wrapper.addChild(Context) -> IllegalStateException: Wrapper container may not have child containers
engine.setParent(Host) -> IllegalArgumentException: Engine cannot have a parent Container
- Wrapper에서
getParent()를 따라가면 Context → Host → Engine에서 끝납니다(Engine의 부모는null). Service와 Server로는getService()·getServer()로 갑니다. - 맨 아래 칸의 클래스가
StandardWrapper가 아니라ExistingStandardWrapper입니다.Tomcat.addServlet()에 서블릿 객체를 넘기면 이미 만들어진 객체를 감싸는StandardWrapper의 하위 클래스를 씁니다(Tomcat.java 399–406행). web.xml로 등록한 서블릿은 예제 1처럼StandardWrapper입니다. - 틀린 자리에 넣으면 표의 예외가 그대로 납니다.
헷갈리기 쉬운 곳
Wrapper의 설명과 구현이 다른 예외를 말합니다. 인터페이스의 설명은 Wrapper에 자식을 붙이면 "addChild()method should throw anIllegalArgumentException"이라고 적었지만(Wrapper.java 37–38행),StandardWrapper는IllegalStateException을 던집니다(StandardWrapper.java 523–527행). 예제 2의 출력도IllegalStateException입니다. 예외를 잡는 코드를 쓴다면 구현을 기준으로 해야 합니다.- Wrapper는 서블릿 객체가 아니라 서블릿의 "정의"입니다. 서블릿 클래스 이름, init 파라미터,
load-on-startup을 들고 있다가, 요청이 오면allocate()로 서블릿 객체를 꺼내 줍니다(Wrapper.java 197행). 서블릿 객체를 언제 만들고init()을 언제 부르는지는 9편에서 봅니다. - 검사는 대부분 부모 쪽에 있습니다.
StandardHost는setParent()를 따로 고치지 않았으므로, Host를 Engine이 아닌 곳에 붙이는 것을 Host 자신이 막지는 않습니다. "Host는 Engine 밑"이라는 규칙은StandardEngine.addChild()가 지킵니다. 반대로 Wrapper만은 자기 쪽(setParent())에서도 부모가 Context인지 봅니다.
4. Catalina와 Coyote — 왜 나눴고, Adapter가 어떻게 잇나
둘이 하는 일
같은 Tomcat 안이지만 하는 일이 다릅니다.
- Coyote(
org.apache.coyote,tomcat-coyote.jar)는 바이트를 다룹니다. 소켓을 받고(5편), 요청 줄과 헤더를 읽어org.apache.coyote.Request라는 객체를 채우고(6편), 응답을 바이트로 내보냅니다. HTTP/1.1, AJP, HTTP/2처럼 프로토콜마다 구현이 따로 있습니다. - Catalina(
org.apache.catalina,catalina.jar)는 서블릿 규격을 다룹니다.HttpServletRequest를 만들고, 어느 웹앱의 어느 서블릿인지 고르고, 밸브와 필터를 거쳐 서블릿을 부릅니다.
둘 사이의 이음매가 server.xml의 <Connector>입니다. Connector는 Catalina 쪽 클래스(org.apache.catalina.connector)이지만, 생성자에서 Coyote의 프로토콜 핸들러(ProtocolHandler)를 하나 만들어 품습니다.
public Connector(String protocol) {
configuredProtocol = protocol;
ProtocolHandler p = null;
Exception ex = null;
try {
p = ProtocolHandler.create(protocol);
} catch (Exception e) {
ex = e;
}
protocolHandlerCreationException = ex;
if (p != null) {
protocolHandler = p;
protocolHandlerClassName = protocolHandler.getClass().getName();
} else {
protocolHandler = null;
protocolHandlerClassName = protocol;
}
// ...
}
java/org/apache/catalina/connector/Connector.java 101–120행
protocol 속성의 글자를 클래스로 바꾸는 곳은 Coyote 쪽 ProtocolHandler.create()입니다.
static ProtocolHandler create(String protocol)
throws ClassNotFoundException, InstantiationException, IllegalAccessException, IllegalArgumentException,
InvocationTargetException, NoSuchMethodException, SecurityException {
if (protocol == null || "HTTP/1.1".equals(protocol) ||
org.apache.coyote.http11.Http11NioProtocol.class.getName().equals(protocol)) {
return new org.apache.coyote.http11.Http11NioProtocol();
} else if ("AJP/1.3".equals(protocol) ||
org.apache.coyote.ajp.AjpNioProtocol.class.getName().equals(protocol)) {
return new org.apache.coyote.ajp.AjpNioProtocol();
} else {
// Instantiate protocol handler
Class<?> clazz = Class.forName(protocol);
return (ProtocolHandler) clazz.getConstructor().newInstance();
}
}
java/org/apache/coyote/ProtocolHandler.java 250–264행
"HTTP/1.1"(또는 생략)이면 NIO(자바의 논블로킹 입출력 API) 방식의 Http11NioProtocol, "AJP/1.3"이면 AjpNioProtocol(AJP는 httpd 같은 앞단 웹 서버가 Tomcat에 요청을 넘길 때 쓰는 이진 프로토콜입니다), 그 밖에는 클래스 이름으로 보고 리플렉션(클래스 이름 문자열로 클래스를 찾아 객체를 만드는 자바 기능)으로 만듭니다. 11.0.x 브랜치의 이 메서드도 같은 모양입니다. Http11NioProtocol은 다시 생성자에서 new NioEndpoint()로 소켓을 다루는 엔드포인트를 만듭니다(Http11NioProtocol.java 36–38행). 엔드포인트의 스레드들은 5편의 주제입니다.
다리 하나: Adapter
Coyote가 요청 하나를 다 읽으면 그것을 누구에게 넘길까요. Coyote는 Catalina의 클래스를 이름으로 알지 못합니다. 대신 자기 패키지에 인터페이스 하나를 정해 두었습니다.
/**
* Adapter. This represents the entry point in a coyote-based servlet container.
*
* @see ProtocolHandler
*/
public interface Adapter {
/**
* Call the service method, and notify all listeners.
*
* @param req The request object
* @param res The response object
// ...
*/
void service(Request req, Response res) throws Exception;
// ...
java/org/apache/coyote/Adapter.java 21–44행
설명이 "coyote 기반 서블릿 컨테이너의 입구"라고 말합니다. service()가 받는 Request·Response는 org.apache.coyote의 것, 곧 서블릿 규격과 상관없는 Coyote 자신의 요청 객체입니다. 이 밖에 비동기 처리용 asyncDispatch(), 로그용 log() 등 메서드가 다섯 개 더 있습니다(98행까지).
이 인터페이스를 구현한 것이 Catalina 쪽의 CoyoteAdapter이고(public class CoyoteAdapter implements Adapter, CoyoteAdapter.java 63행), 꽂는 곳은 Connector의 initInternal()입니다.
// Initialize adapter
adapter = new CoyoteAdapter(this);
protocolHandler.setAdapter(adapter);
java/org/apache/catalina/connector/Connector.java 1300–1302행
예제 1에서 load() 직후 이미 adapter = …CoyoteAdapter였던 이유가 이것입니다. load()가 init()까지 하기 때문입니다. 그 뒤 HTTP/1.1 프로세서는 요청 헤더를 다 읽으면 getAdapter().service(request, response)를 부릅니다(Http11Processor.java 404행). Coyote가 아는 것은 Adapter라는 인터페이스 하나뿐이고, 실제로 불리는 것은 Catalina의 CoyoteAdapter입니다.
다리 건너편: CoyoteAdapter.service()
다리를 건너면 무슨 일이 생기는지 앞부분만 봅니다.
public void service(org.apache.coyote.Request req, org.apache.coyote.Response res) throws Exception {
Request request = (Request) req.getNote(ADAPTER_NOTES);
Response response = (Response) res.getNote(ADAPTER_NOTES);
if (request == null) {
// Create objects
request = connector.createRequest(req);
response = connector.createResponse(res);
// Link objects
request.setResponse(response);
response.setRequest(request);
// Set as notes
req.setNote(ADAPTER_NOTES, request);
res.setNote(ADAPTER_NOTES, response);
}
// ...
try {
// Parse and set Catalina and configuration specific
// request parameters
postParseSuccess = postParseRequest(req, request, res, response);
if (postParseSuccess) {
// check valves if we support async
request.setAsyncSupported(connector.getService().getContainer().getPipeline().isAsyncSupported());
// Calling the container
connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);
}
java/org/apache/catalina/connector/CoyoteAdapter.java 305–348행
- Coyote의 요청(
req)에 붙은 메모(note)에서 Catalina의 요청 객체를 꺼냅니다. 없으면connector.createRequest()로 만들어 메모에 붙여 둡니다. 다음 요청 때는 같은 객체를 다시 씁니다(재활용은 7편). postParseRequest()가 URI를 디코딩·정규화하고, 2절에서 본 Service의 Mapper로 Host·Context·Wrapper를 골라 요청에 적어 둡니다(CoyoteAdapter.java 700행의connector.getService().getMapper().map(…)). 7편의 주제입니다.connector.getService().getContainer()— 내 Service의 Engine — 의 파이프라인 첫 밸브를 부릅니다. 여기서부터 컨테이너 넷의 세계입니다(6절).
위의 회색 둘과 파란 하나가 Coyote, 아래 셋이 Catalina입니다. 둘이 만나는 곳은 Adapter.service() 한 메서드입니다.
예제 3: 의존 방향을 세어 보기
"Coyote는 Catalina를 모른다"는 말이 정말인지, 예제 컨테이너 안의 기준 커밋 소스(/usr/src/tomcat/java)에서 패키지마다 다른 패키지를 import하는 파일 수를 세었습니다.
for pair in coyote:catalina tomcat:catalina jasper:catalina el:catalina juli:catalina naming:catalina \
catalina:coyote catalina:jasper catalina:naming; do
from=${pair%%:*}; to=${pair##*:}
n=$(grep -rl "^import org\.apache\.$to\." org/apache/$from --include=*.java | wc -l)
printf ' org.apache.%-9s -> org.apache.%-9s %4d\n' "$from" "$to" "$n"
done
n=$(grep -rl "^import jakarta\.servlet\." org/apache/coyote --include=*.java | wc -l)
printf ' org.apache.%-9s -> %-20s %4d\n' coyote jakarta.servlet "$n"
### 0b. who imports whom (number of .java files with such an import, in /usr/src/tomcat/java)
org.apache.coyote -> org.apache.catalina 0
org.apache.tomcat -> org.apache.catalina 0
org.apache.jasper -> org.apache.catalina 0
org.apache.el -> org.apache.catalina 0
org.apache.juli -> org.apache.catalina 0
org.apache.naming -> org.apache.catalina 0
org.apache.catalina -> org.apache.coyote 24
org.apache.catalina -> org.apache.jasper 0
org.apache.catalina -> org.apache.naming 6
org.apache.coyote -> jakarta.servlet 20
화살표는 한쪽으로만 갑니다. Catalina는 Coyote를 가져다 쓰지만(24개 파일), Coyote도, 공용 도구인 org.apache.tomcat도 Catalina를 import하지 않습니다. Coyote에서 Catalina로 가는 길은 Adapter 인터페이스로 거꾸로 꽂아 준 객체 하나뿐입니다. 11.0.x 브랜치에서도 coyote와 jasper 아래에 org.apache.catalina를 import하는 파일은 없었습니다.
이렇게 나눈 덕분에 생기는 일이 소스에 보입니다. 새 프로토콜은 Coyote 쪽에 ProtocolHandler 구현 하나를 더하면 되고(ajp/, http11/, http2/ 폴더가 나란히 있습니다), Catalina는 어떤 프로토콜로 왔든 Adapter.service() 한 번으로 요청을 받습니다. 반대로 Catalina의 밸브나 세션을 고쳐도 Coyote는 다시 볼 필요가 없습니다. Tomcat이 처음 이렇게 나눈 역사적인 이유까지는 소스로 확인할 수 없어서 적지 않습니다.
Spring에서도 같은 생각을 보았습니다. WebFlux의 HttpHandler는 특정 서버를 모르고, 서버마다 하나씩 있는 어댑터가 서버의 요청을 Spring 타입으로 감싸 넘겨 줍니다(Spring 24편). CoyoteAdapter가 Coyote 요청을 서블릿 요청으로 감싸는 것과 같은 자리입니다. httpd에서는 연결을 받는 MPM과 요청을 처리하는 코어·모듈이 훅으로 이어졌습니다(httpd 3편).
헷갈리기 쉬운 곳: Request가 둘이다
소스에서 Request라는 이름의 클래스를 만나면 패키지를 먼저 봐야 합니다.
| 클래스 | 선언 | 무엇 |
|---|---|---|
org.apache.coyote.Request |
public final class Request (coyote/Request.java 55행) |
Coyote가 바이트에서 읽어 낸 날것의 요청. 서블릿 인터페이스를 구현하지 않는다 |
org.apache.catalina.connector.Request |
public class Request implements HttpServletRequest (connector/Request.java 129행) |
위의 것을 감싼 서블릿 요청. Mapper가 고른 Host·Context·Wrapper도 여기에 적힌다 |
CoyoteAdapter.service()의 인자 req가 앞의 것, 지역 변수 request가 뒤의 것입니다. 서블릿에 넘어가는 것은 이것을 한 번 더 감싼 RequestFacade인데, 왜 또 감싸는지는 7편에서 봅니다. Response도 똑같이 둘입니다.
헷갈리기 쉬운 곳: Coyote가 서블릿 API를 아예 안 쓰는 것은 아니다
위 출력의 마지막 줄처럼, Coyote에도 jakarta.servlet을 import하는 파일이 20개 있습니다. 가져다 쓰는 것은 ReadListener·WriteListener(논블로킹 입출력), HttpUpgradeHandler·WebConnection(프로토콜 업그레이드), ServletConnection 같은 몇 가지 인터페이스입니다. 서블릿 규격의 비동기 입출력과 업그레이드가 커넥터 깊숙이까지 닿아야 하기 때문입니다(12편, 15편). "Coyote는 Catalina를 모른다"는 맞지만, "Coyote는 서블릿 API를 모른다"는 정확하지 않습니다.
헷갈리기 쉬운 곳: Host를 고르는 것은 Engine이 아니라 Mapper다
server.xml의 <Engine> 위 주석은 Engine이 "HTTP 헤더를 분석해 알맞은 Host로 넘긴다"고 설명합니다(106–109행). 동작으로 보면 맞는 말이지만, 소스에서 Host를 고르는 일은 2번의 Mapper가 이미 끝냈습니다. Engine의 기본 밸브는 요청에 적힌 Host를 꺼내 쓸 뿐입니다.
public void invoke(Request request, Response response) throws IOException, ServletException {
// Select the Host to be used for this Request
Host host = request.getHost();
if (host == null) {
// No Host found for this request (unknown server name or no default host defined)
// Don't overwrite an existing error
if (!response.isError()) {
response.sendError(404);
}
return;
}
if (request.isAsyncSupported()) {
request.setAsyncSupported(host.getPipeline().isAsyncSupported());
}
// Ask this Host to process this request
host.getPipeline().getFirst().invoke(request, response);
}
java/org/apache/catalina/core/StandardEngineValve.java 54–72행
request.getHost()는 Mapper가 채운 mappingData.host를 돌려주는 한 줄짜리 메서드입니다(connector/Request.java 675–677행). Host 밸브가 Context를, Context 밸브가 Wrapper를 꺼내는 것도 같은 방식입니다(6절).
5. jakarta API와 구현, 그리고 Jasper·EL·JULI·Naming의 자리
저장소의 java/ 폴더
java/ 아래는 크게 둘로 나뉩니다. java/jakarta/는 규격(서블릿·EL·WebSocket 같은 Jakarta EE 규격의 인터페이스와 기본 클래스)이고, java/org/apache/는 Tomcat의 구현입니다. 기준 커밋에서 .java 파일 수를 직접 세었습니다.
| 폴더 | .java 수 |
무엇 | 자세히 |
|---|---|---|---|
jakarta/servlet |
117 | 서블릿 규격 (HttpServlet, Filter, ServletContext …) |
|
jakarta/el |
41 | EL 규격 (ExpressionFactory …) |
|
jakarta/websocket |
33 | WebSocket 규격 | 15편 |
org/apache/catalina |
611 | 서블릿 컨테이너 | 이 연재의 대부분 |
org/apache/coyote |
92 | 커넥터 | 5·6편 |
org/apache/tomcat |
577 | 공용 도구 (util/net, util/buf, util/threads, util/digester …)와 WebSocket 구현 |
여러 편 |
org/apache/jasper |
109 | JSP 엔진 | 13편 |
org/apache/el |
82 | EL 구현 | 13편 |
org/apache/naming |
32 | JNDI 구현 | 이 글 |
org/apache/juli |
14 | 로깅 | 16편 |
jakarta/ 아래에는 이 밖에 annotation, security, transaction, mail 등 작은 폴더가 더 있습니다. 폴더 전체 지도는 1편에 있습니다.
예제 4: 빌드된 jar 안에는 무엇이 들었나
설치본의 lib/와 bin/에서는 이 패키지들이 jar로 나뉩니다. 예제 컨테이너의 $CATALINA_HOME에서 jar마다 들어 있는 패키지를 찍었습니다(출력이 길어지지 않게 앞쪽 세 단계까지만 자릅니다).
### 0. jars in $CATALINA_HOME/lib and bin: top-level packages inside each
catalina.jar org/apache/catalina org/apache/naming
tomcat-coyote.jar org/apache/coyote org/apache/tomcat
tomcat-util.jar org/apache/tomcat
servlet-api.jar jakarta/servlet jakarta/servlet/annotation jakarta/servlet/descriptor jakarta/servlet/http
jasper.jar org/apache/jasper
jasper-el.jar org/apache/el
el-api.jar jakarta/el
tomcat-juli.jar org/apache/juli
org/apache/tomcat/util/* is split across jars:
tomcat-coyote.jar bcel http log modeler net
tomcat-util.jar buf collections compat concurrent file json res security threads
tomcat-util-scan.jar descriptor digester scan
where is org/apache/naming ?
catalina.jar: 34 classes
- 규격(
jakarta.*)과 구현이 다른 jar에 있습니다. 웹앱을 컴파일할 때servlet-api.jar하나만 클래스패스에 두면 되는 이유입니다(예제의javac -cp $CATALINA_HOME/lib/servlet-api.jar). tomcat-juli.jar만lib/가 아니라bin/에 있습니다.catalina.sh는 JVM을 띄울 때bin/bootstrap.jar와 이 jar를 클래스패스에 올립니다(bin/catalina.sh 214–230행). 가장 먼저 실행되는Bootstrap클래스부터 JULI로 로그를 남기기 때문입니다(Bootstrap.java 52행).lib/의 jar들이 어느 클래스로더로 올라가는지는 4편에서 봅니다.
화살표는 "가져다 쓴다" 또는 "구현한다"입니다. 어느 화살표도 Catalina 쪽으로 들어가지 않습니다(예제 3).
Jasper: JSP를 서블릿으로 바꾸는 "그냥 서블릿"
Jasper(재스퍼)는 JSP 파일을 자바 서블릿 소스로 바꾸고 컴파일해 실행하는 JSP 엔진입니다. 그런데 Catalina 안에 특별히 꽂혀 있지 않습니다. 모든 웹앱에 합쳐지는 conf/web.xml에 서블릿 하나로 등록되어 있을 뿐입니다.
<servlet>
<servlet-name>jsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
<!-- ... -->
<load-on-startup>3</load-on-startup>
</servlet>
같은 파일 아래쪽에서 *.jsp와 *.jspx를 이 서블릿에 매핑합니다(441–445행). 예제 1에서 ROOT 웹앱에 jsp라는 Wrapper가 있었던 것이 이 서블릿입니다. 예제 3의 출력대로 Catalina는 org.apache.jasper를 import하지 않고, 클래스 이름을 문자열로만 압니다(JSP_SERVLET_CLASS = "org.apache.jasper.servlet.JspServlet", core/Constants.java 43행). 그래서 Jasper는 컨테이너 입장에서 "Wrapper 하나에 든 서블릿"일 뿐이고, Catalina의 소스는 Jasper의 클래스를 직접 참조하지 않습니다. JSP가 서블릿이 되는 과정은 13편에서 봅니다.
EL: 규격의 ExpressionFactory가 찾아 주는 구현
EL(Expression Language)은 JSP의 ${user.name} 같은 식을 계산하는 언어입니다. Jasper는 EL 구현 클래스를 직접 부르지 않고 규격의 jakarta.el.ExpressionFactory.newInstance()를 부릅니다(jasper/compiler/PageInfo.java 77행). 그 메서드의 설명이 구현을 찾는 순서를 적고 있습니다.
- services API (META-INF/services/jakarta.el.ExpressionFactory)
- $JRE_HOME/lib/el.properties - key jakarta.el.ExpressionFactory
- jakarta.el.ExpressionFactory
- Platform default implementation - org.apache.el.ExpressionFactoryImpl
(java/jakarta/el/ExpressionFactory.java 61–68행) 따로 정해 주지 않으면 마지막의 org.apache.el.ExpressionFactoryImpl, 곧 jasper-el.jar의 Tomcat 구현이 쓰입니다. EL은 13편에서 맛만 봅니다. Spring의 SpEL과 이름이 비슷하지만 다른 언어입니다(Spring 15편).
JULI: 모두가 쓰는 로깅
JULI(줄리)는 Tomcat의 로깅 패키지(org.apache.juli)입니다. 저장소의 로깅 문서는 JULI를 Apache Commons Logging의 이름을 바꿔 들여온 포크이고 JDK의 java.util.logging을 쓰도록 고정한 것이라고 설명합니다(webapps/docs/logging.xml 38–40행). 핵심 부품은 JDK의 LogManager를 상속해 클래스로더(곧 웹앱)마다 따로 로그 설정을 두게 한 ClassLoaderLogManager입니다(ClassLoaderLogManager.java 39–44행). 소스 곳곳의 private static final Log log = LogFactory.getLog(…)가 JULI의 것입니다. Container의 getLogger()도 JULI의 Log를 돌려줍니다(import org.apache.juli.logging.Log;, Container.java 26행과 104행). 기준 커밋에서 org.apache.juli를 import하는 파일은 catalina 205개, tomcat 73개, coyote 27개, jasper 19개, naming 5개였고, el에는 없었습니다. 16편에서 봅니다.
Naming: java:comp/env를 만들어 주는 JNDI 구현
Naming(org.apache.naming)은 JNDI(자바의 "이름으로 자원 찾기" API)의 Tomcat 구현입니다. 웹앱이 new InitialContext().lookup("java:comp/env/jdbc/MyDB")처럼 DB 연결 풀을 이름으로 찾을 수 있는 것이 이 패키지 덕분입니다. 예제 4의 출력처럼 따로 jar가 있지 않고 catalina.jar 안에 들어 있습니다.
server.xml의 <GlobalNamingResources>는 NamingResourcesImpl 객체가 되어 Server에 붙고(Catalina.java 517–520행), StandardServer는 생성자에서 naming이 켜져 있으면 NamingContextListener를 자기 리스너로 붙입니다.
if (isUseNaming()) {
namingContextListener = new NamingContextListener();
addLifecycleListener(namingContextListener);
} else {
namingContextListener = null;
}
java/org/apache/catalina/core/StandardServer.java 85–90행
isUseNaming()은 시스템 속성 catalina.useNaming을 봅니다(851–859행). 내장 Tomcat은 getServer()에서 이 속성을 "false"로 둡니다(Tomcat.java 632행). 내장 Tomcat에서 JNDI가 기본으로 꺼져 있는 이유입니다. 이 연재는 Naming을 따로 다루지 않습니다.
헷갈리기 쉬운 곳: org.apache.tomcat.util은 jar 셋에 흩어져 있다
예제 4의 출력처럼 소켓을 다루는 util/net은 tomcat-coyote.jar에, 스레드 풀 util/threads는 tomcat-util.jar에, server.xml을 읽는 util/digester는 tomcat-util-scan.jar에 들어 있습니다. 소스 폴더는 하나인데 jar는 쓰임새에 따라 나뉘었습니다. 1편의 그림이나 이 글의 그림이 "tomcat.util" 하나로 묶어 그린 것은 소스 폴더 기준입니다.
6. 큰 그림 한 장: 요청이 컨테이너를 바깥에서 안으로
이제 앞의 조각을 한 줄로 꿰어 봅니다. 4절에서 다리를 건넌 요청은 Engine → Host → Context → Wrapper 순서로 안쪽 상자로 들어갑니다.
파란 상자 하나가 컨테이너 하나의 파이프라인이고, 그 끝에 기본 밸브가 있습니다. 화살표 위의 메서드는 기본 밸브가 다음 컨테이너를 꺼내는 방법입니다.
예제 5: 서블릿 안에서 호출 스택 찍기
정말 이 순서로 오는지, 예제 2의 내장 Tomcat에 서블릿 하나를 올리고 doGet() 안에서 호출 스택을 찍었습니다.
// 호출 스택을 찍는 서블릿
public static class StackServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
StringBuilder sb = new StringBuilder();
StackTraceElement[] frames = new Throwable().getStackTrace();
for (int i = 0; i < frames.length; i++) {
StackTraceElement f = frames[i];
sb.append(String.format("#%-2d %s.%s%n", i, f.getClassName(), f.getMethodName()));
}
sb.append("thread = ").append(Thread.currentThread().getName()).append('\n');
resp.setContentType("text/plain");
resp.getWriter().print(sb);
}
}
=== 3. call stack inside the servlet (GET /app/stack) ===
#0 EmbeddedTree$StackServlet.doGet
#1 jakarta.servlet.http.HttpServlet.service
#2 jakarta.servlet.http.HttpServlet.service
#3 org.apache.catalina.core.ApplicationFilterChain.doFilter
#4 org.apache.catalina.core.StandardWrapperValve.invoke
#5 org.apache.catalina.core.StandardContextValve.invoke
#6 org.apache.catalina.authenticator.AuthenticatorBase.invoke
#7 org.apache.catalina.core.StandardHostValve.invoke
#8 org.apache.catalina.valves.ErrorReportValve.invoke
#9 org.apache.catalina.core.StandardEngineValve.invoke
#10 org.apache.catalina.connector.CoyoteAdapter.service
#11 org.apache.coyote.http11.Http11Processor.service
#12 org.apache.coyote.AbstractProcessorLight.process
#13 org.apache.coyote.AbstractProtocol$ConnectionHandler.process
#14 org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun
#15 org.apache.tomcat.util.net.SocketProcessorBase.run
#16 org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker
#17 org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run
#18 org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run
#19 java.lang.Thread.run
thread = http-nio-8080-exec-1
스레드 이름 끝의 번호(exec-1)는 실행마다 다릅니다(다시 돌렸을 때는 exec-2가 나왔습니다). 아래에서 위로 읽으면 이 글의 지도가 그대로 겹칩니다.
| 프레임 | 층 | 이 글 | 자세히 |
|---|---|---|---|
| #14–#19 | tomcat.util의 엔드포인트와 스레드 풀 (http-nio-8080-exec-N) |
4절 | 5편 |
| #11–#13 | Coyote: 프로토콜 핸들러와 HTTP/1.1 프로세서 | 4절 | 6편 |
| #10 | 다리: CoyoteAdapter.service() |
4절 | 7편 |
| #9 | Engine의 기본 밸브 | 3·6절 | 8편 |
| #7–#8 | Host의 파이프라인: ErrorReportValve → 기본 밸브 |
6절 | 8편 |
| #5–#6 | Context의 파이프라인: 인증 밸브 → 기본 밸브 | 6절 | 8편, 14편 |
| #4 | Wrapper의 기본 밸브 | 6절 | 9편 |
| #0–#3 | 필터 체인과 내 서블릿 | 9편 |
헷갈리기 쉬운 곳: 아무것도 걸지 않았는데 밸브가 끼어 있다
예제는 <Valve>를 하나도 걸지 않았는데 스택에 ErrorReportValve(#8)와 AuthenticatorBase(#6)가 보입니다. 앞의 것은 StandardHost가 시작할 때(startInternal()) 기본으로 붙이는 오류 페이지 밸브입니다. 붙일 클래스는 errorReportValveClass 속성이고 기본값이 "org.apache.catalina.valves.ErrorReportValve"입니다(StandardHost.java 146행, 붙이는 곳은 786–800행). 뒤의 것은 내장 Tomcat이 addContext()로 만든 Context에 FixContextListener를 달아 두고(Tomcat.java 680행), 이 리스너가 로그인 설정이 없는 Context에 NonLoginAuthenticator를 붙인 것입니다(1133–1135행). 스택에 클래스 이름이 AuthenticatorBase로 찍힌 것은 invoke()가 상위 클래스에 정의되어 있기 때문입니다. 각 밸브가 언제 붙는지는 8편과 14편에서 봅니다.
헷갈리기 쉬운 곳: 11.0.x의 예제 코드가 main에서 컴파일되지 않을 수 있다
예제 2를 처음 쓸 때 서블릿 매핑을 ctx.addServletMappingDecoded("/stack", "stack")으로 적었다가 cannot find symbol 컴파일 오류가 났습니다. 11.0.x 브랜치의 Context에는 addServletMappingDecoded()가 있지만, 기준 커밋(main)의 Context에는 addServletMapping()만 남아 있습니다(Context.java 914행, 928행). 이 블로그의 Spring 25편 예제처럼 Tomcat 11.0.x로 쓴 내장 Tomcat 코드를 main으로 옮길 때 만나는 차이입니다.
이 글에서 뺀 것
지도를 한 장으로 유지하려고 뺀 것들입니다.
- Digester가 규칙을 돌리는 방식(규칙 스택,
SetNextRule).java/org/apache/tomcat/util/digester/— 3편 Bootstrap이 클래스로더를 만들고 리플렉션으로Catalina를 부르는 과정.java/org/apache/catalina/startup/Bootstrap.java— 3편, 4편- 컨테이너에 붙는 부품들:
Loader(클래스로더),Manager(세션),Realm(인증),WebResourceRoot(파일).Container.java49–62행의 설명 — 4편, 11편, 14편 ContainerBase의 백그라운드 스레드(backgroundProcess(), 세션 만료·리로드 검사).core/ContainerBase.java- Engine의
defaultHost, Host의<Alias>,Engine.jvmRoute같은 속성.core/StandardEngine.java,core/StandardHost.java - 클러스터(
org/apache/catalina/ha,tribes)와<Cluster>규칙(Catalina.addClusterRuleSet(), 607–632행) storeconfig(실행 중인 객체 나무를 다시 server.xml로 쓰기).java/org/apache/catalina/storeconfig/- WebSocket 구현(
org/apache/tomcat/websocket) — 15편
정리
| 개념 | 한 줄 요약 |
|---|---|
| server.xml → 객체 | Catalina.createStartDigester()의 규칙대로 요소마다 객체를 만들고(addObjectCreate), 속성을 넣고, 바깥 객체의 addService·addConnector·setContainer·addChild로 붙인다 |
| Server | Tomcat 하나(StandardServer). Service 배열과 종료 포트(8005)를 가진다. Container가 아니다 |
| Service | 커넥터 여럿 + Engine 하나 + Mapper(StandardService). Container가 아니다 |
| 컨테이너 넷 | Engine(엔진 전체) ⊃ Host(가상 호스트) ⊃ Context(웹앱) ⊃ Wrapper(서블릿 정의). 모두 ContainerBase를 상속 |
| 부모·자식 | ContainerBase.addChild()가 이름으로 자식을 넣고 setParent()를 부른다. 층의 순서는 각 addChild()·setParent()가 검사한다. Engine의 부모는 null |
| Context·Wrapper가 생기는 곳 | Context는 HostConfig가 webapps/에서, Wrapper는 ContextConfig가 web.xml에서. 둘 다 시작할 때 |
| Coyote | 커넥터. 바이트 ↔ org.apache.coyote.Request. protocol 속성으로 ProtocolHandler를 고른다. Catalina를 import하지 않는다 |
Adapter |
Coyote가 정한 인터페이스. Connector.initInternal()이 CoyoteAdapter를 꽂고, 프로세서가 getAdapter().service()로 부른다 |
CoyoteAdapter |
coyote 요청을 HttpServletRequest로 감싸고, Mapper로 목적지를 고르고, Engine 파이프라인의 첫 밸브를 부른다 |
| jakarta와 구현 | 규격은 java/jakarta(servlet-api.jar 등), 구현은 java/org/apache(catalina.jar 등) |
| Jasper·EL | conf/web.xml에 등록된 JspServlet 하나. EL 구현은 ExpressionFactory.newInstance()가 찾는다 |
| JULI·Naming | JULI는 bin/tomcat-juli.jar의 로깅. Naming은 catalina.jar 안의 JNDI 구현, 내장 Tomcat에서는 꺼져 있다 |
다음에 볼 것
지도를 접고, 지도의 모든 상자가 공유하는 뿌리부터 읽습니다. 다음 글은 catalina.sh start를 친 뒤 일어나는 일입니다. Bootstrap이 클래스로더를 만들고 Catalina를 부르는 길, 이 글에서 규칙만 본 Digester가 server.xml을 실제로 읽는 과정, 그리고 init()·start()가 Server에서 Wrapper까지 부모에서 자식으로 번져 가는 Lifecycle 상태 기계를, 리스너로 이벤트 순서를 찍는 예제로 확인합니다.
Tomcat 소스 파헤치기 3: Lifecycle과 기동 — catalina.sh start 뒤에 일어나는 일
앞서 끝낸 두 연재에서도 같은 자리에 전체 지도를 폈습니다. httpd 3편에서 httpd는 코어가 훅을 정의하고 MPM과 모듈이 거기에 함수를 거는 구조였고, server_rec·conn_rec·request_rec 세 구조체가 서로를 포인터로 가리켰습니다. Tomcat은 같은 일을 자바 객체의 부모·자식 나무와 인터페이스로 합니다. 가상 호스트가 server_rec이 아니라 Host 객체이고, 연결을 받는 MPM 대신 Coyote가 있습니다. Spring 2편의 지도와 비교하면 자리가 하나 더 보입니다. Spring MVC의 DispatcherServlet은 이 글의 나무에서 Wrapper 하나에 든 서블릿입니다. 예제 5의 #0 자리에 DispatcherServlet이 오고, 그 위로 Spring의 세계가 펼쳐집니다(Spring 21편). Spring의 루트 컨텍스트가 Tomcat의 ServletContext(곧 Context 하나)에 보관되는 모습은 Spring 20편에서 내장 Tomcat으로 직접 띄워 보았습니다.