현대적인 앱 개발에서 모듈화 (Modularization) 는 선택이 아닌 필수로 자리 잡았다.
모듈 분리를 통해 각 관심사에 따라 명확하게 기능을 분리하고 코드의 응집도를 높이며, 수정되지 않은 모듈은 캐시를 재사용시켜 빌드 속도를 개선하기 때문이다.
물론 모든 프로젝트에서 정답은 아니지만, 프로젝트의 확장성과 보수성을 생각하면 초입에 이를 도입하는 것이 편리하다.
하지만 멀티모듈은 하나 단점이 존재한다.
바로 반복적인 Gradle 설정이다.

위 이미지처럼 수 개, 수십 개의 모듈이 생성된다면 dependencies, compileOptions, productFlavors 와 같은 공통 설정을 모든 build.gradle.kts 파일에 복붙해야한다.
이는 단순한 불편함을 넘어 나중에 라이브러리 업데이트나 빌드 설정 변경 시 일일이 모두 수정해주며 휴먼 에러로 이어질 수도 있다.
Convention Plugin 은 이러한 불편함을 해결하기 위해 등장하였는데, 빌드 구성들을 중앙에서 한번에 관리할 수 있게 해준다.
반복되는 Gradle 설정을 미리 약속된 규칙 (Convention) 에 따라 템플릿화하여 중앙에서 관리하고 재사용하는 Gradle Plugin 이다.

이를 사용하면 공통 설정을 가지는 Module 들은 위처럼 Custom Convention Plugin 을 구현만 하면 공통 설정을 가지게 된다.
기존 VersionCatalog 가 정식 도입되기 전에는 BuildSrc 를 통한 의존성 관리 방식이 존재했다. kotlin DSL 과 결합하여 BuildSrc 라는 폴더를 생성하고, 이곳에서 코틀린 객체를 생성하기만 하면 Gradle 이 이를 자동으로 추적하여 IDE 자동완성이 가능했다.
다만 BuildSrc 방식의 경우 내부 코드가 수정되는경우, BuildSrc 의 객체를 참조하는 모든 모듈의 캐싱이 무효화된다.
또한 라이브러리 버전이 업데이트되었을 경우, 이에 대한 알림이 지원되지 않는다.
ConventionPlugin 의 경우 이와는 달리 Composite Build 를 진행하기 때문에 수정된 플러그인만 재빌드 하기 때문에, 전체 빌드 캐시를 깨지 않는다.
또한 Version Catalog 를 통한 버전관리가 이루어지기 때문에 버전 업데이트가 된 경우, 이에 대한 알림 표시를 확인할 수 있다.
시작하기에 앞서 프로젝트를 우선 멀티 모듈로 구현해주세요.
멀티모듈 구현을 완료하였다면 build-logic 이라는 폴더를 Root Project 에 생성해주자.

그 다음 settings.gradle.kts 파일을 build-logic 에 만들고 다음 코드를 추가해준다.
dependencyResolutionManagement {
// 라이브러리는 이곳에서
repositories {
google()
mavenCentral()
}
// 버전 카탈로그를 "libs" 라는 이름으로 생성하고, 데이터 소스를 files("../gradle/libs.versions.toml") 로 설정
versionCatalogs {
create("libs") {
from(files("../gradle/libs.versions.toml"))
}
}
}
rootProject.name = "build-logic"
이제 Project 의 settings.gradle.kts 로 이동하여 includeBuild 라는 코드를 추가해준다.
// 혹시나 include 처리가 되어있다면 지워줄것
includeBuild("build-logic")
이제 Sync Now 를 실행하면 다음과 같이 build-logic 이 자동적으로 모듈화된 것을 확인할 수 있다.

이제 build-logic 하위에 convention 이라는 모듈을 생성해줄 것이다.

생성 후 다음과 같은 에러가 발생할 것인데, Project 의 settings.gradle.kts 에서 include(":build-logic:convention") 코드를 없애고 build-logic 의 settings.gradle.kts 로 이동하여 include(":convention") 코드를 추가한다.
이제 VersionCatalog 로 이동하여 다음 코드를 library 부분에 추가해준다.
[libraries]
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
compose-compiler-gradlePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" }
kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
완료하였으면 convention - build.gradle.kts 의 코드를 다음으로 변경한다.
plugins {
`kotlin-dsl`
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
// 있어도 되고, 없어도 된다.
group = "devgyu.blogproject.build-logic"
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.compose.compiler.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)
}
또한 Project.build.gradle.kts 로 이동하여 다음 플러그인을 설정해주자.
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.jetbrains.kotlin.jvm) apply false
alias(libs.plugins.android.library) apply false
Sync Now 를 하면 다음과 같은 화면을 확인할 수 있다.

마지막으로 build-logic 에 gradle.properties 파일을 추가한 후 다음 코드를 넣어준다.
# 서로 의존성이 없는 모듈들을 동시에 빌드하여 속도를 높임
org.gradle.parallel=true
# 이전 빌드를 캐싱하여 불필요한 재빌드를 막음
org.gradle.caching=true
# 전체 모듈을 다 읽지 않고, 지금 실행할 태스크와 관련된 모듈의 설정만 읽음
org.gradle.configureondemand=true
# 빌드 설정 단계 자체를 캐싱하여, 다음 빌드 시 설정 단계를 아예 건너뜀.
org.gradle.configuration-cache=true
# 구성 캐시 데이터를 저장하고 불러오는 과정도 병렬로 처리, 구형 플러그인 사용 시 충돌나기 때문에 그 경우 끄기 !
org.gradle.configuration-cache.parallel=true
Convention Plugin 을 설정하기 전 확장함수를 구현하여 Plugin 에서 쉽게 사용할 수 있도록 구현하려고 한다.
아까 생성했던 ConventionPlugins 클래스는 잠시 지워두고 다음 폴더들을 추가해주자.

이제 ProjectConfigure.kt 를 생성하고 확장 함수를 만들 것이다.
나의 경우 Compose 전용 함수, JavaVersion 설정 함수, Android 를 사용하는 곳에서 활용하는 함수를 만들었다.
internal enum class ExtensionType {
Application,
AndroidLibrary,
NotAndroid
}
/**
* comopose 를 사용한다면 필수 적용
* */
internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension<*, *, *, *, *, *>
){
pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
dependencies {
add(ConfigurationType.Impl.type, platform(libs.findLibrary("androidx.compose.bom").get()))
add(ConfigurationType.Impl.type, libs.findLibrary("androidx.compose.ui").get())
add(ConfigurationType.Impl.type, libs.findLibrary("androidx.compose.ui.graphics").get())
add(ConfigurationType.Impl.type, libs.findLibrary("androidx.compose.ui.tooling-preview").get())
add(ConfigurationType.Impl.type, libs.findLibrary("androidx.compose.material3").get())
add(ConfigurationType.DebugImpl.type, libs.findLibrary("androidx.compose.ui.tooling").get())
add(ConfigurationType.DebugImpl.type, libs.findLibrary("androidx.compose.ui.test.manifest").get())
add(ConfigurationType.AndroidTestImpl.type, platform(libs.findLibrary("androidx.compose.bom").get()))
add(ConfigurationType.AndroidTestImpl.type, libs.findLibrary("androidx.compose.ui.test.junit4").get())
}
commonExtension.apply {
buildFeatures {
compose = true
}
}
}
/**
* kotlin-android 를 사용한다면 필수 적용
* compileSdk, minSdk 적용
* javaVersion 적용
* JVM_target 적용
* */
internal fun Project.configureKotlinAndroidWithJavaVersion(
commonExtension: CommonExtension<*, *, *, *, *, *>,
extensionType: ExtensionType
) {
pluginManager.apply("org.jetbrains.kotlin.android")
commonExtension.apply {
compileSdk = 36
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
setJavaVersion(extensionType)
if(commonExtension is LibraryExtension){
commonExtension.apply {
defaultConfig {
consumerProguardFiles("consumer-rules.pro")
}
}
}
}
/*
* 자바 버전 설정
* */
internal fun Project.setJavaVersion(extensionType: ExtensionType){
when (extensionType){
ExtensionType.Application -> {
extensions.configure<BaseAppModuleExtension> {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
}
ExtensionType.AndroidLibrary -> {
extensions.configure<LibraryExtension> {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
}
else -> {
extensions.configure<JavaPluginExtension> {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
}
if(extensionType != ExtensionType.NotAndroid){
extensions.configure<KotlinAndroidProjectExtension> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
} else {
extensions.configure<KotlinJvmProjectExtension> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
}
}
build-logic 의 settings.gradle.kts 에서 설정한 versionCatalog name 을 통해 버전 카탈로그에 접근한다.Convention Plugin 을 설정하기 위해서는 Plugin<Project> 를 구현해줘야한다.
커스텀 클래스를 만들고 인터페이스를 구현하자.
class AndroidApplicationComposeConventionPlugin: Plugin<Project> {
override fun apply(target: Project) {
with(target){
pluginManager.apply {
apply("com.android.application")
}
// 공용 종속 모듈을 이곳에 넣었으나, 자신의 입맛에 따라 설정하기
dependencies {
add("implementation", project(":core:designsystem"))
add("implementation", project(":features:splash"))
}
/*
Application = BaseAppModuleExtension
Library = LibraryExtension
*/
extensions.configure<BaseAppModuleExtension> {
defaultConfig {
namespace = "devgyu.blogproject.app"
applicationId = "devgyu.blogproject.app"
versionCode = 1
versionName = "1"
}
configureAndroidCompose(this)
configureKotlinAndroidWithJavaVersion(this, ExtensionType.Application)
}
}
}
}
자신의 입맛에 맞춰 모든 Convention Plugin 을 설정하였으면, 마지막 단계를 진행하자.
convention 모듈 build.gradle.kts 로 이동하여 다음과 같은 코드를 추가한다.
gradlePlugin {
plugins {
register("자유이름"){
id = "자유ID" // 플러그인 설정 ID 임
implementationClass = "아까 구현한 커스텀 클래스 주소"
}
register("자유이름") {
id = "devgyu.blogproject.android.library.compose" // 자유 ID
implementationClass = "devgyu.blogproject...AndroidLibraryComposeConventionPlugin"
}
}
}
이제 플러그인을 적용할 모듈로 이동해 다음과 같이 플러그인을 추가해준다.
plugins {
id("devgyu.blogproject.android.library.compose")
}
이제 각자 모듈에 맞춰 잘 적용되었는지 확인해보자.
나의 경우 다른 모듈들에 Composable 함수를 구현하여 접근이 가능한지와, 실제 구동이 되는지 확인하였다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MoripTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
Text(text = "이것은 App Module Text")
SplashText() // Feature-Splash 모듈
CoreText() // Core-Ui 모듈
}
}
}
}
}
}

중앙관리를 더 확실하게 하기 위해 Plugin 설정도 libs 에서 가져오게 설정하였다.
object Plugins {
const val APPLICATION = "android.application"
const val ANDROID = "kotlin.android"
const val COMPOSE = "kotlin.compose"
const val JVM = "jetbrains.kotlin.jvm"
const val LIBRARY = "android.library"
const val ANDROID_TEST = "android.test"
const val BASELINE_PROFILE = "baselineprofile"
const val HILT_ANDROID = "dagger.hilt.android"
const val KSP = "devtools.ksp"
const val SERIALIZATION = "kotlin.plugin.serialization"
}
internal fun Optional<Provider<PluginDependency>>.getPluginId() = get().get().pluginId
pluginManager.apply(libs.findPlugin(Plugins.APPLICATION).getPluginId())
이전까지는 VersionCatalog 가 정식 지원되기 전, BuildSrc 를 이용한 중앙관리법만 알았지, 이러한 기능이 있었다는 걸 처음 알았다.
skydoves 님의 Poketdex-compose 구조를 살펴보던 와중, 이러한 방식을 통한 중앙 관리 기법이 있다는 것을 깨닫고 글을 써봤다.
역시 남의 라이브러리를 살펴보는게 도움이 엄청 되는 것 같다.