본문 바로가기
Spring/Boot

[Spring] Executable JAR

by 기몬식 2024. 4. 4.

JAR

출처


Executable JAR에 대해 알아보기 전에 먼저 JAR가 무엇인지 살펴보겠습니다. JAR는 Java ARchive의 약자로 여러 개의 자바 클래스 파일과 관련 리소스(텍스트, 이미지 등) 및 메타데이터를 하나의 파일로 압축하여 자바 플랫폼에 응용 소프트웨어나 라이브러리를 배포하기 위한 소프트웨어 패키지 파일 포맷입니다. JAR 파일은 실제로 ZIP 파일 포맷으로 이루어져 있어 압축 파일의 형태로 제공되며 이를 통해 효율적인 배포와 관리를 할 수 있습니다

MANIFEST

JAR 파일의 기본 메타데이터를 포함하는 파일로 JAR 파일의 루트에 위치하며 JAR 파일에 포함된 리소스 및 클래스에 대한 정보를 제공합니다. JAR 파일 생성시 자동으로 MANIFEST.MF 파일이 생성되며 항상 META-INF/MANIFEST.MF 경로에 하나의 파일만이 존재합니다. 다음은 MANIFEST 파일 명세를 나타냅니다.


Key Value
Manifest-file main-section
main-section version-info *main-attribute
version-info Manifest-Version : version-number
version-number digit+{.digit+}*
main-attribute (any legitimate main attribute) newline
individual-section Name : value newline *perentry-attribute
perentry-attribute (any legitimate perentry attribute) newline
newline CR LF | LF | CR (not followed by LF)
digit {0-9}

기본 속성은 매니페스트의 기본 섹션에 있는 속성입니다. 이들은 다음과 같은 다양한 그룹에 속합니다.

  • 일반적인 주요 속성
    • Manifest-Version: 매니페스트 파일 버전을 나타냅니다.
    • Created-By: 매니페스트 파일의 Java 버전과 벤더사를 나타냅니다.
    • Signature-Version: jar 파일의 서명 버전을 나타냅니다.
    • Class-Path: 애플리케이션이나 확장에 필요한 확장이나 라이브러리의 상대 URL을 지정합니다. URL은 하나 이상의 공백으로 구분됩니다
  • 독립 실행형 애플리케이션을 위한 속성
    • Main-Class: 이 속성의 값은 시작 시 실행 프로그램이 로드할 기본 애플리케이션 클래스의 클래스 이름입니다. 값에는 클래스 이름에 .class 확장자가 추가되어서는 안 됩니다.
  • 확장자 식별을 위한 속성
    • Extension-Name: 이 속성은 Jar 파일에 포함된 확장의 이름을 지정합니다. 이름은 확장을 구성하는 기본 패키지의 이름과 같은 고유 식별자여야 합니다.
  • 확장 기능과 패키지 버전 관리 및 봉인 정보에 대해 정의된 속성
    • Implementation-Title: 이 값은 확장 구현의 제목을 정의하는 문자열입니다.
    • Implementation-Version: 이 값은 확장 구현 버전을 정의하는 문자열입니다.
    • Implementation-Vendor: 이 값은 확장 구현을 유지 관리하는 조직을 정의하는 문자열입니다.
    • Implementation-Vendor-Id: (Deprecated) 이 값은 확장 구현을 유지 관리하는 조직을 고유하게 정의하는 문자열 ID입니다.
    • Implementation-URL: (Deprecated) 이 속성은 확장 구현을 다운로드할 수 있는 URL을 정의합니다.
    • Specification-Title: 이 값은 확장 사양의 제목을 정의하는 문자열입니다.
    • Specification-Version: 이 값은 확장 사양의 버전을 정의하는 문자열입니다.
    • Specification-Vendor: 확장 사양을 유지하는 조직을 정의하는 문자열입니다.
    • Sealed: 이 속성은 이 JAR 파일이 봉인되었는지 여부를 정의합니다. 값은 "true" 또는 "false"일 수 있으며 대소문자는 무시됩니다. "true"로 설정되면 JAR 파일의 모든 패키지는 별도로 정의되지 않는 한 기본적으로 봉인됩니다.

Executable JAR

JAR 파일은 소스 코드와 리소스를 가지고 있는 라이브러리 성격 또는 실행 가능한 형식으로 구성이 가능합니다. 이 중에서 실행 가능한 JAR 파일을 직접 만든 후 MANIFEST.MF 파일을 직접 살펴보기 위해 소스 코드와 리소스 파일을 포함한 프로젝트 파일을 생성해보도록 하겠습니다. Gradle 환경에서는 다음과 같이 build.gradle 파일을 작성합니다.


plugins {
    id 'application' // 패키징을 위한 애플리케이션 플러그인 
}

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation platform('org.junit:junit-bom:5.9.1')
    testImplementation 'org.junit.jupiter:junit-jupiter'
}

test {
    useJUnitPlatform()
}


// MANIFEST.MF 파일 속성 추가
mainClassName = 'Main' // 지정할 Main 클래스의 경로 및 클래스 명
jar {
    manifest {
        attributes 'Main-Class': mainClassName
    }
}

application 플러그인은 암시적으로 java, distribution 플러그인을 적용하기 때문에 사실상 애플리케이션을 패키지화하는 배포 버전이 생성됩니다. 그리고 애플리케이션을 계속 무한정 동작시키기 위한 Main 클래스도 추가합니다.


public class Main {

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            Thread.sleep(1000);
            System.out.println("Hello World");
        }
    }
}

그 후 애플리케이션을 빌드합니다.


./gradlew clean build

빌드 툴인 Gradle 을 통해 애플리케이션을 빌드하게 되면 build 디렉토리의 하위 디렉토리 libs 에 example-1.0-SNAPSHOT.jar 파일이 생성됩니다. 단 하나의 Main 클래스를 가지고 있는 파일이라고 했을 때 jar 파일은 다음과 같은 구조를 가집니다.

example.jar
 |
 ├── META-INF
 │ └── MANIFEST.MF
 └── Main.class

MANIFEST.MF 을 직접 확인하기 위해 jar 파일을 압축 해제합니다.


jar -xvf example-1.0-SNAPSHOT.jar

--

생성됨: META-INF/
증가됨: META-INF/MANIFEST.MF
증가됨: Main.class

정상적으로 압축 해제가 됐다면 위와 같은 로그와 함께 성공적으로 완료됩니다. 그 후 MANIFEST.MF 의 내용을 확인합니다.


cat META-INF/MANIFEST.MF

--

Manifest-Version: 1.0
Main-Class: Main

MANIFEST 파일의 버전과 메인 클래스 위치가 작성된 속성을 확인할 수 있습니다. 애플리케이션 실행의 시작점을 의미하는 메인 클래스의 경로가 지정되어 있음으로 실행 가능한 애플리케이션으로 패키징 되었으며 이를 확인하기 위해 JAR 파일을 실행시킵니다.


java -jar example-1.0-SNAPSHOT.jar


1초 간격으로 Hello World 가 출력되는 것을 위와 같이 확인할 수 있습니다.

Spring Boot JAR

spring initializr 를 통해 스프링 부트 프로젝트를 생성합니다.


프로젝트를 생성하게 되면 위와 같이 다양한 외부 라이브러리를 JAR 파일 형태로 가지고 있습니다. 전통적인 JAR 파일은 중첩해서 여러 JAR 파일을 포함할 수 없수 없지만 애플리케이션은 내부에 Jar 파일들을 포함하고도 정상적으로 동작하게 됩니다.


이런 일을 가능하게 한 것은 스프링 부트의 특징 중 하나인 실행 가능한 JAR 파일을 생성해 JAR 파일 내부에 JAR 를 포함하여 실행할 수 있기 때문입니다.
클래스 패스에 있는 필요한 모든 의존성과 라이브러리를 하나의 JAR 파일에 포함하고 내장형 서버도 포함시킴으로써 외부 라이브러리에 대한 관리, 파일명 중복 문제 등과 같은 문제를 깔끔하게 해결한 것 입니다.


그렇다면 위와 같은 문제를 어떤 식으로 해결했는지 살펴 보기 위해 스프링 부트에서 새롭게 정의한 실행 가능한 JAR 파일의 내부 구조와 MANIFEST 파일에 대해서 살펴 보겠습니다.


다음은 빌드하여 생성된 JAR 파일의 내부 구조와 MANIFEST 파일의 내용입니다.


example.jar
 |
 +-META-INF
 |  +-MANIFEST.MF
 +-org
 |  +-springframework
 |     +-boot
 |        +-loader
 |           +-<spring boot loader classes>
 +-BOOT-INF
    +-classes // 개발자가 직접 작성한 클래스와 리소스 파일
    |  +-org
    |     +-exmaple 
    |        +-SpringApplication.class
    +-lib // 외부 라이브러리
       +-lib1.jar
       +-lib2.jar
    +-classpath.idx // 외부 라이브러리 모음
    +-layers.idx // 스프링 부트 구조 정보

Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.launch.JarLauncher // 스프링 부트가 JAR 파일을 구성하기 위해 실행하는 Main 클래스 경로 
Start-Class: org.example.executablejar.ExecutableJarApplication // 개발자가 작성한 Main 클래스의 경로
Spring-Boot-Version: 3.2.4 // 스프링 부트 버전
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: 17
Implementation-Title: executable-jar
Implementation-Version: 0.0.1-SNAPSHOT

스프링 부트가 정의한 실행 가능한 JAR 파일을 생성하기 위한 클래스 정보들이 MANIFEST 파일에 조작을 가하는데 여기서 가장 중요하게 확인해야할 부분은 Main-Class 속성이 JarLauncher 클래스로 작성되어 있으며 개발자가 직접 작성한 Mian 클래스는 Start-Class 속성으로 변경되어 있다는 점입니다.
JAR 를 실행하게 되면 JarLauncher 클래스의 main 메소드가 실행되면 MANIFEST 에 정의된 클래스 경로를 인자로 클래스들을 로드한 후 Start-Class 에 정의된 클래스의 main 메소드가 실행되게 됩니다. 개발자가 직접 작성한 Main 클래스를 스프링 프레임워크가 대신 실행시킴으로써 코드 호출에 대한 통제권이 프레임워크로 이동되는 즉 제어의 역전이 발생하게 됩니다.

JarLauncher

그렇다면 JarLauncher 는 어떻게 Start-Class 를 호출하게 되는지 살펴보도록 하겠습니다.



JarLauncher 는 JarLauncher → ExecutableArchiveLauncher → Launcher 의 상속 관계를 가지고 있습니다. main 메소드를 살펴보면 Launcher 의 launch 메소드를 호출합니다.


public class JarLauncher extends ExecutableArchiveLauncher {
  ....
  public static void main(String[] args) throws Exception {
    (new JarLauncher()).launch(args);
  }
}

JarLauncher 는 단순한 파사드 역할을 하기 때문에 실제 기능이 구현되어 있는 Launcher 클래스를 살펴 보겠습니다.


public abstract class Launcher {
  ...
  protected void launch(String[] args) throws Exception {
      if (!this.isExploded()) {
          Handlers.register();
      }

      try {
          ClassLoader classLoader = this.createClassLoader((Collection)this.getClassPathUrls());
          String jarMode = System.getProperty("jarmode");
          String mainClassName = this.hasLength(jarMode) ? JAR_MODE_RUNNER_CLASS_NAME : this.getMainClass();
          this.launch(classLoader, mainClassName, args);
      } catch (UncheckedIOException var5) {
          throw var5.getCause();
      }
  }

}

launch 메소드에서는 통해 ExecutableArchiveLauncher 구현체를 통해 메인 메소드 클래스명과 클래스로더를 생성합니다.


  protected void launch(ClassLoader classLoader, String mainClassName, String[] args) throws Exception {
    Thread.currentThread().setContextClassLoader(classLoader);
    Class<?> mainClass = Class.forName(mainClassName, false, classLoader);
    Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
    mainMethod.setAccessible(true);
    mainMethod.invoke((Object)null, args);
  }

마지막으로 리플랙션을 사용해 Start-Class 에 작성된 스프링 부트 애플리케이션의 진입점(main class)을 호출하고 실행합니다.


오탈자 및 오류 내용을 댓글 또는 메일로 알려주시면, 검토 후 조치하겠습니다.

'Spring > Boot' 카테고리의 다른 글

[Spring] SpringBoot 의 다중 요청 처리  (0) 2024.08.22