Devin.KR
로그인

자바 빌드와 배포 - Maven 의존성 관리와 실행 가능 jar 만들기 (자바 고급 24단원)

개발자 조회 1

이 단원에서 배우는 것

22단원에서 JMH를, 23단원에서 JUnit 5를 썼다. 둘 다 외부 라이브러리라 javac -cp 뒤에 jar 경로를 손으로 붙여야 했다. 라이브러리가 셋만 넘어가도 이 방식은 무너진다. 게다가 19단원의 GC 로그 옵션, 20단원의 --add-opens, 21단원의 Java 21 타깃 설정까지 실행 시점에 챙겨야 할 것이 계속 늘었다. 이번 단원은 이 모든 것을 재현 가능한 하나의 산출물로 묶는다. 커리큘럼의 마지막이자, 앞의 결과물을 실제로 서버에 올리는 단계다.

  • 메이븐 좌표·스코프·전이 의존성을 이해하고 dependency:tree 로 충돌을 읽는다
  • 의존성이 포함된 실행 가능 jar를 만들고, 실행 옵션까지 배포 단위에 넣는다
  • "내 PC에서는 됩니다"를 만드는 원인 — 버전 미고정, source/target 오용 — 을 제거한다

왜 필요한가

라이브러리 하나를 추가한다고 하자. Gson을 쓰려면 gson-2.10.1.jar 를 내려받아 lib/ 에 넣고 클래스패스에 추가한다. 여기까지는 할 만하다. 문제는 그다음이다.

  • Gson이 다른 라이브러리를 필요로 하면 그것도 찾아 넣어야 한다. 그것이 또 다른 것을 필요로 한다.
  • 팀원 A는 2.10.1을, B는 2.8.9를 넣었다. B의 PC에서만 나는 버그가 생긴다.
  • 서버에 올릴 때 lib/ 를 통째로 복사하는 것을 잊는다. NoClassDefFoundError 가 난다.
  • 6개월 뒤 lib/ 안의 jar가 어떤 버전인지, 왜 들어 있는지 아무도 모른다.

빌드 도구는 이 문제를 "무엇이 필요한지만 적으면 나머지는 도구가 해결한다" 로 바꾼다. 그리고 그 선언이 곧 문서가 된다. 여기서는 스프링 진영의 사실상 표준인 메이븐을 중심으로 보고, 그레이들은 대응 관계로 정리한다.

문법과 예제

1. 좌표와 최소 pom

메이븐은 모든 라이브러리를 groupId : artifactId : version 세 값으로 식별한다. 이걸 GAV 좌표라고 부른다. 앞 단원들의 예제를 이어 주문 서비스를 만든다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>kr.devin</groupId>
  <artifactId>order-service</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <dependency>
      <groupId>com.google.code.gson</groupId>
      <artifactId>gson</artifactId>
      <version>2.10.1</version>
    </dependency>
  </dependencies>
</project>

project.build.sourceEncoding 을 빼면 빌드 서버의 기본 인코딩을 따라가므로, 한글 주석과 문자열이 깨진 클래스가 만들어질 수 있다. 로컬(UTF-8)에서는 멀쩡하고 CI에서만 깨지는 전형적인 사례다. 새 프로젝트를 만들 때 이 두 줄은 반사적으로 넣는다.

디렉터리 구조는 규약으로 정해져 있고, 이걸 따르면 설정이 필요 없다.

order-service/
├── pom.xml
├── mvnw                      # 래퍼 스크립트 (반드시 커밋한다)
├── .mvn/wrapper/
└── src/
    ├── main/java/kr/devin/order/App.java
    ├── main/resources/
    └── test/java/kr/devin/order/AppTest.java

2. 라이프사이클과 스코프

mvn package 를 실행하면 그 앞 단계가 순서대로 전부 실행된다.

validate → compile → test → package → verify → install → deploy

package 하나로 컴파일과 테스트까지 돈다. install 은 로컬 저장소(~/.m2/repository)에, deploy 는 사내 저장소에 올린다.

의존성마다 언제 필요한지를 스코프로 지정한다. 이걸 안 나누면 배포물이 불필요하게 커지거나, 런타임에 클래스가 없어 터진다.

스코프컴파일테스트실행/배포대표 예
compile(기본)OOOGson, 스프링
providedOOX서블릿 API, 롬복, JMH 애노테이션 프로세서
runtimeXOOJDBC 드라이버
testXOXJUnit, Testcontainers

provided 는 "컴파일에는 필요하지만 실행 환경이 이미 갖고 있다"는 뜻이다. WAR로 톰캣에 올릴 때 서블릿 API를 compile 로 넣으면 컨테이너의 것과 충돌해 LinkageError 가 난다. 반대로 JDBC 드라이버를 test 로 넣으면 테스트는 통과하는데 운영에서 No suitable driver 가 난다.

3. 전이 의존성과 버전 충돌

내가 A를 넣으면 A가 쓰는 B도 자동으로 따라온다. 편리하지만, 서로 다른 경로로 같은 라이브러리의 다른 버전이 들어오면 문제가 된다. 실제 트리는 이렇게 확인한다.

$ ./mvnw dependency:tree
[INFO] --- dependency:3.6.0:tree (default-cli) @ order-service ---
[INFO] kr.devin:order-service:jar:1.0.0
[INFO] \- com.google.code.gson:gson:jar:2.10.1:compile

충돌이 있으면 omitted for conflict with 같은 표시가 붙는다. 메이븐의 해결 규칙은 가장 가까운 것이 이긴다(nearest wins) 이다. 버전이 높은 쪽이 아니라 내 pom에서 경로가 짧은 쪽이 선택된다. 그래서 라이브러리를 추가했을 뿐인데 무관한 곳에서 NoSuchMethodError 가 나는 일이 생긴다. 컴파일은 새 버전 API로 했는데 런타임에는 낮은 버전이 올라간 경우다.

해결 수단은 셋이다.

<!-- (1) 원치 않는 전이 의존성을 잘라낸다 -->
<dependency>
  <groupId>com.example</groupId>
  <artifactId>some-lib</artifactId>
  <version>1.4.0</version>
  <exclusions>
    <exclusion>
      <groupId>commons-logging</groupId>
      <artifactId>commons-logging</artifactId>
    </exclusion>
  </exclusions>
</dependency>

<!-- (2) 버전을 한곳에서 못박는다. 여기 적힌 버전이 항상 이긴다 -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.google.code.gson</groupId>
      <artifactId>gson</artifactId>
      <version>2.10.1</version>
    </dependency>
  </dependencies>
</dependencyManagement>

(3)은 BOM(Bill of Materials)이다. 스프링 부트를 쓰면 부모 POM이 수백 개 라이브러리의 검증된 버전 조합을 한꺼번에 정해 준다. 그래서 스프링 부트 프로젝트에서는 의존성에 버전을 적지 않는 것이 정상이다. 버전을 직접 적으면 부트가 맞춰 둔 조합을 깨뜨리게 된다.

4. 실행 가능 jar 만들기

기본 jar 산출물에는 내 클래스만 들어 있고 의존성은 빠져 있다. 실제로 실행해 보면 이렇게 된다.

$ java -cp target/original-order-service-1.0.0.jar kr.devin.order.App
Exception in thread "main" java.lang.NoClassDefFoundError: com/google/gson/Gson
        at kr.devin.order.App.main(App.java:8)
Caused by: java.lang.ClassNotFoundException: com.google.gson.Gson

의존성까지 한 파일에 넣으려면 shade 플러그인을 쓴다. 진입점(Main-Class)도 여기서 지정한다.

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <version>3.5.1</version>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>kr.devin.order.App</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>
$ ./mvnw -q package
$ ls -la target/*.jar
-rw-r--r--  286955  target/order-service-1.0.0.jar
-rw-r--r--    2759  target/original-order-service-1.0.0.jar

$ java -jar target/order-service-1.0.0.jar
order = {"sku":"SKU-1","qty":3}

2.7KB짜리와 287KB짜리의 차이가 곧 의존성이다. original- 접두어가 붙은 쪽이 원래 jar이고, 배포하는 것은 큰 쪽이다.

스프링 부트는 shade 대신 중첩 jar 방식을 쓴다. 의존성 jar를 풀지 않고 BOOT-INF/lib/ 에 그대로 넣고 전용 클래스로더로 읽는다. 클래스 파일을 한 곳에 합치지 않으므로 뒤에서 말할 파일 충돌 문제가 없다.

5. 그레이들 대응

메이븐그레이들 (Kotlin DSL)비고
compile 스코프implementation("g:a:v")이 의존성이 내 API 사용자에게 노출되지 않는다
api("g:a:v")노출된다. 라이브러리 모듈에서만 쓴다
providedcompileOnly("g:a:v")롬복 등
runtimeruntimeOnly("g:a:v")JDBC 드라이버
testtestImplementation("g:a:v")

implementationapi 의 구분이 그레이들의 핵심 장점이다. 내부용 의존성을 implementation 으로 선언하면 그 모듈을 쓰는 쪽 컴파일 클래스패스에 올라가지 않아서, 남의 내부 구현에 실수로 기대는 코드를 애초에 못 쓰게 만든다. 멀티 모듈 프로젝트에서 재컴파일 범위도 줄어든다.

6. 실행 옵션도 배포물의 일부다

앞 단원들에서 나온 옵션을 실행 스크립트에 모아 형상 관리한다. 이 파일이 없으면 서버마다 다른 옵션으로 뜨고, 장애 때 원인을 못 찾는다.

#!/bin/sh
# run.sh - 저장소에 함께 커밋한다
exec java \
  -XX:MaxRAMPercentage=70.0 \
  -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/ \
  -Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20M \
  -Dfile.encoding=UTF-8 \
  -Duser.timezone=Asia/Seoul \
  -jar /opt/app/order-service-1.0.0.jar

user.timezone 을 지정하지 않으면 서버의 로케일을 따라가므로, 23단원에서 만든 날짜 관련 로직이 UTC 서버에서만 다르게 동작한다. 컨테이너 이미지는 대개 UTC이고 개발 PC는 KST라, 이 차이가 "테스트는 통과하는데 운영만 하루 밀리는" 버그를 만든다.

실무에서 자주 틀리는 것

1. source/target 을 쓰고 release 를 안 쓴다

JDK 17로 빌드하면서 Java 8 호환을 원한다고 maven.compiler.source=8, target=8 을 지정하는 경우가 많다. 이건 문법만 8로 제한하고 표준 라이브러리는 17 것을 그대로 쓴다. 직접 확인해 보자.

$ javac -source 8 -target 8 T.java     # List.of() 를 쓴 코드
warning: [options] bootstrap class path not set in conjunction with -source 8
1 warning                               ← 경고만 내고 컴파일 성공

$ javac --release 8 T.java
T.java:4: error: cannot find symbol
        List<String> l = List.of("a");   // Java 9+ API
                             ^
1 error                                 ← 제대로 막아 준다

첫 번째로 만든 클래스 파일은 Java 8 JVM에서 실행하는 순간 NoSuchMethodError 로 죽는다. 빌드는 성공하고 배포도 성공했는데 그 코드 경로를 처음 타는 순간 터진다. --release(maven.compiler.release)를 쓰면 그 버전의 API만 쓰도록 컴파일 단계에서 강제된다. 오늘 새 프로젝트를 만든다면 source/target 을 쓸 이유가 없다.

2. 버전을 고정하지 않는다

버전 범위([1.0,2.0))나 SNAPSHOT 의존성을 쓰면 빌드할 때마다 결과가 달라질 수 있다. 어제 통과한 커밋이 오늘 실패하는데 코드는 그대로다. 릴리스 버전을 고정하고, 올릴 때는 의도적으로 올린다. 같은 이유로 메이븐 래퍼(mvnw.mvn 디렉터리)를 저장소에 커밋해야 한다. 그래야 모든 개발자와 CI가 같은 메이븐 버전을 쓴다. JDK 버전도 마찬가지로 문서가 아니라 CI 설정과 도커 베이스 이미지로 고정한다.

3. fat jar에서 리소스가 덮어써진다

shade 방식은 모든 jar의 내용을 한 디렉터리 구조에 합친다. 그래서 여러 라이브러리가 같은 경로의 리소스를 갖고 있으면 나중 것이 앞의 것을 덮어쓴다. 대표적인 것이 META-INF/services/ 아래의 서비스 로더 파일이다. JDBC 드라이버나 로깅 구현이 조용히 사라져 런타임에만 문제가 드러난다. shade의 ServicesResourceTransformer 를 추가해 병합하도록 해야 한다. 서명된 jar가 섞이면 META-INF/*.SF 때문에 SecurityException 이 나므로 그 항목도 걸러야 한다. 이런 이유로 스프링 부트는 아예 중첩 jar 방식을 택했다.

4. 도커 빌드에서 매번 의존성을 다시 받는다

COPY . /appRUN mvn package 하면, 소스 한 줄만 바뀌어도 캐시가 깨져 의존성을 전부 다시 내려받는다. 빌드가 5분씩 걸리는 원인이다. 변하지 않는 것을 먼저 복사하면 해결된다.

COPY pom.xml mvnw ./
COPY .mvn .mvn
RUN ./mvnw -B dependency:go-offline     # 여기까지가 캐시된다

COPY src src
RUN ./mvnw -B -o package -DskipTests

여기서 -DskipTests이미지 빌드 단계에서만 정당하다. 테스트는 CI 파이프라인의 앞 단계에서 이미 돌았다는 전제다. 그 전제 없이 습관적으로 붙이면 테스트가 있으나 마나가 된다. 참고로 -DskipTests 는 컴파일은 하고 실행만 건너뛰지만 -Dmaven.test.skip=true 는 컴파일조차 안 한다. 후자를 쓰면 테스트 코드가 깨진 채로 몇 주가 지나기도 한다.

5. 빌드 산출물과 설정을 함께 굽는다

DB 접속 정보나 API 키를 application.properties 에 넣고 jar에 함께 패키징하면, 환경마다 다시 빌드해야 한다. 같은 산출물을 개발·스테이징·운영에 그대로 올릴 수 없으니, 스테이징에서 검증한 것과 운영에 올라간 것이 다른 파일이 된다. 한 번 빌드한 산출물을 모든 환경에 그대로 올리고, 다른 것은 환경변수와 외부 설정으로만 주입한다. 스프링 부트라면 SPRING_PROFILES_ACTIVE 와 환경변수 오버라이드로 처리한다.

스스로 확인하기

  1. JDK 17로 빌드하고 Java 11 서버에 배포했더니 특정 API 호출에서만 NoSuchMethodError 가 난다. pom에는 <maven.compiler.target>11</maven.compiler.target> 이 있다. 원인과 조치는?
  2. 라이브러리 X(2.0 필요)와 Y(1.5 필요)가 모두 라이브러리 Z에 의존한다. 내 pom에는 X와 Y만 있다. 어떤 버전의 Z가 선택되며, 어떻게 확인하고 어떻게 고정하는가?
  3. JDBC 드라이버 의존성의 스코프를 test 로 잘못 지정했다. 언제 문제가 드러나는가?

정답

  1. 원인은 target 만 지정한 것이다. target클래스 파일의 바이트코드 버전만 11로 맞출 뿐, 컴파일에 쓰이는 표준 라이브러리는 JDK 17 것이다. 그래서 Java 12 이후에 추가된 메서드를 호출해도 컴파일이 통과하고, Java 11 런타임에서 그 메서드를 찾지 못해 죽는다. 조치는 <maven.compiler.release>11</maven.compiler.release> 로 바꾸는 것이다. release 는 해당 버전의 API 시그니처만 노출하므로 같은 코드가 컴파일 에러가 되어 배포 전에 막힌다. 근본적으로는 빌드 JDK와 실행 JDK를 같은 버전으로 맞추는 것이 가장 안전하다.
  2. 메이븐은 "가장 가까운 것이 이긴다"로 정하고, 깊이가 같으면 pom에 먼저 선언된 쪽이 이긴다. 즉 X와 Y가 둘 다 깊이 2라면 X가 먼저 적혔을 때 Z 2.0이 선택된다. 버전이 높은 쪽이 자동으로 선택되는 것이 아니라는 점이 중요하다(그레이들은 반대로 최신 버전을 고른다). 확인은 ./mvnw dependency:tree -Dverbose 로 하며, omitted for conflict with 표시가 어떤 버전이 밀렸는지 알려 준다. 고정하려면 <dependencyManagement> 에 Z의 버전을 명시한다. 여기 적힌 버전은 거리와 무관하게 항상 이긴다. 다만 낮은 버전으로 고정하면 그것을 요구하는 라이브러리가 깨질 수 있으므로, 고정 후에는 통합 테스트로 확인해야 한다.
  3. 테스트는 전부 통과하고 빌드도 성공한다. test 스코프는 테스트 클래스패스에는 올라가기 때문이다. 드러나는 시점은 배포 후 애플리케이션이 처음 DB에 접속할 때이고, 증상은 java.sql.SQLException: No suitable driver found 또는 스프링 부트라면 데이터소스 자동 설정 실패로 인한 기동 실패다. 올바른 스코프는 runtime 이다. 드라이버 클래스를 코드에서 직접 참조하지 않으므로 컴파일에는 필요 없고, 실행에는 반드시 있어야 하기 때문이다. 이 사례는 "빌드와 테스트가 통과했다"가 배포 안전을 보장하지 않는다는 점을 보여 준다. 최소한 기동 확인까지가 파이프라인에 들어가야 한다.