본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
14장 : Spring Boot와 외부 설정

내장 Tomcat·실행 JAR

외부 컨테이너에 배포하는 WAR 파일과 내장 서버를 포함한 실행 가능 JAR 파일을 비교하고, Boot 로더가 애플리케이션 클래스와 중첩 의존성을 찾는 원리를 설명합니다.

전통적인 WAR 방식에서는 설치된 서블릿 컨테이너가 애플리케이션 아카이브를 배포합니다. 내장 방식에서는 애플리케이션이 서버 라이브러리를 포함하고 메인 메서드와 함께 시작합니다. 배포 단위가 하나가 되지만, 일반적인 JDK 클래스 경로만으로는 JAR 파일 안의 중첩 의존성을 읽을 수 없어 Boot 로더와 실행 아카이브 레이아웃이 필요합니다.


실행 주체 이동

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 packaged=%s%n",
                location.getScheme(),
                location.getPath().endsWith(".jar"));
    }
}

IDE에서 실행하면 디렉터리 경로가 보이지만, 패키징한 산출물에서는 JAR이나 Boot 로더가 제공하는 아카이브 경로가 보일 수 있습니다. 리소스를 파일 시스템 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

아카이브 항목 수만으로 정상적인 클래스 로딩을 증명할 수는 없습니다. 패키징한 프로세스를 띄우고 상태 엔드포인트와 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;
    }
}

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