본문 바로가기

내장 Tomcat·실행 JAR

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

전통적인 WAR 방식에서는 설치된 서블릿 컨테이너가 애플리케이션 아카이브를 배포합니다.

내장 방식에서는 애플리케이션이 서버 라이브러리를 포함하고 메인 메서드와 함께 시작합니다.

배포 단위가 하나가 되지만, 일반적인 JDK 클래스 경로만으로는 JAR 파일 안의 중첩 의존성을 읽을 수 없어 Boot 로더와 실행 아카이브 레이아웃이 필요합니다.

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 스트림으로 읽습니다.


실행 주체 이동

WAR 배포에서는 운영 컨테이너의 버전과 공유 라이브러리, 컨텍스트 구성이 실행 환경입니다.

실행 가능한 JAR에서는 빌드가 서버 버전과 의존성을 고정하고 java -jar가 프로세스를 시작합니다.

환경 차이는 줄지만 이미지 재빌드와 JVM 옵션, 신호 전달은 애플리케이션 배포 과정에서 책임져야 합니다.

내장 서버를 사용해도 HTTP 커넥터와 서블릿 생명 주기는 그대로 존재합니다.

아카이브 형태가 인프라의 의미를 없애지는 않습니다.

실제 패키징 산출물에서 임의 포트 통합 테스트와 단계적 종료를 확인합니다.


JAR 매니페스트

일반 JAR 파일은 Main-Class가 애플리케이션 메인 클래스를 직접 가리킬 수 있습니다.

Boot 아카이브는 런처가 중첩 라이브러리와 애플리케이션 클래스 경로를 구성한 뒤 시작 클래스를 호출하도록 메타데이터를 둡니다.

플러그인 버전에 맞는 레이아웃을 사용하고 매니페스트 키를 임의로 덮어쓰지 않습니다.

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은 서로 다른 아카이브 디렉터리에 배치됩니다.

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);
        }
    }
}
packaged archive 관찰 예
classes=(0보다 큰 값)
nestedLibraries=(starter 의존성 수에 따른 값)
java -jar exitCode=0
health status=UP

아카이브 항목 수만으로 정상적인 클래스 로딩을 증명할 수는 없습니다.

예시 원 JarEntryInventory는 바깥 JarFile의 엔트리 이름을 한 번 훑습니다. loader/Launcher.class와 BOOT-INF/classes/board/Application.class가 있고 BOOT-INF/lib/a.jar·b.jar 및 설정 파일 한 개가 있다면 classes=2·nestedLibraries=2입니다. a.jar 안에 클래스 100개가 있더라도 이 코드는 중첩 JAR을 다시 열지 않아 그 100개를 더하지 않습니다. 실행 JAR의 공식 구조에서 앱 클래스와 중첩 의존성을 구별해 목록과 실제 로딩의 한계를 읽으세요.

뒤 관찰 예의 exitCode=0은 계속 서비스 중인 JVM의 실시간 종료 코드가 아닙니다. 웹 서비스를 요청·종료한 뒤의 프로세스 결과를 함께 모으거나, 별도로 종료하는 콘솔 예제의 결과를 읽는 조건입니다. 준비 HTTP를 먼저 확인하고 정상 종료 뒤 exitCode를 수집하는 순서를 고정합니다.

패키징한 프로세스를 띄우고 상태 엔드포인트와 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 구동 검증은 서로 대체할 수 없습니다.


계층형 아카이브

의존성, 스냅샷 의존성, 로더, 애플리케이션 계층을 분리하면 소스 코드만 바뀐 빌드에서 크고 변경이 적은 계층을 재사용할 수 있습니다.

계층 순서와 내용은 플러그인이 생성한 인덱스를 기준으로 하고 사용자 정의 계층은 실제 이미지 크기와 이미지 내려받기 시간으로 평가합니다.

실행 가능한 JAR 파일을 이미지에 넣을 때 비루트 사용자, 읽기 전용 파일 시스템, 임시 디렉터리, 시간대, CA 인증서를 확인합니다.

서버가 임시 디렉터리에 써야 한다면 별도의 쓰기 가능한 마운트를 제공합니다.

CDS와 AOT를 사용할 때도 산출물 체크섬과 JVM·아키텍처 호환성을 고정합니다.

시작 수치만 보고 진단 기능을 잃지 않습니다.


재현 가능한 빌드

아카이브 항목의 타임스탬프와 파일 순서가 빌드마다 달라지면 체크섬과 계층 캐시가 깨집니다.

Gradle의 재현 가능한 파일 순서와 파일 타임스탬프 비보존 설정을 사용하고 빌드 환경, Java 도구 체인, 의존성 잠금을 고정합니다.

SBOM과 의존성 출처 정보를 산출물에 연결합니다.

JAR에 인증 정보나 환경별 application-prod.yml을 포함하지 않고 런타임 비밀 정보 소스에서 주입합니다.

시그니처 검증과 취약점 검사는 빌드 파이프라인에서 수행하고 배포는 승인된 다이제스트를 사용합니다.

latest 태그만 기록하면 롤백 산출물을 특정하기 어렵습니다.


JVM 종료·트래픽 드레이닝

컨테이너가 SIGTERM을 보내면 애플리케이션 컨텍스트와 웹 서버가 단계적으로 종료될 시간을 줍니다.

오케스트레이터의 종료 유예 시간은 애플리케이션 타임아웃보다 길어야 합니다.

준비 상태를 먼저 내려 새 트래픽을 막고 처리 중인 요청을 끝냅니다.

종료 훅에서 새 비데몬 스레드를 시작하거나 무기한 원격 플러시를 하지 않습니다.

아웃박스처럼 영속적 상태는 다음 인스턴스가 이어받게 설계합니다.


연습 문제

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;
    }
}

프로세스를 강제 종료한 테스트와 정상 종료 테스트를 나눠 영속적 작업 복구를 확인합니다.

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

풀이 확인

첫 원 조건식은 길이만 검사하므로 참입니다. 하지만 z는 16진 다이제스트 값이 아니며 원 코드에는 산출물 해시 계산·16진 형식·승인값 대조가 없습니다. 이 레코드는 신뢰할 수 있는 검증 단계가 계산한 값을 전달한다는 전제로 읽어야 합니다. 두 번째 경우는 healthStatus==200이 거짓이어서 false입니다. 구조·증거 필드 수집과 실제 무결성/준비 판정의 구현을 혼합하지 마세요.