본문으로 건너뛰기

"build-tools" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

모든 태그 보기

Gradle Convention Plugins 삽질기

· 약 5분

오랜만에 Spring Boot 프로젝트를 멀티 모듈로 구성하려고 Gradle 문서를 읽다보니 멀티 프로젝트에서 subprojects {}, allprojects {}의 사용을 더이상 권장하지 않는다는 내용을 보게 되었다.

위와 같은 기존 방식을 cross project configuration 이라고 하는데, 다음과 같은 문제가 있다고 한다.

  • 서브프로젝트의 빌드 스크립트만 봐서는 부모 프로젝트에서 빌드 로직이 주입된다는 것이 분명하게 드러나지 않기 때문에 로직을 파악하기 힘들다.
  • 설정 시점에 프로젝트 간에 커플링이 생기기 때문에 configuration-on-demand와 같은 최적화가 제대로 작동하지 않는다.

큰 프로젝트가 아니라면 사실 별로 상관 없다고 생각하지만, 아무튼 일리가 있다고 생각되니 권장 방식인 Convention Plugins 방식을 사용해본다.

문제 1: UnknownPluginException

공식 문서를 Kotlin DSL 버전으로 따라해보았는데, 다음과 같은 에러가 나면서 잘 되지 않았다.

Build file '/Users/user/myproject/api/build.gradle.kts' line: 1

Plugin [id: 'myproject.java-conventions'] was not found in any of the following sources:

* Try:
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.

* Exception is:
org.gradle.api.plugins.UnknownPluginException: Plugin [id: 'myproject.java-conventions'] was not found in any of the following sources:

- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
- Plugin Repositories (plugin dependency must include a version number for this source)
...

이 문제는, buildSrc/build.gradle.kts를 다음과 같이 만들어주면 해결된다.

plugins {
`kotlin-dsl`
}

repositories {
// for kotlin-dsl plugin
gradlePluginPortal()
}

해당 문서화 버그는 gradle/gradle#19667로 등록되어 있다. (이 자식들 ㅠㅠ)

문제 2: 플러그인 버전 지정 불가

Spring Initializr에서 만들어진 플러그인 설정을 그대로 사용했더니 다음과 같은 에러가 났다.

Invalid plugin request [id: 'org.springframework.boot', version: '2.6.4']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'org.springframework.boot' is an implementation dependency of project ':buildSrc'.
Invalid plugin request [id: 'io.spring.dependency-management', version: '1.0.11.RELEASE']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'io.spring.dependency-management' is an implementation dependency of project ':buildSrc'.
Invalid plugin request [id: 'org.jetbrains.kotlin.jvm', version: '1.6.10']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'org.jetbrains.kotlin.jvm' is an implementation dependency of project ':buildSrc'.
Invalid plugin request [id: 'org.jetbrains.kotlin.plugin.spring', version: '1.6.10']. Plugin requests from precompiled scripts must not include a version number. Please remove the version from the offending request and make sure the module containing the requested plugin 'org.jetbrains.kotlin.plugin.spring' is an implementation dependency of project ':buildSrc'.

일단 plugins {} 블럭에서 다음과 같이 버전을 제거했다.

plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")
kotlin("jvm")
kotlin("plugin.spring")
}

근데 그러면 플러그인 버전을 어떻게 지정해야 할까? 답은 buildSrc/build.gradle.kts에 다음과 같이 의존성을 추가하는 것이다.

dependencies {
implementation("org.springframework.boot:spring-boot-gradle-plugin:2.6.4")
implementation("io.spring.gradle:dependency-management-plugin:1.0.11.RELEASE")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10")
implementation("org.jetbrains.kotlin:kotlin-allopen:1.6.10")
}

참고로 여기에는 플러그인 ID가 아니라, Maven 좌표를 찾아서 적어주었다. Maven 좌표는 plugins.gradle.org에서 플러그인 ID로 검색해서 찾는다.

예를 들어 org.springframework.boot를 찾아서 들어가보면 "Using legacy plugin application:" 아래에 다음과 같이 되어있다.

buildscript {
...
dependencies {
// v~~~~ 이 부분!
classpath "org.springframework.boot:spring-boot-gradle-plugin:2.6.4"
}
}

플러그인 ID만 가지고 할 수 있는 더 좋은 방법이 있을 수도 있는데 찾아보진 않았다. 끝!

Gradle에서 서브 프로젝트를 한 디렉토리에 몰아넣기

· 약 4분

한 문장으로 요약이 잘 안돼서 제목이 이상한데, 읽어보시면 뭔지 알 수 있습니다.

Gradle 멀티프로젝트 기초

일단 간단하게 Gradle에서 멀티프로젝트 설정 방법을 알아봅시다. model, server, util 세 가지 서브프로젝트로 나눈다고 하면 디렉토리 구조는 다음과 같습니다.

  • model/
    • build.gradle
  • server/
    • build.gradle
  • util/
    • build.gradle
  • build.gradle
  • settings.gradle

여기서 settings.gradle에 모든 서브프로젝트를 선언해줍니다.

rootProject.name = "example"

include("model")
include("server")
include("util")

그리고 만약 server에서 model을 참조하고자 한다면 server/build.gradle에서

dependencies {
compile(project(":model"))
}

과 같이 할 수 있습니다.

서브 프로젝트를 한 디렉토리에 몰아넣기

보통 Git 저장소에는 코드만 있는게 아니라 다른 것들도 있습니다. 예를 들면 스크립트를 모아둔 scripts 디렉토리 같은 것인데, 이게 Gradle 프로젝트 디렉토리와 섞여있으면 기분이 나쁩니다. (개인 취향)

그래서 만약 디렉토리 구조를 다음과 같이 변경하고 싶다면,

  • scripts/
  • subprojects/
    • model/
      • build.gradle
    • server/
      • build.gradle
    • util/
      • build.gradle
  • build.gradle
  • settings.gradle

settings.gradle은 다음과 같이 바뀌고...

rootProject.name = "example"

include("subprojects:model")
include("subprojects:server")
include("subprojects:util")

다른 프로젝트를 참조할때도 project(":subprojects:model")과 같이 subprojects:를 꼭 붙여줘야 합니다.

다행히도 Gradle은 논리적인 프로젝트 경로와 실제 프로젝트 디렉토리를 다르게 설정할 수 있습니다. 따라서 settings.gradle를 다음과 같이 구성하면 됩니다.

rootProject.name = "example"

include("model")
include("server")
include("util")

for (project in rootProject.children) {
project.projectDir = file("subprojects/${project.name}")
}

이렇게 하면 서브프로젝트끼리 참조할 때도 project(":model")처럼 해주면 됩니다.

자세한 작동 원리

일단 저렇게 해서 잘 돌아가는 것은 확인했는데 어떻게 해서 되는 것인지 좀 더 알아보았습니다.

  • settings.gradle에서 제공되는 변수, 함수는 Settings 객체에 대응됩니다.
  • rootProjectProjectDescriptor 객체입니다.
  • include(String...)을 호출하면 해당 이름의 프로젝트가 rootProject에 자식으로 추가되고 추가된 프로젝트의 projectDir은 프로젝트 이름과 같게 됩니다.
  • 그러므로, 먼저 include를 한 다음 rootProject.children으로 전체 ProjectDescriptor를 받아올 수 있습니다. (서브 프로젝트가 여러 계층이라면 프로젝트 계층을 탐색해야겠군요.)
  • ProjectDescriptorprojectDir을 실제 원하는 디렉토리로 설정해주면 목적을 달성할 수 있습니다. (projectDirjava.io.File 타입이므로 file(...) 함수를 사용해야 합니다.)

Maven의 Transitive Dependency 길들이기

· 약 6분

Maven으로 의존성을 관리하다보면 라이브러리 버전이 꼬이는 경우가 종종 있습니다. 그동안은 주먹구구식으로 해결하곤 했는데 한번쯤 확실히 알아둬야겠다고 생각해서 정리해 보았습니다.

Transitive Dependency란?

어떤 아티팩트를 의존성으로 추가하면, 그 아티팩트가 가지고 있는 의존성이 함께 딸려옵니다. 그렇게 '딸려온' 의존성을 Transitive Dependency라고 합니다.

아래 의존 관계 트리에서 MyProject ← A이고 A ← X이므로 MyProject ← X의 의존 관계가 생겼습니다.

  • MyProject
    • A
      • X

(참고사항) 의존 관계 디버그

Maven Dependency Plugin을 사용하면 의존 관계 트리를 찍어볼 수 있습니다.

$ mvn dependency:tree
[INFO] [dependency:tree]
[INFO] org.apache.maven.plugins:maven-dependency-plugin:maven-plugin:2.0-alpha-5-SNAPSHOT
[INFO] \- org.apache.maven.doxia:doxia-site-renderer:jar:1.0-alpha-8:compile
[INFO] \- org.codehaus.plexus:plexus-velocity:jar:1.1.3:compile
[INFO] \- velocity:velocity:jar:1.4:compile

또는 IntelliJ에 있는 기능을 사용할 수도 있습니다. (근데 Ultimate Edition에서만 되는 듯 합니다.)

의존 관계 중재 (Dependency Mediation)

의존 관계 트리에 한 아티팩트의 여러 버전이 있으면 어떤 버전이 선택될까요? 가장 가까운 정의가 선택됩니다.

  • MyProject
    • A
      • X 1.0
    • B
      • C
        • X 2.0

위의 트리에서, MyProject 기준으로 X 1.0이 X 2.0보다 가까이 있습니다. 따라서 X 1.0이 선택됩니다.

만약 거리(깊이)가 같으면 어떻게 될까요?

  • MyProject
    • A
      • X 1.0
    • C
      • X 2.0

이 때는 먼저 선언된 쪽이 이깁니다. A가 C보다 먼저 선언되었으므로 X 1.0이 선택됩니다.

원하는 버전으로 고정하기

의존성 사이에 충돌이 일어났을 때 어떤 알고리즘으로 중재되는지 살펴봤습니다. 이제 충돌을 우리가 원하는 버전으로 해결하는 방법을 알아보겠습니다.

방법 1: 직접 의존성으로 포함

MyProject에 원하는 버전의 아티팩트를 직접 포함시키면, 이것이 가장 가까운 의존성이 되므로 항상 선택됩니다.

  • MyProject
    • A
      • X 1.0
    • B
      • C
        • X 2.0
    • X 2.0

하지만 MyProject의 코드에서 X를 직접 사용하지 않는다면 불필요한 의존성을 추가한 것이므로 좋은 방법이 아닐 수 있습니다.

방법 2: 원하지 않는 의존성 제외

<exclusion> 설정을 이용하면 원하지 않는 의존성을 제외할 수 있습니다. 다음과 같이 A에서 X를 제외하면 X 2.0이 가장 가까운 의존성이 되어 선택됩니다.

  • MyProject
    • A
      • X 1.0
    • B
      • C
        • X 2.0

이 방법의 단점은 X 1.0에 의존하는 아티팩트가 여러개라면 일일히 제외시켜줘야 한다는 것입니다. 그리고 의존성 버전을 바꾸게 될 때마다 기존에 의도한 버전이 계속 선택되고 있는지 확인해야 합니다.

방법 3: Dependency Management 설정 사용

<dependencyManagement> 설정으로 특정 아티팩트의 딸려온 의존성을 포함한 모든 의존성의 버전을 고정할 수 있습니다.

POM에 다음 내용을 추가하면 모든 X가 기존에 설정된 버전을 무시하고 2.0 버전으로 고정됩니다.

<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>X</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</dependencyManagement>
  • MyProject
    • A
      • X 1.0 X 2.0
    • B
      • C
        • X 2.0

Dependency Management 활용: BOM

규모가 큰 라이브러리는 여러 모듈로 쪼개져서 배포되는 경우가 있습니다. 예를 들어 Jackson은 jackson-core, jackson-databind, jackson-dataformat-yaml 등의 모듈로 나눠져 있습니다.

보통은 문제가 안되지만, 이렇게 나눠진 모듈끼리 버전이 안 맞으면 공포의 ClassNotFoundException을 유발하는 원인이 됩니다. 예를 들어 jackson-core는 2.8인데 jackson-databind는 2.6이라거나요.

그래서 이렇게 쪼개진 라이브러리들은 대부분 "bill of materials" (BOM)을 함께 배포합니다. BOM을 임포트하면 해당 라이브러리의 모든 모듈을 특정 버전으로 고정할 수 있습니다.

다음 내용을 POM에 추가하면 모든 Jackson의 모듈이 2.9.0 버전으로 강제됩니다.

<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>2.9.0</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>

레퍼런스