빌드노트
Maven 컴파일은 되는데 직접 실행하면 provided 클래스가 없다
provided 의존성은 컴파일에 있어도 실행 경로에서는 빠진다. 새 JVM의 클래스 누락과 명시적 classpath 실행 성공을 비교하고, 실행 환경의 제공 여부에 따라 scope와 실행 경로를 함께 고치는 방법을 확인한다.
Maven 컴파일을 통과한 코드를 java로 실행했는데 NoClassDefFoundError가 난다면, 오류에 나온 클래스를 담은 라이브러리의 scope가 provided인지 확인한다. provided는 실행 환경이 그 의존성을 제공할 것이라는 선언이다. Maven은 컴파일·테스트에 쓸 수 있게 해 주지만 실행 classpath에서는 제외한다. 터미널에서 새 JVM을 띄웠다고 그 제공자가 생기지는 않는다. Maven의 dependency scope 설명 (새 탭에서 열림)
이 경우에는 빌드를 반복하기보다 실제 실행 명령에서 누가 그 JAR을 넣는지 확인해야 한다. 컨테이너가 제공하는 API인지, 독립 실행하는 앱이 직접 가져가야 할 라이브러리인지에 따라 수정할 곳이 달라진다.
의존성 트리에 있어도 실행 경로에는 없을 수 있다
오류가 lab/message/Message를 가리키고 이 클래스가 lab.provided:message-lib에 들어 있다고 하자. 다음은 Windows PowerShell에서 실패한 앱 모듈의 pom.xml이 있는 폴더에서 실행할 조회다. JDK와 Maven이 필요하며, 좌표는 자기 라이브러리의 groupId:artifactId로 바꾼다. 빌드에 사용한 settings·저장소·profile 옵션이 있다면 동일하게 유지한다.
mvn.cmd -B -ntp org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree "-Dincludes=lab.provided:message-lib"
작은 비교 프로젝트에서 얻은 출력 중 의존성 부분은 다음과 같았다. 플러그인 버전은 3.8.1로 고정했다. 이 목표는 현재 프로젝트가 해석한 의존성 관계를 보여 준다. dependency:tree 3.8.1 (새 탭에서 열림)
[INFO] lab.provided:consumer:jar:1.0
[INFO] \- lab.provided:message-lib:jar:1.0:provided
마지막의 provided가 판단할 부분이다. 라이브러리를 못 내려받은 상태와 다르다. 이 조회와 소비 코드의 컴파일은 모두 성공했지만, 그것만으로 실행할 때도 클래스가 있다는 뜻은 아니었다. 조회 자체가 저장소·인증 오류로 실패했다면 먼저 그 실패를 해결해야 한다. 조회는 성공했는데 항목이 없다면 좌표와 대상 모듈·profile부터 다시 본다.
비교에 사용한 소비 POM에는 다음 의존성이 있었다. 자기 POM에서도 일반 dependencies에 선언된 항목이나 상속받은 scope와 대조한다.
<dependency>
<groupId>lab.provided</groupId>
<artifactId>message-lib</artifactId>
<version>1.0</version>
<scope>provided</scope>
</dependency>
Message 클래스의 text()는 문자열 provided-ok를 반환한다. 소비 앱의 lab.app.Main은 이 클래스를 import하고 System.out.println(Message.text());를 호출한다. 클래스가 필요한 지점을 숨기지 않고 직접 사용하는 예제다.
같은 컴파일 결과를 새 JVM에서 두 번 실행했다
실제 앱과 분리한 생산·소비 프로젝트를 만들고, 생산 프로젝트에서 만든 message-lib-1.0.jar와 POM을 전용 로컬 저장소에 배치했다. Windows 11, PowerShell 7.6.6, Maven 3.9.16, OpenJDK 17.0.2에서 Compiler Plugin 3.14.1과 JAR Plugin 3.5.1의 목표를 직접 실행했다. 기존 캐시를 복사한 전용 저장소에서 Maven을 오프라인으로 실행했다.
provided를 유지한 소비 코드의 컴파일과 일반 JAR 생성은 종료 코드 0이었다. 그 JAR을 새 JVM에서 실행할 때는 의존 JAR의 유무만 바꿨다. 다음 명령은 이미 빌드된 앱 JAR, 라이브러리 JAR, 메인 클래스명을 알고 있을 때 자신의 파일로 적용할 수 있다. 앱 모듈 폴더에서 실행하고, $lib에는 실제 라이브러리 JAR의 절대 경로를 넣는다. 아래 C:\libs 경로는 예시이며, 비교 실험에서는 전용 저장소의 JAR을 지정했다.
$app = '.\target\consumer-1.0.jar'
$lib = 'C:\libs\message-lib-1.0.jar'
java -cp "$app" lab.app.Main
java -cp "$app;$lib" lab.app.Main
첫 명령은 종료 코드 1로 실패했다. 스택 추적에서 핵심 두 줄을 남기면 다음과 같다.
java.lang.NoClassDefFoundError: lab/message/Message
Caused by: java.lang.ClassNotFoundException: lab.message.Message
두 번째 명령은 provided-ok를 출력하고 종료 코드 0으로 끝났다. 같은 앱 JAR과 같은 라이브러리 JAR을 썼고, POM이나 Java 소스는 바꾸지 않았다. 이 비교에서 부족했던 것은 컴파일 산출물이 아니라 새 JVM이 검색할 라이브러리 경로였다. JVM은 이 실행 명령을 받았다고 Maven 로컬 저장소에서 POM의 의존성을 자동으로 찾아 주지 않는다.
Windows의 -cp에서는 항목 사이에 ;를 쓰고 전체 문자열을 인용한다. 앱 JAR 경로와 메인 클래스명도 자기 값으로 바꿔야 한다. 여기서는 -cp와 클래스명으로 실행했으며, java -jar와 혼용하지 않았다. -jar 실행에서는 별도로 지정한 다른 classpath 설정이 무시되므로 같은 방식의 대조가 아니다. Java 17 실행 명령의 classpath·jar 옵션 (새 탭에서 열림)
Maven이 만드는 runtime classpath도 따로 본다
실제 실행 스크립트가 Maven의 실행 의존성을 이용한다면, POM을 보고 추측하는 대신 그 경로를 파일로 확인할 수 있다. 같은 앱 모듈 폴더에서 다음 명령을 실행한다. runtime-cp.txt는 이 조회용 출력 파일이며, 명령이 성공한 뒤 내용을 읽는다.
mvn.cmd -B -ntp org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath "-DincludeScope=runtime" "-Dmdep.outputFile=runtime-cp.txt" "-Dmdep.regenerateFile=true"
Get-Content -LiteralPath '.\runtime-cp.txt'
비교 프로젝트에는 의존성이 하나뿐이어서 provided 조건의 파일은 비어 있었다. 플러그인 로그에도 No dependencies found.가 나왔다. 이는 명령 실패가 아니라 runtime으로 고른 의존성이 없다는 관찰이다. 의존성이 많은 자기 앱에서는 파일 전체가 비어 있는지보다 문제의 JAR 경로가 들어 있는지 본다.
같은 플러그인에서 includeScope=compile과 test로 각각 조회하자 두 경로에는 message-lib-1.0.jar가 들어 있었다. runtime은 compile·runtime 의존성을 고르고, compile은 provided도 포함한다. 이름만 보고 모든 조회가 POM의 scope 하나만 정확히 고르는 필터라고 생각하면 결과를 잘못 읽을 수 있다. build-classpath 3.8.1의 includeScope (새 탭에서 열림)
따라서 컴파일이나 테스트에서 클래스를 찾았다는 결과로 독립 실행까지 보장할 수는 없다. 이번에 테스트에 관해 직접 확인한 것은 classpath 목록이며, 단위 테스트를 실행해 통과시킨 것은 아니다. 이 플러그인이 출력하는 것도 의존 JAR 경로다. 직접 java -cp로 쓸 때는 앱의 JAR이나 target/classes를 별도로 포함해야 한다.
실행 환경이 제공하지 않는다면 선언과 배포를 함께 고친다
앱 소스가 직접 참조하고 실행할 때도 필요한 일반 라이브러리를 외부 환경이 제공하지 않는다면, 기존 의존성의 <scope>provided</scope>를 <scope>compile</scope>로 바꾸거나 scope 줄을 제거한다. scope를 생략했을 때의 기본값은 compile이다. 이 예제에서 runtime으로 바꾸면 main 컴파일 경로에 빠지므로 직접 참조하는 코드의 대안이 아니다. compile·provided·runtime의 차이 (새 탭에서 열림)
별도 소비 프로젝트에서 Java 소스는 그대로 두고 scope만 compile로 바꿨다. 컴파일과 JAR 생성은 다시 성공했고, 위와 같은 runtime 조회에 라이브러리 경로가 들어왔다. 앱 JAR에 그 조회 결과를 더해 새 JVM을 실행하자 provided-ok가 출력됐다.
다만 scope를 고쳤다는 이유만으로 일반 앱 JAR 하나가 독립 실행에 필요한 모든 클래스를 품게 되지는 않는다. compile 조건에서도 앱 JAR만 지정한 실행은 똑같이 실패했다. 두 산출물 모두 lab/app/Main.class는 있었지만 lab/message/Message.class나 중첩된 의존 JAR은 없었다. manifest에도 Class-Path 항목이 없었다. JAR Plugin은 프로젝트의 클래스와 리소스로 JAR을 만드는 도구다. JAR Plugin의 jar:jar 설명 (새 탭에서 열림)
수정 후에는 같은 트리 조회에서 compile로 해석되는지 보고, 앱을 다시 빌드한 뒤 실제 실행 명령이 필요한 JAR들을 classpath에 넣는지까지 확인한다. 이 글의 직접 실행 방식이라면 앞서 성공한 두 번째 java -cp 명령처럼 앱 JAR과 라이브러리 JAR을 함께 지정한다. scope 수정과 실행 파일 배치는 이어지는 두 작업이다.
반대로 대상 컨테이너가 제공해야 하는 API라면 provided를 유지할 근거가 있다. 그때는 컨테이너의 제공 버전과 앱의 요구 버전, 실제 기동 방식을 확인한다. 오류를 없애려고 무조건 같은 라이브러리를 앱에도 더 넣는 것으로 결론 내리지 않는다.
여기서 직접 검증한 범위는 일반 JAR 두 개, Maven의 의존성 경로 선택, 새 java -cp 프로세스의 실패·성공이다. 실제 컨테이너, WAR, Spring Boot fat JAR, IDE 실행과 운영 배포는 검증하지 않았다. 같은 JAR을 명시했는데도 실패한다면 오류가 여전히 같은 클래스를 가리키는지와 그 JAR의 버전·내용을 확인한다. Could not initialize class처럼 초기화 실패를 가리키는 메시지까지 모두 provided 누락으로 단정해서는 안 된다.