최근 그래들 태스크에 프로젝트의 자바 코드를 그래들 태스크에서 사용해야 하는 상황이 있었다.
아래와 같은 코드이다. 간단하게 설명하자면 프로젝트의 소스코드를 그래들 태스크에서 불러와서 main 함수를 실행하는 스크립트이다. (실제 코드는 제공할 수 없어서 대체 코드로 작성했다.)
tasks.register("docGenerateTask", JavaExec) {
group = "documentation"
description = "대충 문서화"
def outputDir = "src/main/doc"
classpath = sourceSets.main.runtimeClasspath
mainClass = 'org.testt.Main'
args = [outputDir]
}
public class Main {
public static void main(String[] args) throws IOException {
String rootPath = "src/main/java"; // Java 클래스 파일의 루트 디렉토리
String outputDir = args.length == 0 ? "src/main/doc" : args[0];
System.out.println("rootPath: %s, outputDir: %s".formatted(rootPath, outputDir)");
}
}
해당 프로젝트는 스프링 프로젝트였고, 위 코드가 추가된다면 main 클래스가 두개라 jar 로 실행할 때 manifest 파일에 어떤 클래스를 main 클래스로 해야할지 명시해줘야 한다고 생각이 들었다.
그렇지만 따로 manifest 파일을 지정해주지 않아도 jar를 실행할 때 알아서 스프링 어플리케이션이 켜졌다. 그래서 어떻게 이렇게 동작할 수 있는 것인지 궁금하여 Spring boot 어플리케이션의 jar 파일을 한번 열어보게 됐다.
https://docs.spring.io/spring-boot/specification/executable-jar/index.html 이 문서에 정답이 들어 있으며, 다음 내용은 문서에 대한 약간의 설명과 코드를 분석한 내용이니 본인이 직접 문서를 읽고 분석해보고 싶다면 아래 내용은 읽지 않아도 괜찮다.
디렉터리 구조
디렉터리 구조는 위와 같다. (뒤에 .txt 라고 붙은 파일은 내가 편의상 finder에서 gui로 보기 위해 txt 확장자를 추가했다.)
BOOT-INF
classes | 내가 프로젝트에 작성한 java 파일이 컴파일 된 형태인 class 파일이 들어있다. |
classpath.idx | lib 디렉토리 내부에 있는 jar 파일들의 경로가 들어있다. |
layersr.idx | 해당 jar 파일을 도커 이미지로 만들 때 최적화를 하기 위해 존재하는 파일이다. 링크 |
lib | 이 어플리케이션을 실행하기 위한 의존들이 jar 파일로 들어있다. |
META-INF
BOOT.SF
jar 파일에 대한 서명 파일이다. 프로젝트에서 jar 파일을 따로 서명을 하고 있지도 않고, 현재 글의 관심사는 아니므로 일단 문서 링크만 걸어둔다.
아무것도 없어서 0바이트라고 출력된다.
MANIFEST.MF
jar를 실행하는데 필요한 메타 정보들이 들어있다.
Services
이전 글에서 설명한 서비스 프로바이더 인터페이스에 대한 정보가 들어있다.
spring-configuration-metada.json
스프링의 @ConfigurationProperties로 설정된 빈들의 정보가 들어잇다.
org.springframework.loader
org.springframework 라는 경로에는 이러한 디렉토리가 존재하고 각 디렉토리에는 여러 클래스 파일이 존재한다. 이 디렉토리에 대한 정체는 아래에서 살펴보자.
jar가 실행되는 과정
jar가 실행되기 위해서는 MANIFEST 파일에 main 클래스의 정보를 넣어줘야 한다. https://docs.oracle.com/javase/tutorial/deployment/jar/basicsindex.html
근데 위 MANIFEST 의 구조를 보면 JarLauncher라는 클래스가 메인 클래스라고 선언되어 있지만, 프로젝트 어디에서도 JarLauncher라는 클래스를 찾을 수 없다. 이유는 gradle에 포함되는 springboot 플러그인에 의해 loader와 관련한 클래스 파일이 jar 파일 내에 추가가 된다. https://docs.spring.io/spring-boot/docs/3.2.5/gradle-plugin/reference/htmlsingle/#packaging-executable
정리하자면 jar가 만들어지고 jar 파일에 springboot-loader 클래스 파일이 추가되고, 해당 클래스 파일에 존재하는 JarLauncher에 있는 main 클래스가 실행되는 셈이다.
아래 클래스는 JarLauncher 클래스다. 이 클래스의 main 클래스가 실행되는데,
이 클래스의 상속 구조는 아래와 같다.
Launcher 클래스를 상속받은 ExecutableArcaiveLauncer 클래스를 JarLauncher가 상속받은 구조이다.
그렇다면 제일 위에 있는 Launcher의 상속구조는 어떤지 아래 사진을 통해 확인해보자.
위와 같은 구조이다. 이전에 내가 첨부한 spring boot loader 의존이 추가되는 시점에 관한 문서에 각 Launcher를 사용하는 방법에 대해서도 설명되어 있는데, 현재 내 프로젝트에서는 JarLauncher를 사용하므로 JarLauncher의 launch 메소드가 어떻게 동작하는지 살펴보도록 하자.
전체 클래스는 아래와 같다.
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.loader.launch;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.Method;
import java.net.URL;
import java.util.Collection;
import java.util.Set;
import java.util.jar.Attributes;
import java.util.jar.Manifest;
import org.springframework.boot.loader.launch.Archive.Entry;
import org.springframework.boot.loader.net.protocol.Handlers;
/**
* Base class for launchers that can start an application with a fully configured
* classpath.
*
* @author Phillip Webb
* @author Dave Syer
* @author Scott Frederick
* @since 3.2.0
*/
public abstract class Launcher {
private static final String JAR_MODE_RUNNER_CLASS_NAME = JarModeRunner.class.getName();
protected static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index";
protected static final String DEFAULT_CLASSPATH_INDEX_FILE_NAME = "classpath.idx";
protected ClassPathIndexFile classPathIndex;
/**
* Launch the application. This method is the initial entry point that should be
* called by a subclass {@code public static void main(String[] args)} method.
* @param args the incoming arguments
* @throws Exception if the application fails to launch
*/
protected void launch(String[] args) throws Exception {
if (!isExploded()) {
Handlers.register();
}
try {
ClassLoader classLoader = createClassLoader(getClassPathUrls());
String jarMode = System.getProperty("jarmode");
String mainClassName = hasLength(jarMode) ? JAR_MODE_RUNNER_CLASS_NAME : getMainClass();
launch(classLoader, mainClassName, args);
}
catch (UncheckedIOException ex) {
throw ex.getCause();
}
}
private boolean hasLength(String jarMode) {
return (jarMode != null) && !jarMode.isEmpty();
}
/**
* Create a classloader for the specified archives.
* @param urls the classpath URLs
* @return the classloader
* @throws Exception if the classloader cannot be created
*/
protected ClassLoader createClassLoader(Collection<URL> urls) throws Exception {
return createClassLoader(urls.toArray(new URL[0]));
}
private ClassLoader createClassLoader(URL[] urls) {
ClassLoader parent = getClass().getClassLoader();
return new LaunchedClassLoader(isExploded(), getArchive(), urls, parent);
}
/**
* Launch the application given the archive file and a fully configured classloader.
* @param classLoader the classloader
* @param mainClassName the main class to run
* @param args the incoming arguments
* @throws Exception if the launch fails
*/
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(null, new Object[] { args });
}
/**
* Returns if the launcher is running in an exploded mode. If this method returns
* {@code true} then only regular JARs are supported and the additional URL and
* ClassLoader support infrastructure can be optimized.
* @return if the jar is exploded.
*/
protected boolean isExploded() {
Archive archive = getArchive();
return (archive != null) && archive.isExploded();
}
ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException {
if (!archive.isExploded()) {
return null; // Regular archives already have a defined order
}
String location = getClassPathIndexFileLocation(archive);
return ClassPathIndexFile.loadIfPossible(archive.getRootDirectory(), location);
}
private String getClassPathIndexFileLocation(Archive archive) throws IOException {
Manifest manifest = archive.getManifest();
Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null;
String location = (attributes != null) ? attributes.getValue(BOOT_CLASSPATH_INDEX_ATTRIBUTE) : null;
return (location != null) ? location : getEntryPathPrefix() + DEFAULT_CLASSPATH_INDEX_FILE_NAME;
}
/**
* Return the archive being launched or {@code null} if there is no archive.
* @return the launched archive
*/
protected abstract Archive getArchive();
/**
* Returns the main class that should be launched.
* @return the name of the main class
* @throws Exception if the main class cannot be obtained
*/
protected abstract String getMainClass() throws Exception;
/**
* Returns the archives that will be used to construct the class path.
* @return the class path archives
* @throws Exception if the class path archives cannot be obtained
*/
protected abstract Set<URL> getClassPathUrls() throws Exception;
/**
* Return the path prefix for relevant entries in the archive.
* @return the entry path prefix
*/
protected String getEntryPathPrefix() {
return "BOOT-INF/";
}
/**
* Determine if the specified entry is a nested item that should be added to the
* classpath.
* @param entry the entry to check
* @return {@code true} if the entry is a nested item (jar or directory)
*/
protected boolean isIncludedOnClassPath(Archive.Entry entry) {
return isLibraryFileOrClassesDirectory(entry);
}
protected boolean isLibraryFileOrClassesDirectory(Archive.Entry entry) {
String name = entry.name();
if (entry.isDirectory()) {
return name.equals("BOOT-INF/classes/");
}
return name.startsWith("BOOT-INF/lib/");
}
protected boolean isIncludedOnClassPathAndNotIndexed(Entry entry) {
if (!isIncludedOnClassPath(entry)) {
return false;
}
return (this.classPathIndex == null) || !this.classPathIndex.containsEntry(entry.name());
}
}
이 클래스에 엮여있는 모든 코드를 살펴보기에는 너무 광범위 하기에, 간단하게만 살펴보고 MANIFEST 파일에 적혀있던 설정 값들이 어떻게 이용되는지만 보도록 하자.
launch 메소드만 살펴보면 클래스 로더 객체를 만들고 해당 클래스 로더를 스레드의 클래스 로더로 설정한 후 Start-Class 클래스를 찾아서 main 메소드를 리플렉션으로 실행을 하는 코드다.
이 때 클래스 로더는 MANIFEST 파일에서 키가 Spring-Boot-Classpath-Index 인 값을 읽어서 해당 파일에 있는 jar 파일에 있는 클래스 파일을 전부 로드할 수 있는 클래스로더가 된다.
다음으로 MANIFEST 파일에서 키가 Start-Class인 값을 읽어서 해당 클래스의 main 메소드를 리플렉션을 이용해서 실행한다.
전체 흐름을 정리하자면 아래와 같다.
uber jar, shaded jar, nested jar
스프링은 왜 이런 스프링만의 로더를 사용했을까? 답은 다음 문서에 있다. https://docs.spring.io/spring-boot/specification/executable-jar/nested-jars.html
근데, 이 문서를 이해하기 위해서는 uber jar, shaded jar 에 대해 사전 지식이 있어야 한다.
uber jar, shaded jar에 대해 먼저 알아야 하는데 내용은 다음과 같다.
uber.jar 프로젝트가 의존하는 라이브러리를 모두 포함한 jar 파일이다. 근데, jar 파일을 이런 방식으로 만들면 다음의 상황에서 문제가 된다. 내 프로젝트가 lombok 버전 12 를 갖고 있으며, 라이브러리 B에도 의존하고 있다. 근데, 라이브러리 B는 lombok 버전 10을 가지고 있다. 이런 상황에서 프로젝트가 의존하는 모든 라이브러리를 jar로 만들어버리면 프로젝트에서 사용하게 될 롬복의 버전은 뭐가 될 지 모르게된다.
이런 문제를 해결하는 방법이 shading이다. shading은 같은 이름의 패키지를 가진 파일의 이름을 다시 짓는 행위를 말한다. 이렇게 이름을 다시 짓고 생성된 uber.jar를 shaded.jar 라고 한다. (https://stackoverflow.com/questions/49810578/what-is-a-shaded-jar-file-and-what-is-the-difference-similarities-between-an-ub)
근데, 여전히 shaded.jar는 문제가 있다. 하나에 jar에 모든 라이브러리를 다 집어 넣으니 이 jar 파일이 뭐를 의존하고 있는지 개발자가 알기 너무 힘들다. 또한 shading을 하지 않으면 의존이 충돌이 나는 불상사가 일어나게 된다. (shading 하면 패키지의 네임스페이스가 바뀌어서 디버깅도 어렵다고 한다.)
이런 문제를 spring에서는 nested jar 방식으로 jar 파일을 만들어 해결을 했다.
의존마다 별도의 jar 파일에서 클래스 파일을 로드하므로 의존이 충돌될 일도 없으며, jar 파일 내부를 봤을 때 해당 jar 파일이 어떤 라이브러리를 갖고 있는지 알기도 쉬운 방식인 셈이다.
근데 자바의 표준 스펙에는 jar 파일에서 class 파일을 로드하는 기능이 없기 때문에 스프링은 스프링만의 로더를 사용하게 되어 spring-loader 라는 것이 생기게 된 것이다.
결론적으로는 스프링 부트 프로젝트를 할 때 내부에 main 클래스를 얼마나 넣든지 간에 MANIFEST 파일을 수정해야 할 필요는 없다. 스프링 부트 그래들 플러그인이 Main-Class를 스프링 부트 로더의 메인 클래스로 항상 넣기 때문이다.
jar를 만들 때 METAINF 파일에 main 클래스를 넣어줘야 한다는 지식만 알고 있었고 스프링 프로젝트에서 클래스 로드가 어떤식으로 되는지 메인 클래스는 어떻게 실행되는지 몰랐는데, 이번의 호기심으로 꽤나 많은 것을 알게 된듯 싶다.