이 글에서 다루는 것
"Tomcat 소스 파헤치기" 연재의 열여섯 번째이자 마지막 글입니다. 15편에서는 HTTP/1.1 연결이 업그레이드를 거쳐 HTTP/2와 WebSocket으로 갈아타는 길을 봤습니다. 지금까지는 catalina.sh start로 띄운 Tomcat의 안을 따라왔습니다. 이번에는 바깥쪽 세 가지를 봅니다. Tomcat을 프로그램 안에서 띄우는 법(내장 Tomcat), 실행 중인 Tomcat을 밖에서 들여다보는 창(JMX), 그리고 Tomcat과 웹앱이 남기는 로그(JULI)입니다. 글 끝의 "연재를 마치며"에서 16편 전체를 정리합니다.
답하려는 질문은 네 가지입니다.
new Tomcat()한 줄은 server.xml의 어떤 요소들을 대신 만들어 주고, 무엇은 만들어 주지 않는가addContext()와addWebapp()은 무엇이 다른가- jconsole 같은 도구에 보이는
Catalina:type=Server같은 이름은 누가, 언제 등록하고, 그 속성을 읽으면 무슨 일이 일어나는가 - 같은 JVM 안의 웹앱 둘이 같은 이름의 로거를 써도 설정이 섞이지 않는 까닭은 무엇인가
| 항목 | 내용 |
|---|---|
| 대상 | Apache Tomcat |
| 버전 | 12.0.0-M1 (main) |
| 커밋 | 17aa1769b0 (2026-10-23) |
| 주로 읽는 클래스 | startup/Tomcat, util/LifecycleMBeanBase, tomcat/util/modeler/Registry·BaseModelMBean, juli/ClassLoaderLogManager |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
세 주제는 저마다 글 한 편으로도 모자랄 만큼 큽니다. 이 글은 처음 보는 사람이 "어디서 시작해 어디로 가는지"를 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
내장 Tomcat(embedded Tomcat)은 Tomcat을 따로 설치해 catalina.sh로 띄우지 않고, 우리 프로그램의 main() 안에서 객체로 만들어 띄우는 방식입니다. Tomcat의 jar를 라이브러리처럼 클래스패스에 넣고 new Tomcat()을 부릅니다. Spring Boot로 만든 웹 애플리케이션이 java -jar 하나로 뜨는 것도 이 방식이라고 알려져 있습니다(2절 끝에 적었듯 Spring Boot의 소스로 확인한 것은 아닙니다). 이 블로그의 Spring 연재도 웹 예제를 내장 Tomcat 11.0.15(tomcat-embed-core)로 돌렸습니다. Spring 20편은 그 위에 루트 컨텍스트를, Spring 25편은 DispatcherServlet을 올렸습니다.
Server, Service, Engine, Host, Context, Wrapper는 server.xml의 요소이자 그 요소를 맡는 클래스입니다. 바깥에서부터 서버 전체 → 커넥터와 엔진을 묶는 단위 → 요청을 받는 컨테이너 넷(엔진, 가상 호스트, 웹앱, 서블릿 하나)입니다. 2편에서 봤습니다. 이 객체들이 init() → start() → stop() → destroy()를 차례로 지나는 Lifecycle은 3편에서 봤습니다.
JMX(Java Management Extensions)는 실행 중인 JVM 안의 객체를 밖에서 들여다보고 조작하는 Java 표준입니다. 들여다볼 수 있게 등록한 객체를 MBean(Managed Bean)이라고 하고, MBean마다 도메인:키=값,키=값 꼴의 이름(ObjectName)이 붙습니다. MBean을 모아 두는 곳이 MBeanServer이고, JDK에 들어 있는 jconsole이나 VisualVM 같은 도구가 여기에 붙어 속성(attribute)을 읽고 바꾸고 작업(operation)을 부릅니다.
JULI는 Tomcat이 쓰는 로깅 모듈로, java/org/apache/juli에 있습니다. JDK에 들어 있는 java.util.logging(줄여서 JUL)을 그대로 쓰되, 그 안의 LogManager(로거를 만들고 설정 파일을 읽는 관리자)를 Tomcat의 것으로 바꿔 끼웁니다. JUL의 낱말 셋을 알아 두면 됩니다. 로거(Logger)는 이름이 붙은 기록 창구이고(Logger.getLogger("demo")), 레벨(Level)은 FINE < INFO < WARNING 같은 중요도이고, 핸들러(Handler)는 기록을 실제로 파일이나 콘솔에 쓰는 객체입니다.
클래스로더는 클래스 파일을 찾아 JVM에 올리는 객체입니다. Tomcat은 웹앱마다 클래스로더를 하나씩 따로 둡니다(4편). 그리고 요청을 처리하는 동안에는 그 스레드의 컨텍스트 클래스로더(스레드마다 하나씩 붙어 있는 "지금 기준이 되는 클래스로더")를 그 웹앱의 로더로 바꿔 둡니다(StandardHostValve 99행). 4절의 JULI는 이 사실 하나에 기대어 움직입니다.
1. 내장 Tomcat — server.xml을 코드로
catalina.sh start로 띄우면 Catalina가 Digester로 server.xml을 읽어 StandardServer부터 StandardContext까지 객체를 만듭니다(3편). 내장 Tomcat에서는 server.xml이 없으므로 그 일을 java/org/apache/catalina/startup/Tomcat.java 한 클래스가 메서드로 대신합니다. 클래스 머리의 주석이 자기를 이렇게 소개합니다.
Minimal tomcat starter for embedding/unit tests.
(Tomcat.java 88–134행) "내장용·단위 테스트용 최소한의 시작기"라는 뜻입니다. 주석은 이어서, 가장 흔하고 안정된 방식은 server.xml을 쓰는 Bootstrap이고 이 클래스는 Tomcat을 품는(embed) 프로그램을 위한 것이라고 적어 둡니다.
필요할 때 하나씩 만든다
Tomcat 클래스의 특징은 게으르게(lazy) 만든다는 것입니다. new Tomcat()은 거의 아무것도 하지 않고(175–177행), getServer(), getEngine(), getHost() 같은 메서드가 처음 불릴 때 그 객체가 없으면 만들어 끼웁니다. 맨 바깥의 getServer()는 이렇습니다.
public Server getServer() {
if (server != null) {
return server;
}
System.setProperty("catalina.useNaming", "false");
server = new StandardServer();
initBaseDir();
// Set configuration source
ConfigFileLoader.setSource(new CatalinaBaseConfigurationSource(new File(basedir), null));
server.setPort(-1);
Service service = new StandardService();
service.setName("Tomcat");
server.addService(service);
return server;
}
java/org/apache/catalina/startup/Tomcat.java 626–647행
server.xml의 바깥 두 요소인 <Server port="8005" shutdown="SHUTDOWN">과 <Service name="Catalina">에 해당합니다. 다른 점이 둘 있습니다.
- 종료 포트가
-1입니다.catalina.sh stop은 8005번 포트로SHUTDOWN글자를 보내 서버를 멈춥니다(3편).StandardServer.await()는 포트가-1이면 소켓을 열지 않고 10초마다 깨어나 멈추라는 표시만 확인합니다(StandardServer.java512–533행). 내장 Tomcat은 우리 프로그램이tomcat.stop()을 불러 멈추므로 종료 포트가 필요 없습니다. - 이름이
Catalina가 아니라Tomcat입니다. 서비스 이름도, 곧 볼 엔진 이름도Tomcat입니다. 이 이름은 3절의 JMX 이름과 4절의 로거 이름에 그대로 드러납니다.
initBaseDir()는 작업 폴더(JSP를 컴파일한 결과 같은 것이 들어가는 work/ 등)의 기준이 될 폴더를 정합니다. setBaseDir()로 정하지 않았으면 시스템 속성 catalina.base, 그다음 catalina.home, 둘 다 없으면 프로그램을 실행한 폴더 아래의 tomcat.<포트>를 씁니다(826–837행).
엔진과 호스트도 같은 모양입니다. getEngine()은 StandardEngine을 만들어 이름을 Tomcat, 기본 호스트를 localhost로 두고, createDefaultRealm()이 돌려주는 메모리 속 사용자 목록(SimpleRealm)을 붙입니다(608–619행, 796–820행). addUser()·addRole()로 넣은 사용자가 이 Realm에 들어갑니다. Realm은 14편에서 봤습니다. getHost()는 StandardHost 하나를 localhost라는 이름으로 만들어 엔진에 붙입니다(591–601행).
파란 상자는 아래 상자를 부를 때 저절로 만들어집니다. 빨간 상자인 커넥터만은 따로 불러야 생깁니다.
헷갈리기 쉬운 곳: 커넥터는 저절로 생기지 않습니다
그림에서 커넥터만 빨간색입니다. start()를 보면 까닭이 보입니다.
public void start() throws LifecycleException {
getServer();
server.start();
}
java/org/apache/catalina/startup/Tomcat.java 466–469행
서버를 만들고 시작할 뿐, 커넥터를 챙기지 않습니다. 커넥터는 getConnector()가 불렸을 때만 생깁니다.
public Connector getConnector() {
Service service = getService();
if (service.findConnectors().length > 0) {
return service.findConnectors()[0];
}
// The same as in standard Tomcat configuration.
// This creates a NIO HTTP connector.
Connector connector = new Connector("HTTP/1.1");
connector.setPort(port);
service.addConnector(connector);
return connector;
}
java/org/apache/catalina/startup/Tomcat.java 525–536행
이름은 "가져온다(get)"인데 하는 일은 "없으면 만든다"입니다. 그래서 getConnector()를 한 번도 부르지 않고 start()하면 서블릿 컨테이너는 다 떠 있는데 포트는 열리지 않습니다. 오류도 나지 않습니다. 커넥터가 없는 서비스는 규칙 위반이 아니기 때문입니다. Spring 25편의 예제가 tomcat.getConnector().setProperty("maxThreads", "1")로 스레드 수를 줄였는데, 이 줄이 동시에 커넥터를 만드는 일도 했던 셈입니다. 이 동작은 11.0.x 브랜치에서도 같습니다(Tomcat.java가 두 브랜치에서 글자 하나 다르지 않습니다).
new Connector("HTTP/1.1")가 어떤 프로토콜 처리기를 고르는지, 그 안의 Acceptor·Poller·작업 스레드는 5편에서 봤습니다.
addContext()와 addWebapp()
웹앱(Context)을 붙이는 메서드는 두 갈래입니다. 이름이 비슷하지만 하는 일이 꽤 다릅니다.
public Context addContext(Host host, String contextPath, String contextName, String dir) {
silence(host, contextName);
Context ctx = createContext(host, contextPath);
ctx.setName(contextName);
ctx.setPath(contextPath);
ctx.setDocBase(dir);
ctx.addLifecycleListener(new FixContextListener());
if (host == null) {
getHost().addChild(ctx);
} else {
host.addChild(ctx);
}
return ctx;
}
java/org/apache/catalina/startup/Tomcat.java 674–688행
addContext()가 붙이는 리스너는 FixContextListener 하나입니다. 10편에서 본 것처럼 보통의 웹앱은 ContextConfig라는 리스너가 CONFIGURE_START_EVENT 때 web.xml을 읽고 어노테이션을 뒤진 뒤 "설정 끝(configured = true)"을 표시합니다. 이 표시가 없으면 StandardContext는 시작에 실패합니다(StandardContext.java 4576–4579행). FixContextListener는 그 자리에서 web.xml을 읽지 않고 표시만 해 줍니다.
@Override
public void lifecycleEvent(LifecycleEvent event) {
try {
Context context = (Context) event.getLifecycle();
if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
context.setConfigured(true);
// Process annotations
WebAnnotationSet.loadApplicationAnnotations(context);
// ...
}
} catch (ClassCastException e) {
// Ignore
}
}
java/org/apache/catalina/startup/Tomcat.java 1121–1141행
줄인 자리에는 @ServletSecurity 처리를 위해 로그인 설정이 없으면 빈 설정과 NonLoginAuthenticator 밸브를 붙이는 몇 줄이 있습니다. 여기서 loadApplicationAnnotations()는 이미 등록된 서블릿·필터·리스너 클래스에 붙은 @Resource 같은 어노테이션을 처리하는 것이지, 10편의 ContextConfig처럼 jar를 뒤져 @WebServlet 클래스를 찾아내는 것이 아닙니다. 그래서 addContext()로 만든 웹앱에는 코드로 직접 붙인 서블릿만 있습니다. / 아래의 정적 파일을 내주는 DefaultServlet도, JSP도 없습니다.
addWebapp()은 반대입니다.
public Context addWebapp(Host host, String contextPath, String docBase, LifecycleListener config) {
silence(host, contextPath);
Context ctx = createContext(host, contextPath);
ctx.setPath(contextPath);
ctx.setDocBase(docBase);
if (addDefaultWebXmlToWebapp) {
ctx.addLifecycleListener(getDefaultWebXmlListener());
}
ctx.setConfigFile(getWebappConfigFile(docBase, contextPath));
ctx.addLifecycleListener(config);
if (addDefaultWebXmlToWebapp && (config instanceof ContextConfig)) {
// prevent it from looking ( if it finds one - it'll have dup error )
((ContextConfig) config).setDefaultWebXml(noDefaultWebXmlPath());
}
if (host == null) {
getHost().addChild(ctx);
} else {
host.addChild(ctx);
}
return ctx;
}
java/org/apache/catalina/startup/Tomcat.java 736–764행
인자가 셋인 addWebapp()은 호스트의 configClass(기본값 ContextConfig)를 리플렉션으로 만들어 이 메서드에 넘깁니다(705–717행). 그러니 WEB-INF/web.xml, web-fragment, ServletContainerInitializer, META-INF/context.xml까지 webapps/에 넣은 앱과 똑같이 처리됩니다. 다른 점은 하나, $CATALINA_BASE/conf/web.xml(모든 웹앱에 깔리는 공통 기본값)을 읽지 않는다는 것입니다. 그 대신 DefaultWebXmlListener가 시작 직전(BEFORE_START_EVENT)에 initWebappDefaults()를 불러 같은 내용을 코드로 넣습니다(1156–1161행).
public static void initWebappDefaults(Context ctx) {
// Default servlet
Wrapper servlet = addServlet(ctx, "default", "org.apache.catalina.servlets.DefaultServlet");
servlet.setLoadOnStartup(1);
servlet.setOverridable(true);
// JSP servlet (by class name - to avoid loading all deps)
servlet = addServlet(ctx, "jsp", "org.apache.jasper.servlet.JspServlet");
servlet.addInitParameter("fork", "false");
servlet.setLoadOnStartup(3);
servlet.setOverridable(true);
// Servlet mappings
ctx.addServletMapping("/", "default");
ctx.addServletMapping("*.jsp", "jsp");
ctx.addServletMapping("*.jspx", "jsp");
// ...
}
java/org/apache/catalina/startup/Tomcat.java 1056–1087행
줄인 자리는 세션 시간 30분, MimeTypeMappings.properties에서 읽는 MIME 형식, 환영 파일(index.html, index.htm, index.jsp)입니다. conf/web.xml에서 보던 default와 jsp 서블릿이 여기 있습니다. JSP 서블릿이 .jsp 요청을 받아 컴파일하는 과정은 13편에서 봤습니다.
addContext()는 빈 그릇, addWebapp()은 webapps/에 넣은 앱과 거의 같은 것을 만듭니다.
서블릿을 붙이는 addServlet()도 두 가지입니다. 클래스 이름을 주면 보통의 StandardWrapper가 나중에 그 클래스를 만들고, 이미 만든 서블릿 객체를 주면 ExistingStandardWrapper가 그 객체를 그대로 감쌉니다(363–406행). 후자는 init()도 처음 한 번만 부르고 그 객체를 계속 돌려줍니다(1193–1199행). Spring의 DispatcherServlet처럼 프로그램이 먼저 만들어 둔 서블릿을 넘길 때 쓰는 길입니다. 서블릿 인스턴스를 만들고 init()을 부르는 보통의 길은 9편에서 봤습니다.
예제 1: 내장 Tomcat이 만들어 주는 것, 만들어 주지 않는 것
위 내용을 한 프로그램으로 확인했습니다. addContext()로 빈 웹앱에 서블릿 하나를, addWebapp()으로 빈 폴더 하나를 올리고 getConnector() 없이 start()합니다. 만들어진 객체들을 찍고, 요청을 보내고, 그다음에 getConnector()를 불러 다시 요청합니다.
이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. catalina.sh 없이 java -cp "$CATALINA_HOME/lib/*:$CATALINA_HOME/bin/*" EmbeddedExample.java로 띄웠습니다(ex/u16-embedded).
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... import, 응답 한 줄을 쓰는 서블릿 Hello, 요청을 보내는 get(), 자식 이름을 늘어놓는 children(), 구분 줄(== …)을 찍는 println 생략 ...
Tomcat tomcat = new Tomcat();
tomcat.setBaseDir("/tmp/emb"); // work/ 같은 폴더가 이 아래에 생긴다
// 1) addContext: 서블릿을 코드로 직접 붙인다 (web.xml 을 읽지 않는다)
Context plain = tomcat.addContext("", Files.createTempDirectory("doc").toString());
Tomcat.addServlet(plain, "hello", new Hello());
plain.addServletMapping("/hello", "hello"); // 11.0.x 의 addServletMappingDecoded 는 main 에서 없어졌다
// 2) addWebapp: 빈 폴더 하나를 웹앱으로 올린다 (conf/web.xml 과 같은 기본값이 붙는다)
Context webapp = tomcat.addWebapp("/app", Files.createTempDirectory("app").toString());
// getConnector() 를 부르지 않고 start
tomcat.start();
// ... 서버·서비스·엔진·호스트·웹앱을 찍는다 ...
System.out.println("connectors: " + service.findConnectors().length);
System.out.println("GET /hello -> " + get("http://127.0.0.1:8080/hello"));
tomcat.getConnector(); // 이때 Connector("HTTP/1.1") 가 만들어지고, 서비스가 이미 시작됐으므로 바로 시작된다
// ... 커넥터를 찍고 다시 GET /hello ...
== what Tomcat created for us
server : StandardServer, shutdown port=-1
service : Tomcat
engine : Tomcat, defaultHost=localhost, realm=org.apache.catalina.startup.Tomcat$SimpleRealm
host : localhost, appBase=/tmp/emb/webapps
contexts: "", "/app"
"" servlets: hello
/app servlets: default, jsp
== before getConnector()
connectors: 0
GET /hello -> failed: ConnectException
== after getConnector()
connectors: 1, protocol=org.apache.coyote.http11.Http11NioProtocol, state=STARTED
GET /hello -> 200 hello from Apache Tomcat/12.0.0-M1-dev
== stderr: the 'Starting' lines
INFO: Starting Servlet engine: [Apache Tomcat/12.0.0-M1-dev]
INFO: Starting ProtocolHandler ["http-nio-8080"]
== files under the base dir
/tmp/emb
/tmp/emb/work
/tmp/emb/work/Tomcat
/tmp/emb/work/Tomcat/localhost
/tmp/emb/work/Tomcat/localhost/app
/tmp/emb/work/Tomcat/localhost/ROOT
- 위 다섯 줄은 그림의 파란 상자들입니다. 종료 포트
-1, 이름Tomcat,SimpleRealm이 소스 그대로입니다. - 서블릿 목록.
addContext()로 만든 웹앱에는 붙인hello만 있고,addWebapp()으로 만든/app에는 아무것도 붙이지 않았는데default와jsp가 있습니다. - 커넥터.
getConnector()전에는 커넥터가 0개이고 요청은 연결 단계에서 거절됩니다(ConnectException). 부르고 나면 NIO 커넥터가 생겨 곧바로STARTED가 되고 요청이 통합니다. 이미 시작된 서비스에 커넥터를 더하면 서비스가 그 자리에서 커넥터를 시작해 주기 때문입니다(StandardService.java233–239행). 프로토콜 처리기를 시작했다는 로그(Starting ProtocolHandler)도 그때 한 번 나옵니다. - 폴더.
work/아래 폴더 이름이 엔진 이름(Tomcat)/호스트 이름/웹앱 이름입니다. 루트 웹앱("")은ROOT가 됩니다.
헷갈리기 쉬운 곳: 11.0.x로 쓴 코드가 main에서 컴파일되지 않습니다
처음 이 예제를 쓸 때 Spring 25편의 예제처럼 addServletMappingDecoded("/hello", "hello")를 썼더니 컴파일 오류(cannot find symbol)가 났습니다. 11.0.x 브랜치의 Context 인터페이스에는 이 메서드가 @Deprecated로 남아 있고 "Tomcat 12에서 없앤다"는 주석이 붙어 있습니다. main(12.0.0-M1)에서는 실제로 지워지고 addServletMapping()만 남았습니다(Context.java 906–928행). 내장 Tomcat은 Tomcat의 내부 API를 직접 부르는 방식이라, 판이 바뀔 때 이런 변화를 그대로 맞습니다.
2. 내장 Tomcat의 바깥 — Tomcat.main()과 Catalina
Tomcat 클래스에는 main()도 있습니다. 기준이 되는 설정 파일을 그대로 읽어 띄우는 길입니다.
Tomcat tomcat = new Tomcat();
// Create a Catalina instance and let it parse the configuration files
// It will also set a shutdown hook to stop the Server when needed
// Use the default configuration source
tomcat.init(null, catalinaArguments);
java/org/apache/catalina/startup/Tomcat.java 1299–1303행
init(source, …)는 Catalina를 만들어 load()를 부르고, 그 결과로 생긴 서버를 받아 씁니다(434–447행). 곧 server.xml을 읽는 길과 코드로 만드는 길이 같은 객체(StandardServer …)에 닿는다는 뜻입니다. catalina.sh로 띄운 Tomcat과 내장 Tomcat은 서로 다른 제품이 아니라, 같은 부품을 조립하는 방법이 다를 뿐입니다. main()이 받는 인자 가운데 --no-jmx는 다음 절에서 볼 Registry.disableRegistry()를 부릅니다(1289–1290행).
server.xml을 읽든 코드로 조립하든 만들어지는 부품은 같습니다. 노란 칸이 이 절의 Tomcat.main() 길입니다.
Spring Boot도 내장 Tomcat을 이 Tomcat 클래스로 띄운다고 알려져 있습니다. 다만 이 연재에서 Spring Boot의 소스를 읽어 확인한 것은 아닙니다.
3. JMX — 부품마다 MBean이 하나씩
jconsole 같은 JMX 도구로 Tomcat에 붙으면 Catalina:type=Server, Catalina:type=Connector,port=8080 같은 이름이 줄줄이 보입니다. 서버, 서비스, 커넥터, 호스트, 웹앱, 밸브가 저마다 MBean으로 올라가 있기 때문입니다. 누가, 언제 이것을 등록했을까요.
init() 때 스스로 등록한다
답은 3편의 Lifecycle에 있습니다. StandardServer, StandardService, Connector, 컨테이너 넷의 부모인 ContainerBase, 밸브의 부모인 ValveBase는 모두 LifecycleMBeanBase를 상속합니다. 이 클래스를 직접 상속하는 클래스는 이 커밋에 15개가 있습니다(extends LifecycleMBeanBase를 검색해 세었습니다). init()이 부르는 initInternal()이 여기서 MBean 등록을 합니다.
@Override
protected void initInternal() throws LifecycleException {
// If oname is not null then registration has already happened via
// preRegister().
if (oname == null) {
oname = register(this, getObjectNameKeyProperties());
}
}
java/org/apache/catalina/util/LifecycleMBeanBase.java 50–57행
protected final ObjectName register(Object obj, String objectNameKeyProperties) {
// Construct an object name with the right domain
StringBuilder name = new StringBuilder(getDomain());
name.append(':');
name.append(objectNameKeyProperties);
ObjectName on = null;
try {
on = new ObjectName(name.toString());
Registry.getRegistry(null).registerComponent(obj, on, null);
} catch (Exception e) {
log.warn(sm.getString("lifecycleMBeanBase.registerFail", obj, name), e);
}
return on;
}
java/org/apache/catalina/util/LifecycleMBeanBase.java 120–137행
ObjectName은 도메인:키 속성을 이어 붙여 만듭니다. 두 조각이 어디서 오는지가 재미있습니다.
- 키 속성은 클래스마다
getObjectNameKeyProperties()로 정합니다.StandardServer는type=Server(1031–1033행),StandardContext는j2eeType=WebModule,name=//호스트/경로,…(5564–5567행),Connector는type=Connector,port=포트입니다. - 도메인은 엔진의 이름입니다.
StandardEngine.getDomainInternal()이 자기 이름을 돌려주고(319–321행), 서비스는 엔진의 이름을 쓰고(StandardService.java591–598행), 컨테이너는 부모의 도메인을 따릅니다(ContainerBase.java1002–1010행). server.xml의<Engine name="Catalina">때문에 jconsole에서Catalina:가 보였던 것이고, 엔진 이름을Tomcat으로 짓는 내장 Tomcat에서는Tomcat:이 됩니다.
Registry — 설명서를 찾아 MBean을 만든다
Registry는 java/org/apache/tomcat/util/modeler에 있는, Tomcat이 JMX를 쓰는 창구입니다. JVM에 하나만 있고(137–146행), MBeanServer를 따로 만들지 않습니다. 이미 있는 MBeanServer가 있으면 그것을, 없으면 JDK의 플랫폼 MBeanServer(ManagementFactory.getPlatformMBeanServer())를 씁니다(447–466행). 그래서 jconsole로 JVM에 붙으면 java.lang:type=Memory 같은 JDK의 MBean 옆에 Tomcat의 것이 함께 보입니다.
등록하는 registerComponent()의 본론은 이렇습니다.
try {
if (type == null) {
type = bean.getClass().getName();
}
ManagedBean managed = findManagedBean(null, bean.getClass(), type);
if (managed == null) {
throw new IllegalStateException(sm.getString("registry.noTypeMetadata", type));
}
// The real mbean is created and registered
DynamicMBean mbean = managed.createMBean(bean);
if (getMBeanServer().isRegistered(oname)) {
if (log.isDebugEnabled()) {
log.debug(sm.getString("registry.unregisterExisting", oname));
}
getMBeanServer().unregisterMBean(oname);
}
getMBeanServer().registerMBean(mbean, oname);
java/org/apache/tomcat/util/modeler/Registry.java 652–673행
등록되는 것은 StandardServer 객체 자신이 아닙니다. ManagedBean이라는 설명서를 찾아, 그 설명서로 만든 대리 객체(DynamicMBean)를 등록합니다. 표준 JMX에서는 보통 ServerMBean 같은 인터페이스를 따로 만들어 클래스가 구현하게 하는데, Tomcat은 그 대신 클래스마다 "밖에 보여 줄 속성과 작업의 목록"을 XML로 적어 둡니다. 그 XML이 mbeans-descriptors.xml이고, 이 커밋의 java/ 아래에 25개가 있습니다. StandardServer의 설명서는 이렇게 시작합니다.
<mbean name="StandardServer"
description="Standard Server Component"
domain="Catalina"
group="Server"
type="org.apache.catalina.core.StandardServer">
java/org/apache/catalina/core/mbeans-descriptors.xml 1304–1308행
그 아래에 <attribute name="serverInfo" … writeable="false"/>, <operation name="await" …/> 같은 줄이 이어집니다. 설명서는 어떻게 찾을까요. findManagedBean()은 이미 읽은 설명서에서 클래스 이름으로 찾아보고, 없으면 findDescriptor()가 그 클래스의 패키지부터 위로 한 단계씩 올라가며 패키지/mbeans-descriptors.xml을 읽어 들입니다.
String pkg = type;
while (pkg.indexOf('.') > 0) {
int lastComp = pkg.lastIndexOf('.');
if (lastComp <= 0) {
return;
}
pkg = pkg.substring(0, lastComp);
if (searchedPaths.get(pkg) != null) {
return;
}
loadDescriptors(pkg, classLoader);
}
java/org/apache/tomcat/util/modeler/Registry.java 738–749행
org.apache.catalina.core.StandardServer라면 org/apache/catalina/core/mbeans-descriptors.xml, 그다음 org/apache/catalina/mbeans-descriptors.xml … 순서입니다. 한 번 찾아본 패키지는 searchedPaths에 적어 두고 다시 읽지 않습니다. XML을 객체로 읽는 일은 3편에서 server.xml을 읽던 Digester가 다시 맡습니다(MbeansDescriptorsDigesterSource, loadDescriptors() 687–717행). 그래도 설명서가 없으면 클래스를 리플렉션으로 살펴 getter·setter를 속성으로 삼는 설명서를 즉석에서 만듭니다(507–523행).
노란 상자가 JVM에 하나 있는 MBeanServer입니다. Tomcat의 MBean은 JDK의 MBean과 같은 곳에 등록됩니다.
속성을 읽으면 — 리플렉션으로 진짜 객체의 getter를 부른다
설명서로 만든 대리 객체는 BaseModelMBean입니다. jconsole에서 serverInfo를 읽으면 MBeanServer가 이 객체의 getAttribute("serverInfo")를 부르고, 이 메서드는 설명서에서 getter 이름(getServerInfo)을 찾아 진짜 객체에 리플렉션으로 부릅니다.
Method m = managedBean.getGetter(name, this, resource);
Object result;
try {
Class<?> declaring = m.getDeclaringClass();
// workaround for catalina weird mbeans - the declaring class is BaseModelMBean.
// but this is the catalina class.
if (declaring.isAssignableFrom(this.getClass())) {
result = m.invoke(this, NO_ARGS_PARAM);
} else {
result = m.invoke(resource, NO_ARGS_PARAM);
}
java/org/apache/tomcat/util/modeler/BaseModelMBean.java 164–174행
resource가 감싼 진짜 객체입니다. getter를 대리 객체 쪽에서 먼저 찾는 까닭은, 설명서의 className에 BaseModelMBean을 상속한 클래스를 적어 그 클래스가 진짜 객체 대신 답하게 할 수 있기 때문입니다(ManagedBean.java 504–522행). 웹앱의 ContextMBean이 그런 클래스이고, 커넥터의 ConnectorMBean은 아예 getAttribute()를 덮어써 커넥터에 없는 속성은 프로토콜 처리기(5편)에서 읽어 옵니다(ConnectorMBean.java 43–67행). 쓰기(setAttribute)와 작업 호출(invoke)도 같은 방식으로 진짜 객체의 메서드에 닿습니다. 그래서 JMX로 값을 바꾸면 복사본이 아니라 실행 중인 Tomcat의 객체가 바뀝니다.
헷갈리기 쉬운 곳: 모든 MBean이 LifecycleMBeanBase에서 오지는 않습니다
jconsole에서 늘 보는 type=ThreadPool,name="http-nio-8080"은 Lifecycle 부품이 아니라 5편의 엔드포인트가 init()에서 직접 등록합니다. 이름에 "ThreadPool"이 붙은 것도 소스 주석대로 "역사적인 이름"입니다.
if (this.domain != null) {
// Register endpoint (as ThreadPool - historical name)
oname = new ObjectName(domain + ":type=ThreadPool,name=\"" + getName() + "\"");
Registry.getRegistry(null).registerComponent(this, oname, null);
java/org/apache/tomcat/util/net/AbstractEndpoint.java 2247–2250행
예제 2: 내장 Tomcat의 MBean을 읽고, 바꾸고, 부르기
같은 프로세스 안에서 플랫폼 MBeanServer를 꺼내, jconsole이 하는 일을 코드로 했습니다(ex/u16-jmx). 실행 환경은 예제 1과 같습니다.
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... import, 3초 제한으로 요청을 보내 상태 코드를 돌려주는 get(), 구분 줄(== …)을 찍는 println 생략 ...
Tomcat tomcat = new Tomcat();
tomcat.setBaseDir("/tmp/jmx");
tomcat.getConnector();
Context ctx = tomcat.addContext("/demo", Files.createTempDirectory("doc").toString());
tomcat.start();
// Tomcat 의 Registry 는 따로 만들지 않고 JVM 의 플랫폼 MBeanServer 를 쓴다
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
// ... 도메인 목록과 Tomcat 도메인의 이름 몇 개를 찍는다 ...
ObjectName server = new ObjectName("Tomcat:type=Server");
ObjectName connector = new ObjectName("Tomcat:type=Connector,port=8080");
ObjectName webModule = new ObjectName("Tomcat:j2eeType=WebModule,name=//localhost/demo,"
+ "J2EEApplication=none,J2EEServer=none");
System.out.println("Server.serverInfo = " + mbs.getAttribute(server, "serverInfo"));
// ... stateName, maxThreads, 커넥터 MBean 의 클래스 ...
System.out.println("ctx.getSessionTimeout() before = " + ctx.getSessionTimeout());
mbs.setAttribute(webModule, new Attribute("sessionTimeout", 5));
System.out.println("ctx.getSessionTimeout() after = " + ctx.getSessionTimeout());
try {
mbs.getAttribute(server, "catalinaBase"); // StandardServer 에는 getCatalinaBase() 가 있지만 설명서에 없다
} catch (Exception e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
}
System.out.println("GET /demo/ -> " + get("http://127.0.0.1:8080/demo/"));
mbs.invoke(connector, "stop", null, null);
// ... stateName 을 찍고 다시 GET, 그다음 tomcat.stop(), tomcat.destroy() 뒤에 이름 수를 센다 ...
== domains
[JMImplementation, Tomcat, com.sun.management, java.lang, java.nio, java.util.logging, jdk.management.jfr]
== a few names in the Tomcat domain
total: 27
Tomcat:j2eeType=WebModule,name=//localhost/demo,J2EEApplication=none,J2EEServer=none
Tomcat:type=Connector,port=8080
Tomcat:type=Engine
Tomcat:type=Host,host=localhost
Tomcat:type=Server
Tomcat:type=Service
Tomcat:type=ThreadPool,name="http-nio-8080"
== read attributes through JMX
Server.serverInfo = Apache Tomcat/12.0.0-M1-dev
Server.stateName = STARTED
Connector.maxThreads = 200
MBean class of the connector: org.apache.catalina.mbeans.ConnectorMBean
== write an attribute through JMX
ctx.getSessionTimeout() before = 30
ctx.getSessionTimeout() after = 5
== an attribute that is not in mbeans-descriptors.xml
AttributeNotFoundException: Cannot find attribute [catalinaBase] on resource [StandardServer[-1]]
== invoke an operation through JMX
GET /demo/ -> 404
Connector.stateName = STOPPED
GET /demo/ -> failed: HttpTimeoutException
== after destroy
names in the Tomcat domain: 0
- 도메인이
Tomcat입니다. JDK의 도메인(java.lang등) 옆에Tomcat이 있고,Catalina는 없습니다. 엔진 이름이 도메인이 된다는 것이 그대로 보입니다. 서블릿 하나 없는 웹앱 하나만 올렸는데도 MBean이 27개입니다. - 읽기.
serverInfo는StandardServer.getServerInfo()를 부른 결과입니다. 커넥터의 대리 객체 클래스는 설명서의className에 적힌ConnectorMBean입니다(connector/mbeans-descriptors.xml29–34행). - 쓰기. JMX로
sessionTimeout을 5로 바꾸자, Java 코드에서 들고 있던ctx객체의 값이 30에서 5로 바뀌었습니다. 같은 객체라는 뜻입니다. - 설명서에 없는 속성.
StandardServer에는getCatalinaBase()메서드가 있지만(736행)StandardServer의 설명서에catalinaBase가 없으므로 JMX로는 보이지 않습니다. 예외 문구는ManagedBean.getGetter()가 던진 것입니다(498–502행). 같은 파일의StandardEngine설명서에는catalinaBase가 있습니다(921행). 밖에 무엇이 보이는지는 클래스가 아니라 설명서가 정합니다. - 작업 호출. 커넥터의
stop작업을 부르자 상태가STOPPED가 되었습니다. 그런데 다음 요청은 거절(ConnectException)이 아니라 3초 제한에 걸린 시간 초과로 끝났습니다. 이것은 아래 "헷갈리기 쉬운 곳"에서 봅니다. - destroy 뒤.
destroyInternal()이 등록을 풀므로(LifecycleMBeanBase.java60–63행)Tomcat도메인이 비었습니다.
헷갈리기 쉬운 곳: 커넥터를 stop해도 포트는 열려 있습니다
처음에는 이 예제의 마지막 요청에 제한 시간을 두지 않았는데, 프로그램이 끝나지 않았습니다. jstack으로 보니 main 스레드가 HttpClient.send()에서 응답을 기다리며 멈춰 있었습니다. 원인은 엔드포인트의 bindOnInit 설정입니다. 기본값이 true이고, 소스 주석대로 이때는 포트를 init()에서 열고 destroy()에서 닫습니다(AbstractEndpoint.java 1312–1317행). 엔드포인트의 stop()은 포트를 start()에서 연 경우에만 닫습니다(2367–2373행). 기본 설정에서는 연결을 받고 처리하는 스레드들만 멈추고 듣는 소켓은 열어 둡니다. 그래서 운영체제는 연결을 받아 대기열에 넣지만 꺼내 처리할 Tomcat 쪽 스레드가 없어, 클라이언트는 응답을 하염없이 기다립니다. 그래서 예제의 get()에 3초 제한을 두었습니다. JMX로 커넥터를 멈춰 "포트를 닫았다"고 생각하면 안 되는 이유입니다. 포트를 start()/stop()에 맞춰 열고 닫으려면 커넥터에 bindOnInit="false"를 줍니다(이 설정으로 다시 돌려 보지는 않았습니다).
JMX를 끄려면
JMX 등록에는 비용이 듭니다. 설명서 XML을 읽고, 부품마다 대리 객체를 만듭니다. Registry.disableRegistry()는 아직 Registry가 만들어지지 않았을 때 그 자리에 NoDescriptorRegistry를 넣어 둡니다(153–159행). 이 클래스의 registerComponent()는 아무것도 하지 않습니다(NoDescriptorRegistry.java 138–140행). 이미 Registry가 만들어진 뒤에 부르면 경고만 남기고 끄지 못합니다. 그러니 Tomcat 객체를 만들기 전에 불러야 합니다.
4. JULI — 웹앱마다 따로 보는 로그 설정
JUL의 LogManager는 JVM에 하나뿐이고, 기본 구현은 설정 파일도 하나만 읽습니다. 웹앱이 여럿 올라가는 Tomcat에서는 곤란합니다. 웹앱 A가 com.example 로거의 레벨을 FINE으로 바꾸면 같은 이름을 쓰는 웹앱 B도 영향을 받습니다. 웹앱을 내렸다 다시 올릴 때 옛 웹앱의 클래스로더를 붙잡는 참조가 남지 않게 하는 것도 일입니다(4편의 클래스로더 누수). ClassLoaderLogManager의 소스 주석도 그 때문에 아래의 표를 WeakHashMap으로 만들었다고 적습니다(83–87행).
JULI의 답은 로거 이름표를 클래스로더마다 따로 두는 것입니다.
LogManager를 바꿔 끼운다
catalina.sh는 JVM을 띄울 때 두 시스템 속성을 붙입니다.
# Set juli LogManager config file if it is present and an override has not been issued
if [ -z "$CATALINA_LOGGING_CONFIG" ]; then
if [ -r "$CATALINA_BASE"/conf/logging.properties ]; then
CATALINA_LOGGING_CONFIG="-Djava.util.logging.config.file=$CATALINA_BASE/conf/logging.properties"
else
# Bugzilla 45585
CATALINA_LOGGING_CONFIG="-Dnop"
fi
fi
if [ -z "$LOGGING_MANAGER" ]; then
LOGGING_MANAGER="-Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager"
fi
java.util.logging.manager는 JDK가 정해 둔 속성으로, LogManager 대신 쓸 클래스를 지정합니다. 이제 Logger.getLogger()를 부르면 그 뒤에서 ClassLoaderLogManager가 답합니다. 반대로 말하면, 내장 Tomcat에서는 이 속성을 직접 주지 않는 한 JULI의 LogManager가 쓰이지 않습니다. 예제 1의 로그가 conf/logging.properties의 한 줄 형식이 아니라 JDK 기본 형식(두 줄)으로 표준 오류에 찍힌 것이 그 때문입니다.
Tomcat 자신의 코드는 대부분 JUL을 직접 부르지 않고 org.apache.juli.logging.LogFactory.getLog()로 로거를 얻습니다. 이 LogFactory는 Apache Commons Logging의 것을 고쳐 Tomcat 안에 옮겨 둔 것이고(클래스 주석이 "modified LogFactory"라고 밝힙니다), ServiceLoader로 다른 구현을 찾지 못하면 JUL로 넘기는 DirectJDKLog를 씁니다(LogFactory.java 81–95행, 120–130행). 결국 Tomcat의 로그도, 웹앱의 JUL 로그도 같은 ClassLoaderLogManager를 지납니다.
열쇠는 스레드의 컨텍스트 클래스로더
ClassLoaderLogManager는 클래스로더를 열쇠로 하는 표를 가지고 있습니다.
protected final Map<ClassLoader,ClassLoaderLogInfo> classLoaderLoggers = new WeakHashMap<>(); // Guarded by this
java/org/apache/juli/ClassLoaderLogManager.java 87행
ClassLoaderLogInfo 하나에 그 클래스로더의 로거 이름표, 핸들러 목록, 설정(Properties)이 들어 있습니다. WeakHashMap이라서 웹앱을 내려 클래스로더가 버려지면 그 칸도 함께 사라질 수 있습니다. 로거를 찾을 때 어느 칸을 볼지는 이 메서드가 정합니다.
static ClassLoader getClassLoader() {
ClassLoader result = Thread.currentThread().getContextClassLoader();
if (result == null) {
result = ClassLoaderLogManager.class.getClassLoader();
}
return result;
}
java/org/apache/juli/ClassLoaderLogManager.java 782–788행
"먼저 알아 둘 말"에서 본 것처럼 요청을 처리하는 동안 스레드의 컨텍스트 클래스로더는 그 웹앱의 로더입니다. 그러니 웹앱 A의 서블릿이 Logger.getLogger("demo")를 부르면 A의 칸에서, B의 서블릿이 부르면 B의 칸에서 찾습니다. 이름이 같아도 다른 로거 객체입니다(getLogger(), 252–255행).
칸마다 설정 파일을 찾는다
어떤 클래스로더의 칸이 처음 필요해지면 readConfiguration(ClassLoader)가 그 로더의 설정을 찾습니다.
protected synchronized void readConfiguration(ClassLoader classLoader) throws IOException {
InputStream is = null;
// Special case for URL classloaders which are used in containers:
// only look in the local repositories to avoid redefining loggers 20 times
if (classLoader instanceof URLClassLoader) {
URL logConfig = ((URLClassLoader) classLoader).findResource("logging.properties");
if (null != logConfig) {
// ...
is = classLoader.getResourceAsStream("logging.properties");
} else {
// ...
}
}
// ...
Logger localRootLogger = new RootLogger();
if (is == null) {
// Retrieve the root logger of the parent classloader instead
ClassLoader current = classLoader.getParent();
ClassLoaderLogInfo info = null;
while (current != null && info == null) {
info = getClassLoaderInfo(current);
current = current.getParent();
}
if (info != null) {
localRootLogger.setParent(info.rootNode.logger);
}
}
ClassLoaderLogInfo info = new ClassLoaderLogInfo(new LogNode(null, localRootLogger));
classLoaderLoggers.put(classLoader, info);
java/org/apache/juli/ClassLoaderLogManager.java 572–629행
- 웹앱 로더라면
logging.properties를 자기 저장소에서만 찾습니다. 웹앱 클래스로더WebappClassLoaderBase는URLClassLoader를 상속하므로(105–106행) 첫if에 들어가고, 그findResource()는 부모에게 묻지 않고 웹앱 안(WEB-INF/classes등)만 봅니다. 곧WEB-INF/classes/logging.properties가 그 웹앱만의 설정 파일입니다. - 시스템 클래스로더라면 줄인 자리에서
java.util.logging.config.file속성이 가리키는 파일, 곧catalina.sh가 넘긴conf/logging.properties를 읽습니다(593–613행). - 설정 파일이 없으면 이 칸의 루트 로거를 부모 클래스로더 칸의 루트 로거에 이어 붙입니다. 기록이 부모 쪽 핸들러로 흘러갑니다.
설정이 없는 칸은 속성도 부모에게서 빌립니다. findProperty()는 자기 칸의 설정이 통째로 비어 있을 때만 부모 클래스로더들의 설정을 차례로 봅니다(304–330행). 설정 파일을 하나라도 둔 웹앱은 부모의 설정을 보지 않는다는 뜻입니다.
노란 상자 하나하나가 classLoaderLoggers 표의 칸입니다. 칸마다 로거 이름표가 따로 있습니다.
conf/logging.properties 읽는 법
conf/logging.properties의 첫 줄은 처음 보면 낯섭니다.
handlers = 1catalina.org.apache.juli.AsyncFileHandler, 2localhost.org.apache.juli.AsyncFileHandler, 3manager.org.apache.juli.AsyncFileHandler, 4host-manager.org.apache.juli.AsyncFileHandler, java.util.logging.ConsoleHandler
.handlers = 1catalina.org.apache.juli.AsyncFileHandler, java.util.logging.ConsoleHandler
conf/logging.properties 16–18행
JUL 표준에서는 같은 핸들러 클래스를 설정 파일에 한 번만 쓸 수 있습니다. 설정 키가 클래스 이름이기 때문입니다. JULI는 숫자로 시작하는 접두사를 붙여 같은 클래스(AsyncFileHandler)를 여러 벌 만들게 했습니다. 접두사를 떼어 내는 곳이 여기입니다.
// Parse and remove a prefix (prefix start with a digit, such as
// "10WebappFooHandler.")
if (Character.isDigit(handlerClassName.charAt(0))) {
int pos = handlerClassName.indexOf('.');
if (pos >= 0) {
prefix = handlerClassName.substring(0, pos + 1);
handlerClassName = handlerClassName.substring(pos + 1);
}
}
java/org/apache/juli/ClassLoaderLogManager.java 683–691행
1catalina.org.apache.juli.AsyncFileHandler에서 1catalina.가 접두사이고 나머지가 클래스 이름입니다. 핸들러가 자기 설정을 읽을 때 getProperty()는 접두사가 붙은 키(1catalina.org.apache.juli.AsyncFileHandler.prefix = catalina.)를 먼저 찾습니다(282–294행). 그래서 같은 클래스의 핸들러 넷이 catalina., localhost., manager., host-manager.로 시작하는 서로 다른 파일에 씁니다. logs/ 폴더에서 늘 보던 그 파일들입니다.
handlers는 "이 칸에 만들 핸들러 전부", .handlers는 "그중 루트 로거에 걸 것"입니다. .handlers 줄이 없으면 만든 핸들러를 모두 루트 로거에 겁니다(ClassLoaderLogManager.java 699–701행). 나머지는 특정 로거에 겁니다.
org.apache.catalina.core.ContainerBase.[Catalina].[localhost].level = INFO
org.apache.catalina.core.ContainerBase.[Catalina].[localhost].handlers = 2localhost.org.apache.juli.AsyncFileHandler
conf/logging.properties 59–60행
이 이상한 로거 이름은 컨테이너의 로거 이름입니다. ContainerBase.getLogName()이 자기부터 부모까지 이름을 대괄호로 감싸 이어 붙입니다.
public String getLogName() {
if (logName != null) {
return logName;
}
String loggerName = null;
Container current = this;
while (current != null) {
String name = current.getName();
if ((name == null) || (name.isEmpty())) {
name = "/";
} else if (name.startsWith("##")) {
name = "/" + name;
}
loggerName = "[" + name + "]" + ((loggerName != null) ? ("." + loggerName) : "");
current = current.getParent();
}
logName = ContainerBase.class.getName() + "." + loggerName;
return logName;
}
java/org/apache/catalina/core/ContainerBase.java 293–313행
웹앱 /beta의 로거는 org.apache.catalina.core.ContainerBase.[Catalina].[localhost].[/beta]입니다. 서블릿에서 부르는 ServletContext.log()는 이 로거에 info()로 씁니다(ApplicationContext.java 540–542행). JUL 로거 이름은 점으로 부모·자식이 갈리므로, 이 로거의 부모가 위 설정의 …[Catalina].[localhost] 로거입니다. 그래서 ServletContext.log()는 localhost.<날짜>.log로 갑니다. 엔진 이름이 들어 있으므로, 엔진 이름이 Tomcat인 내장 Tomcat에서는 이 설정 줄이 맞지 않는다는 것도 함께 알 수 있습니다. 1절의 Tomcat 클래스도 웹앱마다 이 이름으로 로거를 만들어 레벨을 정해 둡니다(silence()와 getLoggerName(), 909–966행).
웹앱의 logging.properties에서는 ${classloader.webappName}처럼 그 웹앱의 이름을 끼워 넣을 수 있습니다. ${…}를 풀 때 컨텍스트 클래스로더가 WebappProperties를 구현했으면(웹앱 로더가 그렇습니다) 웹앱·호스트·서비스 이름을 먼저 봅니다.
private String replaceWebApplicationProperties(String propName) {
ClassLoader cl = getClassLoader();
if (cl instanceof WebappProperties wProps) {
return switch (propName) {
case "classloader.webappName" -> wProps.getWebappName();
case "classloader.hostName" -> wProps.getHostName();
case "classloader.serviceName" -> wProps.getServiceName();
case null, default -> null;
};
} else {
return null;
}
}
java/org/apache/juli/ClassLoaderLogManager.java 758–770행
getWebappName()은 웹앱의 기본 이름(getBaseName())을 돌려줍니다(WebappClassLoaderBase.java 359–365행, 2444–2446행). /alpha라면 alpha입니다. 이 switch 식은 main의 문법이고, 11.0.x 브랜치에서는 같은 일을 if/else로 합니다.
예제 3: 같은 이름의 로거, 두 웹앱
이번에는 catalina.sh start로 띄웠습니다(ex/u16-juli). 웹앱 alpha와 beta에 같은 서블릿을 올리고, alpha에만 WEB-INF/classes/logging.properties를 두었습니다. 서블릿은 두 웹앱 모두 Logger.getLogger("demo")로 같은 이름의 로거를 얻어 FINE과 INFO를 한 줄씩 남기고, ServletContext.log()도 한 줄 남깁니다. 실행 환경은 예제 1과 같습니다.
# alpha 웹앱만의 로그 설정 (WEB-INF/classes/logging.properties)
handlers = org.apache.juli.FileHandler
org.apache.juli.FileHandler.level = FINE
org.apache.juli.FileHandler.directory = ${catalina.base}/logs
org.apache.juli.FileHandler.prefix = ${classloader.webappName}.
demo.level = FINE
// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// 두 웹앱에 같은 서블릿을 올려, 같은 이름의 로거 "demo" 가 웹앱마다 따로인지 본다.
// ... import 생략 ...
@WebServlet("/log")
public class LogServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String app = req.getContextPath();
Logger log = Logger.getLogger("demo"); // 두 웹앱이 같은 이름을 쓴다
log.fine("FINE from " + app);
log.info("INFO from " + app);
getServletContext().log("ServletContext.log from " + app);
resp.setContentType("text/plain");
resp.getWriter().println(app + ": manager=" + LogManager.getLogManager().getClass().getName()
+ ", logger@" + Integer.toHexString(System.identityHashCode(log))
+ ", level=" + log.getLevel() + ", handlers=" + log.getParent().getHandlers().length
+ " on its parent");
}
}
run.sh는 두 웹앱에 차례로 요청을 보내고 Tomcat을 멈춘 뒤, logs/ 폴더의 파일마다 from /이 든 줄을 뽑습니다.
== responses
/alpha: manager=org.apache.juli.ClassLoaderLogManager, logger@400bc275, level=FINE, handlers=1 on its parent
/beta: manager=org.apache.juli.ClassLoaderLogManager, logger@3afc2cef, level=null, handlers=2 on its parent
== log files
alpha.<date>.log
catalina.<date>.log
catalina.out
localhost.<date>.log
localhost_access_log.<date>.txt
== alpha.<date>.log
24-Oct-2026 13:56:01.811 FINE [http-nio-8080-exec-2] LogServlet.doGet FINE from /alpha
24-Oct-2026 13:56:01.816 INFO [http-nio-8080-exec-2] LogServlet.doGet INFO from /alpha
24-Oct-2026 13:56:01.817 INFO [http-nio-8080-exec-2] org.apache.catalina.core.ApplicationContext.log ServletContext.log from /alpha
== catalina.<date>.log
24-Oct-2026 13:56:01.886 INFO [http-nio-8080-exec-3] LogServlet.doGet INFO from /beta
== catalina.out
24-Oct-2026 13:56:01.886 INFO [http-nio-8080-exec-3] LogServlet.doGet INFO from /beta
== localhost.<date>.log
24-Oct-2026 13:56:01.887 INFO [http-nio-8080-exec-3] org.apache.catalina.core.ApplicationContext.log ServletContext.log from /beta
로그 파일 이름의 날짜는 run.sh가 <date>로 바꿔 찍었습니다. 시각, logger@ 뒤의 값, exec- 뒤의 번호는 실행마다 다릅니다.
- 같은 이름, 다른 로거. 두 응답의
logger@값이 다릅니다.LogManager는 둘 다ClassLoaderLogManager입니다. - alpha. 자기 설정의
demo.level = FINE덕분에FINE줄까지 남았고,${classloader.webappName}이alpha로 풀려alpha.<date>.log가 생겼습니다. 루트 로거에 핸들러가 하나(FileHandler)뿐이고 부모에 이어지지 않았으므로catalina.out에는 한 줄도 가지 않았습니다.ServletContext.log()도 alpha의 칸에는…[/alpha]로거 설정이 없으니 루트로 흘러 같은 파일에 들어갔습니다. - beta. 설정이 없으니
demo의 레벨은null(부모를 따름)이고, 루트의 기본 레벨INFO에 걸려FINE줄은 버려졌습니다. 그리고 부모 설정의.handlers를 빌려 같은 핸들러 두 개(1catalina파일 핸들러와 콘솔)를 달았습니다. 그래서INFO줄이catalina.<date>.log와catalina.out에 한 번씩 찍혔습니다.ServletContext.log()는 위에서 본[Catalina].[localhost]설정을 따라localhost.<date>.log로 갔습니다.
헷갈리기 쉬운 곳: 설정 파일을 두면 부모 설정을 통째로 잃습니다
alpha에 logging.properties를 두는 순간 conf/logging.properties에 적힌 것은 alpha에 하나도 적용되지 않습니다. 앞에서 본 대로 findProperty()는 자기 칸의 설정이 비어 있을 때만 부모를 보기 때문입니다. 위 출력에서 alpha의 ServletContext.log()가 localhost.<date>.log로 가지 않은 것이 그 결과입니다. 웹앱의 logging.properties로 "레벨 한 줄만 바꾸고 나머지는 conf/logging.properties의 것을 그대로" 쓸 수는 없습니다. 그 웹앱에 필요한 핸들러와 레벨을 그 파일에 다 적어야 합니다.
이 글에서 뺀 것
Tomcat.enableNaming()과 JNDI — 내장 Tomcat은 기본으로 JNDI를 끕니다(catalina.useNaming=false). 켜는 메서드는Tomcat.java1005–1029행, 구현은java/org/apache/naming.Tomcat.addWebapp(String, URL)— URL에서 WAR를 받아 appBase에 복사해 올리는 메서드. 250–292행.- 원격 JMX와 JMX 프록시 서블릿 — 다른 기계에서 붙는 설정(
com.sun.management.jmxremote.*)은 JDK의 기능입니다. manager 웹앱의JMXProxyServlet은java/org/apache/catalina/manager/JMXProxyServlet.java. mbeans/패키지 —MBeanFactory(JMX로 커넥터·웹앱을 새로 만드는 작업들),GlobalResourcesLifecycleListener.java/org/apache/catalina/mbeans.- JULI의 포매터와 핸들러 —
OneLineFormatter,JsonFormatter, 날짜마다 파일을 바꾸는FileHandler, 별도 스레드로 쓰는AsyncFileHandler.java/org/apache/juli. - main에 새로 생긴
ClassLoaderLogManager.reconfigure()·refreshLoggers()— 실행 중에 한 클래스로더의 로그 설정을 다시 읽는 공개 메서드입니다. 11.0.x 브랜치에는 없고, 이 커밋의 Tomcat 안에서 부르는 곳은 없습니다. 364–481행. - 접근 로그 —
AccessLogValve는 JULI를 쓰지 않고 자기 파일에 직접 씁니다. 8편에서 봤습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
Tomcat 클래스 |
server.xml 대신 메서드로 Server → Service → Engine → Host → Context를 필요할 때 하나씩 만든다 |
getConnector() |
이름과 달리 "없으면 만든다". 부르지 않으면 포트가 열리지 않고 오류도 나지 않는다 |
addContext() / addWebapp() |
빈 그릇(web.xml 없음) / webapps/에 넣은 앱과 거의 같음(conf/web.xml 대신 코드로 기본값) |
Tomcat.init(source) |
Catalina.load()로 server.xml을 읽어 같은 객체를 만든다. 두 길이 같은 부품에 닿는다 |
LifecycleMBeanBase |
init() 때 스스로 MBean을 등록하고 destroy() 때 푼다. 도메인은 엔진 이름 |
Registry · mbeans-descriptors.xml |
패키지마다 둔 설명서로 대리 객체를 만들어 플랫폼 MBeanServer에 올린다 |
BaseModelMBean |
JMX의 읽기·쓰기·호출을 리플렉션으로 진짜 객체의 메서드에 잇는다 |
ClassLoaderLogManager |
스레드의 컨텍스트 클래스로더를 열쇠로 로거 이름표와 설정을 따로 둔다 |
WEB-INF/classes/logging.properties |
그 웹앱만의 로그 설정. 두면 부모 설정을 통째로 보지 않는다 |
| 숫자 접두사 핸들러 | 1catalina.처럼 앞에 붙여 같은 핸들러 클래스를 여러 벌 만든다 |
연재를 마치며
이 글로 1편의 지도에 적어 둔 16편을 모두 썼습니다. 연재의 글 전체입니다.
| 편 | 글 | 한 줄 요약 |
|---|---|---|
| 1 | AI와 함께 소스를 읽는 방법과 분석 지도 | 기준 커밋을 정하고 도커로 빌드해, 폴더 지도와 읽는 순서를 세웠다 |
| 2 | 전체 지도 — Server, Service, Connector, 그리고 컨테이너 넷 | server.xml의 요소가 곧 클래스이고, 커넥터(Coyote)와 컨테이너(Catalina)를 어댑터가 잇는다 |
| 3 | Lifecycle과 기동 — catalina.sh start 뒤에 일어나는 일 | Digester가 server.xml을 객체로 만들고, 부모가 자식을 init·start한다 |
| 4 | 클래스로더 — 웹앱마다 따로 보는 클래스 | 웹앱 로더가 부모 위임 순서를 뒤집어(JDK·서블릿 API 같은 것은 빼고) 웹앱의 클래스를 먼저 본다 |
| 5 | 커넥터와 NIO 엔드포인트 — 연결은 어떤 스레드가 받나 | Acceptor가 받고 Poller가 지켜보다가 작업 스레드에 넘긴다 |
| 6 | HTTP/1.1 요청 읽기 — 바이트가 coyote Request가 되기까지 | 요청 줄과 헤더를 버퍼 위의 위치로 읽고, 문자열로는 늦게 바꾼다 |
| 7 | CoyoteAdapter와 Mapper — 요청은 어느 서블릿으로 가나 | URI를 정규화하고 호스트 → 웹앱 → 서블릿을 정해진 규칙 순서로 고른다 |
| 8 | 파이프라인과 밸브 — 컨테이너마다 걸린 처리 사슬 | 컨테이너마다 밸브 사슬이 있고 맨 끝의 기본 밸브가 다음 컨테이너로 넘긴다 |
| 9 | 필터 체인과 서블릿 호출 — service()가 불리기까지 | 요청에 맞는 필터를 골라 체인에 담아 차례로 부르고, 맨 끝에서 서블릿을 부른다 |
| 10 | 웹앱 배포와 Context 설정 — WAR 하나가 서비스되기까지 | HostConfig가 웹앱을 찾고 ContextConfig가 web.xml과 어노테이션을 합친다 |
| 11 | 세션 — JSESSIONID는 어떻게 만들고 지우나 | 매니저가 세션을 만들고 배경 작업이 만료된 세션을 지운다 |
| 12 | 비동기 서블릿과 논블로킹 I/O — 스레드를 놓아주는 법 | 상태 기계가 요청을 스레드에서 떼어 두었다가 다시 컨테이너 스레드로 돌려보낸다 |
| 13 | Jasper — JSP가 서블릿이 되기까지 | JSP를 .java로 만들고 컴파일해 서블릿으로 올린다 |
| 14 | 인증과 Realm — security-constraint는 어떻게 지켜지나 | 인증 밸브가 Realm에 물어 보호할 주소인지 찾고, 사용자와 역할을 확인한다 |
| 15 | HTTP/2와 WebSocket — 업그레이드로 갈아타는 프로토콜 | HTTP/1.1 처리기가 업그레이드 요청을 받아 연결을 다른 처리기에 넘긴다(ALPN h2는 처음부터 HTTP/2) |
| 16 | 내장 Tomcat, JMX, 로깅 — 그리고 연재를 마치며 (이 글) | 같은 부품을 코드로 조립하고, 부품마다 MBean을 올리고, 클래스로더마다 로그 설정을 나눈다 |
요청 하나의 길, 다시 한 번
1편에서는 요청 하나가 Acceptor에서 Servlet.service()까지 지나는 큰 길을 그림으로 그렸습니다. 여기서는 그 앞의 기동·배포와 이 글의 JULI·JMX까지 넣어 다시 그립니다. 칸마다 그 칸을 다룬 편이 적혀 있습니다.
이 글은 맨 아래 칸이었습니다.
catalina.sh start로 시작한 Bootstrap은 클래스로더를 세우고(4편) Catalina를 불러 server.xml을 객체로 바꾼 뒤, 부모가 자식을 차례로 시작시킵니다(3편). 그 사이 HostConfig가 웹앱을 찾아 ContextConfig가 web.xml을 합칩니다(10편). 연결이 오면 Acceptor가 받고 Poller가 지켜보다가 작업 스레드에 넘기고(5편), Http11Processor가 요청을 읽습니다(6편). CoyoteAdapter가 Coyote의 요청을 Catalina의 요청으로 바꿔 Mapper로 서블릿을 고르고(7편), 요청은 엔진·호스트·웹앱·서블릿의 밸브를 차례로 지나며(8편) 인증(14편)과 세션(11편)을 만납니다. 마지막 밸브가 필터 체인을 만들어 서블릿의 service()를 부릅니다(9편). 그 서블릿이 JSP일 수도(13편), 스레드를 놓아주는 비동기 서블릿일 수도(12편), 연결 자체를 다른 프로토콜로 바꿔 갈 수도(15편) 있습니다. 그리고 이 모든 부품은 MBean으로 밖에 드러나 있고, 로그는 웹앱마다 나뉘어 남습니다(이 글).
16편을 지나며 되풀이해 나온 것은 세 가지였습니다. Lifecycle은 기동 순서만이 아니라 MBean 등록(이 글), 웹앱 배포(10편)의 발판이었습니다. "설정 파일의 요소 하나 = 클래스 하나" 라는 대응은 server.xml에서도, mbeans-descriptors.xml에서도, 내장 Tomcat의 메서드에서도 같았습니다. 클래스로더는 웹앱을 서로 떼어 놓는 벽이었고, 그 벽은 클래스(4편)뿐 아니라 로그 설정(이 글)까지 나눕니다. 이 셋을 알면 처음 보는 Tomcat 부품의 소스도 어디부터 볼지 짐작할 수 있습니다.
Spring·httpd 연재와 비교하면
이 블로그는 앞서 Spring 소스 파헤치기(27편)와 Apache httpd 소스 파헤치기(16편)를 읽었습니다. Tomcat은 두 연재 사이에 서 있습니다.
- 웹 서버로서는 httpd와 닮았습니다. 연결을 받는 스레드와 요청을 처리하는 스레드를 나누는 생각은 httpd 8편의 MPM과 같고, 컨테이너마다 밸브를 거는 파이프라인은 httpd 6편의 훅처럼 "정해 둔 자리에 남의 코드를 끼우는" 장치입니다. 다만 httpd가 함수 포인터 표와
void *로 하는 일을 Tomcat은 인터페이스와 상속으로 합니다. 로그도 그렇습니다. httpd 16편에서는 모듈마다 로그 레벨을 배열 한 칸으로 나눴고, 이 글의 JULI는 웹앱마다 클래스로더 한 칸으로 나눴습니다. - 서블릿 컨테이너로서는 Spring의 바닥입니다. Spring의
DispatcherServlet(Spring 21편)은 Tomcat에게는 9편의 필터 체인 끝에서 불리는 서블릿 하나일 뿐이고, Spring이 루트 컨텍스트를 서블릿 컨텍스트에 매다는 길(Spring 20편)은 Tomcat 10편에서 본ServletContainerInitializer호출에서 시작합니다. 같은 일을 위에서 본 것과 아래에서 본 것입니다. - 수명의 장치가 셋 다 다릅니다. httpd는 메모리 풀로, Spring은 컨테이너가 빈의 생명주기를 관리하는 방식으로, Tomcat은
Lifecycle상태 기계로 "언제 만들고 언제 치우는가"를 다룹니다. 이 글의 MBean이init()에 등록되고destroy()에 풀리는 것도 그 상태 기계를 따른 결과였습니다.
다음에 읽을 것
이 블로그의 오픈소스 읽기는 Spring, Apache httpd, Tomcat에 이어 nginx → MariaDB → Gradle → Kafka 연재로 이어졌습니다. 바로 다음으로는 같은 웹 서버이면서 워커 스레드 하나가 연결을 지켜보고 요청까지 처리하는 nginx 소스 파헤치기 1편을 권합니다.