[안드로이드] Custom Convention Plugin
안녕하세요. 오늘은 Custom Convention Plugin에 대한 이야기를 해보려 합니다.
멀티모듈
멀티모듈에 대해서는 저번 글인 멀티모듈, 왜 그리고 어떻게?에서 다뤘었습니다.
글을 쓰며 멀티모듈에 대해서 알아본 후, 실제 구현에 들어가게 되었는데요.
구현하며 정확히 개념 정리가 되지 않아 어려웠던 점이 있습니다.
바로 gradle 빌드였습니다.
저번 글에서 아주 가볍게 지나가듯이 말했었습니다.
“멀티모듈의 이점으로는 증분빌드가 있다. 단일 모듈이면 코드 한 줄만 수정해도 전체를 다시 빌드해야 하지만, 멀티모듈이면 수정된 모듈과 관련된 모듈만 빌드하면 된다”고요.
왜 그럴 수 있을까요?
Gradle은 빌드할 때,
지난 번에 만든 결과물(build output)과 지금 바뀐 소스코드를 비교해서, 변한 부분만 다시 컴파일합니다.
이걸 Gradle은 Task 그래프로 관리하는데, 모듈이 나뉘면 Task 그래프도 자연스럽게 나뉘어서, 필요한 Task만 실행 → 불필요한 Task는 스킵하게 됩니다.
따라서 Gradle이 필요한 Task만 다시 실행하기 때문에 증분 빌드가 가능한 겁니다.
그리고 여기서부터 문제가 시작됩니다.
모듈이 많아질수록 중복 설정이 쌓이고, 버전 관리가 흩어지며, Hilt/Compose 같은 세팅은 계속 복붙하게 되죠.
저희는 이걸 Convention Plugin + Version Catalog로 정리했고, 그 과정을 오늘 글에서 다뤄보려 합니다.
우선 Version Catalog부터 알아봅시다.
Version Catalog
version catalog는 libs.versions.toml 파일에서 관리됩니다.
그래서 얘가 뭐냐?
간단히 말해, 무엇을 쓸지 정하는 의존성 및 플러그인 명세서라고 아시면 됩니다.
모든 라이브러리, 버전, 플러그인 정보를 관리하는 곳이에요.
Gradle이 이 파일을 읽어 타입-세이프 접근자 libs.*를 자동 생성합니다.
# gradle/libs.versions.toml
[versions]
agp = "8.6.0"
kotlin = "2.1.0"
hilt = "2.55"
coreKtx = "1.13.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
[bundles]
compose = [
"androidx-ui",
"androidx-ui-graphics",
# ...
]
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
# 우리가 만든 Custom Convention Plugin의 ID도 중앙에서 관리!
bbooyaaa-blog-application = { id = "com.bbooyaaa.blog.application", version = "unspecified" }
bbooyaaa-blog-feature = { id = "com.bbooyaaa.blog.feature", version = "unspecified" }
libs.versions.toml 파일을 열어보시면 위와 같이 작성되어 있을 겁니다.
versions, libraries, bundles, plugins 순서대로요.
그럼 이것들은 각각 뭘 의미할까요?
[versions] 섹션
여러 라이브러리/플러그인이 공유하는 버전 문자열을 변수처럼 보관하는 곳입니다.
아래 섹션들에서 version.ref = “hilt”처럼 참조하게 돼요.
[versions]
agp = "8.6.0"
kotlin = "2.1.0"
hilt = "2.55"
coreKtx = "1.13.1"
이렇게 version 섹션을 분리하면
한 곳에서 버전을 올리고 내리기 쉽고,
라이브러리 여러 개가 같은 버전을 쓸 때 일관성을 보장합니다.
또 한 눈에 볼 수 있어 가독성이 좋아지죠.
[libraries] 섹션
외부 라이브러리의 Maven 좌표를 별칭(alias)으로 정의합니다.
Maven 좌표란 group:name:version으로 식별되는 라이브러리의 주소입니다.
여기서 group이란 조직/그룹(보통 리버스 도메인), name은 아티팩트(패키지) 이름이에요.
근데 이걸 사용할 때마다 Maven 좌표를 쓸 수는 없으니 별칭(개발자가 읽기 쉬운 이름)으로 정의(=매핑)하여 사용하는거죠.
개발자가 androidx-core-ktx라고 별칭을 정하면,
Gradle이 libs.androidx.core.ktx 접근자를 만들어줘요. 그 흐름으로 사용하는 겁니다.
두 가지 표기 방식이 있어요.
방법 1. group + name (+ version.ref or version)
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
방법 2. module = "group:name" (+ version.ref or version)
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
어떤 방식을 써도 상관없습니다. 편하신대로 사용하시면 돼요.
일관성 유지를 위해 팀 내에서 한 방식으로 컨벤션을 정하시면 좋겠죠.
타입-세이프 접근자 이름 규칙
androidx-core-ktx처럼 하이픈(-)으로 구분하면, Kotlin DSL에서 libs.androidx.core.ktx처럼 점(.)으로 체이닝해 접근합니다.
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.hilt.android)
}
따라서 위와 같이 build.gradle.kts 파일에서 실제로 implementation해서 사용할 때는 위처럼 접근하게 되는거죠.
[bundles] 섹션
여러 개의 [libraries] 별칭을 배열로 묶어 한번에 가져오기 용입니다.
[bundles]
compose = [
"androidx-ui",
"androidx-ui-graphics",
"androidx-ui-tooling-preview",
"androidx-material3"
]
test = [
"junit",
"androidx-test-ext",
"espresso-core"
]
위와 같이 묶어두고
dependencies {
implementation(libs.bundles.compose)
testImplementation(libs.bundles.test)
}
와 같이 사용이 가능합니다.
bundle은 오직 [libraries]의 별칭을 나열하는 곳입니다.
→ 다른 bundle을 중첩해 넣지 않는 것을 권장해요.
기능 단위(네트워킹, 테스트, compose 등)로 주제별 묶음을 만들어두면 유지보수가 크게 쉬워집니다.
[plugins] 섹션
Gradle 플러그인의 ID(+버전)를 별칭(alias)으로 정의합니다.
Gradle 플러그인 ID란 플러그인을 식별하는 문자열 키입니다.
모듈의 plugins{…} 블록에서 alias(libs.plugins.xxx)로 적용합니다.
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
위와 같이 libs.versions.toml 파일에서 plugin을 정의하면
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
}
각 모듈의 build.gradle.kts 파일에서 위와같이 타입-세이프하게 사용이 가능해지죠.
plugins과 libraries
libraries는 앱/라이브러리가 컴파일-런타임에 사용하는 코드 묶음(의존성)을 정의해요.
- 예) androidx.core:core-ktx, com.squareup.okhttp3:okhttp
- 결과적으로 dependencies { implementation(libs.androidx.core.ktx) } 처럼 코드에 필요한 라이브러리를 추가하죠.
plugins는 빌드 시스템을 확장하는 도구(플러그인)을 정의합니다.
- 예) com.android.application, org.jetbrains.kotlin.android, com.google.dagger.hilt.android, 우리 팀의 컨벤션 플러그인
- 결과적으로 plugins { alias(libs.plugins.android.application) } 처럼 빌드에 필요한 기능/규칙을 붙이죠.
플러그인은 task/확장/기본 설정을 추가하고, 필요하면 의존성도 자동 주입할 수 있어요.
반대로 라이브러리는 플러그인 없이도 직접 추가할 수 있지만, 여러 모듈에서 반복되면 컨벤션 플러그인에 묶어 일괄 주입하는 편이 유지보수에 유리합니다.
→ libraries = 무엇을(코드에) 쓸지
plugins = 어떻게(빌드 규칙으로) 쓸지
그럼 이제 plugin에 대해 알았으니 Convention Plugin에 대해 알아봅시다.
Custom Convention Plugin
Convention Plugin이란?
어떻게 설정할지를 코드로 묶어둔 우리 팀만의 규칙 플러그인이라고 알아두시면 됩니다.
예를 들어, feature 모듈은 항상 특정 플러그인과 의존성을 기본으로 가져야할 때, 해당 플러그인과 의존성을 넣어서 feature 전용 custom plugin을 하나 만드는 겁니다.
그리고 생성한 feature 모듈에서 각각의 플러그인과 의존성을 가져올 필요없이 해당 custom plugin만 가져오는 겁니다.
그럼 불필요한 반복 코드를 획기적으로 줄일 수 있게 되겠죠.
[plugins]
bbooyaaa-blog-application = { id = "com.bbooyaaa.blog.application", version = "unspecified" }
bbooyaaa-blog-feature = { id = "com.bbooyaaa.blog.feature", version = "unspecified" }
bbooyaaa-blog-library = { id = "com.bbooyaaa.blog.library", version = "unspecified" }
bbooyaaa-blog-hilt = { id = "com.bbooyaaa.blog.hilt", version = "unspecified" }
위와 같이 custom convention plugin을 생성할 수 있습니다.
이렇게 ID를 넣어서 관리하면
build-logic(컨벤션 구현 모듈)과 각 모듈(플러그인 적용부)에서 타입-세이프하게 접근이 가능합니다.
이런 로컬 플러그인들은 버전을 안적어도 됩니다. 외부 저장소에서 받는게 아니라 우리 프로젝트에서 included build로 제공되니까요.
따라서 version = “unspecified”을 자주 씁니다.(혹은 생략)
어디서 어떻게 만들까?
위에서 custom convention plugin을 생성하는 법에 대해서 다뤘죠.
그럼 이제 해당 plugin을 실제로 구현해야 합니다.
루트 프로젝트에 build-logic이라는 별도의 포함 빌드(included build)를 둡니다.
포함 빌드란 한 Gradle 빌드가 다른 Gradle 빌드를 의존성처럼 끌어다 쓰는 방식입니다.
따라서 루트 빌드가 build-logic(플러그인 구현 빌드)을 포함하여, 거기서 만든 로컬 플러그인을 즉시 사용하게 되는거죠.
이 안에 플러그인 구현 클래스를 넣고, 루트 settings.gradle.kts 파일에 includeBuild(”build-logic”)를 선언해 연결해요.
pluginManagement {
includeBuild("build-logic")
repositories {
google {
content {
includeGroupByRegex("com\\\\.android.*")
includeGroupByRegex("com\\\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
이렇게요.
build-logic
그럼 build-logic모듈은 어떻게 생겼을까요?
build-logic/
├─ convention/
│ ├─ build.gradle.kts # 플러그인 등록
│ └─ src/main/kotlin/
│ ├─ AndroidFeatureConventionPlugin.kt # 플러그인 구현체
│ ├─ AndroidLibraryConventionPlugin.kt
│ ├─ AndroidHiltConventionPlugin.kt
│ └─ ...
└─ settings.gradle.kts
위와 같이 플러그인을 등록과 실제 플러그인 구현체가 존재합니다.
build-logic/convention/build.gradle.kts 파일에서 어떤 커스텀 플러그인을 만들지 정의해요.
// /build-logic/convention/build.gradle.kts
plugins {
`kotlin-dsl` // 플러그인 구현을 Kotlin DSL로 작성
}
gradlePlugin {
plugins {
register("AndroidFeature") {
// ⬇️ Version Catalog에 등록한 ID를 타입-세이프로 꺼냅니다
id = libs.plugins.bbooyaaa.blog.feature.get().pluginId
implementationClass = "AndroidFeatureConventionPlugin"
}
register("Hilt") { // 'Hilt'는 예시 이름, 실제 프로젝트는 'Hilt'
id = libs.plugins.bbooyaaa.blog.hilt.get().pluginId
implementationClass = "AndroidHiltConventionPlugin"
}
// ... 다른 플러그인 등록
}
}
dependencies {
// 플러그인 구현에 필요한 Gradle/AGP/Kotlin 플러그인 API를 컴파일 타임에만 참조
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
// ...
}
위와 같이 register를 통해 이름을 정하고? Version Catalog에 등록한 ID를 타입-세이프로 꺼내고, 실제 구현체 클래스를 명시해줍니다.
이제 실제 구현체를 봅시다.
implementationClass로 지정된 클래스가 바로 그 실제 구현체죠.
feature 모듈용 플러그인을 예로 들어볼게요.
// /build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
import com.android.build.gradle.LibraryExtension
import com.bbooyaaa.blog.configureAndroidCompose
import com.bbooyaaa.blog.configureKotlinAndroid
import com.bbooyaaa.blog.implementation
import com.bbooyaaa.blog.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
with(pluginManager) {
// 필수 플러그인 적용
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlin.plugin.serialization")
}
// 공통 Android 설정 (compileSdk, minSdk, Compose 등)
extensions.configure(LibraryExtension::class.java) {
configureKotlinAndroid(this)
configureAndroidCompose(this) // Compose 관련 설정과 의존성 주입
defaultConfig.targetSdk = 35
}
// 🔎 Version Catalog를 사용한 공통 의존성 주입
dependencies {
implementation(libs.findLibrary("androidx-core-ktx").get())
implementation(libs.findLibrary("kotlinx-coroutines-core").get())
implementation(
libs.findLibrary("org-jetbrains-kotlinx-kotlinx-serialization-json").get()
)
// ... 공통 테스트 의존성
}
}
}
이렇게 등록, 실제 구현까지 마치고 나면 새로운 feature 모듈을 만들 때, 해당 모듈의 build.gradle.kts 파일은 엄청나게 간결해집니다.
// /features/home/build.gradle.kts
plugins {
alias(libs.plugins.bbooyaaa.blog.feature) // 규약 1줄이면 공통 설정 + 공통 의존성 끝
alias(libs.plugins.bbooyaaa.blog.hilt) // Hilt 규약도 1줄
}
android {
namespace = "com.bbooyaaa.blog.features.home"
}
dependencies {
// 이 모듈만의 추가 의존성만 쓰면 됩니다.
implementation(projects.core.model)
implementation(projects.core.designsystem)
}
흐름 요약
위에서 원리, 이유, 개념에 대해 알아보았습니다.
그럼 이제 실제 구현 예시를 통해 흐름을 한번 확실하게 정리하고 글을 마무리해보겠습니다.
제가 가정한 상황은 아래와 같습니다.
feature 모듈에 필요한 compose convention plugin을 만들고, 실제로 feature 모듈에 적용하기
자 그럼 첫번째로 compose convention plugin을 만들기 위해서는 compose 외부 라이브러리가 필요하겠죠?
Version Catalog에 무엇을 쓸지 정의
먼저, libs.versions.toml에서 Compose 관련 라이브러리들을 등록해둡니다.
여기서 뭘 쓸지를 정하는거죠.
[versions]
composeBom = "2024.10.00"
[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { module = "androidx.compose.ui:ui" }
androidx-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
androidx-material3 = { module = "androidx.compose.material3:material3" }
[bundles]
compose = [
"androidx-ui",
"androidx-ui-graphics",
"androidx-material3"
]
[plugins]
bbooyaaa-blog-library-compose = { id = "com.bbooyaaa.blog.library.compose", version = "unspecified" }
이렇게 작성해두면 Compose 관련 라이브러리 버전, BOM, bundle까지 한 곳에서 관리할 수 있어요.
이제 뭘 쓸지 전부 선언을 완료했네요.
+) 참고로
Compose 의존성은 각 모듈에서 직접 추가해도 됩니다.
다만 이 글에서는 convention plugin이 공통 compose 의존성을 대신 주입하도록 설계했어요.
그래서 Feature 모듈에서는 나중에 플러그인 한 줄만 넣으면 Compose 설정이 자동으로 들어갑니다.
build-logic 포함시키기
그럼 이제 어떻게 설정할지를 담은 플러그인을 만들어야 합니다.
이때 필요한게 바로 build-logic이라는 별도 모듈이에요.
build-logic은 쉽게 말해 플러그인 전용 프로젝트라고 보면 돼요.
여기서 Convention Plugin들을 직접 구현하고,
루트 프로젝트에서 includeBuild(”build-logic”)로 불러와서 사용합니다.
// settings.gradle.kts
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
이렇게 선언해두면, 루트 프로젝트가 build-logic에서 만든 플러그인을 즉시 쓸 수 있게 됩니다.
Compose Convention Plugin 만들기
이게 진짜 핵심 부분이에요.
Compose 관련 설정과 의존성을 한 번에 넣어주는 우리만의 플러그인을 만드는 겁니다.
// build-logic/convention/build.gradle.kts
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
register("AndroidLibraryCompose") {
id = libs.plugins.bbooyaaa.blog.library.compose.get().pluginId
implementationClass = "AndroidLibraryComposeConventionPlugin"
}
}
}
위와 같이 플러그인을 등록하고, 아래와 같이 실제 플러그인을 구현하는거죠.
// build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt
import com.android.build.gradle.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.gradle.api.plugins.catalog.VersionCatalogsExtension
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
}
extensions.getByType(LibraryExtension::class.java).apply {
buildFeatures.compose = true
}
val libs = extensions.getByType(VersionCatalogsExtension::class.java).named("libs")
dependencies {
add("implementation", platform(libs.findLibrary("compose-bom").get()))
libs.findBundle("compose").get().forEach { add("implementation", it) }
}
}
}
요약하자면,
buildFeatures.compose = true
Compose BOM(platform(…)) 추가
libs.bundles.compose로 UI 관련 의존성 한 번에 주입
등을 한 번에 처리하는 플러그인을 만든 거예요.
그럼 build.gradle.kts 파일에서 이제 하나하나 각각 설정할 필요가 없게 되겠죠?
Feature 모듈에서 딱 한 줄로 사용하기
이제 새로 만든 feature 모듈(features/home, features/login 등)에서는 더 이상 Compose 설정을 직접 쓸 필요가 없어지죠.
따라서 아래와 같이
// /features/home/build.gradle.kts
plugins {
alias(libs.plugins.bbooyaaa.blog.library.compose) // Compose 규약 한 줄이면 끝
}
android {
namespace = "com.bbooyaaa.blog.features.home"
}
dependencies {
implementation(projects.core.model)
implementation(projects.core.designsystem)
}
저 alias 한줄만 추가하면 이전에 했던 모든 각각의 implementation 등이 자동으로 들어갑니다.
그럼 끝이에요.
결론적으로 정리하자면
- 무엇을 쓸지 → Version Catalog(libs.versions.toml)에 정의
- 어떻게 쓸지 → Convention Plugin(build-logic)으로 규약화
- 어디서 쓸지 → 각 모듈에서 플러그인 한 줄로 적용
이런 흐름이죠.
이 글에서는 Compose만 다뤘지만, 실제로는 Hilt, Navigation, 테스트 세팅, 공통 Android 옵션(compileSdk/minSdk/jvmTarget 등) 등도 전부 같은 방식으로 정리할 수 있습니다.
멀티모듈로 인해 빌드 스크립트에서 반복되는 코드를 최대한 줄여보자.
이게 제가 멀티모듈을 적용하면서 겪은 문제를 해결하기 위해 적용한 custom convention plugin이였습니다.
오늘은 Custom Convention Plugin과 Version Catalog를 알아보는 시간을 가졌습니다.
처음 도입할 때는 정말 머리가 너무 아팠는데

확실히 공부하고 정리하니 코드에 대한 이해도가 높아진 것 같아요.
여러분들도 제 글을 통해 멀티모듈을 도입하며 겪을 중복 코드 문제를 잘 해결할 수 있었으면 좋겠습니다.
감사합니다.