본문 바로가기

내장 Tomcat·실행 JAR

외부 컨테이너용 WAR와 내장 서버 JAR을 비교하고 Boot 로더의 중첩 의존성 탐색을 이해합니다.

전통적인 WAR 방식에서는 미리 설치된 서블릿 컨테이너가 애플리케이션 아카이브를 받아 배포합니다. 내장 방식에서는 애플리케이션이 서버 라이브러리를 포함하고, 자신의 메인 메서드로 서버와 함께 시작합니다.

배포 단위가 하나로 줄어들지만, JDK의 기본 클래스 경로만으로는 JAR 파일 안에 들어 있는 JAR을 읽을 수 없습니다. 그래서 Boot의 로더와 실행 아카이브 레이아웃이 필요합니다. 이 글에서는 실행 주체가 어떻게 바뀌는지, 실행 JAR 안이 어떻게 배치되는지, 그리고 패키징한 산출물을 무엇으로 검증하는지를 살펴봅니다.

EXECUTABLE JAR · STRUCTURE AND EXECUTION

실행 JAR의 구조와 실제 실행을 별도로 확인한다

일반 JAR과 Boot 실행 아카이브는 레이아웃이 다릅니다. 확장자나 CodeSource 경로에 기대지 않고 매니페스트·엔트리와 별도 프로세스의 동작을 확인합니다.

실행 주체와app.jar의 클래스 경로app.jar 영역은Bootloader/애플리케이션클래스/중첩lib를포함한다. 영역밖JVM이매니페스트런처로진입하고런처가앱main을호출하는실선, 런처가중첩의존성클래스경로를구성하는점선을구별한다. 파일엔트리존재만으로실제클래스로딩/DB성공을증명하지않는다. mobile동일4노드3관계이며원목록은그대로지원설명이다.app.jar 영역JVMjava -jar app.jarBoot Launcher매니페스트 진입앱 클래스Start-Class.main중첩 의존성BOOT-INF/lib
실행 주체와app.jar의 클래스 경로app.jar 영역은Bootloader/애플리케이션클래스/중첩lib를포함한다. 영역밖JVM이매니페스트런처로진입하고런처가앱main을호출하는실선, 런처가중첩의존성클래스경로를구성하는점선을구별한다. 파일엔트리존재만으로실제클래스로딩/DB성공을증명하지않는다. mobile동일4노드3관계이며원목록은그대로지원설명이다.app.jar 영역JVMjava -jar app.jarBoot Launcher매니페스트 진입앱 클래스Start-Class.main중첩 의존성BOOT-INF/lib

실선은 실행 진입·main 호출, 점선은 중첩 의존성의 클래스 경로 구성입니다. app.jar 영역은 파일 구조이며 JVM 프로세스 영역과 다릅니다.

app.jar 안의 구조

  1. META-INF/MANIFEST.MF

    Main-Class는 런처, Start-Class는 애플리케이션 시작 클래스를 식별합니다. 플러그인 레이아웃을 따릅니다.

  2. Boot loader classes

    중첩 아카이브를 읽는 런처 코드가 포함됩니다.

  3. BOOT-INF/classes + BOOT-INF/lib

    앞쪽은 앱 bytecode/resources, 뒤쪽은 런타임 의존성의 중첩 JAR입니다.

별도 프로세스의 실행

  1. java -jar app.jar

    JVM이 매니페스트의 런처를 시작합니다.

  2. Launcher → 애플리케이션 main

    런처가 중첩 클래스패스를 구성한 뒤 Start-Class의 main을 호출합니다.

  3. 동작과 정상 종료 증거

    웹 앱은 준비 신호·실제 HTTP·종료를 확인합니다. 종료하는 콘솔 예제는 종료 코드와 출력 계약을 확인합니다.

구조 검사만으로 클래스 로딩은 증명되지 않습니다. IDE와 패키징 실행, 정상 종료와 강제 종료를 구분하고 리소스는 classpath 스트림으로 읽습니다.

그림의 위쪽은 app.jar라는 파일 영역 안에 Boot 로더, 애플리케이션 클래스, 중첩 의존성이 들어 있고, 그 바깥의 JVM이 java -jar app.jar로 매니페스트의 런처에 진입하는 모습입니다. 실선은 런처가 애플리케이션의 main을 호출하는 실행 경로이고, 점선은 런처가 중첩 의존성으로 클래스 경로를 구성하는 관계입니다. 아래 칸들은 META-INF/MANIFEST.MF의 Main-Class가 런처를, Start-Class가 애플리케이션 시작 클래스를 가리킨다는 점과, 파일 항목이 있다는 사실만으로 실제 클래스 로딩이나 DB 연결의 성공을 판단하지 않는다는 점을 정리합니다.


실행 주체의 이동

WAR로 배포할 때는 운영 컨테이너의 버전, 공유 라이브러리, 컨텍스트 구성이 곧 실행 환경입니다. 실행 가능한 JAR에서는 빌드가 서버 버전과 의존성을 고정하고, java -jar가 프로세스를 시작합니다. 환경 간 차이는 줄지만, 이미지 재빌드, JVM 옵션, 종료 신호 전달은 이제 애플리케이션의 배포 과정에서 챙겨야 합니다.

내장 서버를 써도 HTTP 커넥터와 서블릿 생명 주기는 그대로 있습니다. 아카이브 형태가 바뀌었다고 인프라의 의미가 사라지지는 않으므로, 실제 패키징 산출물로 임의 포트 통합 테스트와 단계적 종료를 확인합니다.


JAR 매니페스트

일반 JAR 파일은 Main-Class가 애플리케이션의 메인 클래스를 직접 가리킬 수 있습니다. Boot 아카이브는 Main-Class에 런처를 두고, 런처가 중첩 라이브러리와 애플리케이션 클래스로 클래스 경로를 구성한 뒤 Start-Class의 main을 호출하도록 메타데이터를 둡니다. 플러그인 버전에 맞는 레이아웃을 쓰고, 매니페스트 키를 임의로 덮어쓰지 않습니다.

src/main/java/board/archive/CodeSourceProbe.java
package board.archive;

import java.net.URISyntaxException;

public final class CodeSourceProbe {
    public static void main(String[] args) throws URISyntaxException {
        var location = CodeSourceProbe.class
                .getProtectionDomain()
                .getCodeSource()
                .getLocation()
                .toURI();
        System.out.printf("scheme=%s location=%s%n",
                location.getScheme(),
                location);
    }
}

이 코드는 클래스가 어디서 적재됐는지를 URI의 종류(scheme)와 위치로 출력하는 진단입니다. 디렉터리나 아카이브 경로가 보일 수 있지만, 확장자나 URI 모양만으로 Boot 패키징 여부를 판정하지 않습니다. URI에 별도의 경로 부분이 없으면 getPath()가 null일 수 있으므로, 이 진단은 URI 자체를 출력합니다. 같은 이유로 리소스를 파일 시스템의 Path로 가정하지 말고 클래스 경로 스트림으로 읽습니다.


중첩 의존성

실행 JAR 안에서 애플리케이션 클래스는 BOOT-INF/classes에, 라이브러리 JAR은 BOOT-INF/lib에 따로 배치됩니다. JDK의 기본 클래스 로더는 JAR 안의 JAR을 일반 클래스 경로 항목으로 직접 다루지 못하므로, Boot 로더가 그 URL과 인덱스를 해석합니다. 라이브러리를 풀어 놓은 형태로 실행할 수도 있지만, 검증은 운영 환경과 같은 산출물을 직접 실행해서 합니다.

src/main/java/board/archive/JarEntryInventory.java
package board.archive;

import java.nio.file.Path;
import java.util.jar.JarFile;

public final class JarEntryInventory {
    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            throw new IllegalArgumentException("usage: JarEntryInventory app.jar");
        }
        try (var jar = new JarFile(Path.of(args[0]).toFile())) {
            long classes = jar.stream()
                    .filter(entry -> entry.getName().endsWith(".class"))
                    .count();
            long nested = jar.stream()
                    .filter(entry -> entry.getName().endsWith(".jar"))
                    .count();
            System.out.printf("classes=%d nestedLibraries=%d%n", classes, nested);
        }
    }
}

이 도구는 바깥 JarFile의 항목 이름을 한 번 훑어 .class와 .jar로 끝나는 항목을 셉니다. 실제 산출물에 대해 적으면 다음과 같은 모양이 됩니다.

packaged archive 관찰 예
classes=(0보다 큰 값)
nestedLibraries=(starter 의존성 수에 따른 값)
java -jar exitCode=0
health status=UP

항목 수만으로 정상적인 클래스 로딩을 확인할 수는 없습니다. 이 도구는 중첩 JAR을 다시 열지 않으므로, 중첩 JAR 안의 클래스는 classes에 더해지지 않습니다. 실행 JAR의 중첩 구조에서 애플리케이션 클래스와 중첩 의존성이 어떻게 나뉘는지 보고, 목록과 실제 로딩의 차이를 읽습니다.

위 예의 exitCode=0은 계속 서비스 중인 JVM에서 바로 읽을 수 있는 값이 아닙니다. 웹 서비스에 요청을 보내고 종료시킨 뒤의 프로세스 결과를 함께 모았거나, 스스로 끝나는 콘솔 예제의 결과를 읽었다는 조건입니다. 준비 상태의 HTTP 응답을 먼저 확인하고, 정상 종료 뒤에 종료 코드를 수집하는 순서를 고정합니다.

패키징한 프로세스를 띄운 뒤에는 상태 엔드포인트, JSON 직렬화, DB 드라이버 로딩을 확인합니다. 레이아웃이 깨졌다면 다음과 같은 신호가 나옵니다.

archive layout 실패 신호
manifest launcher present = true
application classes present = true
nested libraries present = true
launcher can resolve nested driver = false
startup exit code = 1
root cause = ClassNotFoundException: org.h2.Driver
result = PACKAGED_CLASSPATH_BROKEN

IDE에서 실행할 때는 성공하는데, 패키징한 레이아웃에서만 이런 오류가 날 수 있습니다. 빌드 출력 디렉터리의 평면 클래스 경로로 돌린 테스트와 java -jar로 구동한 검증은 서로를 대신하지 못합니다.


계층형 아카이브

의존성, 스냅샷 의존성, 로더, 애플리케이션을 계층(layer)으로 나누면, 소스 코드만 바뀐 빌드에서 크고 잘 바뀌지 않는 계층을 컨테이너 이미지가 재사용할 수 있습니다. 이미지가 읽기 전용 계층을 쌓아 만들어지고, 입력이 바뀌지 않은 빌드 단계는 캐시된 계층을 다시 쓰는 원리는 운영체제 교재의 컨테이너와 Docker에서 다룹니다. 계층의 순서와 내용은 플러그인이 생성한 인덱스를 기준으로 하고, 사용자 정의 계층은 실제 이미지 크기와 내려받기 시간으로 평가합니다.

실행 JAR을 이미지에 넣을 때는 비루트 사용자, 읽기 전용 파일 시스템, 임시 디렉터리, 시간대, CA 인증서를 확인합니다. 서버가 임시 디렉터리에 써야 한다면 쓰기 가능한 별도의 마운트를 제공합니다. CDS나 AOT를 쓸 때도 산출물의 체크섬과 JVM·CPU 아키텍처의 호환성을 고정하며, 시작 시간 수치만 보고 진단 기능을 잃지 않게 합니다.


재현 가능한 빌드

아카이브 항목의 타임스탬프와 파일 순서가 빌드마다 달라지면 체크섬과 계층 캐시가 깨집니다. Gradle 9부터는 Jar와 Zip 같은 아카이브 작업이 기본으로 파일 순서를 고정하고 타임스탬프와 권한을 일정한 값으로 맞춰 재현 가능한 아카이브를 만듭니다. 이 기본값을 끄지 말고, 빌드 환경, 자바 도구 체인, 의존성 잠금도 함께 고정합니다. SBOM과 의존성 출처 정보는 산출물에 연결해 둡니다.

JAR에 인증 정보나 환경별 application-prod.yml을 넣지 않고, 실행할 때 비밀 정보 저장소에서 주입합니다. 서명 검증과 취약점 검사는 빌드 파이프라인에서 하고, 배포에는 승인된 다이제스트(digest)를 씁니다. latest 태그만 기록하면 롤백할 산출물을 특정하기 어렵습니다.


JVM 종료와 트래픽 비우기

컨테이너가 종료를 요청하는 신호인 SIGTERM을 보내면 애플리케이션 컨텍스트와 웹 서버가 단계적으로 종료될 시간을 줍니다. SIGTERM이 프로세스가 가로챌 수 없는 강제 종료 신호 SIGKILL과 어떻게 다른지는 운영체제 교재의 프로세스 간 통신에서 다룹니다. 오케스트레이터의 종료 유예 시간은 애플리케이션의 종료 타임아웃보다 길어야 합니다. 준비 상태를 먼저 내려 새 트래픽을 막고, 처리 중인 요청을 끝낸 뒤 종료합니다.

종료 훅에서 새 비데몬 스레드를 시작하거나 끝없이 기다리는 원격 플러시를 하지 않습니다. 아웃박스처럼 영속적인 상태는 다음 인스턴스가 이어받도록 설계합니다.

더 깊이: Boot의 단계적 종료가 실제로 하는 일

Spring Boot 4.1.2에서는 단계적 종료가 기본으로 켜져 있습니다. ServerProperties의 server.shutdown 기본값이 graceful이고, Spring Boot 문서는 이것이 Tomcat, Jetty, Reactor Netty 세 내장 서버와 서블릿·리액티브 애플리케이션 모두에 적용된다고 설명합니다. 종료는 애플리케이션 컨텍스트를 닫는 과정의 일부로, SmartLifecycle 빈을 멈추는 가장 이른 단계에서 일어납니다. 이때 세 서버는 모두 네트워크 계층에서 새 요청을 받지 않고, 이미 받은 요청은 유예 시간 안에 끝나도록 기다립니다.

유예 시간은 spring.lifecycle.timeout-per-shutdown-phase로 정하며, LifecycleProperties의 기본값은 30초입니다. 이름처럼 이 값은 전체 종료 시간이 아니라 같은 단계 값을 가진 SmartLifecycle 묶음 하나의 제한이므로, 오케스트레이터의 종료 유예 시간을 정할 때는 웹 서버 단계 뒤에 오는 다른 단계의 정리 시간도 더해야 합니다. 또 같은 문서는 IDE가 올바른 SIGTERM을 보내지 않으면 종료가 단계적이지 않고 즉시 일어날 수 있다고 경고하므로, 단계적 종료는 실제 배포와 같은 방식으로 신호를 보내 확인합니다. 끄고 싶다면 server.shutdown=immediate를 씁니다.


정리

  • 실행 JAR은 서버 라이브러리를 포함해 java -jar로 시작하며, 런처가 BOOT-INF/lib의 중첩 의존성으로 클래스 경로를 구성한 뒤 Start-Class를 호출합니다.
  • IDE의 평면 클래스 경로 테스트와 패키징한 산출물의 구동 검증은 서로 대신할 수 없습니다.
  • 계층형 아카이브는 이미지 캐시를 재사용하게 해 주며, Gradle 9의 아카이브 작업은 기본으로 재현 가능한 결과를 만듭니다.
  • 종료 신호를 받으면 준비 상태를 내리고 처리 중인 요청을 끝낸 뒤 종료하며, 종료 유예 시간을 그보다 길게 둡니다.

연습 문제

1. CI에서 확인할 단계

IDE 클래스 경로 테스트와 패키징한 JAR의 구동 검증이 무엇이 다른지 정의하세요. 아카이브 항목, 매니페스트, 시작 포트, 상태, 단계적 종료, 체크섬 재현성을 CI의 단계별 검증으로 만드세요.

해설 보기

패키징 산출물의 구동 검증은 비어 있는 임의 포트와 별도의 임시 디렉터리를 쓰고, 프로세스 출력에서 준비 신호를 기다린 뒤 HTTP를 호출합니다. 단계별로 모은 결과를 다음 레코드로 넘길 수 있습니다.

package board.archive;

public record PackageEvidence(
        String sha256,
        int processExitCode,
        int healthStatus,
        boolean gracefulStop
) {
    public boolean releasable() {
        return sha256 != null
                && sha256.length() == 64
                && processExitCode == 0
                && healthStatus == 200
                && gracefulStop;
    }
}

프로세스를 강제로 종료한 테스트와 정상 종료한 테스트를 나눠, 영속적인 작업이 다음 인스턴스에서 복구되는지도 확인합니다.

2. 길이만 보는 체크섬 조건

PackageEvidence에 "z"를 64번 반복한 문자열, processExitCode=0, healthStatus=200, gracefulStop=true를 넣었습니다. releasable()은 참인가요? 이 결과가 실제 산출물의 SHA-256을 검증했다는 뜻인가요? 별도의 경우로 믿을 수 있는 실제 다이제스트는 같지만 상태가 503이면 어떻게 되나요?

해설 보기

조건식은 문자열의 길이만 검사하므로 첫 경우는 참입니다. 하지만 z는 16진수 다이제스트에 쓰이는 문자가 아니며, 이 코드에는 산출물의 해시를 계산하거나 16진 형식을 확인하거나 승인된 값과 대조하는 부분이 없습니다. 이 레코드는 신뢰할 수 있는 검증 단계가 계산한 값을 넘겨준다는 전제로 읽어야 합니다.

두 번째 경우는 healthStatus == 200이 거짓이므로 false입니다. 결과를 모아 두는 구조와, 실제로 무결성과 준비 상태를 판정하는 구현을 섞어 생각하지 않습니다.

3. 항목 수와 중첩 JAR

어떤 실행 JAR의 바깥 항목에 org/springframework/boot/loader/launch/JarLauncher.class와 BOOT-INF/classes/board/Application.class가 있고, BOOT-INF/lib/a.jar, BOOT-INF/lib/b.jar, 설정 파일 하나가 있습니다. a.jar 안에는 클래스가 100개 있습니다. JarEntryInventory의 출력은 무엇인가요?

해설 보기

바깥 항목 가운데 .class로 끝나는 것은 두 개, .jar로 끝나는 것도 두 개이므로 출력은 classes=2 nestedLibraries=2입니다. 설정 파일은 어느 쪽에도 세지 않습니다.

a.jar 안의 클래스 100개는 이 코드가 중첩 JAR을 다시 열지 않으므로 더해지지 않습니다. 그러니 classes 값을 애플리케이션이 쓸 수 있는 전체 클래스 수로 읽으면 안 되며, 실제로 클래스가 적재되는지는 java -jar로 실행해 확인합니다.

다음 글 스타터와 의존성 관리에서는 실행 JAR에 들어갈 의존성을 스타터와 Boot의 BOM이 어떻게 고르고 버전을 맞추는지 다룹니다.