Gradle 빌드 성능을 최적화하는 13가지 방법

2025년 5월 26일 · 읽는 시간 58

thumbnail

이전 포스팅에서도 언급한 적이 있었지만, 빌드의 성능은 팀/조직의 개발 생산성에 크게 영향을 미칩니다. 하루에 여러 번 실행되는 빌드에서 작은 지연도 누적이 된다면, 나중에 보았을 때 결론적으로는 큰 시간적 손실로 이어집니다. 이는 CI/CD 환경에서도 마찬가지입니다.

따라서, 빌드 속도 개선을 위해 팀/조직 차원에서 시간을 투자하는 것은 분명히 가치가 있다고 생각합니다.

최적화를 시작하기 전에...

변경 사항을 적용하기 전에 먼저 Build Scan을 통해 전체 빌드 시간, 빌드에서 느린 부분들을 파악해볼 수 있습니다.

참고로, Gradle 4.3 이상의 버전부터 --scan 커맨드 라인 옵션을 통해 Build Scan을 할 수 있습니다.

$ gradle build --scan

더 오래된 Gradle 버전의 경우, Build Scan Plugin User Manual을 참고하시면 됩니다.

빌드가 끝나고, Gradle은 Build Scan을 찾을 수 있는 URL을 제공해줍니다.

BUILD SUCCESSFUL in 2s
4 actionable tasks: 4 executed

Publishing build scan...
https://gradle.com/s/e6ircx2wjbf7e

performance page link on build scan url

만약 Build Scan이 마음에 들지 않는다면, --profile 커맨드 라인 옵션을 사용하여 루트 프로젝트의 build/reports/profile 디렉토리에 HTML 리포트를 생성할 수 있습니다. 이를 Gradle에서는 Profile report라고 부릅니다.

profile report example

다만, 때로는 빌드 스크립트를 아무리 잘 작성해도 빌드가 느린 경우가 있습니다. 이런 상황은 보통 플러그인이나 커스텀 태스크의 내부 구현이 비효율적이거나, 시스템 리소스가 부족할 때 발생합니다. 이런 경우에는 Gradle Profiler를 사용해서 더 깊이 파고들어야 합니다. 일반적인 Build Scan으로는 찾을 수 없는 세밀한 성능 문제들을 발견할 수 있기 때문입니다. 참고로, Gradle ProfilerJProfilerYourKit 같은 전문 프로파일러와 함께 사용할 수 있습니다. 이런 도구들은 메서드 단위까지 들어가서 어느 부분에서 CPU 시간을 많이 소모하는지 보여줍니다.

gradle profiler

이렇게 빌드를 최적화하는 방법을 설명하기 전에 빌드를 검사하는 방법에 대해서 언급한 이유는, 결국 최적화 적용 후 정량적으로 얼마나 개선되었는지 확인하기 위함입니다.

결국 빌드 최적화 과정은 다음과 같은 순서로 진행하는 것이 좋습니다.

  1. 빌드를 검사한다.
  2. 변경 사항을 적용한다.
  3. 빌드를 다시 검사한다.
  4. 개선되었다면 유지하고, 그렇지 않다면 되돌리고 다른 방법을 시도한다.

이제부터는 Gradle 빌드 성능을 최적화 할 수 있는 13가지 방법들에 대해서 본격적으로 소개하도록 하겠습니다.

1. 버전 업데이트

Gradle

Gradle 릴리스는 성능 개선사항을 포함합니다. 오래된 버전을 사용하면 이런 이점을 놓치게 됩니다. 물론 프로젝트 상황에 맞게, 호환성을 유지하는 것도 중요하지만, Gradle은 마이너 버전 간 하위 호환성을 유지하므로 업그레이드 위험도가 낮습니다. 최신 상태를 유지하면 주요 버전 업그레이드도 더 원활해집니다.

단, 메이저 버전 변경은 주의해야합니다!

Gradle Wrapper를 사용해서 원하는 버전(X.X)으로 Gradle 버전을 업데이트할 수 있습니다.

./gradlew wrapper --gradle-version X.X

Java

Gradle은 JVM 위에서 실행되며, Java 업데이트는 종종 성능을 향상시킵니다. 최고의 Gradle 성능을 얻으려면 최신 Java 버전을 사용하는 것이 좋습니다.

단, Compatibility Matrix를 확인하여 Gradle 버전과 Java 버전이 호환되는지 확인해야 합니다!

Plugins

플러그인은 빌드 성능에서 핵심적인 역할을 합니다. 오래된 플러그인은 빌드를 느리게 할 수 있고, 새 버전에는 종종 최적화가 포함되어 있습니다. 특히 Android, Java, Kotlin 플러그인의 경우 더욱 그렇습니다.

예를 들어, ktlint-gradle 플러그인의 경우 릴리즈 노트를 꾸준히 모니터링하여 성능 관점에서의 새 버전 적용을 검토해볼 수 있습니다.

plugins {
    id("org.jlleitschuh.gradle.ktlint") version "<current_version>"
}

2. 병렬 실행 활성화

대부분의 프로젝트는 여러 서브프로젝트로 구성되며, 일부는 독립적입니다. 하지만 기본적으로 Gradle은 한 번에 하나의 태스크만 실행합니다.

다른 서브프로젝트의 태스크를 병렬로 실행하려면 --parallel 플래그를 사용하면 됩니다.

gradle <task> --parallel

병렬 실행을 기본값으로 설정하려면 프로젝트 루트나 Gradle 홈 디렉토리의 gradle.properties에 다음을 추가할 수 있습니다.

gradle.properties

org.gradle.parallel=true

병렬 빌드는 빌드 시간을 크게 개선할 수 있지만, 그 효과는 프로젝트 구조와 서브프로젝트 간 의존성에 따라 달라집니다. 만약 하나의 서브프로젝트가 실행 시간을 독점하거나 서브프로젝트들 간에 의존성이 많다면 병렬화의 이점을 크게 못느낄 수 있습니다. 하지만 대부분의 멀티 프로젝트 빌드에서는 눈에 띄는 빌드 시간 단축을 경험할 수 있습니다.

Build Scan으로 병렬성 시각화

앞서 언급했던 Build ScanTimeline 탭에서 태스크 실행의 시각적 타임라인을 제공합니다. 이를 통해 병렬 실행의 병목 지점을 식별할 수 있습니다.

병렬 실행에서의 병목
병렬 실행에서의 병목

빌드 구성을 조정해서 이 두 개의 느린 태스크를 더 일찍 그리고 병렬로 실행하도록 하면, 전체 빌드 시간이 8초에서 5초로 단축됩니다.

최적화된 병렬 실행
최적화된 병렬 실행

3. Gradle Daemon 재활성화

Gradle Daemon은 다음과 같은 방법으로 빌드 시간을 크게 단축시킵니다.

  • 빌드 간 프로젝트 정보 캐싱
  • JVM 시작 지연을 피하기 위해 백그라운드에서 실행
  • 지속적인 JVM 런타임 최적화의 이점 활용
  • 파일 시스템을 감시해서 무엇을 다시 빌드해야 하는지 판단

Gradle은 기본적으로 Daemon을 활성화하지만, 일부 빌드에서는 이 설정을 무시하기도 합니다. 만약 빌드에서 Daemon이 비활성화되어 있다면, 이를 활성화하는 것만으로도 상당한 성능 향상을 얻을 수 있습니다.

빌드 시 Daemon을 활성화하려면 다음과 같이 할 수 있습니다.

gradle <task> --daemon

이전 Gradle 버전의 경우 gradle.properties에 영구적으로 활성화할 수 있습니다.

org.gradle.daemon=true

개발자 머신에서는 Daemon을 활성화하면 성능이 향상됩니다. CI 머신의 경우 장기 실행 에이전트에서는 이점이 있지만, 단기 실행 환경에서는 그렇지 않을 수 있습니다. Gradle 3.0부터는 메모리 부족 상황에서 Daemon이 자동으로 종료되기 때문에 Daemon을 계속 활성화해두는 것이 안전합니다.

4. Build Cache 활성화

Gradle Build Cache는 특정 입력에 대한 태스크의 출력을 저장하여 성능을 최적화합니다. 동일한 입력으로 태스크가 다시 실행되면 Gradle은 태스크를 다시 실행하는 대신 캐시된 출력을 검색합니다.

개인적으로 결정론적(Deterministic), 순수 함수(Pure Function)의 개념을 빌드 시스템에 적용한 이 철학을 볼 때마다 아름다움을 느끼고 있습니다 :)

기본적으로 Gradle은 Build Cache를 사용하지 않습니다. 빌드 시 활성화하려면 다음과 같이 사용해야 합니다.

gradle <task> --build-cache

영구적으로 활성화하려면 gradle.properties에 추가하면 됩니다.

org.gradle.caching=true

Build Scan으로 빌드 캐시 시각화

앞서 언급했던 Build ScanPerformance 페이지의 Build Cache 탭을 통해 빌드 캐시 효과를 분석하는 데 도움을 줍니다.

  • 캐시와 상호작용한 태스크 수
  • 사용된 캐시 종류
  • 캐시 항목의 전송 및 압축/해제 속도

inspecting the performance of the build cache for a build

Build Cache에 대해서 더 자세한 정보를 얻고 싶다면, Build Cache documentation을 참고하시면 되겠습니다.

5. Configuration Cache 활성화

Configuration Cache는 구성 단계의 결과를 캐싱하여 빌드 속도를 향상시킵니다. 빌드 구성 입력이 변경되지 않았을 때 Gradle이 이 단계를 완전히 건너뛸 수 있게 해줍니다.

다만 몇 가지 제한사항이 있습니다.

  • 모든 핵심 Gradle 플러그인과 기능이 아직 지원되지 않음
  • 빌드와 플러그인이 Configuration Cache 요구사항을 충족하도록 조정이 필요할 수 있음
  • IDE imports와 sync는 Configuration Cache를 사용하지 않음

Configuration Cache가 활성화되면 Gradle은 다음과 같이 동작합니다.

  • 같은 서브프로젝트 내에서도 모든 태스크를 병렬로 실행
  • 의존성 해결(dependency resolution) 결과를 캐시해서 중복 계산을 피함

빌드 구성 입력에는 다음이 포함됩니다. (오번역의 소지가 있어 Document에 표기된 원문 그대로 가져왔습니다.)

  • Init scripts
  • Settings scripts
  • Build scripts
  • System and Gradle properties used during configuration
  • Environment variables used during configuration
  • Configuration files accessed via value suppliers (providers)
  • buildSrc inputs, including configuration files and source files

기본적으로 Gradle은 configuration cache를 사용하지 않습니다. 빌드 시에 활성화하려면 다음과 같이 사용할 수 있습니다.

$ gradle <task> --configuration-cache

영구적으로 활성화하려면 gradle.properties 파일에 다음 설정을 추가하면 됩니다.

org.gradle.configuration-cache=true

마찬가지로 더 자세한 정보를 얻고 싶다면, Configuration Cache documentation을 참고하시면 되겠습니다.

6. Custom Tasks에 대한 Incremental Build 활성화

증분 빌드(Incremental Build)는 동일한 입력으로 이미 실행된 태스크를 건너뛰는 Gradle 최적화 기법입니다. 태스크의 입력과 출력이 마지막 실행 이후 변경되지 않았다면 Gradle은 해당 태스크를 건너뜁니다.

대부분의 내장(built-in) Gradle 태스크는 증분 빌드를 지원합니다. 사용자 정의 태스크(custom tasks)를 호환되게 만들려면 입력과 출력을 지정해야 합니다.

tasks.register("processTemplatesAdHoc") {
    inputs.property("engine", TemplateEngineType.FREEMARKER)
    inputs.files(fileTree("src/templates"))
        .withPropertyName("sourceFiles")
        .withPathSensitivity(PathSensitivity.RELATIVE)
    inputs.property("templateData.name", "docs")
    inputs.property("templateData.variables", mapOf("year" to "2013"))
    outputs.dir(layout.buildDirectory.dir("genOutput2"))
        .withPropertyName("outputDir")

    doLast {
        // 템플릿 처리 로직을 여기에 작성
    }
}

여기서 중요한 점은 withPropertyName()withPathSensitivity() 같은 세부 설정들입니다. PathSensitivity.RELATIVE은 파일의 절대 경로가 아닌 상대 경로만 고려한다는 뜻인데, 이렇게 해야 다른 머신에서도 캐시를 재사용할 수 있습니다. 예를 들어 개발자 원석이가 /Users/onseok/project에서, 개발자 철수가 /Users/chulsoo/project에서 작업해도 상대 경로는 동일합니다!

Build Scan 타임라인으로 증분 빌드 시각화하기

Build Scan의 Timeline 뷰를 보면 증분 빌드의 이점을 얻을 수 있는 태스크들을 식별할 수 있습니다. 이를 통해 Gradle이 태스크를 건너뛸 것으로 예상했는데 왜 실행되었는지 이해할 수 있습니다.

incremental build inspection 위 예시에서는 입력 중 하나인 timestamp가 변경되어서 태스크가 최신 상태가 아니었고, 이로 인해 태스크가 다시 실행되었음을 알 수 있습니다.

개발자는 "코드를 전혀 바꾸지 않았는데 왜 다시 빌드되지?"라고 생각할 수 있는데, 실제로는 timestamp나 현재 시간 같은 예상치 못한 입력이 변경되어서 그런 경우가 많습니다. 이런 걸 찾아내는 게 Build Scan의 진짜 가치라고 생각합니다.

마지막으로 빌드를 최적화하려면 태스크를 실행 시간 순으로 정렬해서 프로젝트에서 가장 느린 태스크들을 식별해보면 좋습니다.

보통 파레토 법칙이 적용되어서 전체 빌드 시간의 80%를 차지하는 태스크가 20% 정도밖에 안 된다고 합니다. 가장 느린 몇 개 태스크만 최적화해도 전체적인 성능 향상 효과가 클 수 있습니다.

7. 특정 개발자 워크플로우를 위한 빌드 생성

실행되지 않는 태스크가 가장 빠른 태스크입니다. 불필요한 태스크를 건너뛰는 것만으로도 빌드 성능을 크게 개선할 수 있습니다. 빌드에 여러 서브프로젝트가 포함되어 있다면, 각각을 독립적으로 빌드하는 태스크를 정의할 수 있습니다. 이렇게 하면 캐싱 효율성을 최대화하고 한 서브프로젝트의 변경이 다른 서브프로젝트에서 불필요한 재빌드를 트리거하는 것을 방지할 수 있습니다. 또한 서로 다른 서브프로젝트에서 작업하는 팀들이 중복 빌드를 피하는 데도 도움이 됩니다.

예를 들어, 프론트엔드 개발자는 백엔드 서브프로젝트를 빌드할 필요가 없으며, 문서 작성자는 프론트엔드나 백엔드 코드를 빌드할 필요가 없습니다.

실제 회사에서 일해보면 이런 상황이 정말 많습니다. 특히 마이크로서비스 아키텍처나 모노레포를 사용하는 프로젝트에서는 한 팀이 작업하는 부분만 빌드하면 되는데, 전체를 빌드하느라 시간을 낭비하는 경우가 많습니다.

대신 전체 프로젝트에 대한 단일 태스크 그래프를 유지하면서 개발자별 특화 태스크를 생성할 수 있습니다. 각 사용자 그룹은 태스크의 하위 집합만 필요로 합니다. 그 하위 집합을 불필요한 태스크를 제외하는 Gradle 워크플로우로 변환하면 됩니다.

Gradle은 효율적인 워크플로를 만들기 위한 여러 기능을 제공합니다.

  • 태스크를 적절한 groups에 할당
  • 집계(aggregate) 태스크 생성: 다른 태스크들에 의존하지만 자체적인 액션은 없는 태스크 (ex. assemble)
  • 구성을 지연시키기 위해 gradle.taskGraph.whenReady()를 사용해서 필요할 때만 검증을 실행

예를 들어 Kotlin Multiplatform 모노레포 환경에서는 이런 집계 태스크(aggregate task) 패턴이 더욱 유용해질 수 있을 것 같습니다.

// 플랫폼별 집계 태스크
tasks.register("buildAndroid") {
    dependsOn(
        ":shared:compileKotlinAndroid",
        ":androidApp:assembleDebug",
        ":feature:auth:compileDebugKotlinAndroid",
        ":feature:home:compileDebugKotlinAndroid"
    )
    group = "platform"
    description = "Android 관련 모듈만 빌드"
}

tasks.register("buildIOS") {
    dependsOn(
        ":shared:compileKotlinIosX64",
        ":shared:compileKotlinIosArm64", 
        ":iosApp:linkDebugFrameworkIosX64",
        ":feature:auth:compileKotlinIosX64",
        ":feature:home:compileKotlinIosX64"
    )
    group = "platform"
    description = "iOS 관련 모듈만 빌드"
}

tasks.register("buildWeb") {
    dependsOn(
        ":shared:compileKotlinJs",
        ":webApp:browserDevelopmentWebpack",
        ":feature:auth:compileKotlinJs",
        ":feature:home:compileKotlinJs"
    )
    group = "platform"
    description = "Web 관련 모듈만 빌드"
}

tasks.register("buildDesktop") {
    dependsOn(
        ":shared:compileKotlinJvm",
        ":desktopApp:jar",
        ":feature:auth:compileKotlinJvm",
        ":feature:home:compileKotlinJvm"
    )
    group = "platform"
    description = "Desktop 관련 모듈만 빌드"
}

이런 식으로 구성하면 Android 개발자는 ./gradlew buildAndroid만 실행해서 iOS 빌드 시간을 절약할 수 있고, 특히 KMP 프로젝트는 플랫폼별 컴파일 시간이 상당하기 때문에 이런 최적화가 더욱 중요하다고 생각합니다.

8. Heap Size 증가

기본적으로 Gradle은 빌드에 512MB의 힙 공간(heap space)을 예약합니다. 이는 대부분의 프로젝트에 충분합니다.

하지만 매우 큰 빌드의 경우 Gradle의 모델과 캐시를 저장하기 위해 더 많은 메모리가 필요할 수 있습니다. 필요한 경우 프로젝트 루트나 Gradle 홈 디렉토리의 gradle.properties에서 힙 크기를 늘릴 수 있습니다.

org.gradle.jvmargs=-Xmx2048M

더 자세한 정보를 얻고 싶다면, JVM Memory Configuration을 참고하시면 되겠습니다.

9. Configuration 최적화

Gradle 빌드는 초기화(initialization), 구성(configuration), 실행(execution) 의 세 단계를 거칩니다. 구성(configuration) 단계는 실행되는 태스크에 관계없이 항상 실행됩니다. 이 단계에서 비용이 많이 드는 작업은 gradle helpgradle tasks 같은 간단한 명령어도 느리게 만듭니다.

구성 단계가 느린 것의 영향을 최소화하기 위해 configuration cache를 활성화할 수도 있습니다. 하지만 캐싱을 사용하더라도 구성 단계는 가끔씩 실행됩니다. 따라서 최적화는 여전히 중요합니다.

많은 개발자들이 "어차피 configuration cache가 있으니까 구성 단계는 신경 안 써도 되겠지"라고 생각하는데, 캐시가 무효화되는 상황은 생각보다 자주 발생합니다. gradle.properties 파일 수정, 새로운 브랜치 체크아웃, 환경변수 변경 등 작은 변화만으로도 캐시가 무효화될 수 있습니다.

비용이 많이 드는 작업이나 블로킹 작업 피하기

시간 소모적인 작업은 구성 단계에서 피해야 합니다. 하지만 때로는 예상치 못하게 들어갈 수 있습니다. 빌드 스크립트에서 데이터를 암호화하거나 원격 서비스를 호출하는 것은 명백히 문제가 되지만, 이런 로직이 플러그인이나 커스텀 태스크 클래스 내부에 숨어있는 경우가 많습니다. 플러그인의 apply() 메서드나 태스크의 생성자에서 비용이 많이 드는 작업을 하는 것은 피해야합니다.

class ExpensivePlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        // ❌ 나쁨: 구성 시간에 비용이 많이 드는 네트워크 호출
        def response = new URL("https://example.com/dependencies.json").text
        def dependencies = new groovy.json.JsonSlurper().parseText(response)

        dependencies.each { dep ->
            project.dependencies.add("implementation", dep)
        }
    }
}

대신 이렇게 해야 합니다.

class OptimizedPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        project.tasks.register("fetchDependencies") {
            doLast {
                // ✅ 좋음: 태스크가 실행될 때만 실행
                def response = new URL("https://example.com/dependencies.json").text
                def dependencies = new groovy.json.JsonSlurper().parseText(response)

                dependencies.each { dep ->
                    project.dependencies.add("implementation", dep)
                }
            }
        }
    }
}

실제로 이런 실수를 하는 경우가 정말 많습니다. 특히 외부 API에서 설정을 가져오거나, 파일을 읽어서 처리하는 로직을 플러그인 apply 단계에서 해버리는 경우 등... 저도 예전에 빌드할 때마다 Git 정보를 가져와서 버전을 생성하는 로직을 구성 단계에 넣었다가 gradle tasks 명령어조차 느려져서 고생한 적이 있습니다.

필요한 곳에만 플러그인 적용하기

적용된 각 플러그인이나 스크립트는 구성 시간을 추가하며, 일부 플러그인은 다른 플러그인보다 더 큰 영향을 미칩니다. 플러그인을 아예 피하기보다는, 필요한 곳에서만 적용되도록 해야 합니다. 예를 들어 allprojects {}subprojects {}를 사용하면 모든 서브프로젝트에 플러그인이 적용될 수 있는데, 모든 서브프로젝트에서 필요하지 않을 수도 있습니다.

아래 예시에서는 루트 빌드 스크립트가 세 개의 서브프로젝트에 script-a.gradle을 적용합니다.

subprojects {
    apply from: "$rootDir/script-a.gradle"  // ❌ 모든 서브프로젝트에 불필요하게 적용
}

script a gradle

이 스크립트는 서브프로젝트당 1초씩 걸려서 총 3초의 구성 단계 지연을 만듭니다.

이를 최적화하려면,

  • 하나의 서브프로젝트에서만 스크립트가 필요하다면, 다른 곳에서는 제거해서 구성 지연을 2초 줄일 수 있습니다.
project(":subproject1") {
    apply from: "$rootDir/script-a.gradle"  // ✅ 필요한 곳에만 적용
}

project(":subproject2") {
    apply from: "$rootDir/script-a.gradle"
}
  • 여러 서브프로젝트에서 스크립트를 사용하지만 전부는 아니라면, 이를 buildSrc 내부의 커스텀 플러그인으로 리팩토링하고 관련 서브프로젝트에만 적용할 수 있습니다. 이렇게 하면 구성 시간이 줄어들고 코드 중복도 피할 수 있습니다.
plugins {
    id 'com.example.my-custom-plugin' apply false  // ✅ 플러그인을 선언하되 전역 적용하지 않음
}

project(":subproject1") {
    apply plugin: 'com.example.my-custom-plugin'  // ✅ 필요한 곳에만 적용
}

project(":subproject2") {
    apply plugin: 'com.example.my-custom-plugin'
}

이 부분에서 apply false가 핵심입니다. 플러그인을 선언만 하고 바로 적용하지는 않겠다는 의미이기 때문입니다. 그러면 각 서브프로젝트에서 필요할 때만 선택적으로 적용할 수 있습니다. 특히 Android 프로젝트에서 이런 패턴을 자주 쓰는데, 모든 모듈이 Android framework가 필요한 것은 아니기 때문입니다.

태스크와 플러그인 정적 컴파일하기

많은 Gradle 플러그인과 태스크들이 간결한 문법, 함수형 API, 강력한 확장 기능 때문에 Groovy로 작성됩니다. 하지만 Groovy의 동적 해석은 Java나 Kotlin보다 메서드 호출을 느리게 만듭니다. 동적 기능이 필요하지 않은 Groovy 클래스에 @CompileStatic 어노테이션을 추가해서 정적 Groovy 컴파일을 사용하면 이런 비용을 줄일 수 있습니다. 메서드에서 동적 동작이 필요하다면 해당 메서드에 @CompileDynamic을 사용할 수 있습니다. 아니면 기본적으로 정적 컴파일되는 JavaKotlin으로 플러그인과 태스크를 작성하는 것을 고려해볼 수 있습니다.

Gradle의 Groovy DSLGroovy의 동적 기능에 의존합니다. 플러그인에서 정적 컴파일을 사용하려면 더 Java 같은 문법을 채택해야 합니다.

아래 예시는 동적 기능 없이 파일을 복사하는 태스크를 정의합니다.

// src/main/groovy/MyPlugin.groovy
project.tasks.register('copyFiles', Copy) { Task t ->
    t.into(project.layout.buildDirectory.dir('output'))
    t.from(project.configurations.getByName('compile'))
}

이 예시는 태스크, 구성, 의존성, 확장 등 모든 Gradle 도메인 객체 컨테이너에서 사용 가능한 register()getByName()을 사용합니다. TaskContainer 같은 일부 컨테이너는 태스크 타입을 받는 create 같은 특화된 메서드도 있습니다.

정적 컴파일을 사용하면 다음을 통해 IDE 지원이 개선됩니다.

  • 인식되지 않는 타입, 프로퍼티, 메서드의 빠른 감지
  • 메서드 이름에 대한 더 신뢰할 수 있는 자동 완성

10. Dependency resolution 최적화

의존성 해결(Dependency resolution)은 서드파티 라이브러리를 프로젝트에 통합하는 것을 단순화합니다. Gradle은 원격 서버에 접속해서 의존성을 발견하고 다운로드합니다. 의존성이 참조되는 방식을 최적화해서 이런 원격 호출을 최소화할 수 있습니다.

불필요하고 사용하지 않는 의존성 피하기

서드파티 라이브러리와 그들의 전이 의존성을 관리하는 것은 상당한 유지보수 및 빌드 시간 비용을 추가합니다. 사용하지 않는 의존성은 리팩토링 후에도 종종 남아있습니다.

라이브러리의 작은 부분만 사용한다면 다음을 고려해볼 수 있습니다.

  • 필요한 기능을 직접 구현하기
  • 라이브러리가 오픈소스라면 필요한 코드를 복사하기 (출처 표기와 함께)

특히 Android 프로젝트에서는 APK 크기도 고려해야 하니까 더욱 중요한 부분입니다.

저장소 순서 최적화

Gradle은 선언된 순서대로 저장소를 검색합니다. 해결 속도를 높이려면 대부분의 의존성을 호스팅하는 저장소를 먼저 나열해서 불필요한 네트워크 요청을 줄이세요.

repositories {
    mavenCentral()  // ❌ 먼저 선언되었지만 대부분의 의존성은 JitPack에 있음
    maven { url "https://jitpack.io" }
}

개발하다보면 이런 실수를 정말 많이 합니다. 저도 회사 프로젝트에서 사내 Nexus 저장소를 마지막에 선언해놨다가 모든 의존성을 Maven Central에서 먼저 찾으려고 시도해서 resolution 시간이 오래 걸렸던 적이 있습니다.

저장소 개수 최소화

필수적인 저장소의 개수를 최소한으로 제한해볼 수도 있습니다. 커스텀 저장소를 사용한다면, 여러 저장소를 집계(aggregate)하는 가상 저장소를 만들어서 그 저장소만 빌드에 추가합니다.

repositories {
    maven { url "https://repo.mycompany.com/virtual-repo" } // ✅ 집계된 저장소 사용
}

동적 및 스냅샷 버전 최소화

동적("2.+")버전과 스냅샷("-SNAPSHOT")버전은 Gradle이 원격 저장소를 자주 확인하게 만듭니다. 기본적으로 Gradle은 동적 버전을 24시간 동안 캐시하지만, cacheDynamicVersionsForcacheChangingModulesFor 프로퍼티로 구성할 수 있습니다.

configurations.all {
    resolutionStrategy {
        cacheDynamicVersionsFor 4, 'hours'
        cacheChangingModulesFor 10, 'minutes'
    }
}

빌드 파일이나 초기화 스크립트에서 이 값들을 낮추면 Gradle이 저장소를 더 자주 조회합니다. 빌드할 때마다 의존성의 절대 최신 릴리스가 필요하지 않다면, 이런 설정의 커스텀 값을 제거하는 것을 고려해볼 수 있습니다.

Build Scan으로 동적 및 변경 버전 찾기

동적 의존성을 찾으려면 Build Scan을 사용할 수 있습니다. 가능한 곳에서는 더 나은 캐싱을 위해 동적 버전을 "1.2"나 "3.0.3.GA" 같은 고정 버전으로 바꾸는 것을 추천드립니다.

동적 버전은 개발할 때는 편리하지만 재현 가능한 빌드를 만들기 어렵게 합니다. 특히 CI/CD에서 "어제는 되던 빌드가 오늘은 안 돼요"라는 상황이 나올 수 있습니다.

구성 중 의존성 해결 피하기

의존성 해결(Dependency resolution)은 I/O 집약적인 프로세스입니다. Gradle이 결과를 캐시하지만, 구성 단계에서 해결을 트리거하면 모든 빌드에 불필요한 오버헤드가 추가됩니다.

예를 들어, 이 코드는 구성 중에 의존성 해결을 강제해서 모든 빌드를 느리게 만듭니다.

task printDeps {
    doFirst {
        configurations.compileClasspath.files.each { println it } // ✅ 의존성 해결 지연
    }
    doLast {
        configurations.compileClasspath.files.each { println it } // ❌ 구성 중 의존성 해결
    }
}

선언적 구문으로 전환

구성 단계에서 구성 파일을 평가하면 Gradle이 의존성을 너무 일찍 해결하게 되어 빌드 시간이 증가합니다. 일반적으로 태스크는 실행 중에 필요할 때만 의존성을 해결해야 합니다. 구성의 모든 파일을 출력하고 싶은 디버깅 시나리오를 생각해볼 때 발생하는 흔한 실수는 빌드 스크립트에서 직접 출력하는 것입니다.

tasks.register<Copy>("copyFiles") {
    println(">> Compilation deps: ${configurations.compileClasspath.get().files.map { it.name }}")
    into(layout.buildDirectory.dir("output"))
    from(configurations.compileClasspath)
}

files 프로퍼티는 printDeps가 실행되지 않더라도 즉시 의존성 해결을 트리거합니다. 구성 단계는 모든 빌드에서 실행되므로 이것이 모든 빌드를 느리게 만듭니다. doFirst()를 사용하면 Gradle이 태스크가 실제로 실행될 때까지 의존성 해결을 지연시켜서 구성 단계의 불필요한 작업을 방지합니다.

tasks.register<Copy>("copyFiles") {
    into(layout.buildDirectory.dir("output"))
    // 태스크 액션에서 프로젝트를 참조하는 것은 configuration cache와 호환되지 않으므로 구성을 변수에 저장합니다.
    val compileClasspath: FileCollection = configurations.compileClasspath.get()
    from(compileClasspath)
    doFirst {
        println(">> Compilation deps: ${compileClasspath.files.map { it.name }}")
    }
}

Gradle Copy 태스크의 from() 메서드는 해결된 파일이 아닌 의존성 구성을 참조하기 때문에 즉시 의존성 해결을 트리거하지 않습니다. 이는 Copy 태스크가 실행될 때만 의존성이 해결되도록 보장합니다.

"디버깅용으로 한 줄만 추가했는데"라고 생각할 수 있지만, 그 한 줄이 모든 빌드를 몇 초씩 느리게 만들 수 있습니다.

Build Scan으로 의존성 해결 시각화

Build ScanPerformance 페이지에 있는 Dependency resolution 탭은 구성 및 실행 단계에서의 의존성 해결 시간을 보여줍니다. dependency resolution configuration time

Build Scan은 이 문제를 식별하는 또 다른 수단을 제공합니다. 빌드는 project configuration 중에 의존성 해결에 0초를 소비해야 합니다. 이 예시는 빌드가 라이프사이클에서 너무 일찍 의존성을 해결한다는 것을 보여줍니다. Performance 페이지의 Settings and suggestions 탭에서도 찾을 수 있습니다. 이는 구성 단계에서 해결된 의존성을 보여줍니다.

커스텀 의존성 해결 로직 제거 또는 개선

Gradle은 사용자가 의존성 해결을 유연한 방식으로 모델링할 수 있게 해줍니다. 특정 버전을 강제하거나 의존성을 대체하는 것 같은 간단한 커스터마이징은 해결 시간에 최소한의 영향을 미칩니다. 하지만 POM 파일을 수동으로 다운로드하고 파싱하는 것 같은 복잡한 커스텀 로직은 의존성 해결을 현저히 느리게 만들 수 있습니다. Build Scan이나 Profile Report를 사용해서 커스텀 의존성 해결 로직이 성능 문제를 일으키지 않는지 확인해볼 수 있습니다. 이런 로직은 빌드 스크립트에 있거나 서드파티 플러그인의 일부일 수 있습니다.

아래는 커스텀 의존성 버전을 강제하지만 해결을 느리게 만드는 비용이 많이 드는 로직도 적용되는 예시입니다.

configurations.all {
    resolutionStrategy.eachDependency { details ->
        if (details.requested.group == "com.example" && details.requested.name == "library") {
            def versionInfo = new URL("https://example.com/version-check").text  // ❌ 해결 중 원격 호출
            details.useVersion(versionInfo.trim())  // ❌ HTTP 응답을 기반으로 동적으로 버전 설정
        }
    }
}

의존성 버전을 동적으로 가져오는 대신, version catalog에 정의해야 합니다.

dependencies {
    implementation "com.example:library:${versions.libraryVersion}"
}

처음에는 "자동으로 최신 버전을 가져와서 편리하다"고 생각하지만, 빌드할 때마다 HTTP 호출을 하게 되면 네트워크 상태에 따라 빌드 시간이 들쭉날쭉해질 수 있습니다.

느리거나 예상치 못한 의존성 다운로드 제거

느린 의존성 다운로드는 빌드 성능에 상당한 영향을 미칠 수 있습니다. 일반적인 원인들은 다음과 같습니다.

  • 느린 인터넷 연결
  • 과부하되거나 먼 저장소 서버
  • 동적 버전(2.+)이나 스냅샷 버전(-SNAPSHOT)으로 인한 예상치 못한 다운로드

Build ScanPerformance 탭에는 다음과 같은 Network Activity 섹션(의존성 다운로드에 소요된 총 시간, 다운로드 전송 속도, 다운로드 시간별로 정렬된 의존성 목록)이 있습니다.

slow dependency downloads

이를 통해 예상치 못하게 다운로드된 의존성 목록을 검토해볼 수 있는데, 예를 들어 dynamic 버전(1.+)이 빈번한 원격 조회를 트리거할 수 있습니다.

이러한 불필요한 다운로드를 제거하려면, 더 가깝거나 빠른 저장소(Maven Central에서 다운로드가 느리다면 지리적으로 더 가까운 미러나 내부 저장소 프록시)를 사용해볼 수 있고, 아래와 같이 동적 버전에서 고정 버전으로 전환할 수 있습니다.

groovydependencies {
    implementation "com.example:library:1.+" // ❌ 나쁨
    implementation "com.example:library:1.2.3" // ✅ 좋음
}

11. Java 프로젝트 최적화

다음 섹션들은 java 플러그인이나 다른 JVM 언어를 사용하는 프로젝트에 적용됩니다.

테스트 실행 최적화

테스트는 종종 빌드 시간의 상당 부분을 차지합니다. 여기에는 단위 테스트와 통합 테스트가 모두 포함될 수 있으며, 통합 테스트는 일반적으로 실행 시간이 더 깁니다.

Build Scan은 가장 느린 테스트를 식별하고 그에 따라 성능 개선의 우선순위를 정하는 데 도움이 될 수 있습니다.

tests screen 위 이미지는 테스트 지속 시간별로 정렬된 Build Scan의 대화형 테스트 리포트를 보여줍니다.

앞서 언급했던 파레토 법칙의 예시처럼, Build Scan에서 가장 느린 테스트들을 찾아서 집중적으로 최적화하면 효과가 클 것으로 예상됩니다.

Gradle은 테스트 실행 속도를 높이는 여러 전략을 제공합니다.

  • A. 테스트를 병렬로 실행
  • B. 테스트를 여러 프로세스로 포크
  • C. 필요하지 않을 때 테스트 보고서 비활성화

각 옵션을 자세히 살펴보겠습니다.

A. 테스트를 병렬로 실행

Gradle은 여러 테스트 클래스나 메서드를 병렬로 실행할 수 있습니다. 병렬 실행을 활성화하려면 Test 태스크에서 maxParallelForks 프로퍼티를 설정해야 합니다. 좋은 기본값은 사용 가능한 CPU 코어 수나 그보다 약간 적은 수입니다.

tasks.withType<Test>().configureEach {
    maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
}

병렬 테스트 실행은 테스트들이 격리되어 있다고 가정합니다. 파일 시스템, 데이터베이스, 외부 서비스 같은 공유 리소스는 피해야 합니다. 상태나 리소스를 공유하는 테스트는 경쟁 조건이나 리소스 충돌로 인해 간헐적으로 실패할 수 있습니다.

로컬에서는 잘 되던 테스트가 CI에서 병렬 실행되면서 간헐적으로 실패하는 경우가 정말 많은데, 특히 임시 파일을 만들거나 고정 포트를 사용하는 테스트들이 문제가 될 수 있습니다.

B. 테스트를 병렬로 실행

기본적으로 Gradle은 모든 테스트를 단일 포크된 JVM 프로세스에서 실행합니다. 이는 작은 테스트 스위트에서는 효율적이지만, 크거나 메모리 집약적인 테스트 스위트는 긴 실행 시간과 GC 중단으로 어려움을 겪을 수 있습니다.

forkEvery 설정을 사용해서 지정된 수의 테스트 후에 새 JVM을 포크함으로써 메모리 압박을 줄이고 문제가 있는 테스트를 격리할 수 있습니다.

tasks.withType<Test>().configureEach {
    forkEvery = 100
}

JVM을 포크하는 것은 비용이 많이 드는 작업입니다. forkEvery를 너무 낮게 설정하면 과도한 프로세스 시작 오버헤드로 인해 테스트 시간이 증가할 수 있습니다.

이 설정은 정말 미묘한 균형이 필요합니다. 너무 높게 설정하면 메모리 누수나 상태 오염 문제가 발생하고, 너무 낮게 설정하면 JVM 시작 비용 때문에 오히려 느려집니다. 프로젝트 규모와 테스트 특성에 맞게 튜닝해야 합니다.

C. 테스트 보고서 비활성화

Gradle은 보려고 하지 않더라도 기본적으로 HTMLJUnit XML 테스트 보고서를 생성합니다. 보고서 생성은 특히 대규모 테스트 스위트에서 오버헤드를 추가합니다.

다음의 경우 보고서 생성을 완전히 비활성화할 수 있습니다.

  • 테스트가 통과했는지만 알면 되는 경우
  • 더 풍부한 테스트 인사이트를 제공하는 Build Scan을 사용하는 경우

보고서를 비활성화하려면 reports.html.requiredreports.junitXml.requiredfalse로 설정해야 합니다.

tasks.withType<Test>().configureEach {
    reports.html.required = false
    reports.junitXml.required = false
}

조건부로 보고서 활성화

빌드 파일을 수정하지 않고 가끔씩 보고서가 필요하다면, 프로젝트 프로퍼티에 따라 보고서 생성을 조건부로 만들 수 있습니다.

이 예시는 createReports 프로퍼티가 없으면 보고서를 비활성화합니다.

tasks.withType<Test>().configureEach {
    if (!project.hasProperty("createReports")) {
        reports.html.required = false
        reports.junitXml.required = false
    }
}

보고서를 생성하려면 명령줄을 통해 프로퍼티를 전달해야 합니다.

$ gradle <task> -PcreateReports

또는 프로젝트 루트나 Gradle User Home에 있는 gradle.properties 파일에 프로퍼티를 정의합니다.

createReports=true

특히 CI에서는 보고서가 필요하지만 로컬 개발에서는 불필요한 경우가 많기 때문에 평소에는 빠른 피드백을 위해 보고서 생성을 끄고, 필요할 때만 켜는 이런 조건부 설정이 정말 유용해질 수 있습니다.

컴파일러 최적화

Java 컴파일러는 빠르지만, 수백 또는 수천 개의 클래스가 있는 대규모 프로젝트에서는 컴파일 시간이 여전히 상당할 수 있습니다.

Gradle은 Java 컴파일을 최적화하는 여러 방법을 제공합니다.

  • A. 컴파일러를 별도 프로세스에서 실행
  • B. 내부 의존성에 implementation 가시성 사용

A. 컴파일러를 별도 프로세스로 실행

기본적으로 Gradle은 빌드 로직과 같은 프로세스에서 컴파일을 실행합니다. fork 옵션을 사용해서 Java 컴파일을 별도 프로세스로 오프로드할 수 있습니다.

<task>.options.isFork = true

모든 JavaCompile 태스크에 이 설정을 적용하려면 configureEach를 사용할 수 있습니다.

tasks.withType<JavaCompile>().configureEach {
    options.isFork = true
}

Gradle은 빌드 기간 동안 포크된 프로세스를 재사용하므로 시작 비용이 낮습니다. 컴파일을 자체 JVM에서 실행하면 메인 Gradle 프로세스의 가비지 컬렉션을 줄이는 데 도움이 되어 빌드의 나머지 부분을 가속화할 수 있습니다. 특히 병렬 실행과 함께 사용할 때 더욱 그렇습니다. 포크 컴파일은 작은 빌드에서는 거의 효과가 없지만 단일 태스크가 천 개 이상의 소스 파일을 컴파일할 때는 상당히 도움이 될 수 있습니다.

이 최적화는 대규모 프로젝트에서 특히 빛을 발할 것 같습니다. 메인 Gradle 프로세스에서 GC가 자주 발생하면 전체 빌드가 멈춘 것처럼 느껴지는데, 컴파일러를 분리하면 훨씬 안정적인 성능을 얻을 수 있습니다.

B. 내부 의존성에 implementation 사용

Gradle 3.4 이후에서는 다운스트림 프로젝트에 노출되어야 하는 의존성에는 api를, 내부 의존성에는 implementation을 사용할 수 있습니다. 이 구분은 대규모 멀티 프로젝트 빌드에서 불필요한 재컴파일을 줄입니다.

implementation 의존성이 변경되면 Gradle은 다운스트림 소비자를 재컴파일하지 않습니다. api 의존성이 변경될 때만 재컴파일합니다. 이는 연쇄 재컴파일을 줄이는 데 도움이 됩니다.

dependencies {
   api(project("my-utils"))
   implementation("com.google.guava:guava:21.0")
}

내부 전용 의존성을 implementation으로 전환하는 것은 대규모 모듈식 코드베이스에서 빌드 성능을 개선하기 위해 할 수 있는 가장 영향력 있는 변경 중 하나입니다.

이게 정말 핵심입니다! 많은 개발자들이 apiimplementation의 차이를 모르고 무분별하게 api를 사용하는데, 이것 하나만 제대로 해도 빌드 시간이 극적으로 줄어들 수 있습니다. 특히 공통 유틸리티 모듈을 수정했을 때 전체 프로젝트가 재컴파일되는 것을 방지할 수 있습니다.

12. Android 프로젝트 최적화

이 가이드에서 설명한 모든 성능 전략들은 Android 빌드에도 적용됩니다. Android 프로젝트는 내부적으로 Gradle을 사용하기 때문입니다.

하지만 일반적인 Java 프로젝트와 달리 Android는 추가적인 복잡성을 가지고 있습니다. 리소스 처리(이미지, 레이아웃, 문자열 등)는 컴파일 시간에 상당한 영향을 미치고, debug/release 같은 빌드 변형들은 빌드 시간을 배로 늘릴 수 있습니다.

Android에 특화된 추가 팁들은 Android 팀의 공식 리소스 확인을 추천드립니다.

13. 오래된 Gradle 릴리스 성능 개선

최신 성능 개선사항, 버그 수정, 기능의 이점을 얻기 위해 최신 Gradle 버전을 사용하는 것을 권장합니다. 하지만 일부 프로젝트들, 특히 오래 지속되거나 레거시 코드베이스는 쉽게 업그레이드하지 못할 수 있습니다.

오래된 Gradle 버전을 사용하고 있다면, 빌드 성능을 개선하기 위해 다음 최적화들을 고려해볼 수 있습니다.

Daemon 활성화

Gradle Daemon은 빌드 간 JVM 시작 비용을 피함으로써 빌드 성능을 크게 개선합니다. Daemon은 Gradle 3.0부터 기본적으로 활성화되었습니다. 더 오래된 버전을 사용하고 있다면, Gradle을 업그레이드하는 것을 고려해보세요. 업그레이드가 옵션이 아니라면, Daemon을 수동으로 활성화할 수 있습니다.

# gradle.properties
org.gradle.daemon=true

Gradle 3.0 이전 버전을 아직 사용하고 있다면 정말 업그레이드를 진지하게 고려해봐야 합니다. 그 정도로 오래된 버전이면 보안 문제나 호환성 문제도 있을 수 있다고 생각합니다... 하지만 어쩔 수 없는 상황이라면 최소한 Daemon만이라도 켜두는 것이 중요합니다.

증분 컴파일 활성화

Gradle은 클래스 의존성을 분석해서 변경의 영향을 받는 코드 부분만 재컴파일할 수 있습니다. 증분 컴파일은 Gradle 4.10 이후부터 기본적으로 활성화됩니다. 이전 버전에서 수동으로 활성화하려면 build.gradle 파일에 다음 구성을 추가해야 합니다.

tasks.withType<JavaCompile>().configureEach {
    options.isIncremental = true
}

마치며

개발을 하면서 빌드 시간이 길어질 때마다 느끼는 답답함을 해결하고 싶어서 Gradle 빌드 최적화에 대해 공부하고 정리하게 되었습니다. 실제로 이런 최적화 방법들을 하나씩 적용해가면서 빌드시간을 눈에 띄게 줄인 경험도 있고, 회사 내 동료분들이 "빌드가 빨라졌네요!"라고 말해줄 때의 뿌듯함도 있었습니다.

가장 중요한 것은 무작정 모든 최적화를 적용하는 것이 아니라, Build Scan이나 Profile Report를 통해 정확히 측정하고 개선하는 것입니다. 어떤 최적화는 프로젝트에 큰 효과를 주지만, 어떤 것은 거의 변화가 없을 수도 있습니다. 특히 Configuration CacheBuild Cache 같은 기능들은 처음 설정할 때 약간의 러닝 커브가 있지만, 한 번 제대로 설정하면 정말 드라마틱한 효과를 볼 수 있습니다.

개인적으로는 apiimplementation을 제대로 구분해서 사용하는 것, 병렬 빌드 활성화, 그리고 불필요한 태스크를 제거하는 것만으로도 상당한 개선 효과를 경험했습니다. 특히 멀티 모듈 프로젝트에서는 이런 작은 최적화들이 모여서 정말 큰 차이를 만들어냅니다.

빌드 성능 개선은 단순히 시간을 절약하는 것을 넘어서 개발 경험 자체를 바꿔줍니다. 빠른 피드백 루프는 개발자의 집중력과 생산성 모두에 긍정적인 영향을 미치게 됩니다. 혹시라도 이 글을 보게될 분들의 개발 환경을 조금이라도 개선하는 데 도움이 되었으면 좋겠습니다.

About Me
@onseok
배움을 배포하기
© 2025 onseok. Made with code and coffee