new MyClass()를 호출하면 JVM은 MyClass의 바이트코드를 어디서, 어떤 순서로 찾아올까요?

자바 프로그램에서 클래스를 사용하면 JVM이 알아서 로드해주니까 평소에는 신경 쓸 일이 없습니다. 하지만 플러그인 시스템, 핫 리로드, 컨테이너 격리 같은 고급 기능을 이해하려면 ClassLoader의 동작 원리를 알아야 합니다.

ClassLoader 계층 구조

ClassLoader 는 .class 파일의 바이트코드를 JVM 메모리에 적재하는 컴포넌트입니다. JVM은 모든 클래스를 한꺼번에 로드하지 않고, 필요한 시점에 계층적 위임을 통해 로드합니다.

Java 9 이후의 ClassLoader 계층은 다음과 같습니다.

PLAINTEXT
Bootstrap ClassLoader (네이티브 코드)
    ↑ 위임
Platform ClassLoader (구 Extension ClassLoader)
    ↑ 위임
Application ClassLoader (구 System ClassLoader)
    ↑ 위임
사용자 정의 ClassLoader

각 ClassLoader의 역할

ClassLoader로드 대상구현
Bootstrapjava.lang.*, java.util.* 등 핵심 클래스네이티브(C/C++)
Platformjava.sql.*, java.xml.* 등 플랫폼 모듈Java
Application클래스패스/모듈패스의 애플리케이션 클래스Java

각 ClassLoader가 담당하는 영역이 다르기 때문에, 어떤 클래스를 로드했는지 확인하면 해당 클래스가 JDK의 어느 계층에 속하는지 파악할 수 있습니다.

JAVA
System.out.println(String.class.getClassLoader());
// null (Bootstrap ClassLoader — 네이티브 구현이라 Java에서 null로 표현)

System.out.println(java.sql.Connection.class.getClassLoader());
// jdk.internal.loader.ClassLoaders$PlatformClassLoader

System.out.println(MyApp.class.getClassLoader());
// jdk.internal.loader.ClassLoaders$AppClassLoader

위 결과에서 null이 반환되는 이유는 Bootstrap ClassLoader가 C/C++로 구현되어 Java 객체로 표현할 수 없기 때문입니다.

위임 모델 (Parent Delegation Model)

ClassLoader가 클래스를 로드할 때는 항상 부모에게 먼저 위임 합니다. 이 구조가 없으면 누군가 악의적으로 java.lang.String을 만들어서 핵심 클래스를 대체할 수 있기 때문입니다.

PLAINTEXT
1. 이미 로드된 클래스인지 캐시 확인
2. 캐시에 없으면 → 부모 ClassLoader에게 위임
3. 부모도 못 찾으면 → 자신이 직접 로드 시도
4. 자신도 못 찾으면 → ClassNotFoundException

이 흐름을 코드로 보면 더 명확합니다. ClassLoader.loadClass()의 핵심 로직을 간략화하면 다음과 같습니다.

JAVA
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException {

    Class<?> c = findLoadedClass(name);       // 1. 캐시 확인
    if (c == null) {
        try {
            if (parent != null) {
                c = parent.loadClass(name, false); // 2. 부모에게 위임
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) { }
        if (c == null) {
            c = findClass(name);              // 3. 자신이 직접 로드
        }
    }
    return c;
}

여기서 핵심은 자신이 직접 로드를 시도하기 전에 반드시 부모에게 먼저 기회를 준다 는 점입니다. 이 덕분에 두 가지 보장이 성립합니다.

  • **안전성 **: Bootstrap ClassLoader가 먼저 진짜 java.lang.String을 로드하므로, 사용자 코드로 핵심 클래스를 대체할 수 없습니다.
  • ** 일관성 **: 같은 클래스가 여러 ClassLoader에 의해 중복 로드되는 것을 방지합니다.

클래스 로딩의 세 단계

클래스가 JVM에서 사용 가능해지려면 로딩 → 링킹 → 초기화 순서를 거칩니다. 각 단계가 분리된 이유는 ** 보안 검증과 지연 초기화 **를 가능하게 하기 위해서입니다.

1단계: 로딩 (Loading)

.class 파일의 바이트코드를 읽어서 JVM 메모리에 올립니다. 소스는 파일 시스템, JAR, 네트워크 등 다양할 수 있습니다.

2단계: 링킹 (Linking)

링킹은 세 하위 단계로 나뉩니다.

  • ** 검증(Verification)**: 바이트코드가 JVM 명세에 맞는지 확인 — 잘못된 바이트코드가 JVM을 크래시시키는 것을 방지
  • ** 준비(Preparation)**: static 필드에 기본값 할당 (0, null, false) — 아직 실제 값은 아님
  • ** 해석(Resolution)**: 심볼릭 참조를 실제 참조로 변환 (선택적, lazy 가능)

3단계: 초기화 (Initialization)

static 블록 실행, static 필드에 실제 값 할당이 이루어집니다. 다음 코드에서 준비 단계와 초기화 단계의 차이를 확인할 수 있습니다.

JAVA
public class Config {
    // 준비 단계: dbUrl = null (기본값)
    // 초기화 단계: dbUrl = "jdbc:mysql://..." (실제 값)
    static String dbUrl = loadFromFile();

    static {
        System.out.println("Config 클래스 초기화됨");
    }
}

여기서 중요한 점은 초기화가 ** 클래스가 처음 능동적으로 사용될 때** 비로소 발생한다는 것입니다. Class.forName()으로 로드만 하면 초기화가 실행되지만, ClassLoader.loadClass()로 로드하면 초기화 없이 로딩+링킹만 됩니다. 능동적 사용의 조건은 다음과 같습니다.

  • new 키워드로 인스턴스 생성
  • static 메서드 호출
  • static 필드 접근 (final 상수 제외)
  • 리플렉션으로 Class.forName() 호출
  • 하위 클래스 초기화 시 상위 클래스

클래스의 정체성

JVM에서 클래스의 고유성은 ** 완전한 이름(FQCN) + ClassLoader**로 결정됩니다.

JAVA
ClassLoader loader1 = new URLClassLoader(urls);
ClassLoader loader2 = new URLClassLoader(urls);

Class<?> class1 = loader1.loadClass("com.example.Plugin");
Class<?> class2 = loader2.loadClass("com.example.Plugin");

System.out.println(class1 == class2); // false — 다른 클래스!
System.out.println(class1.getName().equals(class2.getName())); // true — 이름은 같음

이 특성 때문에 서로 다른 ClassLoader로 로드한 같은 이름의 클래스 간에는 캐스팅이 불가능합니다.

JAVA
Object obj = class1.getDeclaredConstructor().newInstance();
// class2의 타입으로 캐스팅하면 ClassCastException 발생!

커스텀 ClassLoader 만들기

커스텀 ClassLoader를 만들 때는 loadClass()가 아닌 findClass()를 오버라이드 합니다. loadClass()를 오버라이드하면 위임 모델 자체를 변경하게 되어 위험하기 때문입니다. findClass()를 오버라이드하면 부모가 못 찾은 경우에만 커스텀 로직이 실행됩니다.

다음은 특정 디렉토리에서 .class 파일을 읽어 로드하는 플러그인 ClassLoader입니다.

JAVA
public class PluginClassLoader extends ClassLoader {
    private final Path pluginDir;

    public PluginClassLoader(Path pluginDir, ClassLoader parent) {
        super(parent);
        this.pluginDir = pluginDir;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String fileName = name.replace('.', '/') + ".class";
        Path classFile = pluginDir.resolve(fileName);
        try {
            byte[] bytes = Files.readAllBytes(classFile);
            return defineClass(name, bytes, 0, bytes.length);  // ← 바이트 배열 → Class 객체
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

핵심은 defineClass() 호출입니다. 이 메서드가 바이트 배열을 JVM이 인식하는 Class<?> 객체로 변환합니다. 사용법은 다음과 같습니다.

JAVA
Path pluginDir = Path.of("/opt/plugins/my-plugin/classes");
PluginClassLoader loader = new PluginClassLoader(pluginDir, getClass().getClassLoader());

Class<?> pluginClass = loader.loadClass("com.example.MyPlugin");
Plugin plugin = (Plugin) pluginClass.getDeclaredConstructor().newInstance();
plugin.execute();

이 패턴을 응용하면 암호화된 클래스 파일을 로드하는 것도 가능합니다. findClass() 안에서 바이트 배열을 복호화한 뒤 defineClass()에 넘기면 됩니다.

JAVA
public class EncryptedClassLoader extends ClassLoader {
    private final byte[] key;

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = name.replace('.', '/') + ".enc";
        try {
            byte[] encrypted = Files.readAllBytes(Path.of("classes", path));
            byte[] decrypted = decrypt(encrypted, key);
            return defineClass(name, decrypted, 0, decrypted.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

바이트코드의 출처가 파일이든 네트워크든 암호화된 스트림이든, findClass()defineClass() 패턴은 동일합니다.

핫 리로드 구현 원리

한 번 로드된 클래스는 같은 ClassLoader에서 다시 로드할 수 없습니다. JVM이 클래스의 정체성을 FQCN + ClassLoader 조합으로 결정하기 때문에, 같은 ClassLoader에서 같은 이름의 클래스를 두 번 로드하면 이미 캐시된 결과가 반환됩니다.

그래서 핫 리로드는 ClassLoader 자체를 새로 만드는 방식 으로 동작합니다. 기존 ClassLoader를 버리고 새 ClassLoader를 생성하면, 같은 이름의 클래스라도 새 바이트코드를 로드할 수 있습니다.

JAVA
public class HotReloader {
    private ClassLoader currentLoader;
    private Object plugin;

    public void reload() throws Exception {
        currentLoader = null;  // 기존 ClassLoader 참조 해제 (GC 대상)
        plugin = null;

        currentLoader = new PluginClassLoader(
            Path.of("/opt/plugins"), getClass().getClassLoader()
        );
        Class<?> pluginClass = currentLoader.loadClass("com.example.MyPlugin");
        plugin = pluginClass.getDeclaredConstructor().newInstance();
    }
}

Spring DevTools, JRebel 같은 도구들이 이 원리를 활용합니다. 다만 타입 호환 문제를 해결하기 위해, 인터페이스는 부모 ClassLoader에 두고 구현체만 자식 ClassLoader에서 교체하는 구조를 사용합니다.

PLAINTEXT
부모 ClassLoader: Plugin 인터페이스 로드 (불변)

자식 ClassLoader (교체 가능): PluginImpl 클래스 로드

주의할 점

메모리 누수 — ClassLoader가 GC되지 않는 경우

핫 리로드에서 가장 흔한 사고는 이전 ClassLoader가 GC되지 않아 Metaspace가 꽉 차는 것 입니다. ClassLoader가 GC되려면 해당 ClassLoader가 로드한 모든 클래스의 모든 인스턴스 가 먼저 GC되어야 합니다.

  1. 기존 ClassLoader로 로드한 객체를 static 필드나 ThreadLocal에 저장해두면, 그 참조가 ClassLoader → 모든 Class 객체 → Metaspace 메모리를 붙잡고 있게 됩니다
  2. 리로드를 반복할수록 이전 ClassLoader들이 계속 쌓이면서 OutOfMemoryError: Metaspace 발생
  3. 프로덕션에서 이 문제를 디버깅하려면 힙 덤프에서 ClassLoader 인스턴스 수를 확인해야 합니다

Thread Context ClassLoader 혼동

위임 모델은 항상 아래에서 위로 올라가지만, JDBC나 JNDI 같은 SPI는 ** 위(Bootstrap)에서 아래(Application)의 클래스를 찾아야** 합니다. 이 역방향 참조를 위해 Thread Context ClassLoader가 존재합니다.

JAVA
ClassLoader contextCL = Thread.currentThread().getContextClassLoader();
// 웹 컨테이너가 스레드별로 적절한 ClassLoader를 설정

이 패턴을 모르면 웹 컨테이너에서 ClassNotFoundException이 발생하는 원인을 찾기 어렵습니다.

ClassNotFoundException vs NoClassDefFoundError

이 두 예외는 이름이 비슷하지만 원인이 전혀 다릅니다.

예외원인발생 시점
ClassNotFoundExceptionClass.forName() 등으로 클래스를 찾았지만 없을 때런타임에 동적 로딩 시
NoClassDefFoundError컴파일 시엔 있었지만 런타임에 찾지 못할 때의존 라이브러리 누락 시

NoClassDefFoundError가 더 치명적입니다. 빌드는 성공했는데 배포 환경에서 특정 JAR가 누락된 경우 발생하기 때문입니다.

정리

항목설명
위임 모델부모에게 먼저 위임 → 못 찾으면 자신이 로드. 핵심 클래스 보호
클래스 정체성FQCN + ClassLoader 조합으로 결정. 같은 이름이라도 다른 ClassLoader면 다른 클래스
로딩 3단계로딩(바이트코드 읽기) → 링킹(검증/준비/해석) → 초기화(static 블록 실행)
커스텀 구현findClass()를 오버라이드. loadClass()는 위임 모델을 깨므로 비권장
핫 리로드ClassLoader 자체를 교체. 이전 ClassLoader의 참조가 남으면 Metaspace 누수
Thread Context CLSPI에서 역방향 참조가 필요할 때 사용하는 우회 메커니즘
댓글 로딩 중...