본문 바로가기

Language/Java

[Java] Java 배포의 흐름: 클래스패스부터 Spring Boot + Docker까지

자바 애플리케이션을 실행하고 배포할 때 꼭 알아야 할 개념을 흐름 순으로 정리합니다.
클래스패스의 기본 원리부터 시작해 현대 실무에서 주로 쓰는 Spring Boot + Docker 배포 방식까지 다룹니다.


1. 클래스패스 (Classpath)

클래스패스란?

JVM이 클래스 파일(.class)을 찾을 때 참조하는 경로 목록입니다. 소스코드를 컴파일하고 실행할 때 JVM은 이 경로를 따라 필요한 클래스를 로드합니다.

 

클래스패스 지정 방법

환경 변수로 설정 (비권장)

export CLASSPATH=/home/user/myapp:/home/user/libs/mylib.jar

프로젝트마다 경로가 달라서 전역으로 관리하기 어렵고, 충돌이 생기기 쉽습니다.

 

실행 시 -cp 옵션 사용 (권장)

# 컴파일
javac -cp .:libs/mylib.jar src/Main.java

# 실행
java -cp .:libs/mylib.jar Main

경로 구분자: Linux/macOS는 :, Windows는 ;

 

클래스패스에 포함할 수 있는 것들

종류 예시
디렉토리 ./out, /home/user/classes
JAR 파일 libs/gson.jar
ZIP 파일 libs/utils.zip
와일드카드 libs/* (디렉토리 내 모든 JAR)

 

실전 예시

# 여러 JAR + 현재 디렉토리 포함
java -cp .:libs/gson.jar:libs/log4j.jar com.example.Main

# 와일드카드로 libs 폴더 전체 JAR 포함
java -cp .:libs/* com.example.Main

 

트러블슈팅 — -verbose:class

클래스패스 문제가 생겼을 때 JVM이 어떤 클래스를 어디서 로드하는지 출력해주는 옵션입니다.

java -verbose:class -cp .:libs/* com.example.Main

출력 예시:

[Loaded com.example.Main from file:/home/user/project/out/]
[Loaded com.google.gson.Gson from file:/home/user/project/libs/gson.jar]

ClassNotFoundException이나 버전 충돌이 의심될 때 어느 경로에서 로드되는지 바로 확인할 수 있어 유용합니다.

 


2. JAR Manifest의 Class-Path 속성

MANIFEST.MF란?

JAR 파일 안에 포함된 메타데이터 파일로, 위치는 항상 고정입니다.

myapp.jar
└── META-INF/
    └── MANIFEST.MF

기본 생김새는 이렇습니다.

Manifest-Version: 1.0
Main-Class: com.example.Main
Class-Path: libs/gson.jar libs/log4j.jar

 

Class-Path 속성이 하는 일

JAR를 실행할 때 추가로 로드할 JAR들의 경로를 미리 지정합니다. 덕분에 실행 시 -cp 옵션을 길게 나열하지 않아도 됩니다.

# Class-Path 없을 때
java -cp myapp.jar:libs/gson.jar:libs/log4j.jar com.example.Main

# Class-Path 있을 때
java -jar myapp.jar

현재는 MANIFEST.MF를 직접 작성할 일이 거의 없습니다. Maven이나 Gradle이 빌드 시 자동으로 생성해주기 때문입니다. 다만 -jar 옵션으로 실행할 때 내부적으로 이 파일을 참조한다는 점, 그리고 Spring Boot JAR도 이 구조를 기반으로 동작한다는 점은 알아두면 유용합니다.

 


3. Fat JAR (Uber JAR)

Gradle이 의존성을 자동으로 받아주더라도, 실행 환경에도 의존성 JAR이 있어야 한다는 사실은 변하지 않습니다. 즉 배포할 때 내 JAR 하나만 달랑 보내면 실행이 안 됩니다.

 

이 문제를 가장 단순하게 해결한 방법이 Fat JAR입니다. 모든 의존성 클래스를 하나의 JAR 파일에 전부 합쳐버려서 JAR 하나만 배포하면 끝나게 만드는 방식입니다.

./gradlew jar         → myapp.jar (내 코드만, 실행 불가)
./gradlew shadowJar   → myapp-fat.jar (의존성 포함, 실행 가능)

참고: Fat JAR는 현재 실무에서 단독으로 쓰이는 경우가 많지 않습니다. Spring Boot나 Docker가 더 나은 대안을 제공하기 때문입니다. 다만 왜 이런 방식이 등장했는지 이해하는 배경 지식으로 알아두면 유용하고, Spring Boot가 없는 순수 Java CLI 툴이나 Kafka Streams, Spark 같은 데이터 파이프라인 잡 배포에서는 여전히 쓰입니다.

 

내부 구조

의존 JAR들을 압축 해제해서 클래스 파일을 그대로 집어넣는 방식입니다.

myapp-fat.jar
├── com/example/Main.class       ← 내 코드
├── com/google/gson/Gson.class   ← gson 내부 클래스
├── org/apache/log4j/...         ← log4j 내부 클래스
└── META-INF/MANIFEST.MF

 

Gradle로 만들기 (shadow 플러그인)

build.gradle

plugins {
    id 'java'
    id 'com.github.johnrengelman.shadow' version '8.1.1'
}

group = 'com.example'
version = '1.0.0'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'com.google.code.gson:gson:2.10.1'
    implementation 'org.apache.logging.log4j:log4j-core:2.20.0'
}

shadowJar {
    archiveBaseName.set('myapp')
    archiveClassifier.set('fat')  // 결과: myapp-1.0.0-fat.jar

    manifest {
        attributes 'Main-Class': 'com.example.Main'
    }

    // 패키지 충돌 방지 (Relocation)
    relocate 'com.google.gson', 'com.example.shaded.gson'
    relocate 'org.apache.logging', 'com.example.shaded.logging'

    // META-INF/services/ 파일 자동 병합
    mergeServiceFiles()

    // 서명 파일 제외 (보안 충돌 방지)
    exclude 'META-INF/*.SF'
    exclude 'META-INF/*.DSA'
    exclude 'META-INF/*.RSA'
}

// build 태스크 실행 시 shadowJar도 자동 실행
build.dependsOn shadowJar
./gradlew shadowJar
java -jar build/libs/myapp-1.0.0-fat.jar

 

핵심 문제와 해결책

문제 1 — 클래스 충돌

서로 다른 라이브러리가 같은 경로의 클래스를 가지면 하나가 덮어씌워집니다.

gson.jar  → com/google/common/base/Preconditions.class (v1)
guava.jar → com/google/common/base/Preconditions.class (v2)
                                                      ↑ 둘 중 하나만 살아남음

 

해결: 패키지 Relocation — 경로 자체를 바꿔 두 버전이 공존하게 합니다. 소스코드의 import 문도 자동으로 변환되므로 코드 수정이 필요 없습니다.

relocate 'com.google.common', 'com.example.shaded.google.common'
// com/google/common/... → com/example/shaded/google/common/...

 

문제 2 — 서비스 파일 병합

META-INF/services/ 아래 파일들은 여러 라이브러리가 같은 파일명을 씁니다. 단순히 덮어쓰면 일부 기능이 사라지므로 내용을 합쳐야 합니다.

mergeServiceFiles()  // 같은 이름의 서비스 파일을 자동으로 합쳐줌

 


4. 현대 실무 — Spring Boot + Docker

Spring Boot의 Nested JAR

Fat JAR의 클래스 충돌 문제를 Spring Boot는 중첩 JAR(Nested JAR) 방식으로 해결합니다. 의존 JAR을 압축 해제하지 않고 JAR 안에 그대로 넣고, 전용 클래스 로더(JarLauncher)가 이를 읽어들입니다.

myapp.jar
└── BOOT-INF/
    ├── classes/       ← 내 코드
    └── lib/
        ├── gson.jar   ← 원본 JAR 그대로 보존
        └── log4j.jar

클래스 파일이 섞이지 않으니 충돌이 원천 차단됩니다.

기본 패턴 — bootJar + Dockerfile

Spring Boot의 bootJar로 빌드한 JAR을 Docker 이미지에 넣는 방식이 현재 실무에서 가장 널리 쓰입니다.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.0'
    id 'io.spring.dependency-management' version '1.1.4'
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
}

bootJar {
    archiveFileName = 'myapp.jar'
}

 

Dockerfile

FROM eclipse-temurin:17-jre-alpine

WORKDIR /app
COPY build/libs/myapp.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
./gradlew bootJar
docker build -t myapp .
docker run -p 8080:8080 myapp

bootBuildImage 태스크를 쓰면 Dockerfile 없이 Gradle에서 직접 Docker 이미지를 빌드할 수도 있습니다. 다만 실무에서는 CI/CD 파이프라인에서 docker build를 직접 실행하는 방식이 더 일반적입니다.

 

Docker 캐시 최적화 — 레이어드 JAR

기본 Dockerfile은 코드 한 줄만 바꿔도 전체 JAR을 다시 이미지에 올립니다. 레이어드 JAR로 의존성과 내 코드를 분리하면 실제로 변경된 레이어만 재빌드되어 배포가 빨라집니다.

 

Spring Boot 3.x는 레이어드 JAR이 기본으로 활성화되어 있어 별도 설정 없이 바로 사용할 수 있습니다.

Dockerfile (레이어드)

# 1단계: JAR을 레이어별로 분리
FROM eclipse-temurin:17-jre-alpine AS builder
WORKDIR /app
COPY build/libs/myapp.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# 2단계: 레이어 순서대로 복사 (자주 바뀌는 것을 마지막에)
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./

ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

의존성(dependencies)은 거의 바뀌지 않으므로 캐시가 유지되고, 내 코드(application)만 바뀌었다면 마지막 레이어만 새로 올라갑니다.

 


방식별 비교 정리

방식 장점 단점 추천 상황
-cp 옵션 단순, 명시적 실행 명령이 길어짐 로컬 개발, 스크립트
Manifest Class-Path 실행 명령 단순화 파일 여러 개 배포 필요 레거시 환경
Fat JAR (shadow) 파일 하나로 배포 크기 큼, 충돌 위험 CLI 툴, Spark/Kafka 잡
Spring Boot JAR 충돌 없음, 구조 명확 Spring Boot 전용 Spring 프로젝트
Spring Boot + Docker 환경 격리, 배포 표준화 인프라 필요 실무 서버 배포

클래스패스와 Manifest는 JVM이 클래스를 찾는 원리를 이해하는 데 핵심입니다.
Fat JAR는 그 문제를 단순하게 해결한 방식이고, Spring Boot의 Nested JAR는 그 한계를 다시 개선한 결과입니다.
실제 배포는 Spring Boot + Docker 조합이 현재 사실상 표준이며, 레이어드 JAR까지 적용하면 빌드 효율도 함께 챙길 수 있습니다.