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

빌드노트

Maven 의존성 트리에 2.0이 있는데 1.0이 실행되는 이유

Maven은 일반적인 버전 충돌에서 더 가까운 경로를 선택한다. 합성 JAR로 깊이·선언 순서와 실제 로딩 버전을 비교하고, dependencyManagement와 직접 의존성 선언의 선택 기준을 설명한다.

Maven 의존성 트리에 라이브러리 2.0이 보이는데 실행할 때는 1.0이 잡힌다면, 두 버전이 어느 경로로 들어왔는지 먼저 본다. 관리 버전이 따로 없는 일반적인 충돌에서 Maven은 현재 모듈에서 더 가까운 경로의 버전을 선택한다. 숫자가 큰 버전을 선언한 의존성이 있어도 경로가 더 깊으면 생략될 수 있다. Maven 의존성 중재 규칙 (새 탭에서 열림)

이때 필요한 것은 트리에 등장한 모든 버전의 목록보다, 선택된 버전과 omitted for conflict with ...가 붙은 경로다. 그 결과를 보고 전이 의존성의 버전을 관리할지, 내 코드가 직접 쓰는 의존성으로 선언할지 정한다.

2.0은 보였지만 충돌로 생략됐다

이를 확인하려고 실제 서비스와 분리한 멀티 모듈을 만들었다. 환경은 Windows 11, Maven 3.9.16, OpenJDK 17.0.2였고 Dependency Plugin은 3.8.1로 고정했다. Maven 저장소도 새 전용 디렉터리를 썼다. 모든 의존성은 기본 compile 범위의 일반 JAR이며 classifier와 버전 범위는 사용하지 않았다.

직접 만든 lab.mediation:version-marker의 1.0과 2.0에는 같은 이름의 VersionMarker.version() 메서드가 있고, 반환 문자열만 각각 1.0, 2.0이다. 소비 모듈은 bridge → new-api → version-marker:2.0과 legacy-api → version-marker:1.0이라는 두 경로를 갖도록 했다. 앱의 의존성 목록에는 bridge를 먼저 적었다.

verbose 트리의 출력은 다음과 같았다. 읽기 쉽게 Maven 로그의 [INFO] 접두사만 덜어냈다.

lab.mediation:app-nearest:jar:1.0
+- lab.mediation:bridge:jar:1.0:compile
|  \- lab.mediation:new-api:jar:1.0:compile
|     \- (lab.mediation:version-marker:jar:2.0:compile - omitted for conflict with 1.0)
\- lab.mediation:legacy-api:jar:1.0:compile
   \- lab.mediation:version-marker:jar:1.0:compile

앱에서 2.0까지는 의존성 연결 세 번, 1.0까지는 두 번이다. 2.0 옆의 omitted for conflict with 1.0은 2.0이 선택에서 빠지고 1.0이 남았다는 뜻이다. 이 줄에 2.0이 나타난다고 두 버전이 모두 실행 클래스패스에 있다는 뜻은 아니다.

트리만 보지 않고 Dependency Plugin의 build-classpath goal (새 탭에서 열림)로 includeScope=runtime인 의존성 경로를 구했다. 여기에 컴파일한 앱 클래스 디렉터리를 붙여 새 JVM을 실행했다. 표식 메서드를 호출한 출력은 marker=1.0이었고, 클래스의 실제 로딩 위치도 version-marker-1.0.jar였다. version-marker-2.0.jar는 그 클래스패스에 없었다.

같은 그래프에서 앱의 bridge와 legacy-api 선언 순서를 뒤집어도 결과는 1.0이었다. 경로 깊이가 달랐기 때문이다. 반면 bridge를 거치지 않고 앱이 new-api와 legacy-api를 직접 의존하도록 만들어 두 버전의 깊이를 같게 하자, legacy-api를 먼저 적으면 1.0, new-api를 먼저 적으면 2.0이 실행됐다. 이는 같은 깊이에서는 첫 선언을 선택한다는 공식 규칙과 일치했다. 선언 순서를 버전 정책으로 삼으면 이처럼 경로 구성에 따라 결과가 달라질 수 있다.

내 모듈에서도 생략된 줄을 함께 읽는다

JDK와 Maven이 설치된 환경에서 문제 앱의 pom.xml이 있는 디렉터리로 이동한다. 멀티 모듈이면 최상위 집계 POM보다 문제가 나는 소비 모듈을 기준으로 확인한다. 아래는 PowerShell 7용 명령이다. lab.mediation:version-marker를 조사할 라이브러리의 실제 groupId:artifactId로 바꾼다. 평소 빌드에 필요한 프로필과 설정도 동일하게 적용한다.

mvn.cmd -B -ntp org.apache.maven.plugins:maven-dependency-plugin:3.8.1:tree "-Dverbose=true" "-Dincludes=lab.mediation:version-marker"

includes는 해당 라이브러리가 들어오는 경로로 출력을 좁히고, verbose=true는 생략된 노드까지 보여 준다. 기본 verbose 값은 false다. 예제에서는 위 트리가 나왔고 명령의 종료 코드는 0이었다. Dependency Plugin 3.8.1 tree 옵션 (새 탭에서 열림)

출력에서 선택된 버전과 충돌로 생략된 버전의 깊이를 비교한다. version managed from ...가 보인다면 관리 설정으로 바뀐 버전도 함께 읽는다. 이 경우는 가까운 경로만으로 설명할 수 없으므로 현재 POM과 상속한 부모·BOM의 dependencyManagement를 확인한다. 다운로드나 사내 아티팩트 해석 오류로 명령이 실패했다면 아직 선택 결과를 확인한 것이 아니다. 오류를 먼저 해결하고, 필터 결과가 비었다면 좌표·대상 모듈·프로필을 다시 확인한다.

Maven 트리와 실행 결과가 계속 다르면 실행 중인 앱에서 해당 클래스의 로딩 위치도 확인한다. 예제는 Class.forName("lab.marker.VersionMarker")로 얻은 클래스의 getProtectionDomain().getCodeSource().getLocation()을 출력해 JAR 경로를 대조했다. 자기 앱에서는 실제 사용하는 라이브러리 클래스 이름을 대상으로 삼는다. IDE 실행 설정이나 배포 산출물이 다른 JAR을 싣는 경우까지 Maven 트리 하나로 판정하지 않는다.

전이 의존성의 버전은 dependencyManagement로 정한다

합성 앱은 라이브러리 버전 선택만 확인하므로 2.0을 목표 버전으로 정했다. 전이 경로를 유지하면서 그 버전을 사용하려면 소비 앱 POM의 <project> 바로 아래에 다음 관리 항목을 넣을 수 있다. 기존 dependencyManagement가 있다면 그 목록에 합친다. 부모·BOM에서 이미 관리한다면 기존 항목과 재정의 위치부터 확인한다.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>lab.mediation</groupId>
      <artifactId>version-marker</artifactId>
      <version>2.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>

이 설정은 프로젝트의 전이 의존성 버전을 제어한다. 여러 모듈이 같은 정책을 써야 한다면 이 항목을 모듈들이 실제로 상속하는 공통 부모에서 관리할 수도 있다. POM의 Dependency Management 설명 (새 탭에서 열림)

실험에서는 bridge, legacy-api 의존성을 그대로 두고 소비 앱에 위 항목만 추가했다. 트리의 legacy-api 아래가 version-marker:jar:2.0:compile (version managed from 1.0)으로 바뀌었다. 깊은 경로에도 2.0이 나타났지만 그쪽에는 omitted for duplicate가 붙었다. 두 경로가 같은 버전으로 정리돼 중복을 생략한 것이다. 새 JVM의 출력도 marker=2.0, 로딩 위치도 version-marker-2.0.jar로 바뀌었다.

다만 관리 항목만으로 라이브러리가 추가되지는 않는다. 다른 비교 모듈에서는 일반 <dependencies>를 전부 빼고 위 관리 항목만 남겼다. 의존성 클래스패스는 비었고, 같은 표식 클래스를 로딩하자 ClassNotFoundException: lab.marker.VersionMarker와 종료 코드 1이 나왔다. 관리 설정은 의존성이 들어올 때 사용할 버전을 정하는 역할이다.

직접 사용하는 라이브러리라면 의존성도 직접 선언한다

내 소스가 그 라이브러리의 클래스를 직접 사용한다면 소비 앱의 일반 <dependencies>에 선언한다. 이때는 다음 <dependency>를 기존 목록에 추가한다. 위의 관리 설정과 비교하려고, 실험에서는 관리 설정이 없는 원래 그래프에 이 선언만 추가했다.

<dependency>
  <groupId>lab.mediation</groupId>
  <artifactId>version-marker</artifactId>
  <version>2.0</version>
</dependency>

앱에서 한 번에 도달하는 2.0이 생겼고, 트리에는 이 항목이 직접 의존성으로 남았다. legacy-api를 통해 들어오던 1.0에는 omitted for conflict with 2.0이 붙었다. 실제 실행도 2.0이었다. Maven 공식 가이드 역시 소스가 직접 쓰는 의존성은 명시적으로 선언하도록 권한다. 중간 라이브러리의 의존성 변경에 내 소스의 컴파일 조건까지 맡기지 않기 위해서다. 직접 의존성 선언 지침 (새 탭에서 열림)

전이 의존성의 버전 정책만 정하려면 dependencyManagement, 내 코드의 직접 사용을 드러내려면 일반 의존성 선언이 판단 기준이다. 두 설정은 함께 쓸 수도 있다. 어느 쪽이든 수정 후에는 같은 모듈·프로필로 트리를 다시 보고 실제 실행의 로딩 위치까지 대조한다.

이번 검증은 합성 JAR의 선택과 표식 메서드 호출까지다. 중간 모듈은 의존성 경로를 만들기 위한 것이며 실제 라이브러리 API를 사용하는 코드는 없다. 따라서 2.0 선택 성공을 기존 소비 코드의 호환성 검증으로 볼 수 없다. 공식 POM 문서도 관리 버전이 전이 의존성의 요구와 어긋나면 실패할 수 있다고 설명한다. 관리 버전의 주의점 (새 탭에서 열림) 실제 변경에서는 고른 버전의 호환 조건과 프로젝트의 기존 검사를 별도로 확인해야 한다. Maven 4, 버전 범위, 플러그인 자체의 의존성, IDE·컨테이너 클래스 로더와 실행 JAR 재패키징은 이번에 검증하지 않았다.

← 목록으로 돌아가기