컨벤션 플러그인으로 30개 넘는 Gradle 모듈을 어떻게 DRY하게 유지하는가
컨벤션 플러그인으로 30개 넘는 Gradle 모듈을 어떻게 DRY하게 유지하는가
프로젝트를 모듈 몇 개 이상으로 키워 본 안드로이드 개발자라면 누구나 그 아는 익숙한 절차가 있습니다. 새 모듈을 만들고, build.gradle.kts를 열고, 복사를 시작합니다. compileSdk와 minSdk가 담긴 android {} 블록, 자바 버전, 코틀린 컴파일러 옵션, 테스트 러너, 프로덕트 플레이버, Compose 설정, Hilt와 KSP 의존성을 하나하나 옮깁니다. 모듈이 겨우 컴파일될 즈음이면, 다른 빌드 파일 열댓 개에 거의 똑같이 이미 존재하는 예순 줄을 그대로 다시 찍어 낸 셈입니다. 한 곳에서 compileSdk를 올리면 나머지는 조용히 어긋납니다. Now in Android 샘플은 이를 컨벤션 플러그인(convention plugin)으로 해결하며, 예순 줄짜리 빌드 파일 하나하나를 플러그인 별칭 두세 개로 접어 버립니다. 겉으로는 그저 짧아진 빌드 파일처럼 보입니다. 더 깊은 질문은,
alias(libs.plugins.nowinandroid.android.library) 같은 한 줄이 어떻게 모듈 전체 설정으로 펼쳐지는지, 그리고 그 별칭 하나를 서른 개가 넘는 모든 모듈에서 해석할 수 있게 만드는 Gradle 장치가 무엇인지입니다.
이 글에서는 Now in Android가 모듈 빌드를 어떻게 DRY하게 유지하는지를 깊이 있게 파고듭니다. 컨벤션 플러그인이 Plugin<Project>로서 실제로 무엇인지, 그 apply()가 어떻게 다른 플러그인을 적용하는 동시에 그 익스텐션까지 설정하는지, 안드로이드 라이브러리 플러그인이 어떻게 모듈 관례 전체를 담아내는지, Compose와 Hilt 플러그인이 그 위에 어떻게 층층이 쌓이고 반응하는지, api와 impl로 기능을 나눈 것이 어떻게 컴파일을 얕게 유지하는지, build-logic이 바이너리 플러그인을 내놓는 included build로 어떻게 엮이는지, 버전 카탈로그의 별칭이 생성된 접근자를 거쳐 플러그인 id와 implementationClass로 어떻게 이어지는지, 그리고 이 방식이 왜 subprojects {}나 allprojects {}, apply from보다 나은지를 차례대로 살펴봅니다.
근본적인 문제: 빌드 파일마다 똑같은 예순 줄
중복을 걷어 내기 전, 흔한 core 모듈의 build.gradle.kts를 떠올려 보세요. 모듈이 빌드되려면 Android Gradle Plugin이 필요로 하는 모든 설정을 일일이 적어 줘야 합니다.
android {
compileSdk = 36
defaultConfig {
minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
flavorDimensions += "contentType"
productFlavors {
register("demo") { dimension = "contentType" }
register("prod") { dimension = "contentType" }
}
}
여기에 코틀린 컴파일러 옵션, 코어 라이브러리 디슈가링(desugaring), Compose 빌드 기능과 그 의존성 묶음, 그리고 기본 테스트 라이브러리까지 더하면 예순 줄에 이릅니다. 그다음 그 예순 줄을 다음 모듈에, 또 그다음 모듈에 다시 씁니다. 이 중복은 그저 번거로운 데서 그치지 않고 정확성을 위협합니다. 두 모듈이 compileSdk나 계측 러너를 두고 어긋나는 순간, 어느 한 파일도 드러내 주지 않는 버그가 생기기 때문입니다.
가장 먼저 떠오르는 생각은, 공유되는 블록을 루트 빌드 파일로 끌어올린 뒤 subprojects {}나 allprojects {}로 아래에 내려보내는 것입니다.
subprojects {
apply(plugin = "com.android.library")
extensions.configure<LibraryExtension> {
compileSdk = 36
defaultConfig { minSdk = 23 }
}
}
이 방식은 루트에서, 즉시, 모든 하위 프로젝트에 걸쳐 실행되며 각 프로젝트를 하나의 틀에 밀어 넣습니다. 하지만 app 모듈은 라이브러리가 아니라 애플리케이션이고, util 모듈은 android {} 블록조차 없는 순수 코틀린이라, 그 전제부터 이미 틀렸습니다. 루트에서 설정하는 것은 프로젝트 경계를 넘나드는 일이기도 한데, 이는 구성 회피(configuration avoidance)와 프로젝트 격리가 애초에 막으려는 바로 그것입니다. 결국 즉시 실행되고, 군데군데 타입이 없으며, Gradle이 나아가는 방향과 구조적으로 어긋나는 구성물이 손에 남습니다. 컨벤션 플러그인은 정반대 입장을 취합니다.
컨벤션 플러그인이란: 적용하고 설정하는 Plugin
컨벤션 플러그인은 특별한 Gradle 타입이 아닙니다. Android Gradle Plugin 자체가 구현하는 것과 같은 인터페이스, 평범한 Plugin<Project>입니다. 인터페이스 전체가 메서드 하나입니다.
interface Plugin<T> {
fun apply(target: T)
}
Gradle이 프로젝트에 플러그인을 적용하면, 그 클래스를 인스턴스화하고 apply(project)를 호출합니다. AGP 같은 온전한 플러그인은 이 호출로 태스크와 익스텐션을 등록합니다. 컨벤션 플러그인은 이를 더 좁은 용도로 씁니다. apply() 안에서 다른 플러그인들을 적용하고, 그 플러그인들이 등록한 익스텐션을 설정하는 것입니다. 이 설계 전체를 떠받치는 동사는 둘, 적용(apply)과 설정(configure)입니다.
이 두 동사는 서로 바꿔 쓸 수 없고, 그 구분이 앞으로 이어질 모든 것을 좌우합니다. 플러그인을 적용하면 그 태스크와 익스텐션이 비로소 존재하게 됩니다. 익스텐션을 설정하는 것은 이미 적용된 플러그인의 설정값을 바꾸는 일입니다. 순서는 정해져 있습니다.
apply(plugin = "com.android.library")
extensions.configure<LibraryExtension> {
// 위 줄이 먼저 실행됐기에 비로소 LibraryExtension이 존재한다
}
com.android.library가 등록하기 전에는 LibraryExtension을 설정할 수 없으므로, Now in Android의 모든 플러그인은 기반 플러그인을 먼저 적용하고 그다음에 설정합니다. 이 '적용 후 설정' 순서를 기억해 두세요. 한 부류의 플러그인이 이를 일부러 깨뜨리는데, 그럴 수 있는 이유가 꽤 배울 만하기 때문입니다.
Now in Android가 제공하는 플러그인 목록
개별 플러그인을 따라가기 전에, 샘플이 제공하는 플러그인 목록을 먼저 정리하겠습니다. 각 플러그인은 적용할 때 쓰는 id로 식별됩니다.
nowinandroid.android.application과nowinandroid.android.library: 모듈을 앱이나 라이브러리로 만들고, 공유되는 안드로이드·코틀린 설정을 적용합니다.nowinandroid.android.application.compose와nowinandroid.android.library.compose: 앱 또는 라이브러리 플러그인 위에 Compose를 얹습니다.nowinandroid.android.feature.api와nowinandroid.android.feature.impl: 기능 분리의 두 반쪽입니다.nowinandroid.hilt: 어떤 기반 플러그인이 있든 거기에 반응하는 의존성 주입입니다.nowinandroid.android.room: KSP를 거쳐 엮인 Room 영속성입니다.nowinandroid.jvm.library: 안드로이드가 아닌 순수 코틀린 모듈입니다.nowinandroid.android.lint,nowinandroid.android.test, 그리고...jacoco,...application.firebase,...application.flavors변형들: 각각 린트, 테스트 모듈, 커버리지, Firebase, 독립적인 플레이버 설정을 위한 전용 플러그인입니다.nowinandroid.root: 루트 프로젝트에만 적용됩니다.
핵심 일꾼 들여다보기: 안드로이드 라이브러리 플러그인
거의 모든 core와 feature 모듈이 nowinandroid.android.library라는 플러그인 하나를 적용하며, 그 구현이 나머지 플러그인의 본보기가 됩니다. AndroidLibraryConventionPlugin은 플러그인 두 개를 적용하는 것으로 시작합니다.
abstract class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.library")
apply(plugin = "nowinandroid.android.lint")
// ...
}
}
}
with(target)으로 감싸면 나머지 본문을 마치 프로젝트 안에서 쓴 것처럼 읽을 수 있습니다. 이 플러그인은 LibraryExtension을 등록하는 진짜 AGP 라이브러리 플러그인 com.android.library를 적용하고, 그다음 또 다른 컨벤션 플러그인인 nowinandroid.android.lint를 적용합니다. 여기서 벌써 조합이 눈에 띕니다. 한 컨벤션 플러그인이 다른 컨벤션 플러그인을 적용하는 모습입니다.
이제 LibraryExtension이 등록됐으니, 플러그인은 이를 설정합니다.
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
testOptions.targetSdk = 36
lint.targetSdk = 36
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testOptions.animationsDisabled = true
configureFlavors(this)
configureGradleManagedDevices(this)
resourcePrefix =
path.split("""\W""".toRegex()).drop(1).distinct().joinToString(separator = "_")
.lowercase() + "_"
}
모듈마다 반복될 법한 모든 설정이 여기에 한 번만 자리합니다. 테스트·린트 SDK, 계측 러너, 비활성화된 애니메이션, configureFlavors를 통한 demo·prod 플레이버, 그리고 관리형 테스트 기기입니다. resourcePrefix 줄은 모듈 경로에서 모듈별 리소스 접두사를 뽑아내는데, 단어가 아닌 문자를 기준으로 쪼개고, 맨 앞의 빈 조각을 버리고, 중복을 없앤 뒤 밑줄로 잇습니다. 그래서 :core:module1은 core_module1_이 됩니다. 이 접두사 덕분에 누가 손으로 설정하지 않아도 리소스 이름이 모듈 사이에서 충돌하지 않습니다.
메인 익스텐션 말고도, 플러그인은 배리언트 컴포넌트 익스텐션을 설정하고 기본 의존성 묶음을 더합니다.
extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this)
disableUnnecessaryAndroidTests(target)
}
configureSpotlessForAndroid()
dependencies {
"androidTestImplementation"(libs.findLibrary("kotlin.test").get())
"testImplementation"(libs.findLibrary("kotlin.test").get())
"testImplementation"(libs.findLibrary("junit").get())
"implementation"(libs.findLibrary("androidx.tracing.ktx").get())
}
LibraryAndroidComponentsExtension은 LibraryExtension과는 다른 AGP 배리언트 API로, 여기서는 배리언트별 태스크를 등록하고 안드로이드 테스트가 없는 모듈에 대해서는 그 테스트를 건너뛰는 데 씁니다. dependencies 블록은 모든 모듈이 공유하는 테스트 라이브러리를 더합니다. 뒤를 내다보게 하는 두 가지가 있습니다. 의존성이 "testImplementation" 같은 문자열 구성 이름으로 등록된다는 점, 그리고 각 라이브러리를 생성된 접근자가 아니라 libs.findLibrary(...)로 찾는다는 점입니다. 둘 다 빌드 스크립트가 아닌 플러그인 코드 안에서 실행되기 때문에 생기는 결과이며, 이는 뒤의 절에서 풀어냅니다.
플러그인들 사이에서 코틀린 설정 공유하기
그 블록 맨 위의 configureKotlinAndroid(this) 호출이 바로 애플리케이션 플러그인과 라이브러리 플러그인이 코드를 공유하는 지점입니다. 둘 다 같은 Project 확장 함수를 호출합니다.
internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension,
) {
commonExtension.apply {
compileSdk = 36
defaultConfig.apply { minSdk = 23 }
compileOptions.apply {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
}
configureKotlin<KotlinAndroidProjectExtension>()
dependencies {
"coreLibraryDesugaring"(libs.findLibrary("android.desugarJdkLibs").get())
}
}
매개변수 타입은 ApplicationExtension과 LibraryExtension의 공통 상위 타입인 CommonExtension이라, 함수 하나로 둘 중 어느 쪽이든 설정합니다. 이 함수는 compileSdk, minSdk, 자바 11, 코어 라이브러리 디슈가링, 그리고 디슈가링 의존성을 설정합니다. 핵심은 그것들이 놓인 자리입니다. 컴파일 SDK, 최소 SDK, 자바 버전이 정확히 한 곳에만 존재합니다. 여기서 compileSdk = 36을 바꾸면 앱이나 라이브러리 플러그인을 적용한 모든 모듈이 함께 움직입니다.
코틀린 컴파일러 옵션은 한 단계 더 깊은 곳, private 제네릭 헬퍼에서 공유됩니다.
private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() = configure<T> {
val warningsAsErrors = providers.gradleProperty("warningsAsErrors").map { it.toBoolean() }.orElse(false)
when (this) {
is KotlinAndroidProjectExtension -> compilerOptions
is KotlinJvmProjectExtension -> compilerOptions
else -> TODO("Unsupported project extension $this ${T::class}")
}.apply {
jvmTarget = JvmTarget.JVM_11
allWarningsAsErrors = warningsAsErrors
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
freeCompilerArgs.add("-Xconsistent-data-class-copy-visibility")
}
}
reified 타입 매개변수 덕분에, 같은 함수가 호출자가 고른 대로 안드로이드 코틀린 익스텐션이든 JVM 코틀린 익스텐션이든 설정할 수 있습니다. 이 함수는 CI가 경고를 오류로 바꿀 수 있도록 Gradle 프로퍼티를 읽고, jvmTarget을 11로 설정하며, 옵트인 컴파일러 인자를 더합니다. TODO 분기는 죽은 코드가 아니라 진짜 현재 상태의 코드입니다. 나중에 어떤 호출자가 지원되지 않는 익스텐션 타입을 넘기면 요란하게 실패합니다. 안드로이드 라이브러리 플러그인과 순수 JVM 라이브러리 플러그인이 둘 다 이 함수 하나를 거치므로, 둘의 코틀린 설정은 서로 어긋날 수가 없습니다.
동작을 층층이 쌓기: Compose는 어떻게 합류하는가
Compose는 기반 라이브러리 플러그인의 일부가 아닙니다. 평범한 라이브러리 모듈은 compose = false입니다. Compose가 필요한 모듈은 첫 번째 플러그인 위에 두 번째 플러그인 nowinandroid.android.library.compose를 적용하는데, 이 플러그인은 기반 플러그인이 하지 않는 일을 합니다.
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
val extension = extensions.getByType<LibraryExtension>()
configureAndroidCompose(extension)
}
}
}
핵심은 extensions.configure가 아니라 extensions.getByType<LibraryExtension>()이라는 줄입니다. configure가 익스텐션을 바꿀 콜백을 등록하는 반면, getByType은 이미 존재하는 익스텐션을 읽어 그 같은 인스턴스를 그대로 넘겨줍니다. 이 플러그인은 com.android.library를 스스로 적용하므로 getByType이 돌아갈 때쯤이면 LibraryExtension이 존재한다고 보장됩니다. 실제로는 기반 라이브러리 플러그인이 이미 적용해 두었기에, 다시 적용해도 달라지는 것은 없습니다. 그런 다음 그 인스턴스를 가져와 Compose 헬퍼에 넘깁니다. 이는 교체가 아니라 층을 쌓는 일입니다. 라이브러리 플러그인이 이미 빚어 놓은 모듈에 Compose 플러그인이 Compose를 더하는 것입니다.
라이브러리 Compose 플러그인과 애플리케이션 Compose 플러그인이 함께 쓰는 configureAndroidCompose 헬퍼는 빌드 기능을 켜고 의존성을 엮습니다.
internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension,
) {
commonExtension.apply {
buildFeatures.apply { compose = true }
dependencies {
val bom = libs.findLibrary("androidx-compose-bom").get()
"implementation"(platform(bom))
"androidTestImplementation"(platform(bom))
"implementation"(libs.findLibrary("androidx-compose-ui-tooling-preview").get())
"debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get())
}
}
// ...
}
Compose를 켜고, 그다음 모든 Compose 아티팩트가 하나의 정렬된 버전으로 맞춰지도록 Compose BOM을 platform으로 더하며, 미리보기와 디버그 도구까지 얹습니다. 여기서도 CommonExtension 매개변수는 헬퍼 하나가 앱과 라이브러리 양쪽을 모두 감당한다는 뜻입니다.
또한 선택적인 Compose 컴파일러 지표와 리포트를 Gradle 프로퍼티 뒤에서 게이팅하고, 그다음 컴파일러가 공유되는 안정성 설정 파일을 바라보게 합니다.
extensions.configure<ComposeCompilerGradlePluginExtension> {
// ... 여기서 enableComposeCompilerMetrics / enableComposeCompilerReports를 게이팅한다
stabilityConfigurationFiles
.add(isolated.rootProject.projectDirectory.file("compose_compiler_config.conf"))
}
이것도 또 하나의 '적용 후 설정'이며, 몇 줄 앞에서 적용한 Compose 컴파일러 플러그인을 겨냥합니다. isolated.rootProject를 눈여겨보세요. 프로젝트 격리를 어기지 않는 방식으로 루트 디렉터리에 닿는데, 이 모델이 왜 subprojects {}보다 오래 살아남는지 다루는 절을 살짝 미리 보여 주는 대목입니다.
있는 것에 반응하기: Hilt 플러그인
의존성 주입 플러그인 nowinandroid.hilt는 다른 기법을 씁니다. 자신이 내려앉는 모듈이 안드로이드 모듈인지 순수 코틀린 모듈인지 미리 알지 못하므로, 플래그를 받는 대신 결국 무엇이 있는지에 따라 그 기반 플러그인에 반응합니다. 시작은 조건 없이 이뤄집니다.
class HiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "com.google.devtools.ksp")
dependencies {
"ksp"(libs.findLibrary("hilt.compiler").get())
"ksp"(libs.findLibrary("kotlin.metadata").get())
}
// ...
}
}
}
KSP는 모든 모듈에 적용됩니다. Hilt의 어노테이션 처리가 모듈 타입과 상관없이 KSP를 거쳐 돌아가기 때문이며, 두 컴파일러 아티팩트는 ksp 구성에 올라갑니다. 달라지는 것은 런타임 의존성인데, 플러그인은 안드로이드라고 넘겨짚는 대신 특정 기반 플러그인이 적용됐을 때에만 발동하는 콜백을 등록합니다.
pluginManager.withPlugin("org.jetbrains.kotlin.jvm") {
dependencies {
"implementation"(libs.findLibrary("hilt.core").get())
}
}
pluginManager.withPlugin("com.android.base") {
apply(plugin = "dagger.hilt.android.plugin")
dependencies {
"implementation"(libs.findLibrary("hilt.android").get())
}
}
pluginManager.withPlugin(id) { }은 그 플러그인이 이미 적용돼 있으면, 혹은 나중에 적용되면 그 블록을 실행하고, 그렇지 않으면 결코 실행하지 않습니다. 순수 JVM 모듈은 org.jetbrains.kotlin.jvm을 지니므로 hilt.core를 받습니다. 안드로이드 모듈은 애플리케이션·라이브러리 플러그인의 공통 조상인 com.android.base를 지니므로, Hilt Gradle 플러그인과 hilt.android를 받습니다. 같은 nowinandroid.hilt 별칭이 두 세계 모두에서 알맞게 동작하며, 이를 적용한 모듈은 어느 쪽 가지를 원하는지 말할 필요가 전혀 없습니다. 플러그인은 안드로이드 아티팩트를 늘 더하는 것이 아니라, 이미 있는 것에 맞는 묶음을 더합니다.
익스텐션 두 개 설정하기: Room 플러그인
영속성도 같은 '적용 후 설정' 모양을 따르되, 서로 다른 두 플러그인이 등록한 서로 다른 두 익스텐션을 설정합니다. nowinandroid.android.room은 Room과 KSP를 적용한 뒤, 각각을 차례로 설정합니다.
class AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "androidx.room")
apply(plugin = "com.google.devtools.ksp")
extensions.configure<KspExtension> {
arg("room.generateKotlin", "true")
}
extensions.configure<RoomExtension> {
schemaDirectory("$projectDir/schemas")
}
// ...
}
}
}
KspExtension은 KSP 플러그인에서, RoomExtension은 Room 플러그인에서 오며, 둘 다 각자의 소유 플러그인이 적용된 뒤에야 설정됩니다. 이 플러그인은 KSP에게 코틀린을 생성하라고 이르고, Room에게 스키마 파일을 어디에 쓸지 알려 줍니다. 그다음 Room 의존성을 더합니다.
dependencies {
"implementation"(libs.findLibrary("room.runtime").get())
"implementation"(libs.findLibrary("room.ktx").get())
"ksp"(libs.findLibrary("room.compiler").get())
}
이제 데이터베이스가 필요한 모듈은, 빌드 파일마다 runtime과 ktx, compiler 좌표에 스키마 디렉터리까지 되풀이하는 대신 플러그인 하나만 적용하면 됩니다.
조합, 그리고 api/impl 분리
Now in Android는 대부분의 기능을 모듈 두 개로 나눕니다. 공개 계약을 담은 api 모듈과 구현을 담은 impl 모듈입니다. 각각 자기 컨벤션 플러그인을 갖고 있고, 이 한 쌍이 조합을 가장 또렷하게 보여 줍니다. api 플러그인은 일부러 얇게 만들어져 있습니다.
class AndroidFeatureApiConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "nowinandroid.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
dependencies {
"api"(project(":core:navigation"))
}
}
}
}
api 모듈은 라이브러리에 직렬화, 그리고 :core:navigation 의존성을 더한 것, 그 이상이 아닙니다. Compose도, Hilt도, UI 스택도 없습니다. 빠르게 컴파일되고, 다른 기능이 의존해도 안전합니다. nowinandroid.android.library를 적용한다는 점에 주목하세요. 덕분에 라이브러리 관례 전체를 다시 적을 것 없이 거저 물려받습니다.
impl 플러그인은 전체 스택을 조합합니다.
class AndroidFeatureImplConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "nowinandroid.android.library")
apply(plugin = "nowinandroid.hilt")
extensions.configure<LibraryExtension> {
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
}
// ...
}
}
}
라이브러리 플러그인과 Hilt 플러그인을 둘 다 적용한 뒤, 모든 기능이 필요로 하는 의존성 묶음을 주입합니다.
dependencies {
"implementation"(project(":core:ui"))
"implementation"(project(":core:designsystem"))
"implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
"implementation"(libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
"implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewModelCompose").get())
"implementation"(libs.findLibrary("androidx.navigation3.runtime").get())
"implementation"(libs.findLibrary("androidx.tracing.ktx").get())
"androidTestImplementation"(libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
}
모든 기능의 impl은 :core:ui, :core:designsystem, lifecycle Compose 통합, nav3 런타임, 그리고 트레이싱을 하나도 되풀이하지 않고 얻습니다. 한 모듈로 내지 않고 나누는 이유는 의존성 그래프에 있습니다. 소비자는 다른 기능의 impl이 아니라 api에만 의존하므로, 구현을 바꿔도 하류 기능들이 재컴파일에 끌려 들어가지 않습니다. api는 빠르게 컴파일되는 Compose 없는 계약이고, impl은 그 뒤에 있는 무거운 모듈이며, 그 경계가 빌드를 얕게 유지합니다. 이 분리는 샘플 전반의 기본이지만, 모든 기능이 두 반쪽을 다 내는 것은 아닙니다. 가령 :feature:settings는 impl만 있습니다.
그 결실: 예순 줄 대신 별칭 두 개
플러그인이 자리를 잡으면, 실제 모듈의 빌드 파일은 진짜 내용만 남기고 줄어듭니다. 다음은 core/data/build.gradle.kts입니다.
plugins {
alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.nowinandroid.hilt)
id("kotlinx-serialization")
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.data"
testOptions.unitTests.isIncludeAndroidResources = true
}
dependencies {
api(projects.core.common)
api(projects.core.database)
// ...
}
컨벤션 플러그인 별칭 셋, 네임스페이스 하나, 그리고 모듈의 진짜 의존성. SDK도, 코틀린 옵션도, 플레이버도, 테스트 러너도 없습니다. 그 모두가 플러그인 안에 살기 때문입니다. android {} 블록은 정말로 모듈마다 다른 단 하나, 네임스페이스만 담고 있습니다.
기능 분리도 같은 식으로 읽힙니다. feature/foryou/impl/build.gradle.kts의 impl 모듈은 자신의 컨벤션 플러그인들을 조합합니다.
plugins {
alias(libs.plugins.nowinandroid.android.feature.impl)
alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.roborazzi)
alias(libs.plugins.navgraph)
}
짝이 되는 feature/foryou/api/build.gradle.kts의 api 모듈은 별칭 하나에 그 계약을 더한 것입니다.
plugins {
alias(libs.plugins.nowinandroid.android.feature.api)
}
android { namespace = "com.google.samples.apps.nowinandroid.feature.foryou.api" }
dependencies { api(projects.core.navigation) }
그 별칭 하나 nowinandroid.android.feature.api는 nowinandroid.android.library를 전이적으로 적용하고, 그 라이브러리 플러그인은 다시 com.android.library와 nowinandroid.android.lint를 적용하며 configureKotlinAndroid와 configureFlavors를 실행합니다. impl 쪽에서는 Now in Android 별칭 두 개가 대략 여섯 개의 적용된 플러그인과 완전한 표준 기능 설정으로 펼쳐집니다. 그러지 않았다면 예순 줄이었을 것을 두 줄이 짊어집니다.
그 장치: included build로서의 build-logic
지금까지는 플러그인 id가 그냥 존재하는 것처럼 다뤘습니다. 이제 질문은, 어디에도 maven 좌표 없이 nowinandroid.android.library가 어떻게 서른 개가 넘는 모듈에서 해석되느냐입니다. 답은 루트 settings.gradle.kts에서 시작합니다.
pluginManagement {
includeBuild("build-logic")
repositories {
mavenLocal()
google { /* 콘텐츠 필터 */ }
mavenCentral()
gradlePluginPortal()
}
}
build-logic은 자기 설정 파일을 가진 별도의 Gradle 빌드로, 스스로 이름을 붙이고 :convention 모듈을 포함합니다. includeBuild("build-logic")이 이를 끌어들이는데, pluginManagement 안에 자리하기 때문에 Gradle은 메인 빌드보다 먼저 이를 빌드하고, 여기서 내놓는 플러그인 id를 id로 해석할 수 있게 모든 프로젝트에 노출합니다. 이것이 buildSrc와 다른 점입니다. included build는 독립적으로 빌드할 수 있는 프로젝트라, 그 변경이 메인 빌드 전체를 무효화하지 않으며, 그 플러그인은 전역 클래스패스에 올라앉는 대신 id로 지정됩니다.
바이너리 플러그인 등록하기
그 included build 안에서, 플러그인이 컴파일되고 등록되는 곳이 바로 :convention 모듈입니다. 이 모듈의 빌드 파일은 kotlin-dsl을 적용하고, 자신이 설정하는 플러그인들에 의존합니다.
plugins {
`kotlin-dsl`
alias(libs.plugins.android.lint)
}
group = "com.google.samples.apps.nowinandroid.buildlogic"
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.compose.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.room.gradlePlugin)
// ...
}
kotlin-dsl 플러그인 덕분에 이 모듈은 Gradle Kotlin DSL을 컴파일해 플러그인을 만들어 낼 수 있습니다. 의존성이 compileOnly인 것은 일부러 그렇게 한 것입니다. 컨벤션 플러그인은 ApplicationExtension, LibraryExtension, KspExtension, RoomExtension 같은 익스텐션 타입에 대고 컴파일되므로 컴파일 시점에는 그 타입들이 필요하지만, AGP나 코틀린 플러그인을 소비하는 모듈의 런타임 클래스패스에 올려서는 안 됩니다. 진짜 플러그인은 소비자가 직접, 루트 빌드 파일에 고정된 채로 제공합니다. 이들을 implementation으로 표시하면 틀린 것입니다. 오직 린트 체크 아티팩트(lintChecks 구성에 있는)와 진짜 런타임 조각 하나인 truth만이 compileOnly가 아닙니다. 이것들은 미리 컴파일된 스크립트 플러그인도 buildSrc도 아닌, 컴파일된 클래스, 곧 바이너리 플러그인입니다.
같은 빌드 파일이 각 플러그인을 등록하며, id를 그것을 구현하는 클래스에 묶습니다.
gradlePlugin {
plugins {
register("androidLibrary") {
id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId
implementationClass = "AndroidLibraryConventionPlugin"
}
register("hilt") {
id = libs.plugins.nowinandroid.hilt.get().pluginId
implementationClass = "HiltConventionPlugin"
}
// ...
}
}
각 register 블록은 id 문자열을 완전한 이름의 클래스에 묶습니다. Gradle이 id nowinandroid.android.library를 해석하면, 이 표가 AndroidLibraryConventionPlugin을 인스턴스화하고 apply(project)를 호출하라고 일러 줍니다. 이 id는 리터럴로 하드코딩된 것이 아니라, libs.plugins.nowinandroid.android.library.asProvider().get().pluginId로 버전 카탈로그에서 읽어 온 것입니다. 바로 여기서 둘이 맞물립니다. 여기에 등록된 id가 카탈로그에 선언된 별칭과 반드시 같음이 보장되는데, 둘 다 같은 출처에서 나오기 때문입니다.
버전 카탈로그의 맞물림
마지막 고리는, 빌드 파일의 별칭이 등록의 id와 어떻게 맞아떨어지느냐입니다. 둘 다 gradle/libs.versions.toml을 거치며, 여기 [plugins] 섹션이 각 컨벤션 플러그인을 선언합니다.
[plugins]
nowinandroid-android-library = { id = "nowinandroid.android.library" }
nowinandroid-android-library-compose = { id = "nowinandroid.android.library.compose" }
nowinandroid-hilt = { id = "nowinandroid.hilt" }
nowinandroid-android-feature-impl = { id = "nowinandroid.android.feature.impl" }
여기에는 한 대상을 가리키는 이름이 셋 있는데, 이를 잘 구분하면 버전 카탈로그를 둘러싼 혼란은 대부분 사라집니다. 카탈로그 별칭은 대시를 씁니다. 가령 nowinandroid-android-library입니다. 생성된 접근자는 점을 쓰는데, Gradle이 대시를 하나씩 점으로 바꾸기 때문입니다. libs.plugins.nowinandroid.android.library가 그것입니다. 플러그인 id 문자열은 id 키의 값으로, "nowinandroid.android.library"입니다. 접근자 경로와 id 문자열이 똑같아 보이는 것은 오로지 Now in Android가 둘을 일부러 맞췄기 때문입니다. 둘은 서로 독립적입니다. 접근자 경로는 별칭 이름에서 오고, id 문자열은 TOML 값에서 옵니다.
등록에서 한 가지 세부가 asProvider() 호출을 필요로 합니다. 어떤 별칭이 다른 별칭들의 접두사이기도 하면, 그 생성된 접근자는 리프가 아니라 중간 그룹 노드가 됩니다.
id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId
id = libs.plugins.nowinandroid.hilt.get().pluginId
nowinandroid-android-library는 nowinandroid-android-library-compose와 nowinandroid-android-library-jacoco의 접두사이므로, libs.plugins.nowinandroid.android.library는 그 자식들을 품은 그룹이고, 그 노드에서 리프 플러그인에 닿으려면 .asProvider()가 필요합니다. nowinandroid-hilt처럼 자식이 없는 리프 별칭은 .get()을 곧바로 씁니다. 등록에서 두 형태를 섞어 쓰는 이유가 바로 이것입니다.
플러그인 코드에서 카탈로그 읽기
모든 플러그인을 관통하는 비대칭이 하나 있습니다. 바로 카탈로그를 읽는 방식입니다. 빌드 스크립트는 생성된 libs 익스텐션을 거저 받지만 플러그인 클래스는 그렇지 못하므로, 손으로 작성한 접근자를 통해 카탈로그를 읽습니다.
val Project.libs
get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
이 프로퍼티는 VersionCatalogsExtension을 해석해 "libs"라는 이름의 카탈로그를 반환합니다. 이를 가지고 플러그인 코드는 libs.findLibrary("kotlin.test").get()을 호출합니다. findLibrary는 점으로 구분된 이름을 받으므로, 카탈로그 별칭 androidx-tracing-ktx는 "androidx.tracing.ktx"로 찾습니다. 컨벤션 플러그인 안의 모든 의존성이, 빌드 스크립트에서라면 썼을 libs.androidx.tracing.ktx가 아니라 libs.findLibrary("...").get() 형태로 나타나는 이유가 바로 이것입니다. 두 libs는 같은 객체가 아니며, 생성되는 것은 빌드 스크립트 버전뿐입니다.
이 방식이 subprojects와 apply from을 이기는 이유
다시 순진한 접근들로 돌아가 보겠습니다. subprojects {}와 allprojects {}는 루트에서 설정하는데, 이는 곧 즉시 실행되고, 모든 하위 프로젝트를 하나의 틀로 밀어 넣으며, 구성 회피와 프로젝트 격리를 허무는 방식으로 프로젝트 경계를 넘나든다는 뜻입니다. 이들은 app 모듈은 애플리케이션이고 core 모듈은 라이브러리이며 util 모듈은 순수 JVM이라는 것을 표현하지 못합니다. 컨벤션 플러그인은 모듈마다 옵트인입니다. 모듈은 딱 필요한 플러그인만 적용하고, 설정은 IDE가 따라 들어갈 수 있는 타입 있는 코틀린이며, 루트에서 다른 프로젝트를 설정하는 일이 전혀 없습니다.
Now in Android는 프로젝트 경계를 넘는 설정에서 적극적으로 벗어나고 있으며, 그 점을 자신의 루트 플러그인이 보여 줍니다. RootPlugin은 마지막까지 남은 subprojects {} 사용 하나를 기능 검사 뒤에 두어 지킵니다.
abstract class RootPlugin : Plugin<Project> {
@get:Inject abstract val buildFeatures: BuildFeatures
override fun apply(target: Project) {
require(target.path == ":")
if (!buildFeatures.isIsolatedProjectsEnabled()) {
target.subprojects { configureGraphTasks() }
}
target.configureSpotlessForRootProject()
}
}
RootPlugin은 require(target.path == ":")으로 강제되어 오직 루트에만 적용되고, Isolated Projects가 꺼져 있을 때에만 하위 프로젝트로 손을 뻗습니다. 프로젝트를 넘나드는 것이 금지되는 Isolated Projects 아래에서는 그 경로를 통째로 건너뜁니다. 모듈 그래프 태스크는 Isolated Projects가 꺼져 있을 때에만 등록됩니다. 컨벤션 플러그인 모델이 그 격리와 잘 맞는 것은, 각 플러그인이 오직 자기 프로젝트만 설정하기 때문입니다.
또 하나의 솔깃한 지름길은 apply from: "shared.gradle"로, 공유 스크립트를 빌드 파일마다 끌어오는 것입니다. 적용된 스크립트는 타입이 없고, 버전 관리가 안 되며, 컴파일된 코드를 공유하지 않고, IDE 자동완성도 받지 못하며, 익스텐션을 선언할 수도 없습니다. 컨벤션 플러그인은 진짜 타입을 가진 컴파일된 클래스이고, 빌드와 함께 버전 관리되며, IDE가 그 안으로 따라 들어가고, id로 다른 플러그인과 조합됩니다. 스크립트를 붙여 넣는 것과 플러그인을 적용하는 것 사이의 간극이 바로 이것입니다.
마지막 한 조각이 클래스패스를 하나로 고정합니다. 루트 build.gradle.kts는 모든 서드파티 플러그인을 apply false로 선언합니다.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.compose) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.hilt) apply false
// ...
alias(libs.plugins.nowinandroid.root)
}
apply false는 각 플러그인을 어디에도 적용하지 않은 채 빌드의 클래스패스에 올려 두어, 모든 모듈에 대해 AGP·코틀린·Hilt를 비롯한 나머지의 버전을 하나로 고정합니다. 그러면 컨벤션 플러그인이 그 고정된 플러그인들을 id로 적용하므로, 어떤 모듈도 다른 AGP 버전으로 어긋날 수 없습니다. 실제로 적용되는 것은 nowinandroid.root뿐이고, 그것도 루트에서만 적용됩니다. RootPlugin이 루트 경로를 요구하기 때문입니다.
결론
실질적인 변화는 쓰기엔 작지만 효과는 큽니다. 새 모듈은 plugins { alias(...) }로 시작해, 정확히 한 곳에만 사는 설정을 물려받습니다. 그래서 compileSdk를 올리거나 테스트 러너를 바꾸는 일은, 모든 모듈이 한꺼번에 받아 가는 한 줄짜리 수정이 됩니다. 뒤따르는 습관은 단순합니다. 어떤 빌드 블록이 두 번째 모듈에 다시 나타나기 시작하는 순간, 복사하는 대신 플러그인으로 옮기고, 기능 컴파일이 굼떠지기 시작하면 곧바로 api와 impl 분리를 꺼내 드는 것입니다.
이것을 단순한 중복 제거 이상으로 만드는 것은, 빌드를 바라보는 관점의 전환입니다. 빌드는 더 이상 붙여 넣는 설정 더미가 아니라, 여러분이 조합하는 작은 프로그램이 됩니다. 그 안에서 apply는 기능을 존재하게 하고, configure는 그것을 다듬으며, withPlugin은 넘겨짚어 분기하는 대신 한 플러그인이 다른 플러그인에 반응하게 합니다. 컨벤션 플러그인이 그 정체 그대로, 곧 Android Gradle Plugin 자신과 같은 타입인 평범한 Plugin<Project>로 읽히기 시작하면, 빌드 그래프는 조용히 쌓여 가는 무언가가 아니라 여러분이 의도를 갖고 설계하는 무언가로 바뀝니다.

