ABOUT

성능과 운영 안정성을 함께 끌어올리는 개발자입니다.

92% Positional Error Reduction
79% p95 Latency Improvement
90%+ Long Tasks Reduction

2022.02 · 한국장학재단

우수 멘티

한국장학재단 사회 리더 대학생 멘토링 IT

2022.10 · 동작구청

우수 인재상

동작구청 우수 SW 인재

2025.05 · (주) 그랩

프로그래밍 우수상

(주) 그랩 우수 프로그램 개발

2025.05 · AWSKRUG

AWS한국사용자모임 발표

AI agent 스크립트 튜닝 관련 발표

ComputerScience

Development

Engineering

Trouble Shooting

GUESTBOOK

첫 마음부터
함께 나누는 온기

방명록 작성하러 가기

SUBSCRIBE

최신소식을
편하게 만나보세요.

ClassLoader

도입

ClassLoader는 단순히 classpath에서 파일을 찾는 객체가 아니라, JVM이 타입을 식별하고 의존성을 연결하며 런타임 경계를 나누는 방식을 결정하는 핵심 인프라다

자바를 처음 배울 때는 ClassLoader를 “클래스를 읽어 오는 도구” 정도로 이해하기 쉽습니다. 하지만 실제 런타임에서는 훨씬 더 중요합니다.

JVM은 클래스를 Loading, Linking, Initialization 단계로 준비하고, ClassLoader는 그 진입점에서 어떤 클래스를 누구에게 위임해 어떻게 정의할지를 결정합니다. 결국 ClassLoader를 이해해야 ClassNotFoundException, NoClassDefFoundError, 버전 충돌, SPI/JDBC 로딩, 플러그인 격리 문제를 구조적으로 해석할 수 있습니다.

필요성

ClassLoader를 이해하지 못하면 같은 이름의 클래스가 왜 환경마다 다르게 보이는지 설명하기 어렵고, 의존성 충돌도 표면적인 에러 메시지만 보게 된다

JVM에서 클래스의 정체성은 이름만으로 끝나지 않습니다. 런타임에서는 binary name + defining loader가 함께 클래스 정체성을 이룹니다. 그래서 FQCN이 같더라도 누가 정의했는지가 다르면 다른 타입처럼 취급될 수 있습니다.

이 관점이 중요한 이유는 단순합니다. 애플리케이션 서버, 테스트 격리 환경, 플러그인 구조, SPI, JDBC 드라이버 검색처럼 로더 경계가 여러 층으로 나뉘는 순간, 문제는 “코드가 틀렸다”가 아니라 “어떤 로더가 무엇을 봤느냐”로 바뀌기 때문입니다.

핵심 문장
같은 클래스 이름이라도 정의한 ClassLoader가 다르면 JVM은 같은 타입으로 보지 않을 수 있습니다. ClassLoader는 단순한 로딩 도구가 아니라 타입 경계를 만드는 장치입니다.

정의

ClassLoader는 바이너리 이름을 기준으로 클래스를 찾거나 생성하고, 필요하면 바이트 배열을 실제 Class 객체로 정의하는 추상 기반이다

ClassLoader는 자바 런타임에서 클래스와 리소스를 찾는 추상 기반 클래스입니다. 보통은 클래스 이름을 파일 경로로 바꿔 JAR 또는 디렉터리에서 읽어 오지만, 꼭 파일 시스템만 고집할 필요는 없습니다. 네트워크, 메모리, 암호화된 저장소, 생성된 바이트코드도 소스가 될 수 있습니다.

중요한 점은 ClassLoader가 “파일을 읽는 역할”에서 끝나지 않는다는 것입니다. 실제 바이트를 읽은 뒤에는 defineClass를 통해 JVM이 이해하는 타입으로 등록해야 하고, 이때 어떤 로더가 정의했는지가 이후 타입 비교와 가시성에 계속 영향을 줍니다.

ClassLoader가 맡는 역할
  • 클래스의 바이너리 표현을 찾거나 생성하기
  • 부모 로더에게 먼저 위임할지 결정하기
  • defineClass로 실제 타입 정의하기
  • 클래스뿐 아니라 리소스 검색도 담당하기

클래스 로딩 생명주기

JVM 관점에서 클래스는 Loading → Linking → Initialization 순으로 준비되며, ClassLoader는 Loading의 입구이지만 실제 장애는 Linking과 Initialization에서 자주 드러난다

실무에서 가장 흔한 오해는 “클래스가 로드됐다 = 실행 준비가 끝났다”라고 보는 것입니다. 하지만 JVM은 이 과정을 더 세밀하게 나눕니다.

단계 설명 실무 포인트
Loading 클래스의 바이너리 표현을 찾고 Class 객체를 만드는 단계 ClassLoader가 직접 관여하는 입구
Linking Verification, Preparation, Resolution을 통해 실행 가능 상태로 결합 버전 충돌, 심볼 참조 오류가 자주 드러남
Initialization static 초기화 블록과 static 필드 초기화 식 실행 부작용과 예외가 가장 체감되기 쉬운 지점

Preparation 단계는 특히 자주 헷갈립니다. 이 단계는 static 필드를 기본값으로 준비하는 과정이지, 개발자가 적은 실제 초기화 식을 실행하는 단계가 아닙니다. 명시적 static 초기화는 Initialization에서 실행됩니다.

또 하나 중요한 포인트는 Resolution이 언제 끝나는지가 구현마다 늦춰질 수 있다는 점입니다. 즉, 링크 오류가 클래스 로드 직후가 아니라 실제 참조 사용 시점에 터질 수도 있습니다.

Loading
  ↓
Linking
  ├─ Verification
  ├─ Preparation
  └─ Resolution
  ↓
Initialization

내장 ClassLoader 종류

Java 9+ 기준 런타임은 bootstrap, platform, system(application) loader를 기본 축으로 삼고, 각 로더의 가시성 범위가 곧 클래스 접근 경계가 된다
로더 역할 메모
Bootstrap VM 내장 로더 보통 null로 표현되며 부모가 없음
Platform 플랫폼 클래스 로드 Java SE API 및 런타임 클래스 가시성의 중간 축
System / Application 애플리케이션 class path, module path 기반 로드 대부분의 애플리케이션 코드가 여기에 정의됨

일반적인 애플리케이션 코드는 대부분 system loader 쪽에서 시작합니다. 또한 새 ClassLoader를 만들 때 기본 위임 부모로 system loader가 사용되는 경우가 많기 때문에, 이 계층을 기준으로 가시성 문제가 발생합니다.

System.out.println(String.class.getClassLoader());           // null (bootstrap)
System.out.println(ClassLoader.getPlatformClassLoader());    // platform loader
System.out.println(ClassLoader.getSystemClassLoader());      // app/system loader
고급 포인트
Java 9+ 모듈 환경에서는 가시성 관계가 항상 단순한 나무 구조처럼만 보이지 않을 수 있습니다. 특히 platform loader가 application loader 쪽으로 delegate할 수 있는 경우도 있어, “부모 계층만 보면 끝난다”고 기계적으로 이해하면 놓치는 부분이 생깁니다.

부모 위임 모델

기본 loadClass는 이미 로드된 클래스를 먼저 확인하고, 그다음 부모에게 위임한 뒤, 마지막에 자기 findClass를 호출하므로 일관성과 보안 측면에서 유리하다

Oracle 문서 기준으로 loadClass(name, resolve)의 기본 구현은 다음 순서를 따릅니다.

  1. findLoadedClass(name)로 이미 로드됐는지 확인한다.
  2. 부모 ClassLoader에 loadClass를 위임한다.
  3. 부모가 찾지 못하면 현재 로더의 findClass(name)를 호출한다.
  4. resolve == true라면 마지막에 resolveClass를 수행한다.

이 구조 덕분에 공통 클래스가 여러 곳에서 제멋대로 중복 정의되는 일을 줄일 수 있고, 상위 계층이 먼저 표준 라이브러리와 공용 클래스를 장악하게 됩니다. 그래서 기본 ClassLoader 설계는 “내가 먼저 찾는다”가 아니라 “부모가 먼저 본다”에 가깝습니다.

loadClass와 findClass

실무에서 가장 많이 헷갈리는 지점은 loadClass가 전체 전략이고, findClass가 현재 로더의 실제 조회·정의 지점이라는 점이다

loadClass는 “어떤 순서로 찾을 것인가”를 담당합니다. 반면 findClass는 “부모가 못 찾았을 때, 내 방식으로 실제 클래스를 어디서 구해 정의할 것인가”를 담당합니다.

그래서 커스텀 로더를 만들 때는 대개 loadClass 전체를 뒤엎기보다 findClass를 오버라이드하는 쪽이 안전합니다. Oracle 문서도 서브클래스는 보통 findClass를 오버라이드하라고 안내합니다.

public class ByteArrayClassLoader extends ClassLoader {

    public ByteArrayClassLoader(ClassLoader parent) {
        super(parent);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] bytes = loadBytes(name); // 직접 구현
        if (bytes == null) {
            throw new ClassNotFoundException(name);
        }
        return defineClass(name, bytes, 0, bytes.length);
    }

    private byte[] loadBytes(String name) {
        // 파일, 네트워크, 메모리 등에서 바이트를 읽는 로직
        return null;
    }
}
실전 팁
JAR와 디렉터리 경로를 URL 기반으로 다루는 정도라면 URLClassLoader로 충분한 경우가 많습니다. 처음부터 모든 로직을 직접 구현하기보다, 정말 필요한 정책만 커스터마이즈하는 편이 유지보수에 유리합니다.

초기화와 Class.forName

클래스를 찾았다고 해서 곧바로 static 초기화까지 끝난 것은 아니며, 초기화 시점을 따로 이해해야 런타임 부작용과 예외를 정확히 읽을 수 있다

JLS 기준으로 클래스 또는 인터페이스는 특정 조건에서만 초기화됩니다. 대표적으로 인스턴스 생성, static 메서드 호출, static 필드 대입, 상수가 아닌 static 필드 사용, 일부 리플렉션 호출이 트리거가 됩니다.

이 때문에 “로드됨”과 “초기화됨”은 같은 말이 아닙니다. static 초기화 블록이 무거운 코드일수록 이 차이가 중요해집니다.

초기화가 일어나는 대표 상황
  • 클래스 인스턴스를 생성할 때
  • 그 클래스가 선언한 static 메서드를 호출할 때
  • 그 클래스가 선언한 static 필드에 값을 대입할 때
  • 상수가 아닌 static 필드를 사용할 때
  • 일부 reflective API가 초기화를 유발할 때
ClassLoader loader = Thread.currentThread().getContextClassLoader();

Class<?> a = Class.forName("com.example.Plugin");                // 초기화 O
Class<?> b = Class.forName("com.example.Plugin", false, loader); // 로드 O, 초기화 X
주의할 점
Class.forName("X")는 사실상 Class.forName("X", true, currentLoader)와 같은 의미라서 초기화를 유발합니다. 반대로 initialize = false를 주면 로드만 하고 static 초기화는 미룰 수 있습니다.

Thread Context ClassLoader

부모 위임만으로는 상위 계층 코드가 하위 애플리케이션 구현체를 보지 못하는 경우가 생기기 때문에, Java는 Thread Context ClassLoader라는 별도 진입점을 제공한다

Thread.getContextClassLoader()는 스레드 생성자가 제공하는 로더입니다. 설정하지 않으면 기본적으로 부모 스레드의 context loader를 이어받고, 원시(main) 스레드 쪽에서는 보통 애플리케이션을 시작한 system loader가 들어갑니다.

이 로더가 중요한 이유는 일부 프레임워크와 표준 API가 실제 구현체 탐색에 이 값을 사용하기 때문입니다. 대표적으로 ServiceLoader.load(service)는 현재 스레드의 context ClassLoader를 사용하고, JDBC DriverManager도 드라이버 provider 탐색에 이를 활용합니다.

ClassLoader tccl = Thread.currentThread().getContextClassLoader();

ServiceLoader<MyService> services = ServiceLoader.load(MyService.class);
// ServiceLoader.load(service)는 현재 스레드의 context ClassLoader를 사용

커스텀 ClassLoader

커스텀 ClassLoader는 기본 app loader가 해결하지 못하는 동적 로딩 정책, 격리 정책, 바이트 소스 정책을 구현할 때 등장한다

커스텀 로더는 “특정 위치에서 클래스를 읽는다” 수준을 넘어서, “무엇을 누구에게 먼저 위임할지”, “어떤 클래스를 로컬에서만 정의할지”, “어떤 패키지를 격리할지” 같은 정책을 담습니다.

Oracle 문서 예시처럼 클래스는 네트워크에서 받아도 되고, 애플리케이션이 직접 조립한 바이트 배열에서 정의해도 됩니다. 중요한 것은 defineClass로 JVM 타입을 만들기 전에 이름, 패키지, 중복 정의 여부, 부모 위임 규칙을 일관되게 유지하는 것입니다.

커스텀 로더를 고려하는 상황
  • 플러그인 또는 확장 모듈을 격리해 로딩해야 할 때
  • 외부 JAR / URL / 네트워크에서 동적으로 로딩해야 할 때
  • 메모리상의 바이트코드를 즉시 정의해야 할 때
  • 기본 부모 위임 규칙과 다른 검색 정책이 필요할 때
고급 주의사항
로더 간 위임 구조가 단순하지 않은 환경에서는 병렬 로딩 이슈도 고려해야 합니다. Oracle 문서는 비엄격한 위임 구조에서 병렬 capable 설정이 없으면 로더 락 때문에 데드락 가능성이 있다고 설명합니다.

자주 만나는 오류

ClassLoader 문제는 단순히 못 찾는 상황만이 아니라, 로딩·링킹·초기화 단계마다 서로 다른 예외 이름으로 드러난다
예외 / 에러 주로 의미하는 것 읽는 포인트
ClassNotFoundException 문자열 이름 기반 로드 시 클래스를 찾지 못함 reflection, 직접 로딩, 커스텀 로더에서 많이 봄
NoClassDefFoundError 필요한 클래스 정의를 relevant loader가 찾지 못함 컴파일 시점엔 있었지만 런타임 경로에서 사라진 경우를 의심
ExceptionInInitializerError static 초기화 중 예외 발생 ClassLoader 문제가 아니라 static 부작용일 수도 있음
NoSuchMethodError / NoSuchFieldError 링크 시 심볼 참조 대상이 현재 런타임에 없음 대개 버전 충돌, 바이너리 비호환성
VerifyError 검증 단계에서 바이트코드 구조 문제 발견 바이트코드 조작, 프록시, 에이전트, 손상된 클래스 의심

LinkageError 계열은 특히 중요합니다. 소스코드가 아니라 바이너리 호환성이 깨졌을 때 드러나는 경우가 많기 때문입니다. 그래서 이 계열의 예외는 “컴파일은 됐는데 런타임에 깨진다”는 상황과 연결해서 봐야 합니다.

디버깅

ClassLoader 디버깅은 스택트레이스만 읽는 것이 아니라, 어떤 로더가 어떤 클래스를 정의했는지와 문제가 어느 단계에서 터졌는지를 분리해서 보는 작업이다
1
문제가 Loading인지, Linking인지, Initialization인지 먼저 나눈다.
2
해당 클래스의 ClassLoader가 실제로 무엇인지 출력해 본다.
3
같은 이름의 클래스가 서로 다른 로더로 정의되지 않았는지 확인한다.
4
Thread.currentThread().getContextClassLoader()가 의도한 값인지 본다.
5
커스텀 로더라면 loadClass를 과도하게 뒤엎지 않았는지, findClass 중심으로 설계했는지 점검한다.
6
static 초기화 코드가 예외를 던지는지, 초기화 부작용이 너무 크지 않은지도 별도로 본다.
System.out.println("Foo loader = " + Foo.class.getClassLoader());
System.out.println("Bar loader = " + Bar.class.getClassLoader());
System.out.println("TCCL = " + Thread.currentThread().getContextClassLoader());

요약

ClassLoader의 본질은 클래스를 어디서 읽느냐보다, 어떤 위임 규칙으로 어떤 타입을 누구의 이름으로 정의하느냐에 있으며, 이 이해가 있어야 런타임 의존성 문제를 구조적으로 다룰 수 있다
  • ✅ ClassLoader는 클래스와 리소스를 찾고 정의하는 런타임 핵심 인프라다.
  • ✅ 클래스 준비는 Loading → Linking → Initialization으로 나뉜다.
  • ✅ Java 9+ 기준 내장 로더는 bootstrap, platform, system(application)이다.
  • ✅ 기본 loadClass는 이미 로드 여부 확인 → 부모 위임 → findClass 순서로 동작한다.
  • ✅ 커스텀 로더는 보통 findClass를 오버라이드하는 쪽이 안전하다.
  • Class.forName("X")는 초기화를 유발하고, initialize = false로 이를 미룰 수 있다.
  • ✅ TCCL은 ServiceLoader, JDBC 같은 동적 탐색 패턴에서 매우 중요하다.
  • ✅ ClassLoader 문제는 로딩, 링크, 초기화 중 어느 단계의 문제인지 나눠서 봐야 빠르게 풀린다.
728x90