Skip to main content

Command Palette

Search for a command to run...

JVM Class Loader 계층과 Parent Delegation — 위임이 지키는 것, 깨지는 자리

Updated
16 min read

java.lang.String을 직접 만들어 rt.jar의 String을 갈아치울 수 없는 이유, Tomcat 위에 올린 웹앱이 컨테이너의 라이브러리를 무시하고 자신의 버전을 우선 쓰는 이유, JDBC DriverManager가 어떻게 클래스패스 어디에 있는지도 모르는 드라이버를 발견해서 로드하는지를 설명하는 한 가지 메커니즘이 있습니다. JVM의 Class Loader 계층과 그 위에서 동작하는 Parent Delegation 모델입니다. 이 글은 계층 구조의 형태, 위임의 동작, 위임이 의도적으로 깨지는 자리들, 그리고 잘못 깨졌을 때 보게 되는 에러들을 따라가 봅니다.


1. Class Loader가 하는 일

.classClass<?>가 되기까지

Java 소스를 컴파일하면 .class 바이트코드 파일이 생성됩니다. 이 파일은 디스크 위 바이트열에 불과합니다. JVM이 그 클래스의 정적 필드를 참조하거나 인스턴스를 만들려면, 먼저 바이트열을 읽어 java.lang.Class 객체로 변환해 메서드 영역(JDK 8 이후로는 metaspace)에 등록해야 합니다.

JVM 명세는 이 과정을 세 단계로 정의합니다.

flowchart LR
    BYTES[.class bytes] --> LOADING[Loading]
    LOADING --> LINKING[Linking]
    LINKING --> INIT[Initialization]
    LINKING --> VERIFY[Verification]
    LINKING --> PREPARE[Preparation]
    LINKING --> RESOLVE[Resolution]
  • Loading: 이름으로 바이트를 찾고 Class 객체를 생성합니다. 이 단계의 주체가 바로 Class Loader입니다.
  • Linking: 바이트코드 검증, 정적 필드 메모리 할당과 기본값 세팅, 심볼릭 레퍼런스 해소.
  • Initialization: static 블록과 정적 필드 초기화 코드 실행.

대부분 클래스는 처음 사용되는 시점에 lazy하게 로딩됩니다. new, 정적 메서드 호출, 정적 필드 접근, Class.forName 같은 트리거가 있어야 비로소 로딩이 시작됩니다.

이름이 같아도 같은 클래스가 아니다

JVM이 클래스를 식별하는 단위는 **이름이 아니라 (이름, 정의한 ClassLoader) 쌍**입니다. 같은 com.example.Foo라도 서로 다른 ClassLoader가 각각 정의했다면 JVM 입장에서는 다른 타입입니다. 이 사실 하나가 멀티 클래스로더 환경의 거의 모든 함정의 출발점이고, 또 거의 모든 격리 기능의 토대이기도 합니다.

이 식별 규칙 때문에, ClassLoader는 단순한 파일 로더가 아니라 타입 네임스페이스의 경계가 됩니다. 컨테이너가 여러 웹앱을 한 JVM에서 격리해 돌릴 수 있는 이유, OSGi가 같은 라이브러리의 여러 버전을 공존시킬 수 있는 이유 모두 같은 원리에서 나옵니다.


2. 계층 구조 — 세 개의 빌트인 로더

JVM이 시작될 때 자동으로 만들어지는 ClassLoader는 세 종류입니다. Java 9에서 명칭이 한 번 정리됐고, 본 글은 Java 9 이후 기준으로 설명합니다.

flowchart TD
    Bootstrap[Bootstrap ClassLoader] --> Platform[Platform ClassLoader]
    Platform --> System[System / Application ClassLoader]
    System --> User1[User-defined ClassLoader A]
    System --> User2[User-defined ClassLoader B]

부모 관계는 상속이 아니라 위임 관계입니다. getParent()로 거슬러 올라가는 체인이라고 보면 됩니다.

Bootstrap ClassLoader

JVM 자체에 내장된 로더입니다. C/C++로 구현되어 있어 Java에서 직접 객체로 다룰 수 없습니다. String.class.getClassLoader()를 호출하면 null이 돌아오는데, 이 null이 곧 Bootstrap을 가리키는 관례입니다.

Java 8까지는 rt.jar, i18n.jar 등 부트스트랩 클래스패스에 있는 핵심 JDK 클래스를 로딩했습니다. Java 9 이후로는 java.base 모듈을 비롯한 핵심 모듈을 로딩합니다.

Platform ClassLoader (구 Extension ClassLoader)

Java 9에서 이름이 바뀌었습니다. 이전에는 Extension ClassLoader라고 불렀고 $JAVA_HOME/lib/ext 디렉터리의 jar를 로딩했지만, 이 확장 메커니즘은 JEP 220에서 제거됐습니다.

Java 9 이후 Platform ClassLoader는 다음을 담당합니다.

  • 일부 Java SE / JDK 모듈 (예: java.sql, java.xml, java.logging)을 정의합니다.
  • 핵심 보안 분리의 단위가 됩니다.

JEP 261 표현에 따르면, Bootstrap이 정의한 타입은 모두 AllPermission을 가지지만 Platform이 정의한 타입은 정책 파일에 명시한 권한만 받습니다. 즉 "최소 권한 원칙"을 위해 일부 모듈을 Bootstrap에서 Platform으로 내려보낸 것입니다.

또한 Java 9 이전 Extension ClassLoader는 URLClassLoader였지만, 9 이후 Platform ClassLoader는 더 이상 URLClassLoader의 인스턴스가 아닙니다. JDK 내부 클래스로 바뀌었습니다.

ClassLoader platform = ClassLoader.getPlatformClassLoader();
System.out.println(platform);
// jdk.internal.loader.ClassLoaders$PlatformClassLoader@...

System ClassLoader (Application ClassLoader)

-cp / -classpath 옵션과 모듈 패스(--module-path)에 지정된 위치에서 클래스를 로딩합니다. 우리가 작성하는 애플리케이션 코드가 거의 모두 이 로더에 의해 정의됩니다.

ClassLoader.getSystemClassLoader()가 반환하는 로더가 이것이고, 별다른 설정이 없을 때 메인 스레드의 컨텍스트 클래스로더로도 사용됩니다. Java 9부터 이 로더 역시 URLClassLoader가 아닌 내부 구현체로 변경됐습니다. Java 8 시절 ((URLClassLoader) ClassLoader.getSystemClassLoader()).addURL(...) 같은 트릭이 9 이후 ClassCastException을 내는 이유입니다.

User-defined ClassLoader

사용자가 직접 정의해서 만드는 로더입니다. 보통 URLClassLoader를 상속하거나 ClassLoader를 직접 상속해서, 특정 jar/디렉터리/네트워크에서 바이트를 읽어 defineClassClass 객체를 만듭니다. 플러그인 시스템, 핫 리로드, 멀티 테넌트 애플리케이션, 서블릿 컨테이너의 웹앱 격리가 모두 사용자 정의 로더 위에서 동작합니다.


3. Parent Delegation 모델

loadClass의 표준 흐름

ClassLoader.loadClass(String name)의 기본 구현은 다음 순서로 동작합니다. JDK 표준 라이브러리의 의사코드 형태로 옮기면 이렇습니다.

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 이미 로드한 적이 있는지 캐시 확인
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    // 2. 부모에게 먼저 시도를 위임
                    c = parent.loadClass(name, false);
                } else {
                    // 부모가 null이면 Bootstrap에 위임
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 부모가 못 찾았다 - 계속 진행
            }
            if (c == null) {
                // 3. 마지막에 자기 자신이 직접 찾아본다
                c = findClass(name);
            }
        }
        if (resolve) resolveClass(c);
        return c;
    }
}

핵심은 세 단계입니다.

  1. findLoadedClass — 이 로더가 이미 정의(또는 initiating loader로 기록)한 클래스라면 그대로 반환합니다.
  2. 부모에게 위임 — 부모 체인을 따라 위로 올라가며 같은 절차를 반복합니다. 결국 Bootstrap까지 거슬러 올라갑니다.
  3. findClass — 부모 체인이 아무도 못 찾았을 때만 자기가 직접 바이트를 찾아 defineClass를 호출합니다.

이 "위로 먼저, 못 찾으면 내가" 패턴이 Parent Delegation 모델입니다. 새 로더를 만들어도 별도 설계 없이 그냥 이 흐름을 타게 됩니다.

loadClass / findClass / defineClass의 역할 분리

비슷해 보이는 세 메서드의 책임이 분리되어 있다는 점은 커스텀 ClassLoader를 짤 때 핵심입니다.

메서드 책임 보통 우리가 하는 일
loadClass(name) 위임 알고리즘 자체. 캐시 → 부모 → findClass 순서 건드리지 않습니다
findClass(name) 자기 저장소(파일·jar·네트워크)에서 바이트를 가져와 defineClass 호출 이걸 오버라이드합니다
defineClass(name, bytes, off, len) 바이트 배열을 JVM Class 객체로 변환 findClass 안에서 호출만 합니다

loadClass를 직접 오버라이드하는 일은 위임 정책 자체를 뒤집고 싶을 때(예: Tomcat 같은 자식 우선 모델)뿐입니다. 평범한 사용자 정의 로더는 findClass만 구현하면 표준 위임 흐름을 그대로 활용할 수 있습니다.

위임이 지키는 두 가지

Parent Delegation이 풀려는 문제는 두 개입니다.

(1) 안전성 — 핵심 클래스 변조 방지

만약 사용자가 자기 클래스패스에 다음과 같은 파일을 두었다고 합시다.

package java.lang;

public class String {
    // 악의적인 구현
}

위임이 없다면 System ClassLoader가 이 가짜 String을 정의하고, 그 뒤 모든 코드가 가짜 String을 사용하게 됩니다. 위임이 있으면 System은 먼저 Bootstrap에게 묻고, Bootstrap이 자기 영역의 진짜 java.lang.String을 반환합니다. 사용자가 정의한 가짜 클래스는 도달조차 하지 못합니다.

추가로 JVM은 java.* 패키지에 대한 정의를 사용자 로더에게 금지합니다. 두 겹의 방어선이 있는 셈입니다.

(2) 일관성 — 같은 클래스를 한 번만 정의

같은 클래스가 서로 다른 로더에서 중복 정의되면, 1장에서 짚은 것처럼 JVM은 두 결과를 다른 타입으로 인식합니다. Parent Delegation은 가능한 한 부모(=공통 조상)가 정의를 책임지게 만들어, 자식들이 같은 Class 인스턴스를 공유하게 만듭니다.

웹 컨테이너 위 두 웹앱이 모두 javax.servlet.HttpServlet을 사용한다고 합시다. 두 앱은 별도의 ClassLoader를 가지지만 위임이 동작하면 둘 다 컨테이너 공통 로더가 정의한 동일한 HttpServlet을 보게 됩니다. 만약 각자 자기 jar에 들어 있는 servlet-api.jar를 정의해 버리면, 컨테이너가 넘긴 요청 객체를 캐스팅하는 순간 LinkageError가 터집니다. 이 충돌은 6장에서 다시 다룹니다.

Defining loader vs Initiating loader

JVM 명세는 두 종류의 "로더 관계"를 구분합니다.

  • Initiating loader: 그 클래스를 처음 로딩 요청을 받은 로더. findLoadedClass에 그 이름이 기록되는 로더이기도 합니다.
  • Defining loader: 실제로 defineClass를 호출해 Class 객체를 만들어 낸 로더.

Parent Delegation 환경에서 보통 자식 로더가 initiating, 부모 로더가 defining이 됩니다. Class.getClassLoader()가 반환하는 값은 defining loader입니다. 이 구분이 중요한 이유는 6장의 loader constraint 위반 진단에서 설명합니다.


4. 동작 확인 — 직접 출력해 보기

이론을 코드로 확인해 둡시다.

public class WhoLoadsWhat {
    public static void main(String[] args) throws Exception {
        printLoader("java.lang.String", String.class);
        printLoader("java.sql.DriverManager", java.sql.DriverManager.class);
        printLoader("자기 자신", WhoLoadsWhat.class);

        ClassLoader app = ClassLoader.getSystemClassLoader();
        ClassLoader platform = ClassLoader.getPlatformClassLoader();

        System.out.println();
        System.out.println("System  = " + app);
        System.out.println("Platform = " + platform);
        System.out.println("System.parent  = " + app.getParent());
        System.out.println("Platform.parent = " + platform.getParent());
    }

    static void printLoader(String label, Class<?> c) {
        System.out.printf("%-25s %s%n", label, c.getClassLoader());
    }
}

JDK 21에서 실행한 출력 예시는 다음과 같습니다.

java.lang.String          null
java.sql.DriverManager    jdk.internal.loader.ClassLoaders$PlatformClassLoader@1c20c684
자기 자신                  jdk.internal.loader.ClassLoaders$AppClassLoader@2f0e140b

System  = jdk.internal.loader.ClassLoaders$AppClassLoader@2f0e140b
Platform = jdk.internal.loader.ClassLoaders$PlatformClassLoader@1c20c684
System.parent  = jdk.internal.loader.ClassLoaders$PlatformClassLoader@1c20c684
Platform.parent = null

확인되는 사실은 이렇습니다.

  • String의 정의 로더는 Bootstrap이라 null로 보입니다.
  • DriverManagerjava.sql 모듈에 속하고, 이 모듈은 Platform이 정의합니다.
  • 우리 코드는 System(App)이 정의합니다.
  • Platform의 부모는 null — 즉 Platform은 Bootstrap을 부모로 본다는 관례를 따릅니다.

직접 만들어 보는 사용자 정의 ClassLoader

이론을 가장 빠르게 체화하는 방법은 직접 한 번 짜 보는 것입니다. 디스크의 임의 디렉터리에서 .class 파일을 읽는 단순한 로더를 만들어 보겠습니다.

import java.nio.file.Files;
import java.nio.file.Path;

public class DirectoryClassLoader extends ClassLoader {

    private final Path root;

    public DirectoryClassLoader(Path root, ClassLoader parent) {
        super(parent);                 // 부모 지정 — 위임 흐름의 출발점
        this.root = root;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            Path file = root.resolve(name.replace('.', '/') + ".class");
            byte[] bytes = Files.readAllBytes(file);
            return defineClass(name, bytes, 0, bytes.length);
        } catch (Exception e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

핵심은 세 가지입니다.

  1. loadClass를 오버라이드하지 않았습니다. ClassLoader의 기본 구현이 표준 위임 흐름을 그대로 수행합니다. 우리가 정의한 코드는 절대로 java.lang.String을 가로채지 못합니다 — 부모 체인이 먼저 찾아 주기 때문입니다.
  2. findClass만 구현했습니다. 이 메서드는 부모가 못 찾았을 때만 호출됩니다.
  3. defineClass로 바이트를 클래스 객체로 변환합니다. 인자 name이 바이트 안에 박혀 있는 클래스 이름과 다르면 NoClassDefFoundError가 발생합니다.

이 로더의 두 인스턴스에 같은 디렉터리를 줘서 같은 클래스를 두 번 정의하면, JVM은 두 결과를 다른 타입으로 봅니다.

DirectoryClassLoader cl1 = new DirectoryClassLoader(root, parent);
DirectoryClassLoader cl2 = new DirectoryClassLoader(root, parent);

Class<?> a = cl1.loadClass("com.example.Foo");
Class<?> b = cl2.loadClass("com.example.Foo");

System.out.println(a == b);                          // false — 다른 타입
System.out.println(a.getName().equals(b.getName())); // true  — 이름은 같음

1장의 식별 규칙이 정확히 들어맞는 자리입니다. ClassLoader는 단순한 로더가 아니라 타입의 네임스페이스 경계라는 것이 코드 한 줄로 드러납니다. 이 성질이 핫 리로드, 멀티 테넌트 격리, OSGi의 토대입니다.


5. 위임이 깨지는 자리들

Parent Delegation은 기본 규약이지 절대 법칙은 아닙니다. 표준 모델로는 풀 수 없는 문제 영역에서, 다양한 시스템이 위임을 의도적으로 깨거나 우회합니다.

5-1. SPI와 Thread Context ClassLoader

java.sql.DriverManager는 JDK 일부이므로 Platform ClassLoader가 정의합니다. 그런데 실제 JDBC 드라이버 (예: com.mysql.cj.jdbc.Driver)는 사용자 jar에 들어 있고, System ClassLoader가 정의합니다.

여기서 문제가 발생합니다. DriverManagerServiceLoader로 드라이버 구현체를 찾을 때, 표준 위임만 따른다면 자신을 정의한 Platform ClassLoader로 검색합니다. Platform은 사용자 jar를 모르니 드라이버를 못 찾습니다.

flowchart TD
    SystemCL[System ClassLoader] -->|defines| Driver[com.mysql.cj.jdbc.Driver]
    PlatformCL[Platform ClassLoader] -->|defines| DriverManager[java.sql.DriverManager]
    DriverManager -.->|"loadClass with self loader = Platform"| FAIL[X 못 찾음]

JVM은 이 부모-자식 역방향 의존을 풀기 위해 **Thread Context ClassLoader (TCCL)**라는 우회로를 만들었습니다. 모든 스레드는 Thread.currentThread().setContextClassLoader(...)로 임의의 로더를 매달 수 있고, JDK 부트층의 코드가 사용자 영역 클래스를 찾을 때 이 컨텍스트 로더에 위임합니다.

public final class ServiceLoader<S> implements Iterable<S> {
    public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return new ServiceLoader<>(service, cl);
    }
}

ServiceLoader.load(Driver.class)는 자기 정의 로더가 아니라 현재 스레드의 컨텍스트 로더로 드라이버를 검색합니다. 일반 톰캣/스프링부트 환경에서 메인 스레드의 컨텍스트 로더는 System ClassLoader이므로 사용자 jar의 드라이버가 정상적으로 발견됩니다.

JNDI, JAXP, JCE, 로깅 어댑터 등 부트층에 정의되어 있으면서 사용자가 구현체를 제공하는 모든 SPI가 같은 패턴을 따릅니다. TCCL은 위임 모델을 깨지 않으면서도 "위에서 아래"의 가시성을 우회로로 확보하기 위한 장치입니다.

5-2. Tomcat WebappClassLoader — 의도적으로 뒤집힌 위임

Servlet 명세 (Servlet 2.4 §9.7.2 이후) 는 웹앱 클래스로더에 표준과 반대인 동작을 권고합니다. 로컬을 먼저 보고, 없으면 부모에게 묻는다.

이유는 단순합니다. 웹앱이 자기 WEB-INF/lib에 특정 라이브러리의 버전 X를 포함하고, 컨테이너도 같은 라이브러리의 버전 Y를 가지고 있을 때, 표준 위임을 따르면 컨테이너의 Y가 우선 로딩됩니다. 그러면 웹앱이 의존성으로 명시한 버전이 무시됩니다. 이것은 webapp의 자기 완결성(self-contained) 원칙과 충돌합니다.

Tomcat의 WebappClassLoaderBase는 그래서 다음 순서로 동작합니다.

flowchart TD
    REQ[loadClass request] --> CACHED{already loaded?}
    CACHED -->|yes| RETURN[return]
    CACHED -->|no| JRE{JRE base class?}
    JRE -->|yes| DELEGATE_PARENT[delegate to parent first]
    JRE -->|no| FILTER{Servlet API class?}
    FILTER -->|yes| DELEGATE_PARENT
    FILTER -->|no| DELEGATE_FLAG{delegate flag = true?}
    DELEGATE_FLAG -->|yes| DELEGATE_PARENT
    DELEGATE_FLAG -->|no| LOCAL_FIRST[search WEB-INF/classes & WEB-INF/lib]
    LOCAL_FIRST --> NOT_FOUND_LOCAL{found?}
    NOT_FOUND_LOCAL -->|yes| RETURN
    NOT_FOUND_LOCAL -->|no| DELEGATE_PARENT

순서를 정리하면 이렇습니다.

  1. JRE 베이스 클래스(java.*, javax.* 일부)는 항상 부모에게 먼저 위임. 사용자가 덮어쓸 수 없습니다.
  2. Servlet/JSP/EL/WebSocket 같이 컨테이너가 책임지는 명세 API 클래스도 항상 부모 우선.
  3. 그 외에는 WEB-INF/classes, WEB-INF/lib먼저 뒤지고, 없으면 부모에 위임.

<Loader delegate="true"/> 설정으로 표준 위임 동작으로 되돌릴 수도 있습니다. 기본값이 false인 이유는 위 명세를 따르기 위해서입니다.

이 뒤집힌 위임 덕분에 같은 JVM 위에서 동작하는 두 웹앱이 같은 라이브러리의 다른 버전을 동시에 쓸 수 있고, 한 앱의 버전 충돌이 다른 앱에 영향을 주지 않습니다.

5-3. OSGi와 모듈 ClassLoader

OSGi 번들마다 별도의 ClassLoader가 부여되고, 번들이 명시적으로 import한 패키지만 다른 번들의 클래스로더에서 가져옵니다. 사실상 위임 트리가 그래프로 일반화된 모델입니다.

자세한 동작은 OSGi 명세의 영역이지만, 핵심 메커니즘은 같습니다. ClassLoader가 곧 가시성 경계이고, 그 경계를 어떻게 그릴지는 컨테이너의 자유입니다.


6. JPMS — Java 9 모듈 시스템과의 관계

모듈은 ClassLoader를 대체하지 않는다

흔한 오해 중 하나가 "Java 9 이후로는 모듈 시스템이 ClassLoader를 대체한다"는 인식입니다. 그렇지 않습니다. 모듈 시스템은 ClassLoader 위에 추가로 가시성 규칙을 얹는 레이어입니다.

JEP 261은 명시적으로 적습니다. JDK 9는 호환성을 위해 세 단계 계층 (Bootstrap / Platform / System)을 그대로 유지하면서, 그 위에 모듈 시스템을 구현했습니다.

ModuleLayer

JVM이 시작될 때 boot layer라는 모듈 레이어가 만들어지고, 여기에는 java.base를 비롯한 시스템 모듈이 들어 있습니다. 각 모듈은 그것을 정의한 ClassLoader에 매핑됩니다.

flowchart TD
    BootLayer[Boot ModuleLayer] --> JavaBase[java.base]
    BootLayer --> JavaSql[java.sql]
    BootLayer --> JdkUnsupported[jdk.unsupported]
    JavaBase -.->|defined by| Bootstrap[Bootstrap CL]
    JavaSql -.->|defined by| Platform[Platform CL]
    JdkUnsupported -.->|defined by| Platform

추가로 사용자가 ModuleLayer.defineModulesWithOneLoader(...) 같은 API로 자식 레이어를 만들면, 그 레이어 안의 모듈들은 새 ClassLoader에 묶입니다. 동일한 모듈을 다른 레이어에 따로 올려 격리할 수 있습니다.

Named module의 직접 위임

ModuleLayer 문서가 정확히 적은 부분이 있습니다. 모듈 레이어가 만들어지는 ClassLoader는 클래스 로딩 시 패키지 이름으로 모듈을 직접 찾고, 그 모듈을 정의한 ClassLoader에 위임합니다. 즉 named module 안에 있는 클래스들끼리는 findClass까지 가는 표준 흐름이 아니라, "이 패키지는 X 모듈 소속이고 X는 Y 로더가 정의했다"는 인덱스로 바로 점프합니다.

이 직접 위임은 모듈 그래프상 명시적으로 requires로 의존이 선언된 모듈 사이에서만 작동합니다. 의존 선언이 없으면 ClassNotFoundException이 납니다. 모듈 시스템이 막는 것은 가시성이지 ClassLoader 자체의 도달성이 아닙니다.

Unnamed module

named module에 속하지 않는 클래스는 그것을 정의한 ClassLoader의 unnamed module에 자동 소속됩니다. 모든 ClassLoader는 자기만의 unnamed module을 하나씩 가지며 getUnnamedModule()로 얻을 수 있습니다.

기존 클래스패스 기반 jar (모듈 디스크립터가 없는 jar)는 모두 unnamed module의 멤버가 됩니다. 그래서 모듈을 도입하지 않은 레거시 코드는 Java 9 이후에도 별다른 변경 없이 동작합니다. 단 named module에서는 unnamed module을 requires할 수 없으므로, 모듈로 옮기는 순간 클래스패스 의존성이 자동 가시화되지는 않습니다.

클래스 로딩과 모듈 가시성은 별개의 검증

Java 9 이후에는 클래스가 메모리에 올라가도 누구나 그 클래스를 사용할 수 있는 것이 아닙니다. 모듈 그래프에서 requires/exports/opens로 명시되지 않은 접근은 차단됩니다. 다음 에러는 이 두 번째 관문에서 떨어진 결과입니다.

java.lang.reflect.InaccessibleObjectException: Unable to make ... accessible:
module java.base does not "opens java.lang" to unnamed module ...

여기서 클래스 자체는 분명 로딩되어 있습니다. 다만 모듈이 외부에 열어주지 않았을 뿐입니다. 해결책도 클래스 로딩이 아니라 모듈 시동 옵션입니다.

java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar

문제 진단을 위해 모듈 해석 과정을 직접 들여다볼 때는 다음 옵션이 가장 빠릅니다.

java --show-module-resolution -jar app.jar

ClassLoader 계층은 동일하게 유지하되 그 위에 모듈 캡슐화 한 겹이 더 얹혀 있다는 사실을 받아들이면, "분명히 jar 안에 있는데 왜 쓸 수 없냐"는 의문은 거의 풀립니다.


7. 진단 — 세 가지 에러 구분

ClassLoader 관련 문제는 보통 다음 세 에러 중 하나로 표면화됩니다. 메시지가 비슷해 보여도 원인이 다릅니다.

7-1. ClassNotFoundException

Checked exception, java.lang.Exception 직접 상속.

발생 시점: Class.forName(name), ClassLoader.loadClass(name) 같이 이름 문자열로 명시 로드를 시도했지만 어느 로더도 그 클래스를 못 찾을 때.

전형적인 원인은 단순합니다. 클래스패스에 jar가 빠졌거나, 경로가 잘못됐거나, 패키지 이름의 오타. 사용자 코드가 명시적으로 Class.forName을 호출해야 발생하므로 try-catch로 대응 가능한 영역입니다.

try {
    Class.forName("com.example.Plugin");
} catch (ClassNotFoundException e) {
    log.warn("plugin not present, skipping", e);
}

7-2. NoClassDefFoundError

Unchecked error, java.lang.LinkageError 상속.

발생 시점: 컴파일 시점에는 분명히 있던 클래스가 런타임에 첫 사용 순간 로딩에 실패할 때. 호출 코드가 new Foo()를 명시했고, JVM이 그 호출을 처리하다가 Foo를 찾지 못해 발생합니다.

ClassNotFoundException과 가장 큰 차이는 "누가 트리거했는가"입니다. NoClassDefFoundError는 사용자 코드가 명시 로드를 호출하지 않았는데도 JVM 내부에서 발생합니다. 따라서 try-catch로 잡는 것이 의미가 없는 경우가 대부분입니다 (잡아도 그 다음 줄에서 또 트리거됨).

원인 패턴은 두 가지입니다.

  1. 클래스패스/모듈패스 누락 — 컴파일 시 있던 jar가 배포에서 빠짐. 이 경우 NoClassDefFoundError의 원인 예외(getCause())가 ClassNotFoundException으로 잡힙니다.
  2. <clinit> 실패 — 그 클래스의 정적 초기화 코드가 예외를 던졌고, 그 결과 클래스 자체가 정의 실패 상태로 마킹됨. 다음 사용 시도부터는 영원히 NoClassDefFoundError. 첫 시도 때만 원인 예외(예: ExceptionInInitializerError)가 보이고, 두 번째 시도부터는 원인 없이 NoClassDefFoundError: Could not initialize class com.example.Foo만 남습니다. 진단을 어렵게 만드는 단골 패턴입니다.

7-3. LinkageError — loader constraint violation

LinkageError의 직계 메시지로 보통 다음 같은 모양입니다.

java.lang.LinkageError: loader constraint violation:
  when resolving method "com.example.Foo.bar(Lcom/example/Bar;)V"
  the class loader (instance of <X>) of the current class, com.example.Caller,
  and the class loader (instance of <Y>) for the method's defining class, com.example.Foo,
  have different Class objects for the type Bar used in the signature

원인은 1장에서 짚은 식별 규칙입니다. 같은 이름 com.example.Bar가 서로 다른 ClassLoader에 의해 두 번 정의됐고, 그 두 정의가 같은 시그니처 안에서 만나야 하는 상황. JVM은 메서드 호출 시점에 양쪽이 보는 Bar가 같은 Class 객체인지 검증하고, 다르면 즉시 LinkageError를 던집니다.

이 시나리오는 Parent Delegation을 깨뜨리는 환경 — 예를 들어 Tomcat WebappClassLoader, OSGi, 사용자 정의 격리 로더 — 에서 자주 출현합니다. 같은 jar가 컨테이너에도 있고 웹앱에도 있을 때, 양쪽이 각자 그 jar의 클래스를 정의해 버리면 발생합니다.

진단 순서는 보통 다음과 같습니다.

  1. 메시지에 나온 두 ClassLoader 인스턴스의 종류와 정체를 확인합니다.
  2. 문제의 클래스(예시에서는 Bar)가 어디 jar에 들어 있는지 조사합니다.
  3. 그 jar가 클래스패스/WEB-INF/lib/공통 lib 중 어느 위치에 중복 존재하는지 찾습니다.
  4. 한쪽에서 제거하거나, 컨테이너 공통 위치로 일원화합니다.

-verbose:class JVM 옵션이 결정적인 단서를 줍니다. 클래스가 정의될 때마다 어느 source(jar 경로)에서 어느 로더가 정의했는지를 출력하므로, 같은 클래스가 두 번 출력되는 지점을 찾으면 됩니다.

운영 중인 JVM에서 특정 인스턴스의 출처가 의심될 때는 Class#getProtectionDomain().getCodeSource()로 즉시 확인할 수 있습니다.

Logger l = LoggerFactory.getLogger(MyService.class);
System.out.println(l.getClass().getClassLoader());
System.out.println(l.getClass().getProtectionDomain().getCodeSource().getLocation());

위치(URL)까지 찍어 보면 의외의 jar에서 클래스가 끌려 들어오는 사례를 한 줄로 잡아낼 수 있습니다.


8. 정리

Parent Delegation은 두 가지 약속을 위한 장치입니다.

  • 핵심 클래스를 사용자가 갈아치우지 못하게 한다 (안전성).
  • 같은 클래스가 여러 번 정의되지 않게 한다 (일관성).

이 약속은 단일 애플리케이션 환경에서는 자연스럽게 지켜지지만, 다음 상황에서는 의도적으로 깨지거나 우회됩니다.

  • SPI — 부트층이 사용자층의 구현을 발견해야 하는 역방향 의존. Thread Context ClassLoader가 우회로.
  • 서블릿 컨테이너 — 웹앱의 자기 완결성을 위해 로컬 우선 검색. Servlet 명세가 강제.
  • OSGi/플러그인 — 모듈 단위 가시성 그래프. 위임이 트리에서 그래프로 확장.
  • JPMS — 계층 구조는 유지하되 그 위에 모듈 가시성 레이어 추가. named module은 패키지-로더 인덱스로 직접 위임.

ClassLoader 관련 에러를 만나면, "이름이 같아도 정의한 로더가 다르면 다른 타입"이라는 1장의 식별 규칙으로 돌아가서, 누가 누구를 어떻게 정의했는지를 정리해 보면 거의 모든 미스터리가 풀립니다. Class.getClassLoader()로 정체를 묻고, -verbose:class로 정의 시점을 추적하고, 부모 체인을 getParent()로 거슬러 올라가는 것이 가장 확실한 도구입니다.


참고자료

More from this blog

LangChain, LangGraph, LangSmith — 첫 AI 에이전트를 만들기 전에 알아야 하는 세 도구의 역할

처음으로 AI 에이전트를 만들어 보려는 백엔드 개발자를 대상으로 합니다. 이름이 비슷한 세 도구 — LangChain, LangGraph, LangSmith — 가 각각 어떤 문제를 풀려고 등장했는지, 어떤 관계로 묶여 있는지, 그리고 어디서부터 손을 대야 하는지를 한 흐름으로 정리합니다. 함께 자주 등장하는 단어(Tool, ReAct, Function C

May 14, 202613 min read

Spring @Transactional 동작 원리 — 프록시, 트랜잭션 매니저, 전파와 롤백의 진실

메서드 위에 @Transactional 한 줄을 붙이면 Spring은 그 호출을 가로채 트랜잭션을 시작하고, 예외가 나면 롤백하고, 정상 종료되면 커밋합니다. 이 글은 그 한 줄 뒤에서 일어나는 일을 코드 흐름과 함께 풀어냅니다. 프록시가 어떻게 메서드를 가로채는지, TransactionInterceptor가 어떤 순서로 매니저를 호출하는지, 전파 속성과

May 14, 202612 min read

끄적끄적 테크 블로그

47 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ