Maven 기반 멀티 모듈 프로젝트, Gradle로 전환하기

kindof

·

2023. 5. 21. 13:21

최근 꽤 큰 리소스를 들여 팀의 빌드 툴을 Maven에서 Gradle로 전환하는 작업을 했습니다.

 

Maven을 기반으로 운영하고 있던 멀티 모듈 프로젝트는 빌드 시간도 오래 걸렸고, XML 방식의 의존성 설정이나 플러그인 등은 프로젝트를 운영하는 데 복잡성을 증대시킨다고 생각했기 때문인데요.

 

이번 글에서는 빌드 툴을 Gradle로 전환했던 전반적인 과정과 Gradle을 더 제대로 사용하기 위해 알아야 할 몇 가지 내용을 소개하려고 합니다.

 

1. Why gradle?

왜 Maven에서 Gradle로 빌드 툴을 전환해야 하는가? 에 대한 답은 "가독성 + 빠른 빌드 + 다양한 기능 제공"에 있다고 생각합니다.

 

실제로 저희 프로젝트 중 하나의 프로젝트를 Gradle로 전환했을 때 결과입니다.

...
 
[INFO] Executed tasks
[INFO] ------------------------------------------------------------------------
[INFO] Reactor Summary for project 0.0.1-SNAPSHOT:
[INFO]
[INFO] project .......................................... SUCCESS [  0.146 s]
[INFO] project-core ..................................... SUCCESS [ 19.128 s]
[INFO] project-api ...................................... SUCCESS [  1.209 s]
[INFO] project-service-api .............................. SUCCESS [  3.989 s]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  24.538 s
[INFO] Finished at: 2023-05-19T14:21:29+09:00
[INFO] ------------------------------------------------------------------------
 
Process finished with exit code 0


###############################################################################
2:32:32 PM: Executing 'project-service-api:build -Pprofile=local --parallel --build-cache'...
 
...
BUILD SUCCESSFUL in 7s
 
25 actionable tasks: 2 executed, 23 up-to-date
2:32:39 PM: Execution finished 'project-service-api:build -Pprofile=local --parallel --build-cache'.

기존에 25초 정도 걸리던 빌드 시간이 7초 정도로 확연히 줄었습니다.

 

또한 해당 프로젝트의 core 모듈 기준 pom.xml은 1710 라인이었지만, Gradle 전환 후 build.gradle에서 380 라인으로 줄어들었습니다.

 

사실, build.gradle 기준 380줄도 상당히 길다고 생각할 수 있지만 기존 운영하는 프로젝트는 명시적으로 의존성들을 Exclude하고 있었기 때문에 이를 그대로 가져가는 게 안정적이라고 생각했습니다.

 

어쨌든, Gradle이 Maven과 동일한 역할을 하면서 더 나은 성능을 제공하므로 Gradle을 쓰지 않을 이유가 없겠죠.

 

그럼 이제 Maven에서 Gradle로 이전하기 위한 과정과 Gradle 사용할 때 반드시 알아야 할 내용에 대해 정리해보겠습니다.

 

2. 전반적인 Migration 절차

2-1. 환경

기존 프로젝트는 Maven 3.6.3, Springboot 2.7.3 버전을 사용하고 있었고, Gradle로 이사할 때 사용한 버전은 8.1.1 입니다.

 

Gradle 측에서는 가장 최신 버전을 사용하는 것이 더 나은 빌드 속도와 추가적인 기능들을 제공한다고 했기 때문인데요.

https://docs.gradle.org/current/userguide/performance.html

 

다만, Gradle 6~7 버전에 대한 자료가 인터넷에 더 많기 때문에 Gradle 8 버전에서 달라진 부분들을 적용하는 데 약간의 어려움이 있긴 했습니다.

 

2-2. Init

Gradle이 설치되어 있다면 최상위 프로젝트에서 아래와 같은 명령으로 Init 작업을 수행할 수 있습니다.

~/Desktop/projects/... > gradle init
Starting a Gradle Daemon (subsequent builds will be faster)
 
Found a Maven build. Generate a Gradle build from this? (default: yes) [yes, no] yes
 
Select build script DSL:
  1: Groovy
  2: Kotlin
Enter selection (default: Groovy) [1..2] 1
 
Generate build using new APIs and behavior (some features may change in the next minor release)? (default: no) [yes, no] no
 
> Task :init
Maven to Gradle conversion is an incubating feature.
Get more help with your project: https://docs.gradle.org/8.1.1/userguide/migrating_from_maven.html
 
BUILD SUCCESSFUL in 9s
2 actionable tasks: 2 executed

 

해당 작업을 진행하면 기존 Maven을 인식해서 전반적인 Gradle 환경을 구성해줍니다.

 

이 후에는 기존 pom.xml 파일을 모두 삭제해줍니다. 다만, 같은 프로젝트를 clone해서 Maven으로 작성된 내용을 비교하면서 Migration을 진행해야 합니다!

 

2-3. settings.gradle 작성하기

settings.gradle 파일은 프로젝트 설정을 구성하는 파일입니다.

 

이 파일을 통해 Gradle이 프로젝트의 빌드 구성, 모듈 및 하위 프로젝트 등의 환경 설정을 할 수 있습니다.

 

멀티 모듈 프로젝트에서는 프로젝트의 하위 프로젝트 및 모듈을 정의하고, 이를 통해 멀티 프로젝트 빌드를 수행할 때 프로젝트 간 의존성을 정의하고 각 하위 프로젝트의 빌드 환경을 구성할 수 있습니다.

rootProject.name = 'project'
include(':project-image-api')
include(':project-core')
include(':project-batch')
include(':project-service-api')
include(':project-event-api')
include(':project-async')
include(':project-api')

저희 프로젝트는 위와 같이 크게 7개의 모듈로 이루어져 있었습니다(실제 모듈과 이름은 다릅니다).

 

2-4. build.gradle 작성하기

루트 프로젝트의 build.gradle, 각 서브 프로젝트의 build.gradle에 작성하는 내용은 서비스 환경마다 다를 것 같습니다.

 

하지만 제 생각에서 각 build.gradle이 해야하는 전반적인 역할은 아래와 같다고 생각했습니다.

- rootProject > build.gradle : 프로젝트 전반에 공통으로 사용할 내용 정의

- Core Module > build.gradle : 가장 많은 비즈니스 로직을 담고 있기 때문에 대부분의 의존성을 정의

- Other Modules > build.gradle : 모듈 간의 의존성, 해당 모듈에서만 필요한 의존성과 태스크 등을 정의

 

예를 들어, 루트 프로젝트 하위의 build.gradle에는 아래와 같은 내용을 선언합니다.

...

allprojects {
    group = '...'
    version = '...'
    sourceCompatibility = '17'

    ext {
        defaultProfile = "local"
        if (!project.hasProperty('profile') || !profile) {
            ext.profile = defaultProfile
        }
        deployPath = "deploy"

		// Dependency Versions
        springBootVersion = "2.7.3"
        grpcVersion = "1.34.0"
        cuveVersion = "3.14.0"
        ...
        ...
    }

    apply plugin: 'java-library'
    apply plugin: 'java'
    apply plugin: 'maven-publish'
    apply plugin: 'org.springframework.boot'
    apply plugin: 'io.spring.dependency-management'

    repositories {
        mavenCentral()
        gradlePluginPortal()
        
        ...
        ...
    }

    compileJava.options.encoding = "UTF-8"
    compileTestJava.options.encoding = "UTF-8"

    // 테스트에서 Junit5 사용
    tasks.named('test') {
        useJUnitPlatform()
    }

    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }

    sourceSets {
        main {
            ...
        }

        test {
            ...
        }
    }

    ...
    
    // deploy 경로 삭제해주기
    task deleteDeployModule(type: Delete) {
        delete "../${deployPath}/${project.name}"
    }

    // deploy 경로에 빌드 내용을 복사하고 libs > jar 파일 위치도 조정한다.
    task CopyDeployModule(type: Copy, dependsOn: deleteDeployModule) {
        from 'build'
        into "../${deployPath}/${project.name}"
        from 'build/libs'
        into "../${deployPath}/${project.name}"
    }

    build.finalizedBy(CopyDeployModule)
}


subprojects {
    dependencies {
        // Springboot
        implementation 'org.springframework.boot:spring-boot-starter'
        implementation 'org.springframework.boot:spring-boot-devtools'
        implementation 'org.springframework.boot:spring-boot-starter-validation'

        ...
    }
}

// No springBootApplication
bootJar.enabled = false

plugin 구성과 repository 선언, 빌드 Phase, 전역 변수 선언, 여러 프로젝트에서 사용하게 될 태스크와 의존성 등을 정의했습니다.

 

다음으로 대부분의 모듈이 의존하고 있는 core 모듈같은 경우 아래와 같이 90% 이상을 의존성을 설정하는 데 사용했습니다.

dependencies {
    api ("org.apache.curator:curator-framework:${curatorVersion}") {
        exclude group: "com.google.guava", module: "guava"
        exclude group: "org.slf4j", module: "slf4j-api"
        exclude group: "log4j", module: "log4j"
        exclude group: "org.apache.zookeeper", module: "zookeeper"
    }
    
    ...
}

bootJar {
    enabled = false
}

jar {
    enabled = true
}

사실 여기서 아쉬웠던 부분은 대부분의 의존성 설정을 api로 작성했다는 것입니다.

 

implementation, api 두 방식 모두 자기 자신에 대해서는 runtimeClassPath, compileClassPath 모두에 의존성을 추가합니다. 하지만 core 모듈을 의존하는 다른 프로젝트에서는 implementation은 runtimeClassPath에만 의존성을 추가하는데요.

 

이를 통해 모듈 간의 의존성 전파, Recompile, compileClassPath에서의 불필요한 의존성 추가 및 충돌 등을 방지할 수 있습니다. 

 

그럼에도 불구하고, 기존에 너무 많은 의존성 설정의 스코프가 완벽히 관리되고 있지 못했기 때문에 이를 최적화 된 구조로 다시 작성하는 것은 거의 Maven 에서 Gradle로 전환하는 것만큼의 리소스가 필요하다고 생각하여 별개의 이슈 처리를 했습니다😭

 

한편, Core 모듈은 어플리케이션을 실행시키는 모듈이 아니기 때문에 bootJar.enabled = false, jar.enabled = true 설정을 해주었습니다.

 

이 선언을 통해 불필요한 Executable Jar가 생성되는 것을 막고, Plain Jar만 생성되게 할 수 있습니다.

 

마지막으로 개별 서브 모듈의 build.gradle은 모듈 간 의존성을 정의하고 해당 모듈에서 필요한 의존성이나 태스크를 정의하는 데 사용했습니다.

 

예를 들어, Restdoc을 사용하는 service-api 모듈같은 경우 asciidoctor 관련 설정을 추가했습니다.

plugins {
    id "org.asciidoctor.jvm.convert" version "3.3.2" // gradle version 7 이상
}
ext {
    snippetsDir = file('build/generated-snippets') // {file-name}.adoc 파일들이 생성될 경로 지정
    snippets = "${rootDir}/doc/spring-restdocs/generated-snippets"
}

configurations {
    asciidoctorExt // Asciidoctor 확장하는 종속성에 대한 구성 선언
}

compileTestJava {
    outputs.dir snippetsDir // CompileTestJava Task를 실행할 때 {file.name}.adoc 파일들을 스니펫 경로에 생성하도록 지정
}

asciidoctor {
    inputs.dir snippetsDir // asciidoctor가 adoc 파일을 어느 경로에 생성할지 지정
    configurations 'asciidoctorExt'
    dependsOn compileTestJava

    forkOptions{
        jvmArgs('--add-opens', 'java.base/sun.nio.ch=ALL-UNNAMED',
                '--add-opens', 'java.base/java.io=ALL-UNNAMED')
    }
}

asciidoctor.doFirst {
    delete file("src/main/resources/static/docs")
}

task copyDocument(type: Copy) {
    dependsOn asciidoctor
    // 웹에서 API문서를 조회할 수 있도록 빌드가 끝나면 생성된 html 파일들을 src/main/resources/static/docs에 복사
    // ({file-name}.adoc 파일들이 취합되면 build/docs/asciidoc 경로에 html 파일 생성)
    from file("build/docs/asciidoc")
    into file("src/main/resources/static/docs")
}

jar {
    dependsOn asciidoctor // jar 빌드할 때 asciidoctor, copyDocument 수행
    dependsOn copyDocument
}

build {
    dependsOn asciidoctor // 빌드할 때 asciidoctor 실행
}

dependencies {
    implementation project(':project-core')
    implementation project(':project-api')
    testImplementation project(path: ':project-api', configuration: 'testArtifacts')

    testImplementation("org.springframework.restdocs:spring-restdocs-mockmvc:${restDocsVersion}") {
        exclude(group: "org.springframework", module: "spring-webmvc")
        exclude(group: "org.springframework", module: "spring-core")
        exclude(group: "org.springframework", module: "spring-test")
    }
}

 

마지막으로, Maven 환경에서 사용하고 있던 SourceSet 설정, Copy Task, Git Properties Task, Clean & Delete Task 등을 Gradle로 작성해주면 전반적인 Migration은 마무리됩니다.

 

물론...생각하지 못한 곳에서 터지는 에러나 예외들은 적절히 대응해주셔야 합니다.

 

예를 들어 저같은 경우에는 Servlet 버전과 내장 톰캣에서 사용하는 버전의 충돌로 인해 버전업을 하기도 했고, resources 파일들이 제대로 복사되지 않아 팀 환경에 맞는 Task를 작성하기도 했습니다.

 

 

3. Gradle 제대로 사용하기

마지막으로 Gradle build 방식의 효과를 제대로 느끼기 위해서 몇 가지 알아야 할, 그리고 반드시 적용해야 할 내용이 있습니다.

 

Gradle Wrapper 사용하기

Gradle Wrapper는 프로젝트 안에 정의된 스크립트 파일로 빌드를 진행하기 위해 필요한 Gradle Version을 자동으로 다운로드 합니다.

 

gradle wrapper

이를 통해 로컬 혹은 Jenkins에서 프로젝트를 빌드할 때 다른 어떠한 설정없이 빌드를 진행할 수 있습니다.

 

기존에 Maven을 사용하여 빌드를 할 때는 Jenkins 도커 이미지 안에 Maven과 관련 플러그인 등을 설치했어야 했지만, gradle wrapper를 사용하게 되면서 ./gradlew 명령 하나로 해결할 수 있게 되었습니다.

 

Parallel Build 사용하기

멀티 모듈 프로젝트에서는 하위 모듈부터 상위 모듈까지 순서대로 빌드가 진행됩니다.

 

이 때, '--parallel' 옵션을 사용하면 공통된 의존성을 먼저 처리하고 공통된 의존성이 없다면 병렬 처리가 진행됩니다.

 

예를 들어, 전체 프로젝트를 빌드할 때 --parallel 옵션 없이 빌드를 수행하면 33초 정도 걸리는 반면 --parallel 옵션을 사용했을 시 여러 Task 들이 병렬로 처리되어 13초 안에 모든 빌드가 끝나는 것을 볼 수 있습니다.

 

No --parallel
with --parallel

 

Incremental Build(clean 사용하지 않기)

clean 작업은 build 디렉토리를 깨끗하게 지워줍니다. 그래서 가장 최신의 빌드 결과물을 저장할 수 있도록 합니다. 하지만 실제로 모든 게 지워졌기 때문에 처음부터 모든 걸 빌드해야 하는 가장 큰 병목의 원인이 되기도 합니다.

 

Gradle은 증분 빌드(Incremental Build)라는 기능을 제공하여 변화된 내용(inputs VS outputs)에 대해서만 필요한 Task를 수행하도록 합니다. 

https://docs.gradle.org/current/userguide/incremental_build.html

 

예를 들어, 테스트 클래스를 수정했을 때 Gradle은 메인 클래스 쪽에 대해 다시 컴파일할 이유가 없습니다. 그래서 Incremental Build는 아주 작은 변화밖에 없다면 아주 빠른 빌드 속도를 보장하게 되죠.

그런데 이 때, clean 작업을 하게 되면 Gradle은 inputs에 대한 전부 새로운 outputs를 생성해야 되기 때문에 모든 Task를 다시 수행하게 됩니다.


한편, "파일이 삭제됐을 때 clean을 하지 않으면 빌드 결과에 남아있지 않을까?" 라는 의문이 들 수 있는데요. 이 역시 Gradle에서는 자동으로 빌드 결과에서 삭제해줍니다. 물론 업데이트도 해주죠.

 

실제로 clean을 하고 빌드를 하는 것과 clean 없이 빌드하는 것의 성능 차이가 몇 배 정도 발생했습니다.

 

다만, 프로젝트 환경마다 build > classes 디렉토리에 resources를 복사하는 등의 작업이 있을 수 있는데요. 이 경우 resources Copy 태스크에는 Sync 작업을 걸어서 Main 변경에 대해서는 clean 없이 진행할 수 있지만 resources 변경에 대해서는 Incremental build가 유효하지 않기 때문에 시간이 꽤 소요될 수 있습니다.

 

결론적으로 현재 프로젝트에서 빌드하는 방식에 따라 적절하게 clean 작업을 생략할 수 있다면, 빌드 시간을 많이 단축시킬 수 있다는 것입니다.

 

build cache 사용하기

Gradle은 작업의 입력 및 출력을 기반으로 캐시 키를 생성합니다. 작업의 입력이 동일하면 이전에 저장된 결과를 캐시에서 가져와 재사용합니다.

빌드 캐시는 다양한 작업(컴파일, 테스트, 리소스 처리 등) 수행 중에 발생하는 중복 작업을 피하고, 이미 빌드된 결과를 재사용함으로써 빌드 시간을 줄이는 데 도움을 줍니다.

'--build-cache'  옵션을 사용하면 기존 빌드의 결과물을 사용할 수 있습니다. 

그래서 기존 결과물과 비교했을 때 달라진 점이 없다면 캐시된 빌드 결과물을 이용합니다.

--build-cache

 

repositories 순서 최적화하기

Gradle은 특정 의존성을 저장소에서 찾을 때 저장소를 선언한 순서대로 차례대로 스캔하게 됩니다. 

 

그래서 만약 소수의 의존성을 추가하기 위한 저장소를 맨 위에 선언하면 수 많은 Missing 사건이 발생하여 의존성을 갱신하는 데 시간이 정말 오래 걸리게 됩니다.

 

/main ±  ./gradlew core:build --refresh-dependencies --info | grep missing
Resource missing. [HTTP HEAD: https://plugins.gradle.org/m2/org/springframework/boot/org.springframework.boot.gradle.plugin/2.7.3/org.springframework.boot.gradle.plugin-2.7.3.jar]
Resource missing. [HTTP HEAD: https://plugins.gradle.org/m2/io/spring/dependency-management/io.spring.dependency-management.gradle.plugin/1.1.0/io.spring.dependency-management.gradle.plugin-1.1.0.jar]
Resource missing.
...
...

따라서, 대다수의 의존성을 담는 저장소를 가장 먼저 선언하시는 게 좋습니다.

 

저같은 경우 사내에서 의존성을 모아둔 저장소를 가장 상위에 선언했습니다.

 

4. 정리

Maven에서 Gradle로 전환하는 데 생각보다 오랜 시간이 걸렸습니다.

 

여기에 일일이 정리하지 못한 Maven과 Gradle의 차이, Gradle 빌드 순서와 각 Task 이해하기, Maven 플러그인들을 Gradle로 커스텀화하여 작성하는 것 등의 작업이 쉽지는 않았습니다. 최적화되지 못하거나 엉성한 부분이 있기도 합니다.

 

하지만 이번 작업을 하면서 빌드라는 작업과 Gradle 자체에 대한 많은 이해를 할 수 있었던 것 같습니다.

 

그리고 무엇보다 전체적인 개발의 효율성을 올릴 수 있었다는 데 많은 의미가 있는 것 같습니다. Gradle 자체에 대한 내용이나 Maven에서 Gradle로 빌드 툴을 전환할 필요가 있으신 분들은 꼭 Gradle 공식 문서를 읽어보시길 추천드립니다.

 

감사합니다.