[Android] 멀티 모듈 환경에서 Custom Convention Plugin & Version Catalog 구현하기
이 글에서는 기존 Kotlin DSL + buildSrc 방식의 한계를 짚어보고, Gradle Version Catalog(libs.versions.toml)를 활용한 의존성 관리와, 멀티 모듈 및 build-logic 모듈로의 전환 과정을 단계별로 설명합니다. 샘플 코드를 통해 설정 방법과 장·단점을 살펴보며, 최종적으로 유지보수 효과까지 확인해보았습니다. 추가적으로 모듈 그래프를 만들어 멀티 모듈의 구조를 한눈에 확인해봤습니다.
다음 레포지토리에서 전체 코드를 확인할 수 있습니다.
GitHub - Genti2024/Genti-Android: 내 마음대로 표현하는 하나뿐인 AI 사진, Genti
내 마음대로 표현하는 하나뿐인 AI 사진, Genti. Contribute to Genti2024/Genti-Android development by creating an account on GitHub.
github.com
1. Gradle Version Catalog란?
buildSrc
buildSrc 디렉터리를 생성한 후 의존성과 버전 등 빌드 관련 변수들을 모아두어, 프로젝트 전반에서 공통 버전을 재사용할 수 있습니다.
gradle은 buildSrc 디렉터리를 발견하면 이 코드를 자동으로 컴파일 및 테스트하고 빌드 스크립트의 클래스 패스에 넣으며 작동됩니다.
이러한 방식으로 SDK, 라이브러리 버전, 플러그인 버전을 코드로 한곳에 모아 관리함으로써 버전 충돌 위험을 줄입니다.
기존의 buildSrc에서는 다음과 같은 단점을 확인할 수 있었습니다.
- 빌드 캐시 Invalidation: buildSrc 내부 파일 수정 시 buildSrc 전체가 재컴파일되어 캐시 무효화가 발생합니다.
- 클린 빌드 느림: 초기 컴파일과 buildSrc 빌드 오버헤드로 Groovy DSL 대비 빌드가 느립니다.
- Java 8 이상 필요: buildSrc가 별도 JVM 모듈로 동작하므로 최소 Java 8를 요구합니다.
- IDE 버전 관리 미지원: 버전 업그레이드 알림에 대한 자동 Inspection 기능이 없어 수동 관리가 필요합니다.
Gradle Version Catalog
libs.versions.toml 한 파일에서 버전·플러그인·번들을 선언하는 방식입니다.
Version Catalog을 활용한다면 다음의 이점을 얻을 수 있습니다.
- 리빌드 감소: 함수 호출의 형태라 상수 값의 변경 여부는 무관하기 때문에, 버전 변경이 일어난다고 해도 리빌드를 하지 않습니다.
- IDE 자동 완성: libs.xxx로 안전하게 의존성 추가를 지원합니다.
- 그룹화(Bundle): 관련 라이브러리를 묶어 일괄 추가가 가능합니다.
- 플러그인 관리: plugins 섹션으로 AGP, Kotlin DSL 플러그인 버전을 통합할 수 있습니다.
2. Custom Convention Plugins란?
(1) 멀티 모듈
멀티 모듈 (Multi Module)
멀티모듈 아키텍처는 애플리케이션을 여러 개의 독립적인 모듈로 나누어 개발하는 방법으로, 애플리케이션을 모듈이라는 독립적인 단위로 분리하여 각각의 모듈이 독립적으로 기능을 수행하도록 하는 방법입니다.
멀티 모듈 구조를 활용하면 다음과 같은 장점을 얻을 수 있습니다.
- 모듈 분리: Presentation / Domain / Data / Core 등 관심사별로 모듈을 분리할 수 있습니다.
- 빌드 속도 최적화: 변경된 모듈만 증분 컴파일하여 빌드 시간을 단축할 수 있습니다.
- 독립 개발/테스트: 각 모듈이 독립적으로 테스트·릴리즈가 가능합니다.
- 의존성 명확화: 모듈별 의존성 흐름을 개별 gradle 설정을 통해 강제하여 의존성 관리를 명확하게 할 수 있습니다.
(2) 커스텀 플러그인과 build-logic
Custom Plugin
만약 하나의 모듈에서 많은 플러그인이 사용되고, 이러한 플러그인이 다른 모듈에서도 사용된다면 Custom Plugin을 고려할 수 있습니다. Custom Plugin은 직접 Plugin을 그룹화하여 관리할 수 있는 작업이며, 빌드 로직을 재사용하고 다른 모듈과 공유할 수 있습니다.
Custom Convention Plugin
Convention Plugin은 모듈에 특정한 컨벤션을 적용하는 플러그인으로, Custom Plugin에서 각 모듈에서 공통된 빌드 구성을 뽑아내어 재사용할 수 있도록 구성하는 방법입니다.
build-logic
build-logic 모듈은 공통 빌드 스크립트를 포함해 빌드 구성 자체를 모듈화할 수 있어 재사용성과 유지보수성을 향상할 수 있습니다.
멀티 모듈 구조를 사용할 때, Custom Convention Plugin과 build-logic을 활용하면 여러 이점을 얻을 수 있습니다.
- 중앙집중형 설정 관리: 프로젝트 공통 설정을 build-logic에 모아 두면 각 모듈에 반복 적용할 필요 없이 일괄 관리할 수 있습니다.
- 코드 재사용성: 여러 모듈에서 apply() 호출만으로 동일 기능을 손쉽게 적용할 수 있습니다.
- 유지보수 용이: 설정 변경 시 build-logic 모듈만 수정하면 되므로, 대규모 프로젝트에서도 설정 일관성을 유지하며 변경 범위를 최소화할 수 있습니다.
3. Version Catalog 설정 (libs.versions.toml)
의존성과 버전들에 대해서 libs.versions.toml을 활용하여 적용하는 과정을 예시 코드로 알아보겠습니다.
(1) 의존성 이전
기존에는 단순 String 형태로 관리되던 버전과 의존성을, group, name, version ref로 나누어서 적용할 수 있습니다.
// 기존 build.gradle.kts
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// libs.versions.toml 설정
[versions]
kotlinxCoroutines = "1.9.0"
[libraries]
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
// 변경된 build.gradle.kts
implementation(libs.kotlinx.coroutines.android)
또는 관련 라이브러리를 Bundle로 묶어 일괄 추가가 가능합니다.
// libs.versions.toml 설정
[bundles]
coroutines = [
"kotlinx-coroutines-core",
"kotlinx-coroutines-android",
]
// 변경된 build.gradle.kts
implementation(libs.bundles.coroutine)
(2) 플러그인 이전
alias()를 사용하면 플러그인 ID와 버전을 한 번에 관리할 수 있으며, Gradle 버전 업그레이드 시 TOML만 수정하면 됩니다.
// 기존 코드
// Top-level `build.gradle.kts` file
plugins {
id("com.android.application") version "7.4.1" apply false
}
// Module-level `build.gradle.kts` file
plugins {
id("com.android.application") version "7.4.1"
}
// libs.versions.toml 설정
[versions]
android-gradle-plugin = "7.4.1"
[plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
// 전환된 코드
// Top-level build.gradle.kts
plugins {
alias(libs.plugins.android.application) apply false
}
// module build.gradle.kts
plugins {
alias(libs.plugins.android.application)
}
4. build-logic 모듈 생성
1. build-logic 모듈을 추가한 후, 내부에 플러그인, 확장 함수, 상수를 정의할 convention 모듈을 추가합니다.
2. 모듈 내부에 setting.gradle.kts를 추가하고 Version Catalog를 적용합니다.
// Gradle의 Typesafe Project Accessors 기능 활성화
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
// Version Catalog를 build-logic 모듈에서도 참조하도록 설정
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
// 이 모듈의 루트 프로젝트 이름과 포함할 서브모듈 정의
rootProject.name = "build-logic"
include(":convention")
3. 모듈 내부에 gradle.properties를 추가하고, CI/CD 환경에서 빌드 속도를 극대화하도록 설정합니다.
org.gradle.parallel=true // 병렬 빌드
org.gradle.caching=true // 빌드 캐시 활성화
org.gradle.configureondemand=true // 요청된 프로젝트만 구성
4. 프로젝트의 settings.gradle.kts에 build-logic를 등록합니다.
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
// build-logic 모듈을 포함시켜, convention 플러그인을 인식하게 함
includeBuild("build-logic")
repositories {
google()
mavenCentral()
}
}
5. 프로젝트의 build.gradle.kts에서 기존 플러그인을 Version Catalog alias로 대체합니다.
buildscript {
repositories {
google()
mavenCentral()
}
}
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.google.crashlytics) apply false
}
6. Convention 모듈의 build.gradle.kts도 추가적으로 설정해줍니다.
plugins {
`kotlin-dsl` // Kotlin DSL 플러그인 적용
}
java {
// 플러그인 코드 자체는 Java 17 호환으로 컴파일
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
dependencies {
// Version Catalog에서 플러그인 의존성 참조
implementation(libs.android.gradlePlugin) // com.android.tools.build:gradle
implementation(libs.kotlin.gradlePlugin) // org.jetbrains.kotlin:kotlin-gradle-plugin
}
5. build-logic 모듈 설정
(1) Constants 객체 추가
build-logic/convention/src/main/kotlin/Constants.kt에 buildSrc처럼 프로젝트 전역에서 사용할 상수를 정의합니다.
추가적으로 Constants 객체를 만들지 않고, version catalog에서 정의하는 레포지토리도 많은걸 보아 취향 차이인 것 같습니다...?
import org.gradle.api.JavaVersion
object Constants {
const val packageName = "kr.genti.android"
const val compileSdk = 34
const val minSdk = 28
const val targetSdk = 34
const val versionCode = 18
const val versionName = "2.0.2"
const val jvmVersion = "17"
val JAVA_VERSION = JavaVersion.VERSION_17
}
(2) 확장함수 설정
다양한 확장 함수를 사전에 설정하여, Build Script를 더욱 간결하고 타입 안전하게 작성할 수 있습니다.
1. VersionCatalogExt
Gradle의 VersionCatalog에서 Bundle, Library, Plugin을 간편하고 타입 안전하게 가져올 수 있도록 제공하는 용도입니다.
fun VersionCatalog.getBundle(bundleName: String): Provider<ExternalModuleDependencyBundle> =
findBundle(bundleName).orElseThrow {
NoSuchElementException("Bundle with name $bundleName not found in the catalog")
}
fun VersionCatalog.getLibrary(libraryName: String): Provider<MinimalExternalModuleDependency> =
findLibrary(libraryName).orElseThrow {
NoSuchElementException("Library with name $libraryName not found in the catalog")
}
fun VersionCatalog.getPlugin(pluginName: String): String =
findPlugin(pluginName).orElseThrow {
NoSuchElementException("Plugin with name $pluginName not found in the catalog")
}.get().pluginId
2. ProjectExt
Gradle 프로젝트 설정을 직관적이고 간결하게 만들기 위한 용도입니다. 기존의 extensions.configure<...>() 형태를 프로퍼티 호출만으로 사용할 수 있도록 개선하며, Kotlin 컴파일러 옵션 및 Android/Java 설정을 간결하게 적용할 수 있습니다.
val Project.libs: VersionCatalog
get() = extensions.getByType<VersionCatalogsExtension>().named("libs")
val Project.androidApplicationExtension: ApplicationExtension
get() = extensions.getByType()
val Project.androidLibraryExtension: LibraryExtension
get() = extensions.getByType()
val Project.javaLibraryExtension: JavaPluginExtension
get() = extensions.getByType()
fun Project.applyKotlinCompilerOptions(jvmVersion: JvmTarget) {
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(jvmVersion)
}
}
}
fun Project.applyJUnitPlatform() {
tasks.withType<Test> {
useJUnitPlatform()
}
}
3. DependencyHandlerScopeExt
Gradle의 DependencyHandlerScope를 확장하여 다양한 유형의 의존성을 간편하게 추가할 수 있도록 설정하는 용도입니다.
fun DependencyHandlerScope.implementation(project: Project) {
"implementation"(project)
}
fun DependencyHandlerScope.androidTestImplementation(provider: Provider<*>) {
"androidTestImplementation"(provider)
}
fun DependencyHandlerScope.testImplementation(provider: Provider<*>) {
"testImplementation"(provider)
}
//...
(3) 기본 플러그인 설정
build-logic/convention 모듈에 커스텀 플러그인을 정의하여, 공통 의존성과 설정을 한 번에 묶습니다.
1. HiltPlugin
class HiltPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply {
apply(libs.getPlugin("ksp"))
apply(libs.getPlugin("hilt"))
}
dependencies {
implementation(libs.getLibrary("hilt-android"))
ksp(libs.getLibrary("hilt-android-compiler"))
}
}
}
2. KotlinPlugin
class KotlinPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply {
apply(libs.getPlugin("kotlin-android"))
apply(libs.getPlugin("kotlin-parcelize"))
apply(libs.getPlugin("kotlin-serialization"))
}
applyKotlinCompilerOptions(Constants.JVM_VERSION)
dependencies {
implementation(libs.getBundle("kotlinx"))
implementation(libs.getBundle("coroutines"))
}
}
}
3. ComposePlugin
class ComposePlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply {
apply(libs.getPlugin("kotlin-compose"))
}
androidLibraryExtension.apply {
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform(libs.getLibrary("androidx-compose-bom")))
implementation(libs.getBundle("compose"))
implementation(libs.getBundle("navigation"))
implementation(libs.getBundle("ui"))
}
}
}
4. TestPlugin
class TestPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
applyJUnitPlatform()
dependencies {
testImplementation(libs.getLibrary("junit"))
androidTestImplementation(libs.getLibrary("junit-androidx-test"))
androidTestImplementation(libs.getLibrary("espresso-core"))
}
}
}
5. VersionPlugin
class VersionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
with(extensions) {
// Constants에 정의된 버전 정보를 Gradle extra 속성에 추가
extraProperties["versionName"] = Constants.VERSION_NAME
extraProperties["versionCode"] = Constants.VERSION_CODE
}
}
}
(4) 컨벤션 플러그인 설정
1. com.android.application 적용할 그래들(app모듈)을 위한 플러그인
class AndroidApplicationPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply {
apply(libs.getPlugin("android-application"))
apply<KotlinPlugin>()
apply<HiltPlugin>()
apply<CommonPlugin>()
apply<TestPlugin>()
}
androidApplicationExtension.apply {
namespace = Constants.PACKAGE_NAME
compileSdk = Constants.COMPILE_SDK
defaultConfig {
applicationId = Constants.PACKAGE_NAME
targetSdk = Constants.TARGET_SDK
minSdk = Constants.MIN_SDK
versionCode = Constants.VERSION_CODE
versionName = Constants.VERSION_NAME
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = Constants.JAVA_VERSION
targetCompatibility = Constants.JAVA_VERSION
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
buildFeatures {
buildConfig = true
}
}
dependencies {
implementation(libs.getBundle("androidx"))
}
}
}
2. com.android.library 적용할 그래들(core, data 모듈)을 위한 플러그인
class AndroidLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) =
with(target) {
pluginManager.apply {
apply(libs.getPlugin("android-library"))
apply<KotlinPlugin>()
apply<HiltPlugin>()
apply<CommonPlugin>()
apply<TestPlugin>()
}
androidLibraryExtension.apply {
compileSdk = Constants.COMPILE_SDK
defaultConfig {
minSdk = Constants.MIN_SDK
consumerProguardFiles("consumer-rules.pro")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = Constants.JAVA_VERSION
targetCompatibility = Constants.JAVA_VERSION
}
}
dependencies {
implementation(libs.getBundle("androidx"))
}
}
}
3. com.android.library + Jetpack Compose 적용할 그래들(각 feature 모듈)을 위한 플러그인
class AndroidComposePlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply {
apply<AndroidLibraryPlugin>()
apply<ComposePlugin>()
}
}
}
}
4. java-library 적용할 순수 코틀린/자바 그래들(domain 모듈)을 위한 플러그인
class JavaLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) =
with(target) {
pluginManager.apply {
apply(libs.getPlugin("java-library"))
apply(libs.getPlugin("kotlin-jvm"))
}
javaLibraryExtension.apply {
sourceCompatibility = Constants.JAVA_VERSION
targetCompatibility = Constants.JAVA_VERSION
}
applyKotlinCompilerOptions(Constants.JVM_VERSION)
dependencies {
implementation(libs.getBundle("kotlinx"))
implementation(libs.getLibrary("kotlinx-coroutines-core"))
implementation(libs.getLibrary("javax-inject"))
}
}
}
6. 각 모듈의 Gradle에 Custom Convention Plugin 적용
각 모듈의 Gradle에 Custom Convention Plugin을 추가한 후, 추가적으로 필요한 작업을 진행하면 적용됩니다.
ex. feature/main 모듈
plugins {
id("kr.genti.androidLibrary")
id("kr.genti.version")
}
android {
namespace = "kr.genti.presentation"
defaultConfig {
buildConfigField("String", "VERSION_NAME", "\"${extra["versionName"]}\"")
buildConfigField("String", "VERSION_CODE", "\"${extra["versionCode"]}\"")
}
}
dependencies {
implementation(projects.core)
implementation(projects.domain)
implementation(platform(libs.okhttp.bom))
implementation(libs.bundles.networking)
implementation(platform(libs.firebase.bom))
implementation(libs.bundles.firebase)
implementation(libs.bundles.androidx)
implementation(libs.bundles.ui)
implementation(libs.kakao)
implementation(libs.app.update)
implementation(libs.amplitude)
}
다음과 같이, 중복적으로 각 모듈의 Gradle에서 사용되던 코드를 하나로 모으고, 중앙에서 한번에 관리할 수 있는 이점을 얻게 되었습니다.
7. 멀티모듈 구조 확인
(1) 모듈 그래프
코드로 모듈 그래프를 그려서 전체 구조를 확인할 수 있습니다.
1. gradle 폴더에 projectDependencyGraph.gradle를 추가하고 내용을 추가합니다.
성빈랜드 님의 미디엄 글을 참고했습니다 : 안드로이드 프로젝트 의존 그래프 만들기
tasks.register('projectDependencyGraph') {
doLast {
def dot = new File(rootProject.buildDir, 'dependency-graph/project.dot')
dot.parentFile.mkdirs()
dot.delete()
dot << 'digraph {\n'
dot << " graph [label=\"${rootProject.name}\\n \",labelloc=t,fontsize=30,ranksep=1.4];\n"
dot << ' node [style=filled, fillcolor="#bbbbbb"];\n'
dot << ' rankdir=TB;\n'
def rootProjects = []
def queue = [rootProject]
while (!queue.isEmpty()) {
def project = queue.remove(0)
rootProjects.add(project)
queue.addAll(project.childProjects.values())
}
def projects = new LinkedHashSet<Project>()
def dependencies = new LinkedHashMap<Tuple2<Project, Project>, List<String>>()
def multiplatformProjects = []
def jsProjects = []
def androidProjects = []
def androidDynamicFeatureProjects = []
def javaProjects = []
queue = [rootProject]
while (!queue.isEmpty()) {
def project = queue.remove(0)
queue.addAll(project.childProjects.values())
if (project.plugins.hasPlugin('org.jetbrains.kotlin.multiplatform')) {
multiplatformProjects.add(project)
}
if (project.plugins.hasPlugin('kotlin2js')) {
jsProjects.add(project)
}
if (project.plugins.hasPlugin('com.android.library') || project.plugins.hasPlugin('com.android.application')) {
androidProjects.add(project)
}
if (project.plugins.hasPlugin('com.android.dynamic-feature')) {
androidDynamicFeatureProjects.add(project)
}
if (project.plugins.hasPlugin('java-library') || project.plugins.hasPlugin('java')) {
javaProjects.add(project)
}
project.configurations.all { config ->
if (config.name.toLowerCase().contains("test")) return
config.dependencies
.withType(ProjectDependency)
.collect { it.dependencyProject }
.each { dependency ->
projects.add(project)
projects.add(dependency)
rootProjects.remove(dependency)
def graphKey = new Tuple2<Project, Project>(project, dependency)
def traits = dependencies.computeIfAbsent(graphKey) { new ArrayList<String>() }
if (config.name.toLowerCase().endsWith('implementation')) {
traits.add('style=dotted')
}
}
}
}
projects = projects.sort { it.path }
dot << '\n # Projects\n\n'
for (project in projects) {
def traits = []
if (rootProjects.contains(project)) {
traits.add('shape=box')
}
if (multiplatformProjects.contains(project)) {
traits.add('fillcolor="#ffd2b3"')
} else if (jsProjects.contains(project)) {
traits.add('fillcolor="#ffffba"')
} else if (androidProjects.contains(project)) {
traits.add('fillcolor="#baffc9"')
} else if (androidDynamicFeatureProjects.contains(project)) {
traits.add('fillcolor="#c9baff"')
} else if (javaProjects.contains(project)) {
traits.add('fillcolor="#ffb3ba"')
} else {
traits.add('fillcolor="#eeeeee"')
}
dot << " \"${project.path}\" [${traits.join(", ")}];\n"
}
dot << '\n {rank = same;'
for (project in projects) {
if (rootProjects.contains(project)) {
dot << " \"${project.path}\";"
}
}
dot << '}\n'
dot << '\n # Dependencies\n\n'
dependencies.forEach { key, traits ->
dot << " \"${key.first.path}\" -> \"${key.second.path}\""
if (!traits.isEmpty()) {
dot << " [${traits.join(", ")}]"
}
dot << '\n'
}
dot << '}\n'
def p = 'dot -Tpng -O project.dot'.execute([], dot.parentFile)
p.waitFor()
if (p.exitValue() != 0) {
throw new RuntimeException(p.errorStream.text)
}
dot.delete()
println("Project module dependency graph created at ${dot.absolutePath}.png")
}
}
2. 이후 프로젝트 수준의 build.gradle에 task를 추가합니다.
apply {
from("gradle/projectDependencyGraph.gradle")
}
3. 마지막으로 터미널에서 해당 task를 실행시켜주면 결과물을 얻을 수 있습니다.
./gradlew projectDependencyGraph
결과물은 다음과 같습니다.
(2) GitDiagram
간단한 방법으로는, 다음 GitDiagram 링크에서 레포지토리 링크를 넣기만 하면 구조를 한눈에 확인해볼 수 있습니다.
GitDiagram - Repository to Diagram in Seconds
Turn any GitHub repository into an interactive diagram for visualization.
gitdiagram.com
참고 자료 :
https://github.com/ddan-dda-ra/ddan-ddan-android
https://github.com/Team-Hankki/hankki-android
https://github.com/boostcampwm-2024/and05-MAPISODE
https://github.com/Link-MIND/Toaster_Android
Android build-logic : Custom Convention Plugins
[Android] build-logic으로 Custom Plugin을 설계해 활용하기
[Android] Version Catalog + Convention Plugin으로 build.gradle 버전을 관리해보자!