우아한테크코스 테코톡
히포의 클래스 패스와 클래스 로더
https://youtu.be/n2AgeY77YbQ?si=DO1sod3r0oHcJ4h-
히포의 클래스 패스와 클래스 로더
- 히포의 클래스 패스와 클래스 로더
- 애플리케이션 실행 시 JVM은 클래스 파일을 어떻게 찾고 로드할까
- 클래스 패스란 무엇인가
- 클래스 패스는 어떻게 추가될까
- JAR 파일 안의 MANIFEST.MF는 어떤 역할을 할까
- BOOT-INF/classes와 BOOT-INF/lib에는 무엇이 들어 있을까
- 클래스 로더란 무엇인가
- 클래스 로더는 클래스의 위치를 어떻게 특정할까
- 클래스 로더는 생각보다 단순하지 않다
- 클래스 로더의 3계층 구조
- 부트스트랩 클래스 로더
- 플랫폼 클래스 로더
- 애플리케이션 클래스 로더
- 위임 모델은 어떻게 동작할까
- 왜 이렇게 복잡한 구조를 쓰는가
- 클래스를 못 찾으면 어떻게 될까
- 커스텀 클래스 로더도 존재한다
- 같은 클래스 파일이라도 클래스 로더가 다르면 다른 타입이다
- 스프링부트 애플리케이션에서 이 흐름을 어떻게 이해하면 좋을까
- 마무리
애플리케이션 실행 시 JVM은 클래스 파일을 어떻게 찾고 로드할까
자바 애플리케이션을 실행할 때 우리는 보통 new 키워드로 객체를 만들고 메서드를 호출하는 것만 본다. 하지만 JVM 입장에서는 그보다 먼저 해결해야 할 문제가 있다. “이 클래스의 정보가 어디에 있는지, 어떤 순서로 찾아서 메모리에 올려야 하는지” 를 알아야 한다는 점이다.
빌드된 JAR 파일 안에는 클래스 파일, 리소스 파일, 의존성 JAR까지 수많은 파일이 들어 있다. JVM이 이 많은 파일들 사이에서 필요한 클래스를 정확히 찾아내기 위해 사용하는 핵심 개념이 바로 클래스 패스(Classpath) 와 클래스 로더(ClassLoader) 다. 클래스 패스는 “어디를 찾아야 하는지”를 알려주는 경로 목록이고, 클래스 로더는 “그 경로를 실제로 탐색해 클래스를 메모리에 적재하는 주체”다.
클래스 패스란 무엇인가
클래스 패스는 JVM이 애플리케이션 실행에 필요한 클래스 파일이나 리소스를 찾기 위한 경로 목록이다. 쉽게 말하면 JVM이 리소스를 탐색할 때 참고하는 지도와 같다. JVM은 클래스 패스에 등록된 위치들을 기준으로 “내가 지금 필요한 클래스가 어디에 있을까?”를 찾기 시작한다.
이 개념이 중요한 이유는 JVM이 클래스 이름만 안다고 해서 바로 클래스를 읽을 수 있는 것이 아니기 때문이다. 클래스 파일은 디스크 어딘가에 있고, JVM은 그 위치를 알아야만 로드할 수 있다. 즉, 클래스 패스는 클래스 탐색의 출발점이다.
클래스 패스는 어떻게 추가될까
자료에서는 클래스 패스에 경로를 추가하는 방법을 세 가지로 설명한다.
첫째는 커맨드 라인 옵션이다. -classpath 옵션으로 실행 시점에 임시 경로를 추가할 수 있다. 둘째는 CLASSPATH 환경 변수다. 시스템 전역적으로 사용할 경로를 지정할 수 있다. 셋째는 실행 가능한 JAR 파일 내부의 MANIFEST.MF 파일이다. 실제 애플리케이션 실행에서는 이 방식이 자주 활용된다.
실무에서는 대부분 JAR 파일로 애플리케이션을 실행하므로, 매니페스트 기반 클래스 패스 구성이 가장 익숙한 흐름이다.
JAR 파일 안의 MANIFEST.MF는 어떤 역할을 할까
빌드 도구로 JAR 파일을 만들면 내부에 META-INF/MANIFEST.MF 파일이 생성된다. 이 파일에는 애플리케이션 실행에 필요한 여러 메타데이터가 들어 있고, 그중 하나가 클래스 패스 관련 정보다. 일반 애플리케이션과 스프링부트 애플리케이션의 매니페스트는 차이가 있지만, 공통적으로 “클래스 로더가 참고해야 할 경로 정보”를 담고 있다는 점은 같다.
특히 스프링부트 JAR에서는 다음 두 경로가 핵심이다.
BOOT-INF/classes/ BOOT-INF/lib/
이 두 경로가 클래스 로더가 탐색할 루트 경로 역할을 한다. 즉, 클래스 패스에 추가되는 핵심 기반 경로인 셈이다.
BOOT-INF/classes와 BOOT-INF/lib에는 무엇이 들어 있을까
BOOT-INF/classes/ 아래에는 우리가 프로젝트에서 작성한 자바 코드가 컴파일된 .class 파일들과 리소스들이 들어 있다. BOOT-INF/lib/ 아래에는 프로젝트 의존성으로 추가한 각종 라이브러리 JAR 파일들이 모여 있다.
이 구조를 이해하면 JVM이 애플리케이션 실행 시 왜 저 두 경로를 중요하게 다루는지 자연스럽게 보인다. 내가 직접 만든 클래스는 BOOT-INF/classes 쪽에서, 프레임워크나 외부 라이브러리 클래스는 BOOT-INF/lib 쪽에서 찾게 되는 것이다.
즉, 클래스 패스는 단순히 “경로 목록”이 아니라, JVM이 애플리케이션 세계를 탐색할 수 있도록 해주는 출발점이다.
클래스 로더란 무엇인가
클래스 패스가 길을 알려준다면, 클래스 로더는 그 길을 따라 실제로 탐색하고 클래스를 읽어오는 주체다. 자료에서는 클래스 로더를 “클래스 패스 안의 경로 목록을 탐색해 지정된 클래스를 찾아 메모리에 적재하는 역할”로 설명한다.
즉, 클래스 로더는 단순히 파일을 찾는 데서 끝나지 않는다. 찾아낸 클래스 파일을 읽고, JVM 메모리에 적재해서 실제 실행 가능한 형태로 준비하는 일까지 맡는다.
이 지점이 중요하다. 자바 애플리케이션은 소스 코드를 직접 실행하는 것이 아니라 컴파일된 클래스 파일을 기반으로 동작한다. 그리고 그 클래스 파일을 메모리에 올리는 관문이 클래스 로더다.
클래스 로더는 클래스의 위치를 어떻게 특정할까
클래스 로더는 단순히 “클래스 이름”만 보고 탐색하지 않는다. 우리가 코드에서 자주 보는 String, Moment, MemberService 같은 이름은 대부분 축약된 심플 클래스 네임(Simple Class Name) 이다. 하지만 실제 탐색에 사용되는 것은 패키지 경로까지 포함한 풀리 퀄리파이드 클래스 네임(Fully Qualified Class Name) 이다.
예를 들어 어떤 클래스의 이름이 패키지까지 포함해 com.example.app.Moment 라면, 클래스 로더는 이를 경로 형태로 바꿔서 탐색한다. 점(.)은 슬래시(/)로 변환되고, 뒤에 .class 확장자가 붙는다. 그러면 상대 경로는 다음처럼 된다.
com/example/app/Moment.class
이 상대 경로를 앞에서 확보한 루트 경로, 예를 들어 BOOT-INF/classes/ 와 결합하면 실제 탐색 대상의 위치를 특정할 수 있다. 즉, 클래스 로더는 “루트 경로 + FQCN 기반 상대 경로” 조합으로 클래스를 찾는다.
클래스 로더는 생각보다 단순하지 않다
겉으로 보면 클래스 로더는 그냥 클래스 패스를 뒤져서 파일을 찾는 도구처럼 보인다. 하지만 내부 구조는 훨씬 정교하다. 자료에서는 클래스 로더가 3계층 구조를 가지며, 위임 모델(Delegation Model) 이라는 규칙에 따라 동작한다고 설명한다.
이 구조를 이해하면 왜 자바가 안정적이고, 왜 핵심 라이브러리가 함부로 덮어씌워지지 않는지까지 연결해서 볼 수 있다.
클래스 로더의 3계층 구조
자바의 기본 클래스 로더 계층은 크게 세 단계로 나뉜다.
가장 위에는 부트스트랩 클래스 로더(Bootstrap ClassLoader) 가 있다. 그 아래에는 플랫폼 클래스 로더(Platform ClassLoader) 가 있다. 가장 아래에는 애플리케이션 클래스 로더(Application ClassLoader) 가 있다.
이 세 계층은 각자 책임지는 영역이 다르다.
부트스트랩 클래스 로더
부트스트랩 클래스 로더는 최상위 계층이며, JVM 실행에 핵심적으로 필요한 객체들을 로드한다. 자료에서는 이 계층이 네이티브 코드로 구현되어 있고, java.base 모듈처럼 JVM 구동의 기반이 되는 영역을 적재한다고 설명한다.
즉, String, Object 같은 자바의 가장 기본적인 클래스들이 이 영역과 연결된다고 이해하면 된다.
플랫폼 클래스 로더
플랫폼 클래스 로더는 java.base 를 제외한 나머지 JDK 핵심 라이브러리들을 적재하는 중간 계층이다. 즉, 자바 실행 환경에 포함된 표준 라이브러리 중 부트스트랩이 담당하지 않는 영역을 맡는다.
이 계층 덕분에 애플리케이션 코드와 JDK 핵심 라이브러리 사이에 역할 구분이 생긴다.
애플리케이션 클래스 로더
애플리케이션 클래스 로더는 우리가 가장 자주 마주치는 계층이다. 흔히 시스템 클래스 로더라고도 부른다. 이 로더는 앞서 말한 루트 경로 하위, 즉 BOOT-INF/classes 와 BOOT-INF/lib 안에 있는 애플리케이션 클래스와 의존성 JAR들을 탐색하고 로드한다. 결국 우리가 직접 작성한 클래스나 외부 라이브러리 대부분은 이 계층에서 로드된다고 보면 된다.
위임 모델은 어떻게 동작할까
클래스 로더의 핵심 규칙은 아래에서 바로 찾지 않고, 먼저 위로 위임한다는 점이다. 애플리케이션 클래스 로더가 어떤 클래스 로드 요청을 받더라도 곧바로 자기 경로를 뒤지지 않는다. 먼저 플랫폼 클래스 로더에게 위임하고, 플랫폼도 다시 부트스트랩 클래스 로더에게 위임한다. 그래서 로드 요청은 항상 최상위까지 먼저 올라간다.
그다음 탐색은 위에서 아래로 진행된다. 부트스트랩이 자기 영역에서 클래스를 찾고, 없으면 플랫폼으로 내려간다. 플랫폼도 없으면 애플리케이션 클래스로 내려간다. 최종적으로 대부분의 사용자 정의 클래스는 애플리케이션 클래스 로더에서 로드된다.
이 동작 방식을 보면 “왜 애플리케이션 로더가 바로 자기 클래스를 로드하지 않을까?”라는 의문이 생길 수 있다. 바로 다음 이유 때문이다.
왜 이렇게 복잡한 구조를 쓰는가
자료에서는 이 계층 구조와 위임 모델의 목적을 보안과 안정성이라고 설명한다. 만약 이런 구조가 없다면, 애플리케이션 쪽에서 악의적인 클래스를 만들어 String 같은 JVM 핵심 객체를 덮어씌우는 시도를 할 수도 있다. 그렇게 되면 JVM 자체의 신뢰성이 무너질 수 있다.
하지만 위임 모델 덕분에 핵심 클래스는 항상 상위 로더가 먼저 책임지고 로드한다. 따라서 하위 로더가 같은 이름의 클래스를 준비해도, 상위에서 이미 로드해버리면 하위 것은 사용되지 않는다. 이 구조는 불순한 덮어쓰기 시도를 막고, 중복된 객체 로드를 줄이는 데도 도움이 된다.
즉, 클래스 로더 구조는 단순한 구현 디테일이 아니라, 자바의 실행 안전성을 지탱하는 장치다.
클래스를 못 찾으면 어떻게 될까
위임 모델에 따라 부트스트랩부터 애플리케이션 로더까지 모두 탐색했는데도 해당 클래스를 찾지 못하면, JVM은 ClassNotFoundException 을 발생시킨다. 우리가 종종 보는 이 예외는 결국 “클래스 패스 어디에도 그 클래스가 없었다”는 뜻이다.
그래서 이 예외를 마주했을 때는 보통 다음을 점검해야 한다.
해당 클래스가 실제로 빌드 결과물에 포함됐는지 의존성 JAR가 누락되지 않았는지 클래스 패스 설정이 맞는지 실행 JAR 구조가 올바른지
이 개념을 모르면 ClassNotFoundException 을 단순한 실행 오류로만 보게 되지만, 알고 나면 클래스 탐색 실패의 결과라는 것을 이해할 수 있다.
커스텀 클래스 로더도 존재한다
자바의 기본 클래스 로더 구조는 3계층이지만, 필요하면 커스텀 클래스 로더를 만들어 중간 계층에 삽입할 수도 있다. 자료에서는 톰캣을 예로 들며, Common Class Loader나 WebApp Class Loader 같은 추가 계층이 사용된다고 설명한다.
이 개념은 애플리케이션 서버, 플러그인 시스템, 격리된 모듈 실행 같은 상황에서 특히 중요하다. 같은 JVM 안에서도 각 웹 애플리케이션이 서로 독립적으로 클래스 공간을 갖게 만드는 데 클래스 로더가 핵심 역할을 한다.
같은 클래스 파일이라도 클래스 로더가 다르면 다른 타입이다
자료에서 특히 흥미로운 부분은 이것이다. 동일한 클래스 파일이라도 서로 다른 클래스 로더가 로드하면 JVM 메모리 안에서는 전혀 다른 존재로 취급된다.
즉, 클래스의 정체성은 단순히 파일 내용만으로 결정되지 않는다. “어떤 클래스 로더가 로드했는가”까지 포함해서 결정된다.
이 특성 때문에 플러그인 구조나 WAS 환경에서 예상치 못한 ClassCastException 이 발생하기도 한다. 파일은 같아 보여도, 서로 다른 로더가 올린 클래스라면 JVM은 같은 타입으로 보지 않기 때문이다.
이 점은 평소엔 잘 드러나지 않지만, 톰캣 같은 서버 내부 구조나 프레임워크 격리 모델을 이해할 때 매우 중요하다.
스프링부트 애플리케이션에서 이 흐름을 어떻게 이해하면 좋을까
스프링부트 애플리케이션을 JAR로 실행하면, JAR 내부 매니페스트가 BOOT-INF/classes 와 BOOT-INF/lib 를 루트 경로로 제공한다. 애플리케이션 클래스 로더는 이 루트들 아래에서 우리가 만든 클래스와 의존성 라이브러리를 탐색한다. 클래스 이름은 FQCN을 기반으로 상대 경로로 바뀌고, 그 상대 경로가 각 루트와 결합되어 실제 위치가 판별된다. 그 과정 전체가 클래스 로더 계층과 위임 모델 위에서 동작한다.
즉, 스프링부트 실행은 단순히 “메인 메서드가 실행된다”가 아니라, 그 전에 이미 JVM이 클래스 패스와 클래스 로더 규칙을 활용해 필요한 클래스들을 차례로 찾아 메모리에 올리는 과정을 거친다.
마무리
자바 애플리케이션 실행의 출발점은 메서드 호출이 아니라 클래스 탐색과 적재다. 이때 클래스 패스는 JVM이 리소스를 찾기 위한 경로 목록을 제공하고, 클래스 로더는 그 경로를 실제로 탐색해 클래스를 메모리에 올린다. 그리고 이 클래스 로더는 3계층 구조와 위임 모델을 통해 보안과 안정성을 지키면서 동작한다.
정리하면 흐름은 이렇게 볼 수 있다.
클래스 패스가 루트 경로를 제공한다. 클래스 로더가 FQCN을 바탕으로 상대 경로를 만든다. 루트 경로와 상대 경로를 합쳐 실제 클래스 위치를 찾는다. 위임 모델에 따라 상위부터 하위로 탐색하며 적절한 로더가 클래스를 로드한다. 찾지 못하면 ClassNotFoundException 이 발생한다.
평소에는 잘 보이지 않지만, 자바 애플리케이션이 실행될 수 있는 이유 자체가 이 구조 위에 있다. 그래서 클래스 패스와 클래스 로더는 단순한 JVM 내부 개념이 아니라, 자바 실행 모델의 핵심 축이라고 볼 수 있다.