namalzi

← 글 목록

Tomcat 소스 파헤치기 16: 내장 Tomcat, JMX, 로깅 — 그리고 연재를 마치며

Tomcat · 게시

server.xml 없이 Tomcat을 코드로 띄우는 startup/Tomcat 클래스가 무엇을 대신 만들어 주고 무엇을 만들어 주지 않는지, 부품마다 MBean이 올라가는 JMX 등록 과정, 웹앱마다 로그 설정을 따로 두는 JULI의 ClassLoaderLogManager를 소스와 예제로 확인하고, 16편 연재를 정리합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 열여섯 번째이자 마지막 글입니다. 15편에서는 HTTP/1.1 연결이 업그레이드를 거쳐 HTTP/2와 WebSocket으로 갈아타는 길을 봤습니다. 지금까지는 catalina.sh start로 띄운 Tomcat의 안을 따라왔습니다. 이번에는 바깥쪽 세 가지를 봅니다. Tomcat을 프로그램 안에서 띄우는 법(내장 Tomcat), 실행 중인 Tomcat을 밖에서 들여다보는 창(JMX), 그리고 Tomcat과 웹앱이 남기는 로그(JULI)입니다. 글 끝의 "연재를 마치며"에서 16편 전체를 정리합니다.

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

항목 내용
대상 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">에 해당합니다. 다른 점이 둘 있습니다.

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행).

내장 Tomcat이 객체를 만드는 순서. new Tomcat()은 아무것도 만들지 않는다. getServer()가 종료 포트 -1인 StandardServer와 이름이 Tomcat인 StandardService를 만들고, getEngine()이 이름이 Tomcat인 StandardEngine과 SimpleRealm을, getHost()가 localhost라는 StandardHost를, addContext()나 addWebapp()이 StandardContext를, addServlet()이 StandardWrapper를 만든다. 그리고 start()가 server.start()를 부른다. 커넥터는 getConnector()를 직접 불러야만 생긴다. 파란 상자는 아래 상자를 부를 때 저절로 만들어집니다. 빨간 상자인 커넥터만은 따로 불러야 생깁니다.

헷갈리기 쉬운 곳: 커넥터는 저절로 생기지 않습니다

그림에서 커넥터만 빨간색입니다. 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의 차이. addContext는 FixContextListener만 붙여 configured를 true로 두고 어노테이션만 처리하므로, 코드로 붙인 서블릿만 있다. addWebapp은 DefaultWebXmlListener가 initWebappDefaults로 default·jsp 서블릿과 MIME, 환영 파일을 넣고, ContextConfig가 WEB-INF/web.xml과 조각, SCI를 처리하지만 conf/web.xml은 읽지 않으므로, webapps에 넣은 앱과 거의 같다. 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

헷갈리기 쉬운 곳: 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행).

같은 객체 나무에 닿는 세 길. 첫째, catalina.sh start는 Bootstrap과 Catalina를 거쳐(3편) Catalina.load()에서 Digester가 server.xml을 읽는다. 둘째, Tomcat.main(args)는 --no-jmx가 있으면 Registry.disableRegistry()를 부르고, tomcat.init(null, catalinaArguments)에서 new Catalina()를 만들어 같은 Catalina.load()를 부른 뒤 server = catalina.getServer()로 결과를 받는다. 셋째, 코드로 new Tomcat()을 만들고 getConnector(), getHost(), addWebapp() 같은 메서드로 조립한다(1절). 세 길 모두 StandardServer, StandardService, Connector와 StandardEngine, StandardHost, StandardContext로 이어지는 같은 객체 나무에 닿는다. 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은 도메인:키 속성을 이어 붙여 만듭니다. 두 조각이 어디서 오는지가 재미있습니다.

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행).

JMX 등록 과정. init()이 LifecycleMBeanBase.initInternal()을 부르면, 엔진 이름을 도메인으로 하고 getObjectNameKeyProperties()를 붙여 ObjectName을 만든다. Registry.registerComponent()가 findManagedBean()으로 같은 패키지와 위 패키지의 mbeans-descriptors.xml을 찾고, 설명서가 없으면 리플렉션으로 직접 살핀다. ManagedBean.createMBean()이 실제 객체를 감싼 BaseModelMBean을 만들어 플랫폼 MBeanServer에 registerMBean()으로 등록한다. jconsole 같은 JMX 도구는 그 MBeanServer에 getAttribute와 invoke를 보낸다. 노란 상자가 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

헷갈리기 쉬운 곳: 커넥터를 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

bin/catalina.sh 254–266행

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행

  1. 웹앱 로더라면 logging.properties를 자기 저장소에서만 찾습니다. 웹앱 클래스로더 WebappClassLoaderBase는 URLClassLoader를 상속하므로(105–106행) 첫 if에 들어가고, 그 findResource()는 부모에게 묻지 않고 웹앱 안(WEB-INF/classes 등)만 봅니다. 곧 WEB-INF/classes/logging.properties가 그 웹앱만의 설정 파일입니다.
  2. 시스템 클래스로더라면 줄인 자리에서 java.util.logging.config.file 속성이 가리키는 파일, 곧 catalina.sh가 넘긴 conf/logging.properties를 읽습니다(593–613행).
  3. 설정 파일이 없으면 이 칸의 루트 로거를 부모 클래스로더 칸의 루트 로거에 이어 붙입니다. 기록이 부모 쪽 핸들러로 흘러갑니다.

설정이 없는 칸은 속성도 부모에게서 빌립니다. findProperty()는 자기 칸의 설정이 통째로 비어 있을 때만 부모 클래스로더들의 설정을 차례로 봅니다(304–330행). 설정 파일을 하나라도 둔 웹앱은 부모의 설정을 보지 않는다는 뜻입니다.

JULI가 로거를 찾는 길. Logger.getLogger('demo')를 부르면 ClassLoaderLogManager.getClassLoader()가 스레드의 컨텍스트 클래스로더를 본다. 시스템 클래스로더는 conf/logging.properties를 읽고, common 로더에는 logging.properties가 없다. alpha 웹앱 로더는 WEB-INF/classes/logging.properties가 있어 alpha의 demo 로거가 FINE 레벨로 alpha.날짜.log에 쓴다. beta 웹앱 로더는 설정이 없어 beta의 demo 로거가 부모 설정을 빌려 catalina.날짜.log와 콘솔에 쓴다. 노란 상자 하나하나가 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- 뒤의 번호는 실행마다 다릅니다.

헷갈리기 쉬운 곳: 설정 파일을 두면 부모 설정을 통째로 잃습니다

alpha에 logging.properties를 두는 순간 conf/logging.properties에 적힌 것은 alpha에 하나도 적용되지 않습니다. 앞에서 본 대로 findProperty()는 자기 칸의 설정이 비어 있을 때만 부모를 보기 때문입니다. 위 출력에서 alpha의 ServletContext.log()가 localhost.<date>.log로 가지 않은 것이 그 결과입니다. 웹앱의 logging.properties로 "레벨 한 줄만 바꾸고 나머지는 conf/logging.properties의 것을 그대로" 쓸 수는 없습니다. 그 웹앱에 필요한 핸들러와 레벨을 그 파일에 다 적어야 합니다.

이 글에서 뺀 것

정리

개념 한 줄 요약
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까지 넣어 다시 그립니다. 칸마다 그 칸을 다룬 편이 적혀 있습니다.

Tomcat 연재의 주 경로. catalina.sh start에서 Bootstrap과 Catalina가 클래스로더와 server.xml과 Lifecycle을 준비하고(3·4편), HostConfig와 ContextConfig가 웹앱을 배포한다(10편). NioEndpoint의 Acceptor와 Poller와 exec 스레드가 연결을 받고(5편), Http11Processor가 바이트를 coyote Request로 읽고(6·15편), CoyoteAdapter와 Mapper가 서블릿을 고르고(7편), Engine·Host·Context·Wrapper 밸브를 지나(8편, 인증 14편, 세션 11편), ApplicationFilterChain이 Servlet.service()를 부른다(9편, JSP 13편, 비동기 12편). 마지막에 AccessLogValve와 JULI와 JMX가 남는다(8·16편). keep-alive이면 Poller가 같은 연결의 다음 요청을 기다린다. 이 글은 맨 아래 칸이었습니다.

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은 두 연재 사이에 서 있습니다.

다음에 읽을 것

이 블로그의 오픈소스 읽기는 Spring, Apache httpd, Tomcat에 이어 nginx → MariaDB → Gradle → Kafka 연재로 이어졌습니다. 바로 다음으로는 같은 웹 서버이면서 워커 스레드 하나가 연결을 지켜보고 요청까지 처리하는 nginx 소스 파헤치기 1편을 권합니다.

다른 글