
.jar 파일을 .zip 확장자로 변경한 후 내부를 살펴보니 META-INF/MANIFEST.MF 파일에서 Main-Class가 우리가 만든 클래스가 아니라 JarLauncher로 지정되어 있었다. 그리고 우리가 작성한 main() 메서드가 포함된 클래스는 Start-Class로 지정되어 있었다..cp 옵션으로 외부 라이브러리를 명시해야 한다.java -cp my-app.jar:lib/library1.jar:lib/library2.jar com.example.Main
- 애플리케이션에 포함된 라이브러리를 명확하게 확인하기 어려움→ 어떤 라이브러리가 포함되어 있는지 한눈에 보기 어려움
- 파일명이 같지만 내용이 다른 경우 충돌 발생 가능→ 예를 들어, 여러 라이브러리가 동일한 리소스 파일(
application.properties등)을 가지고 있을 경우,어떤 파일이 최종적으로 포함될지 예측하기 어려움
출처 : https://docs.spring.io/spring-boot/specification/executable-jar/nested-jars.html
relocate를 통해 패키지를 변경하는 방식으로 해결 가능하다. Logger.class가 두 개의 버전(1.7.36, 2.0.5) 중 최신 버전(2.0.5)으로 선택된 것을 확인할 수 있었다. 하지만, 모든 충돌을 완벽하게 자동 해결하는 것은 아니며, relocate를 명시적으로 사용해야 하는 경우도 존재한다.의도적으로 다른 버전의 slf4j 추가해보자
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.4.0'
id 'io.spring.dependency-management' version '1.1.6'
id 'com.github.johnrengelman.shadow' version '8.1.1' // Shadow 플러그인 추가
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.slf4j:slf4j-api:1.7.36'
implementation 'org.slf4j:slf4j-api:2.0.5' // 의도적으로 다른 버전 추가
}
tasks.withType(Test) {
useJUnitPlatform()
}
// Shadow JAR 설정 추가 (우버 JAR 설정)
tasks.named("shadowJar") {
archiveBaseName.set("myapp")
archiveVersion.set("1.0")
archiveClassifier.set("")
mergeServiceFiles()
}
bootJar {
enabled = false // Spring Boot 기본 JAR 비활성화
}
jar {
enabled = true // 일반 JAR 활성화 (Shadow JAR 생성 목적)
}
실행 스크립트
./gradlew shadowJar
실행 결과 Jar 내부

실제로 충돌이 발생되는 /org/slf4j/Logger.class에 대한 중복 클래스 확인해보자
하지만 한 개의 클래스만 발견되었고
두 개의 버전 1.7.36과 2.0.5 중 어떤 버전을 선택했는지 해시값으로 확인해 본 결과 가장 최신의 2.0.5의 slf4j를 선택한 것을 확인할 수 있었다.
의도적으로 두 개의 버전의 slf4j를 이용해서 shaded jar를 만들고 Logger.class의 해시값을 출력
shasum -a 256 build/libs/myapp-1.0/org/slf4j/Logger.class
38e077e4e423decda598adc0595cd7330bbf5f96444037f54c00eef41c6f275e build/libs/myapp-1.0/org/slf4j/Logger.class
1.7.36 버전의 slf4j를 이용해서 shaded jar를 만들고 Logger.class의 해시값을 출력
shasum -a 256 build/libs/myapp-2.0/org/slf4j/Logger.class
3ceafee60490504508a46e6eaa3d019601b479484ae80b310e22730eb04ecb88 build/libs/myapp-2.0/org/slf4j/Logger.class
2.0.5 버전의 slf4j를 이용해서 shaded jar를 만들고 Logger.class의 해시값을 출력
shasum -a 256 build/libs/myapp-3.0/org/slf4j/Logger.class
38e077e4e423decda598adc0595cd7330bbf5f96444037f54c00eef41c6f275e build/libs/myapp-3.0/org/slf4j/Logger.class
Shaded JAR에서는 기본적으로 중복된 클래스를 자동으로 제거하고 최신 버전을 유지하지만, 같은 패키지를 사용해야 하는 경우(org.slf4j:slf4j-api:1.7.36과 org.slf4j:slf4j-api:2.0.5가 동시에 필요할 때) 이 중 하나가 사라지면 특정 라이브러리가 정상 동작하지 않을 수 있다. 이럴 때 Shadow 플러그인의 relocate 기능을 사용하면 패키지명을 변경하여 두 버전을 모두 유지할 수 있다. 즉, relocate는 필수가 아닌 선택이지만, 같은 패키지의 클래스를 여러 개 유지해야 하는 경우 필수적이다.
tasks.shadowJar {
relocate("org.slf4j", "shadow.slf4j1") // 1.7.36 버전
relocate("org.slf4j", "shadow.slf4j2") // 2.0.5 버전
}
그래서 정리하면 shaded jar의 단점은 아래와 같다.
BOOT-INF/lib 폴더에 라이브러리 JAR을 그대로 두고 실행 가능하도록 만든다.📌 Spring Boot JAR 구조
example.jar
├─ META-INF
│ ├─ MANIFEST.MF (Main-Class: JarLauncher)
├─ org/springframework/boot/loader/ (Spring Boot Loader 클래스)
├─ BOOT-INF
│ ├─ classes/ (애플리케이션 클래스)
│ ├─ lib/ (의존성 JAR 파일)
| |- classpath.idx
그렇다면 위 구조를 가지는 jar는 어떻게 실행되는 걸까?
알아보기 전에 용어 정리를 하고 순서도를 통해서 그 과정을 살펴보도록 하자.
Archive 개념을 이용하면 내부에 라이브러리 JAR을 포함하는 Nested JAR 구조를 만들 수 있다.
Launcher (최상위 추상 클래스)ExecutableArchiveLauncher (Launcher를 상속받음)JarLauncher (ExecutableArchiveLauncher를 상속받음)간단하게 정리하면 아래와 같다.
하지만 자세한 과정을 생략했기 때문에 이해가 어려우신 분들은 더 자세히 만든 draw.io 링크를 공유드리도록 하겠다.

JarLauncher의 main() 메서드 실행java -jar myapp.jar을 실행하면 JarLauncher의 main() 메서드가 실행됨.JarLauncher는 Spring Boot의 기본 실행 엔트리 포인트 역할을 하며, ExecutableArchiveLauncher를 상속받음.JarLauncher 생성자 실행 (super())JarLauncher는 ExecutableArchiveLauncher를 상속받으므로 super()를 호출하면서 상위 클래스의 생성자가 실행됨.ExecutableArchiveLauncher의 생성자는 현재 실행 중인 JAR 파일을 찾고, 이를 Archive 객체로 변환함.BOOT-INF/lib/*.jar)을 관리할 준비를 함.launch() 메서드 호출JarLauncher가 launch(args)를 호출하면서 본격적인 실행 프로세스가 시작됨.launch() 메서드는 실행에 필요한 ClassLoader를 설정하고, 내부 JAR들을 로드한 후 애플리케이션의 main()을 실행하는 역할을 함.Archive 내부의 BOOT-INF/classes/와 BOOT-INF/lib/ 검색Archive 객체는 실행 JAR(myapp.jar)을 나타내며, 내부의 BOOT-INF/classes/(애플리케이션 클래스)와 BOOT-INF/lib/(라이브러리 JAR)를 검색함.ClassPathIndex는 이전 실행 시 이미 로드된 라이브러리를 캐싱하여 불필요한 중복 로딩을 방지하는 역할을 함.LauncherClassLoader에 전달ClassLoader는 JAR 내부의 JAR 파일을 직접 로드할 수 없음.LauncherClassLoader라는 별도의 ClassLoader를 사용하여 Nested JAR을 지원함.BOOT-INF/lib/*.jar)을 LauncherClassLoader를 통해 로드할 수 있도록 URL로 변환하여 추가함.MANIFEST.MF에서 Start-Class 확인 후 main() 실행META-INF/MANIFEST.MF 파일에서 Start-Class 값을 읽어옴.Start-Class에 명시된 애플리케이션의 메인 클래스를 실행함. → 즉 이때내가 만든 main method가 실행된다.Spring Boot에서 내가 만든 main() 메서드가 직접 실행되지 않는 이유를 분석해 보았다.
그 핵심 이유는 "외부 라이브러리를 어떻게 관리할 것인가"에 초점이 맞춰져 있다.
자바는 기본적으로 JAR 내부에 또 다른 JAR을 포함할 수 없는 구조이다.
→ 따라서, 외부 라이브러리는 cp(classpath) 옵션을 사용해 직접 지정해야 하며, 이는 의존성이 많아질수록 번거로워진다.
이를 해결하기 위해 Shaded JAR 방식이 등장했다.
relocate 기능을 사용해 패키지명을 변경하여 두 버전을 공존하도록 명시적으로 설정해야 하는 불편함이 존재한다.Spring Boot는 이러한 문제를 해결하기 위해 "Nested JAR" 방식을 도입했다.
BOOT-INF/lib/ 디렉토리를 만들어 필요한 라이브러리를 그대로 포함하도록 설계했다.relocate 없이도 모든 의존성을 유지할 수 있다.ClassLoader는 JAR 내부의 JAR을 직접 로드할 수 없기 때문에, Spring Boot는 이를 처리할 수 있는 JarLauncher라는 별도의 실행 클래스를 추가했다.main() 메서드는 직접 실행되지 않고, JarLauncher가 먼저 실행되어 Nested JAR을 관리한 후, 최종적으로 애플리케이션의 main()을 실행하게 된다.https://docs.spring.io/spring-boot/specification/executable-jar/index.html
https://gradleup.com/shadow/
재미있는 글 잘 읽었습니다!