Spring Boot JAR에서 진짜 main()은 따로 있다?

byeol·2025년 3월 15일

1. 계기

  • 최근 spring docs를 읽다가 내가 작성한 Main method가 스프링 부트의 Main method가 아니라는 것을 알게 되었다.

  • .jar 파일을 .zip 확장자로 변경한 후 내부를 살펴보니 META-INF/MANIFEST.MF 파일에서 Main-Class가 우리가 만든 클래스가 아니라 JarLauncher로 지정되어 있었다. 그리고 우리가 작성한 main() 메서드가 포함된 클래스는 Start-Class로 지정되어 있었다..
  • 따라서 내가 작성한 Main mehtod가 어떻게 실행하게 되는지 알아보고자 한다.

2. Java의 기본 ClassLoader 한계

  • Java의 기본 ClassLoader는 JAR 내부의 JAR 파일을 직접 읽을 수 없다.
  • 그래서 보통 아래처럼 cp 옵션으로 외부 라이브러리를 명시해야 한다.
    java -cp my-app.jar:lib/library1.jar:lib/library2.jar com.example.Main
  • 이에 대한 해결 책으로 Shaded Jar를 개발자들이 만들었다. 필요한 외부 라이브러리의 압축을 풀어 직접 Jar안에 넣는 것이다. Spring Boot 공식 문서에서는 Shaded JAR의 단점으로 "파일명이 같지만 내용이 다른 경우 충돌이 발생할 가능성이 있다"고 언급한다.
    1. 애플리케이션에 포함된 라이브러리를 명확하게 확인하기 어려움→ 어떤 라이브러리가 포함되어 있는지 한눈에 보기 어려움
    2. 파일명이 같지만 내용이 다른 경우 충돌 발생 가능→ 예를 들어, 여러 라이브러리가 동일한 리소스 파일(application.properties 등)을 가지고 있을 경우,어떤 파일이 최종적으로 포함될지 예측하기 어려움
      출처 : https://docs.spring.io/spring-boot/specification/executable-jar/nested-jars.html
  • 하지만, Shadow 플러그인(Gradle)과 Maven Shade 플러그인은 이러한 충돌을 자동으로 해결하려고 시도한다. 예를 들어, 같은 FQCN(Fully Qualified Class Name)을 가진 클래스가 있으면 최신 버전을 유지하거나, relocate를 통해 패키지를 변경하는 방식으로 해결 가능하다.
  • 실제로 실험한 결과, SLF4J의 Logger.class가 두 개의 버전(1.7.36, 2.0.5) 중 최신 버전(2.0.5)으로 선택된 것을 확인할 수 있었다. 하지만, 모든 충돌을 완벽하게 자동 해결하는 것은 아니며, relocate를 명시적으로 사용해야 하는 경우도 존재한다.

Shaded Jar 실행

의도적으로 다른 버전의 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.36org.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의 단점은 아래와 같다.

    1. 애플리케이션에 포함된 라이브러리를 명확하게 확인하기 어려움→ 어떤 라이브러리가 포함되어 있는지 한눈에 보기 어려움
    2. 파일명이 같지만 내용이 다른 경우 충돌 발생 가능성이 있어서 내부적으로 알아서 최신의 파일로 대체되지만 라이브러리간 사용하는 패키지의 버전이 다를 수도 있어 여러 버전을 동시에 유지하려면 relocate기능을 사용해서 직접 명시해줘야 하는 불편함이 존재한다.

3. Spring Boot의 Nested JAR 실행 방식

  • Spring Boot는 JAR 내부에 또 다른 JAR을 포함하는 방식을 사용한다. 따라서 앞서 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 파일
    • Spring Boot는 실행 가능한 JAR을 Archive로 추상화한다. 일반적인 JAR은 내부에 다른 JAR을 포함할 수 없지만, Spring Boot의 Archive 개념을 이용하면 내부에 라이브러리 JAR을 포함하는 Nested JAR 구조를 만들 수 있다.
  • Entry
    • Archive는 여러개의 Entry를 갖는다.
    • 따라서 내가 만든 Jar 파일 내부의 파일들은 Entry로 추상화 된다.

UML

  • 상속 관계를 통해 실행 가능한 JAR 구조를 지원하는 방식
  • Launcher (최상위 추상 클래스)
    → Spring Boot 애플리케이션을 실행하는 기본 구조 제공 (추상 클래스)
  • ExecutableArchiveLauncher (Launcher를 상속받음)
    → JAR 또는 WAR 내부에 포함된 실행 파일(Archive)을 로드하는 기능 제공
  • JarLauncher (ExecutableArchiveLauncher를 상속받음)
    Spring Boot의 실행 가능한 JAR(Application JAR)를 로드하고 실행

실행과정

간단하게 정리하면 아래와 같다.
하지만 자세한 과정을 생략했기 때문에 이해가 어려우신 분들은 더 자세히 만든 draw.io 링크를 공유드리도록 하겠다.

1️⃣ JarLaunchermain() 메서드 실행

  • 사용자가 java -jar myapp.jar을 실행하면 JarLaunchermain() 메서드가 실행됨.
  • JarLauncher는 Spring Boot의 기본 실행 엔트리 포인트 역할을 하며, ExecutableArchiveLauncher를 상속받음.

2️⃣ JarLauncher 생성자 실행 (super())

  • JarLauncherExecutableArchiveLauncher를 상속받으므로 super()를 호출하면서 상위 클래스의 생성자가 실행됨.
  • ExecutableArchiveLauncher의 생성자는 현재 실행 중인 JAR 파일을 찾고, 이를 Archive 객체로 변환함.
  • 동시에 ClassPathIndex를 생성하여, 내부 JAR(BOOT-INF/lib/*.jar)을 관리할 준비를 함.

3️⃣ launch() 메서드 호출

  • JarLauncherlaunch(args)를 호출하면서 본격적인 실행 프로세스가 시작됨.
  • launch() 메서드는 실행에 필요한 ClassLoader를 설정하고, 내부 JAR들을 로드한 후 애플리케이션의 main()을 실행하는 역할을 함.

4️⃣ Archive 내부의 BOOT-INF/classes/BOOT-INF/lib/ 검색

  • Archive 객체는 실행 JAR(myapp.jar)을 나타내며, 내부의 BOOT-INF/classes/(애플리케이션 클래스)와 BOOT-INF/lib/(라이브러리 JAR)를 검색함.
  • ClassPathIndex에 없는 파일들만 URL 목록으로 변환하여 관리
    ClassPathIndex는 이전 실행 시 이미 로드된 라이브러리를 캐싱하여 불필요한 중복 로딩을 방지하는 역할을 함.

5️⃣ 생성된 URL 목록을 LauncherClassLoader에 전달

  • 기존 Java의 기본 ClassLoader는 JAR 내부의 JAR 파일을 직접 로드할 수 없음.
  • 따라서 Spring Boot는 LauncherClassLoader라는 별도의 ClassLoader를 사용하여 Nested JAR을 지원함.
  • 내부 JAR 파일(BOOT-INF/lib/*.jar)을 LauncherClassLoader를 통해 로드할 수 있도록 URL로 변환하여 추가함.

6️⃣ MANIFEST.MF에서 Start-Class 확인 후 main() 실행

  • META-INF/MANIFEST.MF 파일에서 Start-Class 값을 읽어옴.
  • Start-Class에 명시된 애플리케이션의 메인 클래스를 실행함. → 즉 이때내가 만든 main method가 실행된다.

✅ 정리

Spring Boot에서 내가 만든 main() 메서드가 직접 실행되지 않는 이유를 분석해 보았다.

그 핵심 이유는 "외부 라이브러리를 어떻게 관리할 것인가"에 초점이 맞춰져 있다.

Java에서 외부 라이브러리 관리의 한계

자바는 기본적으로 JAR 내부에 또 다른 JAR을 포함할 수 없는 구조이다.
→ 따라서, 외부 라이브러리는 cp(classpath) 옵션을 사용해 직접 지정해야 하며, 이는 의존성이 많아질수록 번거로워진다.

Shaded JAR의 등장과 한계

이를 해결하기 위해 Shaded JAR 방식이 등장했다.

  • Shaded JAR은 외부 라이브러리(JAR)의 압축을 풀어 하나의 JAR로 만드는 방식이다.
  • 하지만, 같은 패키지명을 가진 다른 버전의 라이브러리가 포함될 경우, 최신 버전으로 덮어써버리는 문제가 있다.
  • 예를 들어, SLF4J 1.7.36과 2.0.5를 동시에 포함해야 할 경우, 하나가 사라질 위험이 있다.
  • 이를 해결하려면 relocate 기능을 사용해 패키지명을 변경하여 두 버전을 공존하도록 명시적으로 설정해야 하는 불편함이 존재한다.

Spring Boot의 Nested JAR 방식

Spring Boot는 이러한 문제를 해결하기 위해 "Nested JAR" 방식을 도입했다.

  • JAR 내부에 BOOT-INF/lib/ 디렉토리를 만들어 필요한 라이브러리를 그대로 포함하도록 설계했다.
  • 덕분에, 라이브러리 간 충돌 없이 별도의 relocate 없이도 모든 의존성을 유지할 수 있다.
  • 하지만, Java의 기본 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/

profile
꾸준하게 Ready, Set, Go!

2개의 댓글

comment-user-thumbnail
2025년 3월 23일

재미있는 글 잘 읽었습니다!

답글 달기
comment-user-thumbnail
2025년 6월 19일

궁금했던 내용이었는데 잘 정리하셨네요 잘 읽었습니다!! ㅎㅎ

답글 달기