티스토리 뷰
[SpringBoot] 스프링 부트의 JAR 파일 처리 방식의 발전과 자바의 CDS 극대화를 위한 개선들(Self-extracting executable JAR)
망나니개발자 2025. 9. 23. 10:00
1. 스프링 부트의 JAR 파일 처리 방식
[ WAR 방식의 한계점과 스프링 부트의 등장 ]
이전 포스팅에서 살펴보았듯, 스프링 부트는 컨테이너리스(Containerless) 웹 애플리케이션 아키텍처에 대한 요구와 함께 등장했다.

과거에는 톰캣 같은 웹 애플리케이션 서버(WAS)를 먼저 설치하고, 애플리케이션 코드를 WAR로 빌드한 후, 빌드한 WAR 파일을 WAS로 옮기고(배포) WAS를 실행하는 절차들이 필요했다.
하지만 이는 WAS를 서버에 직접 설치해야 하는 번거로움 뿐만 아니라, 개발 환경 설정과 배포 과정의 복잡함 등 많은 단점이 있었다. 따라서 스프링이 “애플리케이션의 설정을 처음부터 끝까지 지원하는 아키텍처를 제공해준다면, 단순히 main 메소드를 호출하는 것 만으로도 애플리케이션의 실행과 종료를 관리하게 될 것이고, 많은 불편함을 해소하고 구성들을 간소화할 수 있다”는 요구사항으로부터 스프링 부트가 탄생하게 되었다. 그리고 스프링 부트는 이러한 요구사항을 반영하기 위해 WAR가 아닌 자바의 JAR 방식으로 스프링 부트 애플리케이션을 패키징하고 배포 및 실행 가능하도록 하고자 하였다.

JAR(Java ARchive) 파일은 내부적으로 사실상 ZIP 포맷을 사용하는데, 그 내부에는 여러 개의 클래스 파일(.class), 리소스(이미지, 설정 파일 등), 그리고 메타데이터(META-INF/Manifest.mf)로 구성된다. 이러한 JAR 파일로 스프링 부트 애플리케이션을 패키징하고 배포하려다 보니 여러 가지 문제가 있었다.
먼저 기존에 WAR 파일로 패키징을 하면 다음과 같은 구조로 내부가 구성되었는데, 내부에 외부 라이브러리 JAR 파일이 포함되어 있다는 특징이 있다.
example.war
|
+-WEB-INF
| +-classes
| | +-mycompany
| | +-project
| | +-YourClasses.class
| +-lib
| +-dependency1.jar
| +-dependency2.jar
+-index.html
+-...
하지만 JAR 파일의 경우 내부적으로 다음과 같이 구성되는데, JAR 파일은 스펙 상으로 다른 JAR 파일을 포함할 수 없다는 특징이 있다.
example.jar
|
+-META-INF
| +-MANIFEST.MF
+-com
+-mycompany
+-project
+-YourClasses.class
즉, 자바는 중첩된 JAR 파일(JAR 내부에 존재하는 또 다른 JAR)을 불러올 수 있는 표준적인 방법을 제공하지 않는다. 이를 해결하려면 라이브러리 jar 파일들을 모두 저장하고 MANIFEST.MF에 해당 경로를 저장하는 방법도 있기는 한데, JAR 파일 안에 JAR 파일을 포함할 수 없어 모든 라이브러리 JAR을 함께 옮겨줘야 한다는 번거로움이 있다.
[ Fat JAR의 등장과 한계점 ]
이러한 공식 JAR 스펙의 한계를 극복하기 위해, Fat JAR 또는 Uber JAR 이라는 패키징 방식이 등장하게 되었다. 외부 라이브러리들의 JAR를 풀어 클래스 파일들을 획득하고, 해당 파일들과 함께 JAR로 패키징하는 것이다. 그 결과로 모든 외부 라이브러리의 클래스 파일들로 인해 무거운 JAR가 탄생하게 되는데, 이것이 바로 Fat JAR이다.
Fat JAR 방식을 통해 JAR 방식으로 패키징 및 배포가 가능해졌지만, 여러 가지 단점이 그대로 존재하고 있었다.
- 모든 JAR가 압축 해제되고 클래스 파일로 포함되므로, 사용되는 라이브러리를 파악하기 어려움
- 클래스 패스 충돌 시에 충둘이 발생하여 문제가 생길 수 있음
따라서 스프링 부트는 이러한 문제를 완전히 해결하기 위해, 자바 공식 스펙이 아닌 자체적인 JAR 패키징 방식을 고안하게 되었다.
[ 스프링 부트의 Executable JAR의 등장 ]
Executable JAR의 내부 구성
스프링 부트는 JAR 내부에 JAR을 중첩시킬 수 있는 Nested JAR 방식을 지원하고자 하였다. 이는 스프링 부트만의 독창적인 방식으로, 실행 가능 Jar(Executable Jar)라 부른다. 스프링 부트는 기본적으로 실행 가능 Jar 방식으로 JAR 패키징을 진행하는데, 패키징된 JAR 파일을 압축 해제하면 다음과 같은 구조를 확인할 수 있다.
example.jar
|
+-META-INF
| +-MANIFEST.MF
+-org
| +-springframework
| +-boot
| +-loader
| +-<spring boot loader classes>
+-BOOT-INF
+-classes // 애플리케이션 클래스가 위치함
| +-mycompany
| +-project
| +-YourClasses.class
+-lib // 외부 라이브러리 JAR가 위치함
+-dependency1.jar
+-dependency2.jar
+-classpath.idx // JAR들이 클래스패스에 추가되어야 하는 순서를 정의함
+-layers.idx // JAR을 논리적인 계층으로 나누어 Docker/OCI 이미지를 생성할 때 활용함
스프링의 실행 가능 JAR 구조에서 애플리케이션 클래스는 중첩된 BOOT-INF/classes 디렉터리에 배치해야 하고, 외부 라이브러리 JAR는 중첩된 BOOT-INF/lib 디렉터리에 배치해야 한다. BOOT-INF 디렉토리 하위에는 2가지 인덱스 파일을 추가할 수 있는데, classpath.idx 파일은 JAR들이 클래스패스에 추가되어야 하는 순서를 정의하며, layers.idx는 JAR을 논리적인 계층으로 나누어 Docker/OCI 이미지를 생성할 때 활용할 수 있다.
스프링 부트의 NestedJarFile 클래스
중첩된 JAR을 로딩하기 위해 사용되는 핵심 클래스는 스프링 부트의 NestedJarFile 클래스이다. 이 클래스는 중첩된 하위 JAR 데이터에서 JAR 콘텐츠를 읽을 수 있게 해주는데, 최초 로딩 시 각 JarEntry의 위치는 외부 JAR의 실제 파일 오프셋(위치)과 매핑된다.
myapp.jar
+-------------------+-------------------------+
| /BOOT-INF/classes | /BOOT-INF/lib/mylib.jar |
|+-----------------+||+-----------+----------+|
|| A.class ||| B.class | C.class ||
|+-----------------+||+-----------+----------+|
+-------------------+-------------------------+
^ ^ ^
0063 3452 3980
예를 들어 위의 예시는 A.class가 myapp.jar의 /BOOT-INF/classes 안에서 위치 0063에 있음을 보여준다. 중첩된 JAR 안에 있는 B.class는 실제로 myapp.jar에서 위치 3452에 있고, C.class는 위치 3980에 있다. 이 정보를 이용하면 외부 JAR의 특정 지점을 탐색하여 필요한 중첩 엔트리를 로드할 수 있다. 즉, 아카이브 전체를 압축 해제할 필요도 없고, 모든 엔트리 데이터를 메모리에 올릴 필요도 없다.
Executable JAR의 동작 방식
java -jar 방식으로 JAR 파일을 실행하면, 먼저 META-INF/MANIFEST.MF 파일을 찾는다. 그리고 해당 파일의 Main-Class 부분을 읽어 main() 메서드를 실행시킨다.
Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: hello.boot.BootApplication
Spring-Boot-Version: 3.2.12
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
Build-Jdk-Spec: 21
흥비로운 부분은 Main-Class가 사용자가 개발한 클래스가 아닌 스프링 부트의 JarLauncher라는 ****클래스라는 점이다. JarLauncher는 중첩된 JAR 파일에서 클래스 파일 등의 리소스를 로드하는 것으로, BOOT-INF/lib 하위의 고정된 경로를 탐색하게 된다. JarLauncher가 클래스 파일과 외부 라이브러리 JAR을 인식하고 나면, 이후 실행하고자 하는 실제 클래스가 지정된 Start-Class 부분을 읽어 실행시킨다.
참고로 우리는 인텔리제이와 같은 IDE에서 개발을 하고 서버를 실행시키게 되는데, 이 경우에는 실행 가능 JAR가 아니라 IDE의 도움을 받아 우리가 개발한 메인 클래스를 직접 실행하는 방식으로 동작하게 된다.
2. 자바 CDS 극대화를 위한 Self-extracting executable JAR
[ CDS(Class Data Sharing) 이란? ]
CDS(Class Data Sharing)는 클래스 데이터 공유기술로, 여러 JVM 간에 클래스를 공유하여 Java 애플리케이션의 시작 시간과 메모리 사용량을 줄이는 데 도움을 준다. CDS의 주요 동기는 시작 시간의 단축으로, 애플리케이션이 사용하는 코어 클래스 수에 비해 애플리케이션 자체가 작을수록 절약되는 시작 시간의 비율이 커진다. JDK 12부터는 Oracle JDK 바이너리에 기본 CDS 아카이브가 포함되어 있어서, 우리는 이미 어느 정도 자연스레 CDS 기능을 사용하고 있다고 볼 수 있다.

예를 들어 자바 프로그램이 실행될 때 매번 기본 라이브러리 클래스(java.lang., java.util. 등)를 새로 로딩하고 초기화하는 것은 비효율 적이다. 따라서 미리 준비된 스냅샷을 메모리에 매핑해서 빠르게 시작할 수 있도록 도와주는 기술이 바로 CDS인 것이다. 이러한 자바 기본 CDS 아카이브는 -Xshare:dump를 실행하여 빌드 시점에 생성되며, G1 GC와 128MB의 힙을 사용한다. JVM이 시작될 때, 공유 아카이브는 메모리 매핑되어 여러 JVM 프로세스 간에 읽기 전용 JVM 메타데이터를 공유할 수 있으며, 이는 클래스 로딩보다 빠르므로 시작 시간이 단축된다.
JVM은 Application CDS (AppCDS) 기능을 제공하는데, 애플리케이션 클래스들을 공유 드라이브에 배치하여 여러 Java 프로세스에서 공통 클래스 메타데이터를 공유할 수 있게 한다. 여러 JVM이 동일한 아카이브 파일을 공유할 경우 메모리가 절약되고 시스템 전체의 응답 속도가 향상된다. 또한 Dynamic CDS Archive 기능도 존재하는데, 이는 AppCDS를 확장하여, Java 애플리케이션이 종료될 때 클래스들을 동적으로 아카이브할 수 있게 한다.
[ AppCDS를 위한 학습 실행(training run)과 Dynamic CDS Archive ]
애플리케이션 개발자들의 입장에서 CDS 활용을 극대화하려면 결국 AppCDS 기능을 활용해야 하는데, 이를 위해서는 학습 실행(training run) 기능을 반드시 사용해주어야 한다. trial run은 말 그대로 시험 실행 기능으로, CDS를 쓰려면, 어떤 클래스를 아카이브할지 알아야 한다. 그런데 애플리케이션이 실행되기 전에는 실제로 어떤 클래스들이 로딩될지 정확히 알 수 없기에, 다음과 같은 과정들이 필요했다.
- 1차 실행 (Trial run)
- 그냥 “클래스 목록만 뽑는” 용도로 실행.
- 실제 아카이브를 만들기 위해 필요하지만, 이 실행은 성능 이득이 전혀 없음
- 아카이브 생성
- trial run에서 기록된 class list 기반으로 AppCDS 아카이브 생성
- 2차 실행 (실제 실행)
- 이제야 CDS 아카이브를 써서 빠른 시작 가능.
즉, 훈련 실행을 통해 로딩하는 클래스 목록을 수집하여 “이 클래스는 어느 JAR의 몇 번째 엔트리에서 왔고, 메타데이터 레이아웃은 이렇다” 등의 정보를 저장하는 것이다. 그리고 2차 실행에서 JVM이 클래스 로딩 시 이미 준비된 아카이브에서 메타데이터를 그대로 mmap해서 쓰기 때문에, 클래스 로딩 속도는 높이고 메모리 공유로 사용폭은 줄이는 효과를 얻을 수 있다.
이러한 부분으로 인해 최소 2번 실행해야 효과를 볼 수 있었기 때문에, 개발자 입장에서는 번거롭고 비효율적이었다. 따라서 JVM은 Dynamic CDS 기술을 도입했는데, 애플리케이션을 그냥 실행하면 종료 시점에 JVM이 알아서 “이번에 로딩된 클래스들”을 아카이브에 기록해주는 것이다. 그래서 다음 실행부터는 그 아카이브를 바로 활용 가능해졌다. 하지만 컨테이너 환경에서 이를 활용하기에는 어느 정도 한계가 있다.
[ 스프링 부트 3.3 이전까지 CDS의 한계 ]
CDS 자체는 이미 사용 가능한 성숙한 기술이지만, 우리는 아직 이를 충분히 활용하지 못하고 있다. 왜냐하면 이를 제대로 활용하려면 다음과 같은 몇 가지 제약 조건을 충족시켜주어야 하기 때문이다.
- 동일한 JVM을 사용해야 함
- CDS 아카이브(.jsa)는 생성 당시의 VM에 강하게 결합되어 있음
- 벤더(Temurin, Oracle 등), 메이저/마이너 버전, OS/아키텍처(x64/ARM), 일부 중요 JVM 옵션 조합에 따라 클래스 메타데이터 레이아웃과 해시가 달라질 수 있음
- 클래스패스는 JAR 목록으로 지정해야 하며, 디렉토리, * 와일드카드 문자, Nested JAR 사용을 피해야 함
- CDS는 클래스가 JAR 리스트 중 몇 번째 JAR에 해당하고, 그 JAR 내부의 클래스 파일의 경로를 바탕으로 클래스와 JAR 매핑을 고정시킴
- 따라서 디렉토리 또는 와일드카드를 사용하면 내부 스캔 순서나 구성이 달라질 수 있어 고정된 매핑이 깨질 수 있음
- 중첩 JAR 역시 실제 파일 경로가 아니라 “jar:file:…!/… “같은 가상 경로로 읽혀서 메모리 매핑/식별이 어려워 CDS가 제대로 활용될 수 없음
- JAR의 타임스탬프가 보존되어야 함
- CDS는 그 JAR이 정확히 같은 파일인지를 크기/타임스탬프 등으로 검사함
- 타임스탬프가 다르면 “다른 파일”로 판단해 아카이브 사용이 무효화될 수 있음
- 프로덕션 실행에서 CDS 아카이브를 사용할 때 클래스패스는 아카이브를 생성할 때 사용한 것과 동일해야 하며, 순서도 같아야 함
- CDS 아카이브는 “클래스패스 엔트리의 정확한 순서”를 기준으로 인덱스를 부여하므로 중간에 하나만 끼워 넣어도 이후 인덱스가 다 밀려 전부 불일치됨
- 맨 끝에 붙이면 기존 인덱스엔 영향이 없어 “기존 캐시”는 유효하지만, 새로 붙인 엔트리에서 로드되는 클래스는 캐시가 불가능하므로 성능 이점이 없음
따라서 이를 활성화하려면 애플리케이션의 학습 실행(training run)이 필요할 뿐만 아니라, 스프링 부트와 같이 해당 기능을 위한 전용 지원이 필요하다. 그래야만 위와 같은 깨지기 쉬운 제약 조건을 충족시킬 수 있다.
스프링 부트 3.3은 이러한 잠재력을 활용할 수 있도록 두 가지 새로운 기능을 제공하는데, 바로 Self-extracting 실행 가능 JAR(self-extracting executable JAR)와 Buildpacks CDS 지원이다.
[ Self-extracting executable JAR 이란? ]
스프링 부트가 제공하는 실행 가능 JAR 방식은 내부에서 중첩된 JAR을 활용하는 등의 이유로 CDS 활용을 극대화 할 수 없었다. 따라서 스프링 부트 3.3은 실행 가능 JAR에 대해, 애플리케이션 실행에 이미 사용되는 java 명령만으로 스스로 압축을 해제(self-extract) 할 수 있는 기능을 제공한다.

압축 해제 기능은 다음과 같이 사용할 수 있다.
java -Djarmode=tools -jar my-app.jar extract --destination application
해당 기능은 CDS의 제약 조건을 충족하도록 설계되었기에, CDS의 훈련 실행 기능과 결합하면 다음과 같이 App CDS 아카이브를 생성할 수 있다.
java -XX:ArchiveClassesAtExit=application.jsa -Dspring.context.exit=onRefresh -jar application/my-app.jar
그리고 CDS가 활성화된 상태로 애플리케이션을 실행할 수 있는 것이다. 생성된 아카이브 파일은 애플리케이션이 업데이트되지 않는 한 재사용할 수 있다.
java -XX:SharedArchiveFile=application.jsa -jar application/my-app.jar
[ Buildpacks의 CDS 지원 ]
CDS 사용과 self-extracting executable JAR 기능은 유연하지만 여전히 많은 수동 작업이 요구된다. 그래서 스프링 부트와 Buildpacks는 CDS를 위한 통합 지원을 제공하는데, 이는 다음을 수행해준다.
- 컨테이너 이미지를 생성할 때 자동으로 훈련 실행을 수행함
- 스프링 부트의 executable JAR을 위에서 언급한 CDS 친화적 파일 레이아웃으로 추출함
- CDS 아카이브를 컨테이너에 포함시킴
- 컨테이너 이미지를 실행할 때 CDS를 자동으로 활성화함

훈련 실행 동안에는 Spring 라이프사이클을 시작하지 않고 Spring 빈들이 인스턴스화되기 때문에, 데이터베이스와의 조기 상호작용에 의한 부작용 등이 발생할 수 있다. 하지만 이는 애플리케이션 설정(또는 CDS_TRAINING_JAVA_TOOL_OPTIONS 환경 변수를 이용한 훈련 실행 전용 설정)을 통해 방지할 수 있다. 또한 BP_SPRING_AOT_ENABLED 환경 변수를 통해 Spring AOT 활성화 지원을 트리거할 수도 있으므로 참고해주도록 하자.
참고자료