Back to posts

그레이들 모듈을 제작할땐 가급적 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의 단점은 다음과 같다.

  1. api가 Guava에 직접 접근 가능해져 결합도가 높아진다.
  2. Guava 버전이 올라가면 api까지 재컴파일해야 한다.

결론

B 모듈 제작자 입장에서는 C 의존성은 api보다 implementation이 기본이다. 의도적으로 C를 노출시키고 싶을 때만 api를 고민하면 된다.

버전 충돌 문제는 api/implementation 차이보다는 충돌 해결 전략을 좀 더 확인해야 할 것 같다.