namalzi

← 글 목록

Tomcat 소스 파헤치기 4: 클래스로더 — 웹앱마다 따로 보는 클래스

Tomcat · 게시

Tomcat 한 프로세스 안에서 두 웹앱이 같은 이름의 서로 다른 클래스를 쓸 수 있는 이유를 소스에서 찾습니다. Bootstrap 이 만드는 common·server·shared 로더와 catalina.properties, 웹앱마다 하나씩 생기는 WebappClassLoaderBase 가 부모 위임 순서를 뒤집는 loadClass(), jakarta.servlet 같은 예외를 고르는 filter(), delegate 설정, reloadable 리로드와 스레드 누수 경고까지 기준 커밋을 빌드한 Tomcat 으로 직접 확인합니다.

이 글에서 다루는 것

"Tomcat 소스 파헤치기" 연재의 네 번째 글입니다. 3편에서는 catalina.sh start 뒤에 Bootstrap 이 Catalina 를 부르고, server.xml 이 객체가 되고, 부모가 자식을 차례로 시작시키는 과정을 봤습니다. 그때 "Bootstrap 이 클래스로더를 만들고 리플렉션으로 Catalina 를 부른다"고만 하고 넘어간 부분을 이번에 엽니다.

webapps 폴더에 WAR 두 개를 넣으면, 두 앱이 같은 라이브러리의 서로 다른 버전을 써도, 심지어 같은 이름의 클래스를 각자 따로 만들어 둬도 서로 부딪히지 않습니다. JVM 은 하나인데 어떻게 그럴 수 있을까요. 답하려는 질문은 네 가지입니다.

항목 내용
대상 Apache Tomcat
버전 12.0.0-M1 (main)
커밋 17aa1769b0 (2026-10-23)
주로 읽는 클래스 startup/Bootstrap, startup/ClassLoaderFactory, loader/WebappLoader, loader/WebappClassLoaderBase, loader/ParallelWebappClassLoader, core/StandardContext

글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.

WebappClassLoaderBase.java 하나가 2,500줄이 넘고, 그 가운데 4분의 1 남짓(clearReferences() 부터 이어지는 1472–2118행, 약 650줄)이 누수를 막는 코드입니다. 이 글은 처음 보는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.

이 글의 중심 파일은 기준 커밋과 안정판 11.0.x 브랜치(같은 날짜의 origin/11.0.x)를 비교해 봤습니다. Bootstrap, WebappLoader, ParallelWebappClassLoader, catalina.properties, JreMemoryLeakPreventionListener 는 두 브랜치가 글자 하나까지 같고, WebappClassLoaderBase 는 loadClass()·filter()·stop()·clearReferences() 가 같습니다. 다른 곳은 11.0.x 에 남아 있는 SecurityManager 용 PrivilegedAction 클래스 세 개 등으로, 이 글의 내용과는 상관없습니다.

먼저 알아 둘 말

클래스로더(class loader)는 "클래스 이름을 받아 .class 파일의 바이트를 찾아 JVM 에 올려 주는 객체"입니다. Java 의 java.lang.ClassLoader 를 상속한 보통 객체이고, 하나의 JVM 안에 여러 개가 있을 수 있습니다. 가장 중요한 성질은 이것입니다. JVM 은 클래스를 "이름 + 그 클래스를 정의한 로더"로 구별합니다. 그래서 이름이 demo.Greeting 으로 같아도 다른 로더가 정의했으면 서로 다른 클래스입니다. Tomcat 이 웹앱마다 로더를 하나씩 만드는 이유가 여기 있습니다.

부모 위임(parent delegation)은 Java 의 클래스로더가 따르는 기본 규칙입니다. 로더마다 부모가 하나 있고, 클래스를 찾을 때 먼저 부모에게 묻고 부모가 못 찾을 때만 자기가 찾습니다. 그래서 웬만한 클래스는 사슬의 위쪽에서 한 번만 올라갑니다. JDK 가 기본으로 두는 로더는 셋입니다.

로더 맡는 것 getName()
bootstrap java.lang.String 같은 JDK 의 핵심. Java 객체가 아니라서 null 로 보인다 없음
platform java.sql 같은 나머지 JDK 모듈 platform
system(app) java -classpath 로 준 jar app

common·server·shared 로더는 Tomcat 이 catalina.properties 를 보고 만드는 로더 셋입니다. 1절에서 봅니다.

웹앱 클래스로더는 웹앱(Context) 하나마다 하나씩 생기는 로더로, 그 웹앱의 WEB-INF/classes 와 WEB-INF/lib/*.jar 를 읽습니다. 공통 부분은 WebappClassLoaderBase 에 있고, 실제로 만들어지는 것은 그 자식 ParallelWebappClassLoader 입니다. 이 로더를 만들고 버리는 관리자가 WebappLoader 입니다. server.xml·context.xml 의 <Loader> 요소가 이 객체입니다.

컨텍스트 클래스로더(thread context class loader, TCCL)는 스레드마다 하나씩 붙여 둘 수 있는 "이 스레드가 지금 일하는 곳의 로더"입니다. Thread.currentThread().getContextClassLoader() 로 꺼냅니다. JDBC 드라이버나 XML 파서처럼 라이브러리가 "사용자 클래스"를 이름으로 찾아야 할 때 이 로더를 씁니다. Tomcat 은 요청을 처리하는 동안 이 값을 그 웹앱의 로더로 바꿔 둡니다.

Lifecycle 은 3편에서 본 init → start → stop → destroy 상태 기계입니다(3편). 웹앱 로더도 이 상태를 가집니다. 5절에서 "멈춘 로더"가 무엇을 거절하는지 봅니다.

1. Bootstrap 이 만드는 로더 셋 — common, server, shared

catalina.properties 의 세 줄

conf/catalina.properties 에는 로더마다 한 줄씩, 그 로더가 읽을 경로가 적혀 있습니다.

common.loader="${catalina.base}/lib","${catalina.base}/lib/*.jar","${catalina.home}/lib","${catalina.home}/lib/*.jar"
# ...
server.loader=
# ...
shared.loader=

conf/catalina.properties 33–70행

기본값은 common.loader 만 채워져 있고 server.loader 와 shared.loader 는 비어 있습니다. $CATALINA_HOME 은 Tomcat 을 설치한 곳, $CATALINA_BASE 는 설정·로그·webapps 를 둔 곳입니다(한 설치로 여러 인스턴스를 띄울 때 둘이 갈립니다). 그래서 common 로더는 두 곳의 lib/ 디렉터리와 그 안의 jar 전부를 읽습니다. servlet-api.jar, catalina.jar, tomcat-coyote.jar, jasper.jar 같은 Tomcat 의 본체가 거기 있습니다.

initClassLoaders() — 비어 있으면 부모를 그대로 쓴다

    private void initClassLoaders() {
        try {
            commonLoader = createClassLoader("common", null);
            if (commonLoader == null) {
                // no config file, default to this loader - we might be in a 'single' env.
                commonLoader = this.getClass().getClassLoader();
            }
            catalinaLoader = createClassLoader("server", commonLoader);
            sharedLoader = createClassLoader("shared", commonLoader);
        } catch (Throwable t) {
            handleThrowable(t);
            log.error("Class loader creation threw exception", t);
            System.exit(1);
        }
    }

java/org/apache/catalina/startup/Bootstrap.java 142–156행

server 로더(필드 이름은 catalinaLoader)와 shared 로더는 common 을 부모로 만듭니다. 그런데 만드는 함수의 첫머리가 이렇습니다.

    private ClassLoader createClassLoader(String name, ClassLoader parent) throws Exception {

        String value = CatalinaProperties.getProperty(name + ".loader");
        if ((value == null) || (value.isEmpty())) {
            return parent;
        }
        // ... "${catalina.base}" 같은 값을 풀고, 경로마다 URL·JAR·디렉터리·"*.jar" 를 가린다 ...
        return ClassLoaderFactory.createClassLoader(repositories, parent);
    }

java/org/apache/catalina/startup/Bootstrap.java 159–196행

설정 값이 비어 있으면 새 로더를 만들지 않고 부모를 그대로 돌려줍니다(162–164행). 기본 설정에서는 server.loader 와 shared.loader 가 비어 있으므로, 세 변수 commonLoader, catalinaLoader, sharedLoader 가 모두 같은 객체를 가리킵니다. 이름은 셋이지만 실제 로더는 하나입니다.

값이 있을 때 만들어지는 로더는 JDK 의 평범한 URLClassLoader 입니다. ClassLoaderFactory 가 경로들을 URL 배열로 바꾼 뒤 끝에서 new URLClassLoader(array)(부모 null 일 때) 또는 new URLClassLoader(array, parent) 를 합니다(ClassLoaderFactory.java 227–231행). 부모를 주지 않은 URLClassLoader 의 부모는 JDK 규칙대로 system 로더입니다. 그래서 common 로더의 부모는 system 로더가 됩니다.

클래스로더 사슬. bootstrap 로더 아래 platform 로더, 그 아래 bin/bootstrap.jar 와 bin/tomcat-juli.jar 를 읽는 system 로더가 있다. 그 아래 lib 의 jar 를 읽는 common 로더가 있고, server 로더와 shared 로더는 설정이 비어 있으면 common 과 같은 객체다. shared 로더 아래에 웹앱마다 ParallelWebappClassLoader 가 하나씩 있다. 회색은 JDK 와 catalina.sh 가 정하는 로더, 파란색은 Tomcat 의 Bootstrap 이 만드는 로더, 노란색은 웹앱마다 생기는 로더입니다.

setenv.sh 에서 CLASSPATH 를 따로 더하지 않으면, system 로더에 있는 것은 catalina.sh 가 -classpath 로 주는 두 jar 뿐입니다. 스크립트는 CLASSPATH= 로 사용자가 정한 값을 먼저 비우고(bin/catalina.sh 150행), bin/bootstrap.jar 와 bin/tomcat-juli.jar 를 붙입니다(214행, 225–231행). Tomcat 의 본체 catalina.jar 는 이 경로에 없습니다.

Bootstrap.init() — Catalina 는 server 로더로 올린다

        initClassLoaders();

        Thread.currentThread().setContextClassLoader(catalinaLoader);

        // ...
        Class<?> startupClass = catalinaLoader.loadClass("org.apache.catalina.startup.Catalina");
        Object startupInstance = startupClass.getConstructor().newInstance();

        // ...
        String methodName = "setParentClassLoader";
        Class<?>[] paramTypes = new Class[1];
        paramTypes[0] = Class.forName("java.lang.ClassLoader");
        Object[] paramValues = new Object[1];
        paramValues[0] = sharedLoader;
        Method method = startupInstance.getClass().getMethod(methodName, paramTypes);
        method.invoke(startupInstance, paramValues);

java/org/apache/catalina/startup/Bootstrap.java 253–274행

3편에서 "리플렉션으로 Catalina 를 부른다"고 한 까닭이 여기서 보입니다. Bootstrap 자신은 system 로더(bootstrap.jar)에 있고, Catalina 는 그 아래의 server 로더(catalina.jar)에 있습니다. 부모 쪽 클래스는 자식 로더의 클래스를 코드에서 이름으로 쓸 수 없습니다. Bootstrap 을 컴파일할 때 Catalina 타입을 직접 쓰면, 실행할 때 system 로더가 Catalina 를 찾다 실패합니다. 그래서 loadClass() 로 꺼내 getMethod()·invoke() 로 부릅니다.

마지막 줄이 이 글에서 중요합니다. Catalina 에게 shared 로더를 "부모 클래스로더"로 넘깁니다. 이것이 나중에 모든 웹앱 로더의 부모가 됩니다(2절).

Bootstrap 클래스 머리의 주석이 이 설계의 목적을 적어 두었습니다. "Catalina 내부 클래스(와 그것이 쓰는 XML 파서 같은 것)를 system 클래스패스 밖에 두어 애플리케이션이 볼 수 없게 하려는 것"입니다(37–43행).

헷갈리기 쉬운 곳: 기본 설정에서는 웹앱도 Tomcat 내부 클래스를 볼 수 있습니다

주석의 목적대로라면 웹앱은 org.apache.catalina.* 를 볼 수 없어야 할 것 같습니다. 그런데 기본 설정에서는 server 로더와 shared 로더가 같은 common 로더이므로, 웹앱 로더의 부모 사슬에 catalina.jar 가 그대로 있습니다. 아래 예제 1에서 웹앱이 org.apache.catalina.core.StandardContext 를 이름으로 찾자, HttpServlet 과 같은 common 로더에서 찾아집니다. 정말로 웹앱에게서 감추려면 server.loader 에 경로를 따로 적어 server 로더를 갈라야 합니다. 이 연재는 기본 설정만 돌려 봤습니다.

2. 웹앱 로더는 언제, 무엇을 부모로 만들어지나

shared 로더가 웹앱까지 내려가는 길

Catalina 는 받은 shared 로더를 필드에 넣어 두었다가(Catalina.java 337–339행), server.xml 을 읽는 Digester 에 규칙 하나를 겁니다.

        // When the 'engine' is found, set the parentClassLoader.
        digester.addRule("Server/Service/Engine", new SetParentClassLoaderRule(parentClassLoader));

java/org/apache/catalina/startup/Catalina.java 596–597행

server.xml 에서 <Engine> 을 만나면 그 StandardEngine 에 setParentClassLoader(shared) 를 부릅니다(1193–1201행). Host 와 Context 에는 따로 정해 주지 않습니다. 대신 묻는 쪽이 부모를 따라 올라갑니다.

    @Override
    public ClassLoader getParentClassLoader() {
        if (parentClassLoader != null) {
            return parentClassLoader;
        }
        if (parent != null) {
            return parent.getParentClassLoader();
        }
        return ClassLoader.getSystemClassLoader();
    }

java/org/apache/catalina/core/ContainerBase.java 451–460행

자기 값이 없으면 부모 컨테이너에게 묻고, 끝까지 없으면 system 로더입니다. Context → Host → Engine 으로 올라가 Engine 이 가진 shared 로더를 얻습니다. StandardContext 는 이것을 한 번 더 덮어씁니다.

    @Override
    public ClassLoader getParentClassLoader() {
        if (parentClassLoader != null) {
            return parentClassLoader;
        }
        if (getPrivileged()) {
            return this.getClass().getClassLoader();
        } else if (parent != null) {
            return parent.getParentClassLoader();
        }
        return ClassLoader.getSystemClassLoader();
    }

java/org/apache/catalina/core/StandardContext.java 2217–2228행

privileged="true" 인 웹앱(Tomcat 이 함께 주는 manager 앱 같은 것)은 shared 가 아니라 StandardContext 자신을 올린 로더, 곧 server 로더를 부모로 씁니다. Tomcat 내부를 다뤄야 하는 앱을 위한 길입니다.

웹앱 로더가 부모를 얻는 길. Bootstrap.initClassLoaders 가 로더 셋을 만들고, Bootstrap.init 이 server 로더로 Catalina 를 읽어 setParentClassLoader(shared) 를 부른다. Catalina 의 Digester 가 Engine 요소에서 SetParentClassLoaderRule 로 StandardEngine 의 parentClassLoader 를 shared 로 정한다. StandardHost 는 부모에게 묻고, StandardContext.startInternal 이 WebappLoader 를 만들어 delegate 를 넘긴다. WebappLoader.createClassLoader 가 context.getParentClassLoader 를 부모로 ParallelWebappClassLoader 를 만들고, start 가 WEB-INF/classes 와 WEB-INF/lib 을 기억한다. 회색은 Bootstrap·Catalina 가 기동할 때 한 번 하는 일, 파란색은 컨테이너(Engine·Host·Context)와 WebappLoader 쪽, 초록색은 웹앱 로더가 준비를 마친 상태입니다.

StandardContext 가 WebappLoader 를 만든다

웹앱이 시작할 때 StandardContext.startInternal() 은 리소스(웹앱의 파일들)를 준비한 다음 로더를 챙깁니다.

        if (getLoader() == null) {
            WebappLoader webappLoader = new WebappLoader();
            webappLoader.setDelegate(getDelegate());
            setLoader(webappLoader);
        }

java/org/apache/catalina/core/StandardContext.java 4432–4436행

context.xml 에 <Loader> 를 적었으면 Digester 가 이미 WebappLoader 를 만들어 넣었으므로(ContextRuleSet.java 103–105행) 이 if 를 건너뜁니다. 없으면 여기서 기본 WebappLoader 를 만들고 <Context delegate="..."> 값을 넘깁니다. 조금 뒤 loader.start() 가 불리고(4474–4477행), WebappLoader.startInternal() 이 진짜 클래스로더를 만듭니다.

    private WebappClassLoaderBase createClassLoader() throws Exception {

        if (classLoader != null) {
            return classLoader;
        }

        if (ParallelWebappClassLoader.class.getName().equals(loaderClass)) {
            return new ParallelWebappClassLoader(context.getParentClassLoader());
        }

        Class<?> clazz = Class.forName(loaderClass);

        ClassLoader parentClassLoader = context.getParentClassLoader();

        Class<?>[] argTypes = { ClassLoader.class };
        Object[] args = { parentClassLoader };
        Constructor<?> constr = clazz.getConstructor(argTypes);

        return (WebappClassLoaderBase) constr.newInstance(args);
    }

java/org/apache/catalina/loader/WebappLoader.java 419–438행

loaderClass 의 기본값은 ParallelWebappClassLoader 입니다(98행). 기본이면 리플렉션 없이 바로 new 하고, <Loader loaderClass="..."> 로 다른 클래스를 적었을 때만 리플렉션으로 만듭니다. 어느 쪽이든 부모는 방금 본 context.getParentClassLoader() 입니다.

만든 뒤에는 리소스와 delegate 를 넣고 start() 합니다(333–342행). 로더의 start() 는 WEB-INF/classes 디렉터리와 WEB-INF/lib 의 jar 들을 목록으로 기억하고, jar 마다 수정 시각을 적어 둡니다(WebappClassLoaderBase.java 1386–1405행). 이 수정 시각이 5절의 리로드에서 쓰입니다.

Parallel 은 무엇이 병렬인가

ParallelWebappClassLoader 는 73줄짜리 클래스입니다. 하는 일은 사실상 하나, static 블록에서 registerAsParallelCapable() 을 부르는 것입니다(ParallelWebappClassLoader.java 32–38행). 이렇게 등록한 로더에서는 JDK 의 getClassLoadingLock(name) 이 클래스 이름마다 다른 잠금 객체를 돌려줍니다. 3절에서 볼 loadClass() 는 이 잠금으로 synchronized 하므로(1134행), 서로 다른 클래스를 여러 요청 스레드가 동시에 읽을 수 있습니다. 같은 패키지의 형제 WebappClassLoader 는 getClassLoadingLock() 을 덮어써 늘 this 를 돌려주므로 로더 하나에 잠금이 하나입니다(WebappClassLoader.java 60–66행). 두 클래스는 이 차이 말고는 같은 WebappClassLoaderBase 를 씁니다.

3. loadClass() — 부모 위임을 뒤집는 순서

왜 뒤집나

Java 의 기본 규칙대로 "부모 먼저"를 하면 어떻게 될까요. 웹앱이 WEB-INF/lib 에 어떤 라이브러리의 2.0 을 넣었는데 $CATALINA_HOME/lib 에 1.0 이 있으면, 부모(common)가 먼저 찾아 1.0 을 줍니다. 웹앱은 자기가 넣은 jar 를 쓸 수 없습니다. 그래서 서블릿 스펙은 웹앱 로더가 자기 것을 먼저 보도록 권하고, Tomcat 은 그것을 기본값으로 둡니다. delegate 필드의 주석이 "기본값 false 가 서블릿 스펙이 요구하는 동작"이라고 적어 두었습니다(WebappClassLoaderBase.java 206–212행).

그렇다고 무엇이든 덮어쓸 수 있으면 곤란합니다. 웹앱이 자기 java.lang.String 을 넣거나, 자기 jakarta.servlet.http.HttpServlet 을 넣으면 Tomcat 이 넘겨주는 객체와 타입이 맞지 않게 됩니다. loadClass() 는 이 두 요구를 순서로 풉니다.

WebappClassLoaderBase.loadClass 의 순서. 먼저 (0)과 (0.1)에서 이미 읽은 클래스인지 보고 있으면 돌려준다. 없으면 (0.2)에서 Java SE 로더에 그 .class 가 있는지 보고 있으면 거기서 읽는다. 없으면 delegate 이거나 filter(name) 이 true 인지 본다. 그렇다면 (1) 부모에게 먼저 묻고, 없으면 (2) 내 저장소에서 찾는다. 기본은 바로 (2) 내 저장소로 가고, 거기서 없으면 (3) 마지막으로 부모에게 묻는다. 끝까지 없으면 ClassNotFoundException 이다. 번호 (0)~(3)은 소스 주석에 적힌 번호 그대로입니다.

(0)·(0.1) 이미 읽었나

findLoadedClass0() 은 이 로더가 직접 정의해 resourceEntries 맵에 넣어 둔 클래스를 찾고(2325–2334행), findLoadedClass() 는 JVM 이 이 로더 이름으로 기억하는 클래스를 찾습니다(1143–1165행). 그런데 그 앞에 한 줄이 더 있습니다. checkStateForClassLoading(name)(1141행) — 로더가 멈췄으면 여기서 거절합니다. 5절의 예제 2에서 이 줄이 얼마나 일찍 걸리는지 봅니다.

(0.2) Java SE 클래스는 덮어쓸 수 없다

            String resourceName = binaryNameToPath(name, false);

            ClassLoader javaseLoader = getJavaseClassLoader();
            boolean tryLoadingFromJavaseLoader;
            try {
                // ... (getResource 로 확인하는 이유를 적은 주석) ...
                URL url = javaseLoader.getResource(resourceName);
                tryLoadingFromJavaseLoader = url != null;
            } catch (Throwable t) {
                // ...
                tryLoadingFromJavaseLoader = true;
            }

            if (tryLoadingFromJavaseLoader) {
                try {
                    clazz = javaseLoader.loadClass(name);
                    // ... resolve 하고 return clazz ...

java/org/apache/catalina/loader/WebappClassLoaderBase.java 1171–1198행

delegate 가 무엇이든 가장 먼저 JDK 쪽에 묻습니다. 소스 주석이 "웹앱이 Java SE 클래스를 덮어쓰지 못하게 한다. SRV.10.7.2 를 구현한다"고 적었습니다(1167–1170행). loadClass() 로 바로 묻지 않고 getResource() 로 .class 파일이 있는지부터 보는 것은, 없는 클래스마다 비싼 ClassNotFoundException 을 만들지 않으려는 것입니다(1176–1184행 주석).

그런데 이 javaseLoader 는 무엇일까요. 생성자가 정합니다.

        ClassLoader j = String.class.getClassLoader();
        if (j == null) {
            j = getSystemClassLoader();
            while (j.getParent() != null) {
                j = j.getParent();
            }
        }
        this.javaseClassLoader = j;

java/org/apache/catalina/loader/WebappClassLoaderBase.java 179–186행

String 의 로더는 bootstrap 이라 null 입니다. 그래서 system 로더에서 시작해 부모가 null 이 될 때까지 올라갑니다. JDK 9 이후라면 그 끝은 platform 로더입니다. platform 로더는 자기 부모로 bootstrap 을 쓰므로, 여기에 물으면 bootstrap 과 platform 이 맡은 JDK 클래스가 모두 나옵니다. 예제 1에서 java.sql.Driver 가 platform 로더에서 나오는 것으로 확인합니다.

(1)~(3) delegate 와 filter() 가 고르는 두 순서

            boolean delegateLoad = delegate || filter(name, true);

            // (1) Delegate to our parent if requested
            if (delegateLoad) {
                // ...
                    clazz = Class.forName(name, false, parent);
                // ...
            }

            // (2) Search local repositories
            // ...
                clazz = findClass(name);
            // ...

            // (3) Delegate to parent unconditionally
            if (!delegateLoad) {
                // ...
                    clazz = Class.forName(name, false, parent);
                // ...
            }

java/org/apache/catalina/loader/WebappClassLoaderBase.java 1210–1271행

delegateLoad 하나로 두 순서가 갈립니다.

delegateLoad 순서 언제
false (기본) 내 저장소 → 부모 웹앱 자기 클래스, 웹앱이 넣은 라이브러리
true 부모 → 내 저장소 delegate="true" 이거나, filter() 가 고른 이름

delegate="true" 면 모든 클래스가 Java 의 보통 규칙대로 갑니다. filter() 는 delegate 와 상관없이 "이 이름만큼은 부모 먼저"를 고르는 목록입니다.

        if (name.startsWith("jakarta")) {
            /* 7 == length("jakarta") */
            if (name.length() == 7) {
                return false;
            }
            ch = name.charAt(7);
            if (isClassName && ch == '.') {
                /* 8 == length("jakarta.") */
                if (name.startsWith("servlet.jsp.jstl.", 8)) {
                    return false;
                }
                if (name.startsWith("annotation.", 8) || name.startsWith("el.", 8) || name.startsWith("servlet.", 8) ||
                        name.startsWith("websocket.", 8) || name.startsWith("security.auth.message.", 8)) {
                    return true;
                }

java/org/apache/catalina/loader/WebappClassLoaderBase.java 2352–2366행

filter() 가 true 를 돌려주는 이름은 세 무리입니다(2345–2431행).

이 목록이 필요한 이유는 "타입이 맞아야 하는 것"이기 때문입니다. Tomcat 은 common 로더의 HttpServletRequest 를 구현한 객체를 서블릿에 넘깁니다. 웹앱이 WEB-INF/lib 에 servlet-api.jar 를 실수로 넣었는데 그것이 먼저 쓰이면, 웹앱 쪽 HttpServletRequest 와 Tomcat 쪽 HttpServletRequest 는 이름만 같은 다른 타입이 되어 ClassCastException·LinkageError 가 납니다. 부모 먼저로 두면 웹앱에 그 jar 가 있어도 common 의 것이 쓰입니다.

findClass() — WEB-INF/classes 가 WEB-INF/lib 보다 먼저

(2)의 findClass() 는 결국 resources.getClassLoaderResource(path) 로 바이트를 찾습니다(2137–2146행). 이것은 웹앱 파일을 관리하는 StandardRoot 의 메서드로, 경로 앞에 /WEB-INF/classes 를 붙여 찾습니다(StandardRoot.java 237–239행). WEB-INF/lib 의 jar 들은 "/WEB-INF/classes 에 마운트된 jar"로 따로 등록됩니다(635–643행). 찾는 순서는 리소스 묶음 목록의 순서입니다.

    {
        allResources.add(preResources);
        allResources.add(mainResources);
        allResources.add(classResources);
        allResources.add(jarResources);
        allResources.add(postResources);
    }

java/org/apache/catalina/webresources/StandardRoot.java 93–99행

mainResources(웹앱 폴더 자체, 그 안의 진짜 WEB-INF/classes)가 classResources(WEB-INF/lib 의 jar)보다 앞이고, 앞에서 찾으면 바로 돌려줍니다(289–300행). 그래서 같은 클래스가 둘 다에 있으면 WEB-INF/classes 의 것이 이깁니다. 이 순서는 소스로만 확인했고 예제로 돌려 보지는 않았습니다.

예제 1: 두 웹앱, 같은 이름, 다른 클래스

웹앱 둘(app1, app2)에 같은 서블릿 demo.WhoServlet 을 넣고, 이 세 클래스를 준비했습니다.

클래스 app1 의 WEB-INF/classes app2 의 WEB-INF/classes $CATALINA_BASE/lib/demo-shared.jar
demo.Greeting "hello from app1's Greeting" "HELLO FROM APP2'S …" (다른 내용) 없음
demo.Shared 있음 있음 있음 (내용이 다름)
jakarta.servlet.Extra 있음 있음 없음 (servlet-api.jar 에도 없다)

app2 에만 META-INF/context.xml 로 <Loader delegate="true"/> 를 두었습니다. 서블릿은 이름으로 클래스를 찾아 그 클래스를 정의한 로더를 찍고, 자기 로더에서 부모를 끝까지 따라갑니다.

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... package·import 와 클래스 설명 주석 줄임 ...
@WebServlet("/who")
public class WhoServlet extends HttpServlet {

    // 로더를 사람이 읽을 수 있게: 클래스 이름 + getName() + 객체 번호(실행마다 다르다)
    static String describe(ClassLoader cl) {
        if (cl == null) {
            return "bootstrap (null)";
        }
        String name = cl.getName() == null ? "" : " name=" + cl.getName();
        return cl.getClass().getSimpleName() + name + " @" + Integer.toHexString(System.identityHashCode(cl));
    }

    // 클래스 이름으로 찾아, 그 클래스를 정의한 로더를 찍는다 (찾는 것은 이 웹앱의 로더가 한다)
    static String origin(String className) {
        try {
            Class<?> c = Class.forName(className, false, WhoServlet.class.getClassLoader());
            return describe(c.getClassLoader());
        } catch (ClassNotFoundException e) {
            return "not found";
        }
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        // ... out(응답 Writer), mine(= WhoServlet 의 로더)을 준비하고
        //     Greeting.message(), Shared.where() 를 찍는다 ...

        // 1) 이 웹앱 로더에서 시작해 부모를 끝까지 따라간다
        // ... cl = cl.getParent() 를 null 이 될 때까지 describe(cl) 로 찍는다 ...

        // 2) 클래스마다 실제로 정의한 로더
        // ... 클래스 이름 여덟 개마다 origin(이름) 을 찍는다 ...

        // 3) 요청을 처리하는 스레드의 컨텍스트 클래스로더
        ClassLoader tccl = Thread.currentThread().getContextClassLoader();
        out.println("TCCL == webapp loader : " + (tccl == mine));
    }
}

[예제 ex/u04-classloaders/src/both/WhoServlet.java 에서 줄임]

이 예제는 기준 커밋(17aa1769b0)을 그대로 빌드한 Tomcat 12.0.0-M1-dev 로 도커 컨테이너(Eclipse Temurin JDK 21)에서 실행했습니다. run.sh 는 클래스들을 javac 로 컴파일해 CATALINA_BASE=/tmp/base 아래에 두고 catalina.sh start 한 뒤 두 주소를 curl 합니다. @ 뒤의 16진수는 객체마다 붙는 번호(System.identityHashCode)라 실행마다 다릅니다. 같은 번호면 같은 로더 객체입니다.

===== curl http://127.0.0.1:8080/app1/who =====
context            : /app1
Greeting.message() : hello from app1's Greeting
Shared.where()     : copy in WEB-INF/classes (webapp loader)
-- parent chain --
  [0] ParallelWebappClassLoader @4bf48f6
  [1] URLClassLoader @61e4705b
  [2] AppClassLoader name=app @764c12b6
  [3] PlatformClassLoader name=platform @60410cd
  [4] bootstrap (null)
-- who defined it --
  demo.Greeting                              ParallelWebappClassLoader @4bf48f6
  demo.Shared                                ParallelWebappClassLoader @4bf48f6
  jakarta.servlet.Extra                      ParallelWebappClassLoader @4bf48f6
  jakarta.servlet.http.HttpServlet           URLClassLoader @61e4705b
  org.apache.catalina.core.StandardContext   URLClassLoader @61e4705b
  org.apache.catalina.startup.Bootstrap      AppClassLoader name=app @764c12b6
  java.lang.String                           bootstrap (null)
  java.sql.Driver                            PlatformClassLoader name=platform @60410cd
TCCL == webapp loader : true
===== curl http://127.0.0.1:8080/app2/who =====
context            : /app2
Greeting.message() : HELLO FROM APP2'S GREETING (a different class)
Shared.where()     : copy in $CATALINA_BASE/lib (common loader)
-- parent chain --
  [0] ParallelWebappClassLoader @5066d65f
  [1] URLClassLoader @61e4705b
  [2] AppClassLoader name=app @764c12b6
  [3] PlatformClassLoader name=platform @60410cd
  [4] bootstrap (null)
-- who defined it --
  demo.Greeting                              ParallelWebappClassLoader @5066d65f
  demo.Shared                                URLClassLoader @61e4705b
  jakarta.servlet.Extra                      ParallelWebappClassLoader @5066d65f
  jakarta.servlet.http.HttpServlet           URLClassLoader @61e4705b
  org.apache.catalina.core.StandardContext   URLClassLoader @61e4705b
  org.apache.catalina.startup.Bootstrap      AppClassLoader name=app @764c12b6
  java.lang.String                           bootstrap (null)
  java.sql.Driver                            PlatformClassLoader name=platform @60410cd
TCCL == webapp loader : true

예제 1에서 클래스마다 그것을 정의한 로더. 맨 위 bootstrap(null)이 java.lang.String을, PlatformClassLoader @60410cd가 java.sql.Driver를, AppClassLoader name=app @764c12b6이 org.apache.catalina.startup.Bootstrap을 정의했다. 그 아래 두 앱이 함께 쓰는 common 로더 URLClassLoader @61e4705b가 jakarta.servlet.http.HttpServlet, org.apache.catalina.core.StandardContext, 그리고 app2만 쓰는 demo.Shared를 정의했다. common 아래에 웹앱 로더가 둘 있다. delegate가 기본(false)인 app1의 ParallelWebappClassLoader @4bf48f6은 demo.Greeting(hello from app1's …), WEB-INF/classes의 demo.Shared, jakarta.servlet.Extra를 정의했고, delegate=true인 app2의 ParallelWebappClassLoader @5066d65f는 demo.Greeting(HELLO FROM APP2'S …)과 jakarta.servlet.Extra를 정의했다. 위 출력의 "who defined it" 두 묶음을 로더 사슬 위에 옮긴 그림입니다. @ 번호는 이 실행의 것이라 다시 돌리면 달라집니다.

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

헷갈리기 쉬운 곳: filter() 는 "금지"가 아니라 "부모 먼저"입니다

WebappClassLoaderBase 의 클래스 주석에는 서블릿 API 클래스는 "웹앱 저장소에서 절대 읽지 않는다(never loaded from the webapp repositories)"고 적혀 있습니다(84–89행). 그런데 loadClass() 의 코드는 filter() 가 true 일 때 (1) 부모에게 먼저 묻고, 없으면 (2) 내 저장소로 갑니다. 예제의 jakarta.servlet.Extra 는 jakarta.servlet. 로 시작하므로 filter() 가 true 이지만, common 로더에는 그런 클래스가 없어 결국 웹앱 로더가 정의했습니다. 막히는 것은 "부모에게 이미 있는 이름"뿐입니다. 웹앱이 servlet-api.jar 를 넣어도 큰 사고가 나지 않는 것은 그 jar 의 클래스가 모두 부모에 이미 있기 때문입니다.

4. delegate 는 언제 쓰나

delegate 는 두 곳에 적을 수 있습니다. <Context delegate="true"> 는 StandardContext 의 필드이고 2절에서 본 대로 WebappLoader 를 만들 때 넘어갑니다. <Loader delegate="true"/> 는 WebappLoader 에 바로 들어갑니다. 어느 쪽이든 결국 classLoader.setDelegate(this.delegate)(WebappLoader.java 335행)로 웹앱 로더의 필드가 됩니다.

delegate 는 클래스만이 아니라 리소스에도 같은 순서를 씁니다. getResource(), getResources(), getResourceAsStream() 이 모두 delegate || filter(name, false) 로 순서를 고릅니다(903행, 956행, 984행). 그래서 log4j2.xml 이나 META-INF/services/... 처럼 이름으로 찾는 설정 파일도 delegate 에 따라 웹앱의 것과 lib/ 의 것 중 먼저 찾히는 쪽이 바뀝니다. 이 부분은 소스로만 확인했습니다.

정리하면 이런 선택입니다.

상황 고르는 값
웹앱마다 다른 버전의 라이브러리를 쓰고 싶다 (보통) delegate="false" (기본)
공통 라이브러리를 lib/ 에 두고, 웹앱에 실수로 들어간 같은 jar 보다 그것을 쓰게 하고 싶다 delegate="true"

헷갈리기 쉬운 곳: delegate="false" 여도 부모가 먼저인 이름이 있습니다

delegate 를 끈다고 웹앱이 모든 것을 덮어쓰는 것이 아닙니다. Java SE 클래스는 (0.2)에서, filter() 의 이름들은 (1)에서 이미 부모에게 갑니다. 거꾸로 delegate="true" 여도 (0.2)가 맨 앞이라는 점은 같습니다. delegate 가 바꾸는 것은 그 둘을 뺀 나머지 클래스의 순서뿐입니다.

delegate가 정해지는 길과 바꾸는 범위. Context 요소의 delegate 속성은 StandardContext의 필드였다가 WebappLoader를 만들 때 넘어가고, Loader 요소의 delegate 속성은 WebappLoader에 바로 들어간다. 어느 쪽이든 WebappLoader.java 335행의 classLoader.setDelegate(this.delegate)로 웹앱 로더의 delegate 필드가 되고, 이 필드는 loadClass()와 getResource()·getResources()·getResourceAsStream()이 함께 쓴다. 이름마다 보면, Java SE 클래스는 (0.2)에서 언제나 먼저이고 filter()가 고른 이름(jakarta.servlet., org.apache.catalina. 등)은 (1)에서 언제나 부모가 먼저라 둘 다 delegate와 무관하다. delegate가 바꾸는 것은 나머지, 곧 웹앱 클래스와 라이브러리의 순서뿐이다. false면 내 저장소 다음 부모, true면 부모 다음 내 저장소다. 노란 칸은 설정이 필드가 되는 길, 회색 칸은 delegate 로 바뀌지 않는 이름, 파란 칸이 delegate 가 실제로 바꾸는 부분입니다.

5. 리로드와 누수 — 로더를 버린다는 것

reloadable 은 누가 보나

<Context reloadable="true"> 면 Tomcat 은 웹앱의 클래스 파일이 바뀌었는지 주기적으로 봅니다. 3편에서 본 컨테이너의 백그라운드 처리가 그 시계입니다. StandardEngine 은 생성자에서 backgroundProcessorDelay = 10 을 두고(StandardEngine.java 62행), Server 의 유틸리티 스레드 풀(Catalina-utility-N)에 10초 간격 작업을 겁니다(ContainerBase.java 1090–1092행). 이 작업이 Engine 부터 자식들을 따라 내려가며 backgroundProcess() 를 부르고(1164–1185행), Context 차례가 되면 WebappLoader.backgroundProcess() 에 닿습니다.

    @Override
    public void backgroundProcess() {
        Context context = getContext();
        if (context != null) {
            if (context.getReloadable() && modified()) {
                Thread currentThread = Thread.currentThread();
                ClassLoader originalTccl = currentThread.getContextClassLoader();
                try {
                    currentThread.setContextClassLoader(WebappLoader.class.getClassLoader());
                    context.reload();
                } finally {
                    currentThread.setContextClassLoader(originalTccl);
                }
            }
        }
    }

java/org/apache/catalina/loader/WebappLoader.java 229–244행

modified() 는 두 가지를 봅니다(WebappClassLoaderBase.java 592–640행). 이미 읽은 클래스(resourceEntries)마다 파일의 수정 시각이 읽을 때와 다른지, 그리고 WEB-INF/lib 의 jar 가 더해지거나 바뀌거나 빠졌는지입니다. 아직 한 번도 읽지 않은 클래스 파일은 바꿔도 리로드 이유가 되지 않습니다.

reload() 직전에 컨텍스트 클래스로더를 WebappLoader 를 올린 로더(Tomcat 쪽)로 바꾸는 데 주의하세요. 백그라운드 작업은 Context 를 처리하는 동안 컨텍스트 클래스로더를 웹앱 로더로 바꿔 두므로(ContainerBase.java 1177행), 그대로 reload() 하면 버릴 로더를 붙든 채 새 웹앱을 시작하게 됩니다.

StandardContext.reload() 자체는 짧습니다. 요청을 잠시 받지 않게 setPaused(true), stop(), start(), setPaused(false) 입니다(StandardContext.java 3393–3425행). 클래스를 "다시 읽는" 특별한 코드는 없습니다. 로더를 통째로 버리고 새로 만들 뿐입니다. 이미 정의된 클래스를 JVM 안에서 바꾸는 방법은 없으므로, 새 클래스를 쓰려면 새 로더가 있어야 합니다.

리로드의 흐름. Catalina-utility 스레드가 10초마다 Engine 부터 backgroundProcess 를 부르고, WebappLoader.backgroundProcess 가 reloadable 이고 modified 이면 StandardContext.reload 를 부른다. reload 는 setPaused(true), stop, start 순이다. 옛 로더의 stop 은 STOPPING_PREP 상태에서 clearReferences 를 불러 JDBC 드라이버 해제, 남은 스레드 경고, ThreadLocal 검사, 캐시 비우기를 하고, STOPPED 에서 resourceEntries 를 비우며 destroy 에서 close 한다. 그 뒤 새 ParallelWebappClassLoader 가 클래스를 처음부터 다시 읽는다. 옛 로더를 붙든 스레드나 ThreadLocal 이 남으면 옛 클래스 전부가 메모리에 남는다. 빨간 상자가 "memory leak" 경고가 가리키는 일입니다.

옛 로더를 멈추는 순서

    @Override
    public void stop() throws LifecycleException {

        state = LifecycleState.STOPPING_PREP;

        // Clearing references should be done before setting started to
        // false, due to possible side effects
        clearReferences();

        state = LifecycleState.STOPPING;

        resourceEntries.clear();
        notFoundClassResources.clear();
        jarModificationTimes.clear();
        resources = null;

        permissionList.clear();
        loaderPC.clear();

        state = LifecycleState.STOPPED;
    }

java/org/apache/catalina/loader/WebappClassLoaderBase.java 1408–1428행

STOPPING_PREP 은 아직 "쓸 수 있는" 상태입니다(LifecycleState.java 51행의 첫 인자 true). 그동안 clearReferences() 가 정리에 필요한 클래스를 이 로더로 읽을 수 있습니다. 그다음 STOPPING 부터는 쓸 수 없는 상태가 되고, 읽어 둔 클래스 목록을 비웁니다. WebappLoader.stopInternal() 은 이어서 destroy() 를 불러 URLClassLoader.close() 로 jar 파일을 닫고, 자기 classLoader 필드를 null 로 만듭니다(WebappLoader.java 387–410행).

왜 "누수"가 생기나

Tomcat 이 로더를 버려도, 로더 객체가 실제로 메모리에서 사라지는 것은 아무도 그것을 가리키지 않을 때(가비지 컬렉터가 회수할 때)입니다. 그리고 로더는 자기가 정의한 클래스 전부를, 클래스는 자기 로더를 서로 가리킵니다. 그래서 옛 웹앱의 객체 하나라도 어딘가 살아 있으면 그 클래스 → 로더 → 그 로더가 읽은 모든 클래스가 줄줄이 남습니다. 리로드를 여러 번 하면 클래스 메타데이터를 담는 메모리(Metaspace)가 차오르는 이유입니다.

자주 붙드는 쪽은 웹앱 바깥에 오래 사는 것들입니다. 웹앱이 띄우고 멈추지 않은 스레드(스레드의 컨텍스트 클래스로더와 실행 중인 코드가 옛 로더를 가리킴), 요청 스레드 풀의 ThreadLocal 에 넣고 지우지 않은 값, JDK 의 DriverManager 에 등록된 채 남은 JDBC 드라이버 같은 것입니다. clearReferences() 가 바로 이것들을 하나씩 찾습니다(1472–1519행).

순서 하는 일 기본
1 웹앱이 등록하고 남긴 JDBC 드라이버 해제 (clearReferencesJdbc) 늘
2 컨텍스트 클래스로더가 이 로더인 스레드를 찾아 경고 (clearReferencesThreads). 멈추기까지는 clearReferencesStopThreads 를 켰을 때만 경고만
3 스레드들의 ThreadLocal 에 이 로더의 객체가 남았는지 검사·경고 켜짐
4 RMI 대상 정리 켜짐
5 IntrospectionUtils, java.beans.Introspector 같은 캐시 비우기 늘

기본값은 StandardContext 의 필드에 있고(684–719행), 웹앱이 시작할 때 로더에 옮겨집니다(4481–4489행). 스레드를 강제로 멈추는 것은 기본이 꺼져 있습니다(693행 clearReferencesStopThreads = false). 남의 스레드를 끊는 것은 위험하므로 경고만 하는 것이 기본입니다.

스레드 검사의 핵심은 한 줄입니다. thread.getContextClassLoader() == this 인 스레드를 찾습니다(1580–1581행). 새 스레드는 만든 스레드의 컨텍스트 클래스로더를 물려받으므로, 웹앱 코드가 만든 스레드는 대개 이 조건에 걸립니다.

예제 2: 스레드를 남기는 웹앱을 리로드하기

reloadable="true" 인 웹앱 leaky 를 만들었습니다. 서블릿은 시작할 때(loadOnStartup = 1) leaky-worker 라는 스레드를 띄우고 멈추지 않습니다. 이 스레드는 1초마다 자기를 만든 로더에게 demo.Version 을 다시 묻습니다. 응답은 Version.text() 와 그 클래스를 정의한 로더입니다.

// 이 글을 위해 쓴 예제입니다 (Tomcat 의 소스가 아닙니다)
// ... package·import 와 클래스 설명 주석 줄임 ...
@WebServlet(urlPatterns = "/v", loadOnStartup = 1)
public class LeakyServlet extends HttpServlet {

    @Override
    public void init() {
        ClassLoader myLoader = LeakyServlet.class.getClassLoader();
        String loaderId = Integer.toHexString(System.identityHashCode(myLoader));
        Thread t = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(1000);
                    // 자기를 만든 로더에게 클래스를 1초마다 다시 묻는다
                    myLoader.loadClass("demo.Version");
                } catch (InterruptedException e) {
                    return;
                } catch (ClassNotFoundException e) {
                    System.out.println("[leaky-worker] loader @" + loaderId + " refused: " + e.getMessage());
                    return;
                }
            }
        }, "leaky-worker");
        t.setDaemon(true);
        t.start();   // 새 스레드는 만든 스레드의 컨텍스트 클래스로더(= 이 웹앱 로더)를 물려받는다
    }
    // ... doGet 은 Version.text() 와 Version 을 정의한 로더를 찍는다 ...
}

[예제 ex/u04-reload-leak/src/LeakyServlet.java 에서 줄임]

run.sh 는 Tomcat 을 띄워 한 번 요청하고, WEB-INF/classes/demo/Version.class 를 v2 로 컴파일한 파일로 덮어쓴 뒤, 응답에 v2 가 나올 때까지 1초마다 요청합니다. 그다음 catalina.out 에서 관련 줄만 골라냅니다(긴 스택 트레이스는 ... 로 줄였습니다). 시각과 로더 번호, Catalina-utility-N 의 번호, 걸린 초는 실행마다 다릅니다.

===== before: curl /leaky/v =====
Version v1  loaded by ParallelWebappClassLoader @4a00d9cf
===== replace WEB-INF/classes/demo/Version.class with v2 =====
===== after (~8s): curl /leaky/v =====
Version v2  loaded by ParallelWebappClassLoader @40241b1c
===== log lines =====
24-Oct-2026 13:43:19.908 INFO [Catalina-utility-1] org.apache.catalina.core.StandardContext.reload Reloading Context with name [/leaky] has started
24-Oct-2026 13:43:19.935 WARNING [Catalina-utility-1] org.apache.catalina.loader.WebappClassLoaderBase.clearReferencesThreads The web application [leaky] appears to have started a thread named [leaky-worker] but has failed to stop it. This is very likely to create a memory leak. Stack trace of thread: ...
24-Oct-2026 13:43:20.181 INFO [Catalina-utility-1] org.apache.catalina.core.StandardContext.reload Reloading Context with name [/leaky] is completed
24-Oct-2026 13:43:20.860 INFO [leaky-worker] org.apache.catalina.loader.WebappClassLoaderBase.checkStateForResourceLoading Illegal access: this web application instance has been stopped already. Could not load [demo.Version]. ...
24-Oct-2026 13:43:20.864 INFO [leaky-worker] org.apache.catalina.loader.WebappClassLoaderBase.checkStateForResourceLoading Illegal access: this web application instance has been stopped already. Could not load [java.io.PrintStream]. ...
Exception in thread "leaky-worker" java.lang.NoClassDefFoundError: java/io/PrintStream
Caused by: java.lang.ClassNotFoundException: Illegal access: this web application instance has been stopped already. Could not load [java.io.PrintStream]. ...
===== threads named leaky-worker (jstack) =====
1

막혔던 곳: 멈춘 로더는 java.io.PrintStream 도 못 읽습니다

예제를 쓸 때 기대한 출력은 [leaky-worker] loader @... refused: ... 한 줄이었습니다. catch (ClassNotFoundException e) 안에서 System.out.println 을 하게 해 두었으니까요. 그런데 그 줄은 한 번도 찍히지 않았고, 대신 Could not load [java.io.PrintStream] 과 NoClassDefFoundError 가 나왔습니다.

이유는 순서에 있습니다. catch 블록에서 처음으로 PrintStream 을 쓰려 하자, JVM 은 그 코드가 든 클래스(람다의 본문은 LeakyServlet 안의 메서드로 컴파일됩니다)를 정의한 로더, 곧 멈춘 옛 로더에게 java.io.PrintStream 을 달라고 합니다. loadClass() 는 3절의 (0.2) Java SE 단계보다 앞인 1141행에서 상태부터 검사하므로, java.* 클래스조차 거절합니다. 그래서 catch 블록 안에서 다시 예외가 났고 스레드가 죽었습니다. 멈춘 웹앱의 코드는 아직 한 번도 쓰지 않은 클래스라면 JDK 클래스조차 새로 쓸 수 없다는 뜻입니다. 남은 스레드가 리로드 뒤 이상한 NoClassDefFoundError 를 내며 죽는다면 이 경우를 의심해 볼 만합니다.

JreMemoryLeakPreventionListener — 서버 쪽에서 미리 막기

clearReferences() 가 웹앱을 멈출 때 치우는 쪽이라면, server.xml 기본 설정에 들어 있는 JreMemoryLeakPreventionListener(conf/server.xml 32행)는 서버가 시작할 때 미리 막는 쪽입니다. 클래스 주석의 설명은 이렇습니다. JDK 의 어떤 코드는 처음 불릴 때 그 순간의 컨텍스트 클래스로더를 싱글턴에 붙들어 두는데, 그 처음이 웹앱 요청 중이면 웹앱 로더가 영원히 붙들립니다. 그래서 Tomcat 이 시작할 때(웹앱이 하나도 없을 때) 그 코드들을 먼저 불러 둡니다(JreMemoryLeakPreventionListener.java 34–45행).

Server 의 before_init 이벤트(3편의 Lifecycle 이벤트)에서 기본으로 하는 일은 두 가지입니다(181–242행). DriverManager.getDrivers() 를 먼저 불러 lib/ 의 JDBC 드라이버를 Tomcat 쪽에서 올리고(driverManagerProtection, 기본 true), jar: URL 연결의 캐시를 꺼서 jar 파일이 잠기지 않게 합니다(urlCacheProtection, 기본 true). ImageIO(appContextProtection)와 SecureRandom 씨앗 생성(initSeedGenerator)은 기본이 꺼져 있습니다(62행, 160행).

이 글에서 뺀 것

정리

개념 한 줄 요약
common·server·shared catalina.properties 로 Bootstrap 이 만든다. 값이 비면 부모를 그대로 쓰므로 기본은 셋 다 같은 URLClassLoader
Bootstrap.init() server 로더로 Catalina 를 리플렉션으로 올리고 shared 로더를 부모로 넘긴다
부모 찾기 Engine 에만 shared 가 정해지고, Host·Context 는 getParentClassLoader() 로 위로 묻는다. privileged 면 server 로더
WebappLoader Context 마다 하나. ParallelWebappClassLoader 를 만들고 멈추고 버린다
ParallelWebappClassLoader 클래스 이름마다 다른 잠금을 쓰도록 등록한 웹앱 로더(기본)
loadClass() 순서 이미 읽음 → Java SE 로더 → (부모 먼저일 때) 부모 → 내 저장소 → (아니면) 부모
filter() jakarta.servlet. 등 Tomcat 이 주는 API 와 org.apache.catalina. 등 내부 클래스는 부모 먼저. 금지가 아니라 순서
delegate true 면 모든 클래스·리소스를 부모 먼저. 기본 false 가 서블릿 스펙의 동작
리로드 10초마다 modified() 를 보고, 바뀌었으면 Context 를 stop·start 해 로더를 새로 만든다
clearReferences() 옛 로더를 붙든 드라이버·스레드·ThreadLocal·캐시를 찾아 치우거나 경고한다. 스레드 강제 종료는 기본 꺼짐
멈춘 로더 상태 검사가 맨 앞이라 java.* 클래스도 거절한다

다음에 볼 것

여기까지가 "Tomcat 이 어떻게 생겨서 어떻게 켜지는가"였습니다. 다음 글부터는 요청 하나를 따라갑니다. 첫 관문은 소켓입니다. 5편: 커넥터와 NIO 엔드포인트 — 연결은 어떤 스레드가 받나에서 Connector 가 포트를 열고, Acceptor·Poller 스레드가 연결을 받아 http-nio-8080-exec-N 작업 스레드에게 넘기는 과정을 봅니다. 이 글의 예제 1에서 "요청 스레드의 컨텍스트 클래스로더가 웹앱 로더"였던 그 스레드가 어디서 오는지입니다.

httpd 와 비교하면 차이가 뚜렷합니다. httpd 의 모듈은 LoadModule 로 한 프로세스에 함께 실리고(httpd 6편), 같은 이름의 함수를 둘이 따로 쓰는 식의 격리는 없습니다. 설정을 바꾸려면 서버 전체가 graceful 재시작을 합니다(httpd 5편). Tomcat 은 JVM 의 "이름 + 로더" 규칙 덕분에 한 프로세스 안에서 웹앱 하나만 갈아 끼웁니다. 대신 httpd 의 메모리 풀이 풀을 지우는 순간 확실히 돌아오는 것과 달리(httpd 4편), Tomcat 의 로더는 누군가 하나라도 붙들고 있으면 돌아오지 않습니다. 이 글 5절의 경고들이 그 대가입니다. Spring 이 리플렉션 캐시를 "비워질 수 있는 맵"(ConcurrentReferenceHashMap)으로 두는 것도 같은 문제를 피하려는 것입니다(Spring 16편).

다른 글