Android에서 효율적인 모듈 디펜던시 관리하기, build-logic으로 전환하기?



개인 광고 영역

모듈을 지속적으로 추가하는 요즘 개발에서는 디펜던시 관리는 어떻게 하는 것이 좋을까?

필자는 모듈 240개 정도를 회사 프로젝트에서 활용하고 있는데 아래와 같다.

  • test 모듈
  • 구현체에 대한 인터페이스 정의하는 모듈
  • 구현체 모듈

초점은 최대한 손 안 대고 자동으로 모듈을 import 하고, 디펜던시를 관리하는 것이다.

build-logic을 활용하고, version catalog을 활용하여 디펜던시를 관리하고, 추가되는 모듈은 자동 import 되도록 하는 방법을 소개한다.

읽기 전에

사용하던 기존 환경을 소개한다.

  • versionCatalog
  • build-logic
  • gradle kts

build-logic을 활용하였고, app 모듈에서의 import 자동화를 하기 위한 프로젝트를 구성하였다. 이를 buildSrc로 변경하면서 불편하게 활용하던 부분을 제거하였던 부분을 소개하는 글이다.


기존엔 어떻게 했을까?

build-logic을 활용하기 전에는 buildSrc를 활용하여 관리하였다.

이때는 build-logic에서 Project object 클래스를 별도로 생성하고 여기에 앱 내의 모든 모듈 정보를 정의하였는데 아래와 같은 형식이다.

object Projects {
    object Core {
        const val core = ":core:data:home"
        const val coreApi = ":core:data:homeApi"
    }

    object Feature {
        const val feature = ":feature:home"
        const val featureApi = ":feature:homeApi"
    }

    object Test {
        cont val test = ":test"
    }

    object Flavor {
        const val api = ":flavor:api"
        const val release = ":flavor:release"
        const val debug = ":flavor:debug"
    }
}

당연하지만 settings.gradle.kts에도 이를 정의해야 한다.

그리고 Project exteions 함수를 정의하여 활용하였다. 이제 이 부분이 바뀔 일은 없다.

fun Project.localProjects() {
    dependencies {
        Project.Core::class.loadLibrary { projectName ->
            "implementation"(project(projectName))
        }
        Project.Feature::class.loadLibrary { projectName ->
            "implementation"(project(projectName))
        }
        Project.Test::class.loadLibrary { projectName ->
            "testImplementation"(project(projectName))
        }
        "implementation"(project(Project.Flavor.api))
        "relaseImplementation"(project(Project.Flavor.release))
        "debugImplementation"(project(Project.Flavor.debug))
    }
}

참고로 loadLibrary는 아래와 같다.

internal fun KClass<out Any>.loadLibrary(body: (projectName: String) -> Unit) {
    buildLocalProjects().forEach { (_, projectName) ->
        body(projectName)
    }
}

private fun KClass<out Any>.buildLocalProjects(): HashMap<String, String> {
    val locals = hashMapOf<String, String>()

    for (member in declaredMembers) {
        locals[member.name] = member.call() as String
    }

    return locals
}

위에서 정의한 Project extensions 함수를 app 모듈에 한 줄 추가하면 끝이 난다.

plugins {
    id("kotlin-android")
    kotlin("kapt")
}

android {
    localProjects() // extensions 확장 함수
}


전혀 이점이 없다.

이런 방식은 사실 전혀 이점이 없다. 모듈 하나 추가하면 다음 작업을 항상 필요로 한다.

settings.gradle.kts 파일과 build-logic에 정의해둔 Projects 파일에 항상 새로운 모듈 이름을 추가해야 한다.

그리고 동기화해야 한다. 그래야 app 모듈에 알아서 포함되어 빌드가 완료될 수 있다.

얼마나 귀찮은 작업을 편하다고 사용하고 있던 걸까 싶다.


가장 쉬운 방법

build-logic을 사용하든 buildSrc를 사용하던 가장 쉬운 방법을 소개하겠다.

  1. enableFeaturePreview(“TYPESAFE_PROJECT_ACCESSORS”)을 활성화한다.
  2. app 모듈에서 forEach {} 문을 활용해 자동 implementation을 활용한다.
  3. 이제 settings.gradle.kts 파일에 새로운 모듈 정보만 추가하면 자동 import를 한다. 이제 app 모듈은 신경 쓰지 않아도 된다.

settings.gradle.kts 파일에 옵션 추가

최신 프로젝트를 생성했다면 이미 포함되어 있을 것이다. 없다면 아래와 같이 추가한다. 위치는 상관없고, 워닝이 뜨긴 하지만 무시한다.

enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

2번째 단계

여기가 중요한 부분이다. 모듈화를 하면

  • feature 모듈 모음
  • data 모듈 모음
  • core 모듈 모음

등 다양한 방식으로 접근하게 될 것인데 이와 같을 수 있다.

그럼 이제 이 형태를 1번에서 활성화한 feature preview를 통해 쉽게 사용할 수 있는데 아래와 같다.

app 모듈 내의 build.gradle.kts 파일을 열고

plugins {
    id("kotlin-android")
    kotlin("kapt")
}

android {
    projects.Core.filterImplementation {
        if (it.name.contains("assertion")) {
            testImplementation(it)
        } else {
            implementation(it)
        }
    }
    projects.Feature.filterImplementation {
        if (it.name.contains("assertion")) {
            testImplementation(it)
        } else {
            implementation(it)
        }
    }
    projects.Test.filterImplementation {
        testImplementation(it)
    }
    "implementation"(project(Project.Flavor.api))
    "relaseImplementation"(project(Project.Flavor.release))
    "debugImplementation"(project(Project.Flavor.debug))
}

필자는 filter가 필요해서 아래와 같이 extensions을 추가하여 사용하고 있다.

fun DelegatingProjectDependency.filterImplementation(
    body: (target: Project) -> Unit,
) {
    dependencyProject.allprojects.forEach {
        if (it.buildFile.isFile) {
            body(it)
        }
    }
}

앞에서 사용하던 이전 방식은 build-logic에 포함되어 있던 Projects.kt 파일에 항상 디펜던시 한 줄 더 추가했어야 하지만 이젠 settings.gradle.kts 파일만 수정해 주면 알아서 반영하게 된다.


buildSrc에서 build-logic으로

buildSrc이 있는 상태에서 build-logic가 처음 나왔을 때는 크게 장점이 있진 않았다. 성능이 유사하다 보니 특별히 바꿔야 할 이유가 많지 않았다.

하지만 지금은 증분 옵션을 제공함으로써 성능적 이점을 가진다. 완전히 코틀린 문법을 통해 사용할 수 있다는 장점도 있지만 gradle로 작성하는 것 역시 가능하다.

buildSrc 한 줄 수정하면 모든 buildSrc 파일 내의 gradle 파일도 전부 재확인 작업이 들어가는데 이때 시간의 소요가 발생한다.

반면 build-logic을 활용하는 경우는 증분 옵션을 통해 수정한 부분만 빌드 반영할 수 있어 조금 더 빠른 빌드가 가능하다.


buildSrc을 build-logic로 변경하는 작업은 사실 쉬운 작업은 아니다.

빌드 자체를 수정해야 하는 부분이다 보니 폭 넓게 사용하는 buildSrc를 수정하는 일은 쉽게 할 수 있는 일은 아니다.

잘못 수정하면 빌드가 가능해질 때까지 걸리는 시간 역시 그만큼 지연 발생 가능하다.

  • 전체 프로젝트를 기반하는 모든 코드를 수정하고 갈 것인가?
  • buildSrc를 build-logic에서 최대한 활용하고 갈 것인가?

를 잘 선택하고 가야 한다. 혼자 하는 프로젝트야 이렇게 하든 저렇게 하든 사실 상관없지만 작업이 진행 중인 협업 프로젝트에서는 영향도가 매우 크다.

그대로 활용할 순 있지만 아래의 규칙을 미리 바꿔두는 것도 좋다.

  • plugins 적용할 네이밍 규칙을 미리 정의한다.
    • Now in android에서는 네이밍 규칙을 2단계로 재 정의한다.
    • build-logic 내부에서 정의하는 kotlin 파일과 build.gradle에서 이를 gradle.kts로 컴파일 하기 위한 정의
    • versionCatalog에서 build-logic에서 정의한 이름을 내부에서 활용하기 위한 규칙으로 변경한다.

build-logic 코틀린 파일명

  • AndroidApplicationConventionPlugin.kt

build-logic에서 정의하는 빌드 할 파일 명

gradlePlugin {
    plugins {
        register("androidApplication") {
            id = "my.android.application" // gradle 파일 매칭 id
            implementationClass = "AndroidApplicationConventionPlugin" // 파일명
        }
    }
}

마지막으로 versionCatalog에서 한 번 더 매핑해서 각 모듈에서 alias로 활용할 이름

// libs.versions.toml 에 정의

my-android-application = { id = "my.android.application", version = "unspecified" }

최종적으로 각 build.gradle.kts 파일에 아래와 같이 적용하여 사용할 수 있다.

plugins {
    alias(my.android.application)
}

이런 과정을 진행하기 때문에 alias만이라도 미리 정의해둘 수 있는데, my.android.application.kts.kt을 kts 파일로 정의하였다면 libs.version.toml에 아래와 같이 미리 정의해두고 사용할 수 있다.

my-android-application = { id = "my.android.application", version = "unspecified" }

최소한 파일명 변경되어야 하는 걸 미리 대응할 수 있다.

추가로 gradle 9.0부터 buildConfig가 자동 비활성화된다. 필요한 feature에서 만 buildConfig를 활성화할 수 있는데 이런 부분은 위 작업이 끝나고 적용할 수 있다.

  • buildConfig 옵션을 미리 재정의


gradle.kts에서 kt 파일로 변환

이런 예는 google now in android를 잘 참고하여 적용할 수 있다.

convention 모듈을 build-logic 내부에 따로 두고 있다는 점이 또 다른데 몇 가지 설정을 추가할 수 있다.

application에서 활용할 my.application.gradle.kts 파일이 아래와 같다. 이를 kt로 변환해 보겠다.

plugins {
    id("com.android.application")
    kotlin("android")
}

val versionCatalog = project.extensions.getByType<VersionCatalogsExtension>()
val libs = versionCatalog.named("libs")


android {
    compileSdk = libs.findVersion("compileSdk").get().requiredVersion.toInt()
    buildToolsVersion = libs.findVersion("buildToolsVersion").get().requiredVersion

    defaultConfig {
        minSdk = libs.findVersion("minSdk").get().requiredVersion.toInt()
        targetSdk = libs.findVersion("targetSdk").get().requiredVersion.toInt()
    }

    kotlinOptions {
        jvmTarget = "17"
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

dependencies {
    testImplementation(libs.findLibrary("test.mockito").get())
    testImplementation(libs.findLibrary("test.mockitoKotlin").get())

    testImplementation(libs.findLibrary("test.junit5").get())
    testRuntimeOnly(libs.findLibrary("test.junit5.engine").get())
    testRuntimeOnly(libs.findLibrary("test.junit5.vintage").get())
}

참고로 이 코드에는 build-logic에서 libs에 쉽게 접근하기 위한 exteions 코드를 포함하고 있는데 아래와 같다.

val versionCatalog = project.extensions.getByType<VersionCatalogsExtension>()
val libs = versionCatalog.named("libs")

// 사용할 때는
testImplementation(libs.findLibrary("test.mockito").get())

이 파일을 build-logic에서 Project를 상속받는 구조로 변경하면 아래와 같이 변경되는데 먼저 아래의 코드가 필요하다. Library 인지 Application 인지 구분하고,

internal val Project.applicationExtension: CommonExtension<*, *, *, *, *, *>
    get() = extensions.getByType<ApplicationExtension>()

internal val Project.libraryExtension: CommonExtension<*, *, *, *, *, *>
    get() = extensions.getByType<LibraryExtension>()

internal val Project.androidExtension: CommonExtension<*, *, *, *, *, *>
    get() = runCatching { libraryExtension }
        .recoverCatching { applicationExtension }
        .onFailure { println("Could not find Library or Application extension from this project") }
        .getOrThrow()

또 하나의 코드가 필요한데 libs에 쉽게 접근하기 위한 2개의 코드이다.

internal fun Project.findLibrary(name: String): Provider<MinimalExternalModuleDependency> =
    extensions.libs.findLibrary(name).get()

internal fun Project.findVersion(name: String): String =
    extensions.libs.findVersion(name).get().requiredVersion

공통 코드로 활용될 거니 아래와 같이 사전 작업해둔다.

이 코드는 now in android 코드에 있으니 그대로 참고 NIA - KotlinAndroid

internal fun Project.configureKotlinAndroid() {
    // Plugins
    pluginManager.apply("org.jetbrains.kotlin.android")

    // Android settings
    androidExtension.apply {
        compileSdk = findVersion("compileSdk").toInt()
        buildToolsVersion = findVersion("buildToolsVersion")

        defaultConfig {
            minSdk = findVersion("minSdk").toInt()
            vectorDrawables.useSupportLibrary = true
        }

        buildFeatures {
            buildConfig = false
        }

        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_17
            targetCompatibility = JavaVersion.VERSION_17
        }
    }

    configureKotlin<KotlinAndroidProjectExtension>()
}

/**
 * Configure base Kotlin options
 */
private inline fun <reified T : KotlinTopLevelExtension> Project.configureKotlin() = configure<T> {
    // Treat all Kotlin warnings as errors (disabled by default)
    // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
    val warningsAsErrors: String? by project
    when (this) {
        is KotlinAndroidProjectExtension -> compilerOptions
        is KotlinJvmProjectExtension -> compilerOptions
        else -> TODO("Unsupported project extension $this ${T::class}")
    }.apply {
        jvmTarget = JvmTarget.JVM_11
        allWarningsAsErrors = warningsAsErrors.toBoolean()
        freeCompilerArgs.add(
            // Enable experimental coroutines APIs, including Flow
            "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
        )
    }
}

최종적으론 아래와 같다.

class MyApplicationConventionPlugin : Plugin<Project> {

    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.application")
            }

            configureKotlinAndroid()

            dependencies {
                testImplementation(findLibrary("test.mockito"))
                testImplementation(findLibrary("test.mockitoKotlin"))

                testImplementation(findLibrary("test.junit5"))
                "testRuntimeOnly"(findLibrary("test.junit5.engine"))
                "testRuntimeOnly"(findLibrary("test.junit5.vintage"))
            }
        }
    }
}

그리고 이를 build-logic에서 build.gradle.kts에 아래와 같이 추가 정의한다.

gradlePlugin {
    plugins {
        register("androidApplication") {
            id = "my.android.application"
            implementationClass = "MyApplicationConventionPlugin"
        }
    }
}

alias 활용에 사전에 정의했던 부분이 있다면 이름을 교체하고, app의 build.gradle.kts에 아래와 같이 정의 추가한다.

plugins {
    alias(libs.plugins.my.application)
}


마무리

개발할 때 가장 귀찮은 부분이 설정 부분이다. 이를 일부 자동화하고, app 모듈을 손 안 대는 것이 목적이었다.

hilt를 쓰면 이건 자연스럽게 해결되지만, settings.gradle.kts에 추가하고 이 역시 자동화 시켜 자동 import 처리할 수 있다.

이 작업을 하기 전에는 모듈을 추가한다면 아래와 같다.

  • 새로운 모듈을 추가
  • settings.gradle.kts에 새로운 모듈 정보 추가
  • app 모듈에서 새로운 모듈 정보 추가

필자는 마지막 작업이 귀찮아서 이를 줄이기 위한 코드 작업을 하였다. gradle feature만으로도 충분히 간단하게 작업하는 것이 가능했다.

더 나가면 flover마다 빌드에 포함이거나 제외를 해야하는데 이 역시 자동화가 가능하다는 점이다.

폴더 레벨로 모듈 관리해 주면 귀찮은 작업이 줄어단다.

보통 개발 화면이 포함되어야 하는 모듈과 그렇지 않은 모듈이 있으니 그에 맞게 분리한 것이다.

이와 관련한 좋은 영상과 발표 자료들이 있으니 아래에 링크를 포함한다.



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments