본문 바로가기
빌드노트 검색

빌드노트

Maven 라이브러리는 빌드되는데 소비 앱에는 클래스가 없다

Maven의 optional 의존성은 제공 라이브러리에는 남아도 소비 앱에 자동 전달되지 않는다. 합성 라이브러리로 트리·컴파일 실패·실행 중 클래스 누락을 비교하고, 사용하는 기능의 의존성을 앱에 직접 선언하는 방법을 확인한다.

Maven으로 라이브러리를 빌드할 때는 보이던 클래스가, 그 라이브러리를 사용하는 앱에서는 없을 수 있다. 라이브러리 POM에 의존성이 적혀 있다는 이유만으로 앱에도 전달된다고 기대했다면 <optional>true</optional>인지 확인한다. 선택적 의존성은 선언한 라이브러리의 클래스패스에는 들어가지만, 그 라이브러리를 사용하는 앱으로 자동 전파되지 않는다. 앱이 해당 기능을 쓴다면 필요한 의존성을 앱에서도 직접 선언해야 한다. Maven의 optional 의존성 설명 (새 탭에서 열림)

소비 앱의 트리부터 확인한다

컴파일 오류가 난 앱 모듈의 pom.xml이 있는 디렉터리에서 다음 명령으로 실제 의존성 트리를 확인한다. Maven과 해당 프로젝트에 맞는 JDK가 필요하며, 아래 명령은 Windows PowerShell용이다. 다른 셸에서는 mvn.cmd를 mvn으로 바꾼다. 여러 모듈을 묶은 최상위 POM의 트리와 혼동하지 말고, 실패한 빌드에 프로필 옵션이 있었다면 같은 옵션을 붙인다.

mvn.cmd -B -ntp org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree

이 명령은 현재 프로젝트가 해석한 의존성 관계를 보여 준다. 플러그인 버전은 아래 실험과 같게 고정했다. 명령 자체가 실패하면 그 오류부터 해결해야 하며, 클래스가 없다는 결론을 내릴 수는 없다. dependency:tree 3.8.1 문서 (새 탭에서 열림)

차이를 확인하려고 작은 라이브러리 두 개와 이를 사용하는 앱을 만들었다. codec:1.0은 문자열 앞에 csv:를 붙인다. report-lib는 이 codec을 호출하며, 앱은 report-lib를 사용한다. report-lib:1.0과 1.1의 Java 소스는 같고, 버전 표기와 codec 의존성의 optional 설정만 다르게 했다. 모든 실험 의존성의 scope는 compile로 유지했다.

다음은 report-lib:1.1의 일반 dependencies 안에 넣은 항목이다. 자신이 사용하는 라이브러리에서도 앱이 실제 참조하는 버전의 POM에서 대응하는 선언을 확인한다. 개발 중인 다른 버전의 POM을 읽으면 소비 앱의 트리와 맞지 않을 수 있다.

<dependency>
  <groupId>lab.optional</groupId>
  <artifactId>codec</artifactId>
  <version>1.0</version>
  <scope>compile</scope>
  <optional>true</optional>
</dependency>

report-lib:1.0에서는 이 항목의 optional 줄을 생략했다. 두 버전을 각각 빌드하고 전용 로컬 저장소에 설치하자 모두 BUILD SUCCESS로 끝났다. 선택적 의존성을 선언한 report-lib:1.1 자체의 트리에도 codec:jar:1.0:compile (optional)이 있었다. 라이브러리가 codec을 사용해 컴파일되는 것은 그대로였다.

그런데 앱에서 위 트리 명령을 실행하자 다음 차이가 났다. 아래는 직접 얻은 출력에서 [INFO] 접두어와 빌드 안내를 덜어 내고 조건 이름을 붙인 것이다.

일반 의존성을 가진 report-lib:1.0을 사용하는 앱
lab.optional:app:jar:1.0
\- lab.optional:report-lib:jar:1.0:compile
   \- lab.optional:codec:jar:1.0:compile

optional 의존성을 가진 report-lib:1.1을 사용하는 앱
lab.optional:app:jar:1.0
\- lab.optional:report-lib:jar:1.1:compile

두 번째 앱에는 report-lib만 들어오고 codec은 빠졌다. 파일은 같은 로컬 저장소에 이미 설치돼 있었다. 이 비교는 다운로드 실패가 아니라, 어느 프로젝트에 의존성을 전달할지의 차이를 보여 준다. codec의 scope나 버전을 바꾸지 않았으므로 scope에 따른 사용 단계나 버전 충돌 중재와도 구분할 수 있었다.

자기 앱의 트리에 필요한 의존성이 없다면 제공 라이브러리의 POM에서 optional 여부를 확인한다. 반대로 트리에 이미 있다면 이 실험의 누락 조건과 다르다. 그때는 트리의 좌표·버전이 찾는 클래스와 맞는지, 실제 실행 환경도 그 의존성을 포함하는지 확인할 차례다.

앱도 컴파일되면 괜찮은 걸까

선택적 의존성이 빠져도 실패가 항상 앱 컴파일에서 드러나지는 않았다. 같은 합성 프로젝트에서 앱 코드를 두 가지로 나눠 확인했다.

앱이 import lab.codec.Codec;로 클래스를 직접 사용하면 clean compile이 종료 코드 1로 실패했다. 컴파일러의 핵심 메시지는 package lab.codec does not exist였다. 일반 의존성이 전파되던 조건에서는 같은 앱 소스의 컴파일과 실행이 모두 성공했고 csv:report가 출력됐다.

반면 앱이 Report.export()만 호출하도록 만들면 optional 조건에서도 컴파일은 성공했다. 이 메서드는 String을 반환하고 내부에서 Codec.encode("report")를 호출한다. 앱 소스에 Codec 타입이 직접 나타나지 않아서, 이 예제의 앱 컴파일 경로에는 report-lib만 있어도 충분했다.

그 앱을 Maven이 해석한 실행 의존성과 함께 실행하자 Report.export() 안에서 다음 오류가 났다. 클래스패스 문자열은 dependency 플러그인의 build-classpath로 얻고 앱의 컴파일 결과를 추가했다. 라이브러리 내부 호출을 거쳐 실제로 Codec이 필요해지는 지점에서 멈춘 것이다.

java.lang.NoClassDefFoundError: lab/codec/Codec
Caused by: java.lang.ClassNotFoundException: lab.codec.Codec

같은 앱에서 codec을 호출하지 않는 Report.core()를 선택하면 core:ok가 출력되고 정상 종료했다. 이 결과는 이 합성 라이브러리의 두 메서드에 대한 관찰이다. 실제 라이브러리가 시작할 때 선택 기능까지 초기화한다면 다른 시점에 실패할 수 있다. optional 표시는 Java 코드가 누락된 클래스를 안전하게 처리하도록 바꿔 주지 않는다.

사용하는 기능의 의존성을 앱에 선언한다

앱이 내보내기 기능을 써야 한다면 report-lib:1.1 의존성은 유지하고, 앱 POM의 일반 dependencies에 codec을 직접 추가한다. 실험에서 추가한 항목은 다음과 같다. 합성 좌표이므로 실제 프로젝트에서는 해당 기능이 요구하는 라이브러리와 호환 버전을 사용한다.

<dependency>
  <groupId>lab.optional</groupId>
  <artifactId>codec</artifactId>
  <version>1.0</version>
</dependency>

scope를 생략했으므로 이 선언은 기본값인 compile이다. 이 예제에서는 앱의 직접 호출을 컴파일할 때도, 내보내기 기능을 실행할 때도 같은 라이브러리가 필요하다. 버전이 부모나 BOM에서 이미 관리된다면 프로젝트의 관리 규칙을 따른다. 다만 dependencyManagement에 버전만 적는 것으로 선택적 의존성이 앱에 추가되지는 않는다. 실제 사용 선언은 dependencies에 있어야 한다. Maven의 의존성 관리와 명시적 선언 (새 탭에서 열림)

수정 후에는 먼저 같은 트리 명령을 다시 실행해 필요한 의존성이 앱의 직접 의존성으로 보이는지 확인한다. 실험에서는 report-lib:1.1:compile과 codec:1.0:compile이 앱 바로 아래에 나란히 나타났다. 이어 같은 앱 모듈에서 다음 명령으로 소스를 새로 컴파일한다.

mvn.cmd -B -ntp clean compile

직접 선언을 추가한 앱에서는 이 명령이 종료 코드 0으로 끝났다. 이어 실행한 Report.export() 호출은 csv:report, 앱의 Codec 직접 호출은 csv:direct를 출력했다. 자신의 앱에서도 컴파일 통과에 이어 해당 기능을 실제 실행해 봐야 한다. 컴파일만 성공했던 앞 조건이 실행 중 실패했기 때문이다.

기능을 사용하지 않는 앱이라면 그 의존성을 추가할 이유가 없을 수 있다. 제공 라이브러리의 optional을 일괄 제거하기 전에 어느 소비 앱에서 그 기능이 필요한지 정한다. Maven 공식 가이드도 선택 기능을 쓰는 프로젝트에서 필요한 의존성을 다시 선언하도록 설명한다. optional을 사용하는 조건 (새 탭에서 열림)

직접 확인한 환경은 Windows 11, Maven 3.9.16, OpenJDK 17.0.2, compiler 플러그인 3.14.0과 dependency 플러그인 3.8.1이다. 새 POM과 합성 소스, 분리한 로컬 저장소로 일반 JAR의 트리·컴파일·클래스패스 실행을 비교했다. 실제 외부 라이브러리의 버전 호환성, Spring Boot 재패키징·shading, IDE 실행 설정, 운영 배포물의 포함 여부는 검증하지 않았다.

← 전체 글 보기