그레이들 모듈을 제작할땐 가급적 implementation을 활용하자
gradle에서 implementation과 api의 의존성 전이 차이를 정리.
모듈 A -> B -> C 가정
나는 B를 만드는 라이브러리 개발자다. B를 납품하면 클라이언트 A가 B를 사용한다. B는 외부 의존성 C가 필요하다.
요약하면 의존성 방향은 아래와 같다.
- A -> B -> C
이제 B를 제작해보자. 먼저 C 의존성을 추가한다.
implementation 'com.project.c:project-c:1.0'그리고 B 로직을 작성한다. 심심치 않게 C를 반환하거나 인자로 받는 코드를 만든다.
public class B {
public C b로직(C c) {
// ..
return new C();
}
}개발이 끝나면 Maven에 올리고, A에서 B를 사용한다. 이제 A에서는 의존성 문제가 생긴다.
implementation은 A에서 C를 직접 의존할 수 없다
A에서 B를 추가해 b로직을 쓰고 싶다.
하지만 인자/반환값으로 C가 쓰이기 때문에 컴파일이 실패한다.
public class A {
public void doB() {
B b = new B();
// C를 활용하는 인자와 반환값은 모두 컴파일이 불가하다
C c = b.b로직(new C());
}
}B에서 C를 implementation으로 추가했다면 C는 전이되지 않는다.
api는 A에서 C를 직접 의존할 수 있다
A에서 C를 못 쓰는 이유는 implementation이 runtimeClasspath에만 전이되기 때문이다.
compileClasspath까지 전이되는 api를 쓰면 해결된다.
implementation과 api의 의존성 전이 차이
api 모듈 -> core 모듈 구조를 예로 든다. core 모듈은 Guava 31을 쓰고, api는 Guava가 없다.
project(':core') {
dependencies {
implementation 'com.google.guava:guava:31.1-jre'
}
}
project(':api') {
dependencies {
implementation project(':core')
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
}api 모듈의 runtimeClasspath, compileClasspath에 core가 추가된 걸 볼 수 있다. Guava는 runtimeClasspath의 core 하위에만 들어간다. 그래서 api 모듈에서는 Guava를 쓸 수 없지만, 런타임에는 core가 쓰는 Guava가 정상 동작한다.

이제 core 모듈의 Guava 의존성을 api로 바꿔보자.
Guava가 두 클래스패스의 core 하위에 모두 들어간다.

버전충돌
api 모듈에서도 Guava 의존성을 추가하는 상황을 생각해보자. api 모듈과 core 모듈의 Guava 버전이 다를 수 있다. 이때 버전 충돌이 발생한다. 일반적으로는 더 높은 버전이 선택된다고 한다.
하지만 직접 테스트해 봤을 때는 달랐다. 내 경우에는 api 모듈에서 지정한 버전이 채택되었다.
api 모듈에서 core의 Guava(31)보다 낮은 버전(30)을 추가했다.
implementation 'com.google.guava:guava:30.1.1-jre'runtimeClasspath를 보면 31이 아니라 30으로 내려간다.

IDE 문제인가 싶어서 명령어로 모든 클래스패스를 확인했다. 전부 30으로 조정되어 있다.
./gradlew :api:dependencies
...
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project :core
| \--- com.google.guava:guava:31.1-jre -> 30.1.1-jre
| +--- com.google.guava:failureaccess:1.0.1
| +--- com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
| +--- com.google.code.findbugs:jsr305:3.0.2
| +--- org.checkerframework:checker-qual:3.8.0
| +--- com.google.errorprone:error_prone_annotations:2.5.1
| \--- com.google.j2objc:j2objc-annotations:1.3
+--- com.google.guava:guava:30.1.1-jre (*)
문서상 기본 규칙은 더 높은 버전 선택이다. 하지만 실제 프로젝트에선 아래 요인 때문에 결과가 달라질 수 있다.
- dependency constraints / platform(BOM)
- resolutionStrategy.force
- version catalog에서 강제/선호 버전 지정
- dependency locking
그래서 아래처럼 원인을 확인하는 게 정확하다.
./gradlew :api:dependencyInsight --dependency guava --configuration runtimeClasspath
./gradlew :api:dependencyInsight --dependency guava --configuration compileClasspath내 케이스도 위 항목 중 하나가 영향을 줬을 가능성이 높다. 추적해서 원인을 명확히 해야 한다.
아래는 나와는 다른 이슈이긴 한데 비슷한 뭔가가 있을 것 같아서 참고 링크. (컴파일 클래스패스는 적용이 안 되고, 런타임 클래스패스는 높은 버전으로 적용된다고 함. 나는 둘 다 안 됨)
api 구성의 단점
여기까지 보면 api 구성이 더 좋아 보일 수 있다.
하지만 편의성이 높아지면 위험성도 커진다.
그래서 기본은 implementation을 권장한다.
api의 단점은 다음과 같다.
- api가 Guava에 직접 접근 가능해져 결합도가 높아진다.
- Guava 버전이 올라가면 api까지 재컴파일해야 한다.
결론
B 모듈 제작자 입장에서는 C 의존성은 api보다 implementation이 기본이다. 의도적으로 C를 노출시키고 싶을 때만 api를 고민하면 된다.
버전 충돌 문제는 api/implementation 차이보다는 충돌 해결 전략을 좀 더 확인해야 할 것 같다.