이전 포스팅에서 멀티모듈 설계와 같이 빌드 로직을 설계했었어요. 당시에는 사용하는데에 문제가 없었고 나름 잘 설계했다고 생각했었어요. 이전 방식은(해당 포스팅) feature나 data 같은 아키텍처 레이어별로 미리 만들어둔 플러그인을 적용하는 방식이었어요. 그러나 최근에 baselineprofile처럼 특수한 모듈이 생기면서 문제가 보이기 시작했어요.
baselineprofile 모듈을 추가 하면서 테스트 환경에 중복되는 설정을 줄이려고 하니 플러그인을 재사용하기 어려웠어요. 그리고 Hilt가 필요 없는 :core:designsystem 같은 모듈에도 Hilt 설정이 적용되고 있었어요. 그리고 추후에 Proto dataStore 를 적용한다고 가정했을때도 지금의 플러그인을 사용할 수 없었어요.
이번 포스팅의 작업 PR입니다. 글을 읽는데 도움이 되었으면 해요.
[REF/#429] Build Logic 개선 by angryPodo · Pull Request #430 · Hi-lingual/Hilingual-Android
Related issue 🛠 closed [REFACTOR] Build-logic을 개선합니다. #429 Work Description ✏️ 기존 build-logic 시스템의 구조적 문제(높은 결합도, 낮은 확장성, 유지보수 어려움)를 해결하기 위해 전체 빌드 시스템
github.com
뭐가 문제냐..
가장 근본적인 문제는 hilingual.android.library라는 이름의 과한 책임의 플러그인이었어요. 이 플러그인 하나로 코틀린, 안드로이드 공통 설정, 코루틴, Hilt까지 너무 많은 책임을 지고 있었어요. SRP에 위배되는 설계였어요 :(
리팩토링 전의 hilingual.android.library.gradle.kts 이에요.
plugins {
id("com.android.library")
}
configureKotlinAndroid()
configureCoroutineAndroid()
configureHiltAndroid()
extensions.configure<LibraryExtension> {
configureBuildTypes(this)
}
Hilt가 필요 없는 모듈에도 불필요한 Hilt 관련 코드가 포함되면서 빌드 시간이 늘어날 가능성도 있었고, 모듈의 역할도 모호해졌어요. com.android.test 플러그인을 쓰는 baselineprofile 모듈을 추가할 땐 더 불편했어요. 기존에 있던 hilingual.android.library 플러그인은 com.android.library 플러그인에 의존하고 있었거든요. 내부적으로 안드로이드 라이브러리 설정을 위한 LibraryExtension을 찾아 구성하는데, baselineprofile 모듈은 com.android.test 플러그인을 사용하니까 TestExtension을 가지고 있어요. 당연히 플러그인을 적용하면 LibraryExtension을 찾을 수 없다는 에러가 나면서 빌드가 실패했어요. 결국 baselineprofile 모듈의 build.gradle.kts에는 compileSdk, minSdk 같은 보일러플레이트 코드를 남겨야 했어요.
그래서 레퍼런스를 찾아봤어요
이 문제를 해결하려고 다른 오픈소스 프로젝트를 찾던 중 *Now in Android (NIA)*와 SOPT Makers 두 프로젝트의 설계가 구조적으로 좋다고 생각이 들었어요.
- NIA는 Gradle 플러그인을 작은 기능 단위로 나눠요.(e.g android.library, android.compose, hilt) 그리고 특정 모듈용 플러그인은 이 작은 플러그인들을 pluginManager.apply()로 조합해서 만드는 구조에요. 상속보다는 조합을 사용한 이 방식이 제가 겪던 높은 결합도와 낮은 확장성 문제를 해결할 방법이라고 판단했어요.
- Makers는 DependencyManager라는 중앙 관리자를 통해 모든 의존성을 한 곳에서 관리해요. 라이브러리 버전을 직접 쓰는 대신, 미리 정의된 함수를 호출하는 형식이에요. 의존성을 중복으로 넣는 실수도 방지하고 중앙에서 관리해 유지보수성을 높일 좋은 방법이었어요.
두 방식을 합쳐보자
저는 두 레퍼런스의 장점을 합친 하이브리드 모델을 만들기로 했어요.
플러그인 구조는 NIA처럼 기능 중심의 작은 단위로 쪼갰어요. hilingual.android.library, hilingual.android.compose, hilingual.hilt 와 같이 기능단위의 플러그인을 만들고 AndroidPresentationConventionPlugin 같은 레이어별 플러그인은 이 작은 플러그인들을 조합해서 만들도록 설계했어요.
의존성 관리는 DependencyManager.kt라는 싱글톤 객체를 만들어서 모든 dependencies {} 블록의 내용을 옮겼어요. addComposeDependencies처럼 역할별로 함수를 나누고, 각 플러그인에서 이 함수를 호출하면 끝나는 구조에요.
먼저 싱글톤 객체를 작성했어요.
object DependencyManager {
fun addAndroidLibraryDependencies(project: Project) {
project.dependencies {
// 기본 라이브러리에 사용되는 의존성 추가
}
}
fun addComposeDependencies(project: Project) {
project.dependencies {
// 컴포즈 의존성 추가
}
}
fun addHiltDependencies(project: Project) {
project.dependencies {
// Hilt 사용시 필요한 의존성 추가
}
}
fun addSerializationDependencies(project: Project) {
}
// Presentation 모듈에서 사용하는 메서드
fun addPresentationDependencies(project: Project) {
project.dependencies {
// 모듈
add("implementation", project.project(":core:common"))
add("implementation", project.project(":core:designsystem"))
add("implementation", project.project(":core:navigation"))
// AndroidX
add("implementation", project.libs.findBundle("androidx").get())
// Navigation
add("implementation", project.libs.findLibrary("hilt-navigation-compose").get())
add("androidTestImplementation", project.libs.findLibrary("androidx-ui-test-junit4").get())
// Timber
add("implementation", project.libs.findLibrary("timber").get())
// Immutable
add("implementation", project.libs.findLibrary("kotlinx-immutable").get())
}
}
fun addDataDependencies(project: Project) {
project.dependencies {
// 모듈
add("implementation", project.project(":core:network"))
add("implementation", project.project(":core:common"))
add("implementation", project.project(":core:localstorage"))
// Timber
add("implementation", project.libs.findLibrary("timber").get())
// Retrofit
add("implementation", project.libs.findBundle("retrofit").get())
}
}
}
이렇게 작성하면 아래처럼 플러그인 클래스에서 사용할 수 있어요.
class AndroidPresentationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
// 1. NIA 스타일로 기반 플러그인들을 '조합'해요.
pluginManager.apply("hilingual.android.library")
pluginManager.apply("hilingual.android.compose")
pluginManager.apply("hilingual.hilt")
pluginManager.apply("hilingual.serialization")
// 2. Makers 스타일로 중앙에서 의존성을 주입해요.
DependencyManager.addPresentationDependencies(this)
}
}
}
결국 리팩토링의 핵심은 공통 로직을 build-logic으로 옮기는 거에요. 예를 들어 모든 안드로이드 모듈에 적용되는 compileSdk, minSdk 같은 설정은 KotlinAndroid.kt라는 파일에 확장 함수로 만들었어요.
fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
compileSdk = libs.findVersion("compileSdk").get().requiredVersion.toInt()
defaultConfig {
minSdk = libs.findVersion("minSdk").get().requiredVersion.toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
}
configureKotlin()
}
가장 기본 단위 플러그인인 AndroidLibraryConventionPlugin은 이 확장 함수를 호출해서 공통 설정을 적용하게 했어요.
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
pluginManager.apply("org.jlleitschuh.gradle.ktlint")
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
configureBuildTypes(this)
}
extensions.configure<LibraryAndroidComponentsExtension> {
disableUnnecessaryAndroidTests(target)
}
DependencyManager.addAndroidLibraryDependencies(this)
}
}
}
리팩토링 결과, 각 모듈의 build.gradle.kts 파일이 깔끔해졌어요. 이제 프로젝트의 모듈은 복잡한 설정 코드가 아니라, 해당 모듈의 '정체성'을 선언하는 역할에 집중하게 되었어요.
import com.hilingual.buildlogic.setNamespace
plugins {
alias(libs.plugins.hilingual.android.presentation)
}
android {
setNamespace("presentation.home")
}
dependencies {
implementation(projects.data.user)
implementation(projects.data.diary)
implementation(projects.data.calendar)
}
과연 장점만 있나?
물론 이 방식에도 단점은 있어요. build-logic 모듈의 코드가 변경되면 이걸 사용하는 모든 모듈의 캐시가 무효화돼서 클린 빌드가 더 자주 발생할 수 있어요. 그래서 build-logic은 최대한 안정적으로 유지할 수 있게 설계해야하고, 무분별한 공통 로직 추출은 오히려 성능 저하를 유발할 수 있어요.
프로젝트가 아주 커진다면 build-logic을 별도 Git 저장소로 분리하고 composite build로 참조하는 방법도 있다고 해서 해당 방법도 한번 파볼 예정입니다 :)
그리고 DependencyManager는 모든 의존성이 모여있어 편리하지만, 자칫하면 모든 책임을 떠안는 Object가 될 수 있어요. 이걸 방지하기 위해 역할에 따라 파일을 분리하는 방법도 있어요. 예를 들어 ComposeDependencies.kt, NetworkDependencies.kt처럼 파일을 나누고 DependencyManager는 이들을 조합하는 역할만 하도록 구조를 나눌 수도 있어요.
관점의 변화
이번 리팩토링을 끝내고 나서 build.gradle.kts 파일을 보는 관점이 바뀐것 같아요. 이전에는 그저 필요한 라이브러리를 추가하고 설정을 복사해 붙여넣는, 귀찮은 작업 공간이었지만 이제 각 모듈의 build.gradle.kts 파일은 그 모듈의 '정체성'을 선언하는 문서라고 생각해요.
plugins 블록에 hilingual.android.presentation 한 줄이 적혀있다면, '이 모듈은 UI 레이어고, 당연히 Compose와 Hilt를 사용하겠구나'라는 아키텍처 정보가 바로 드러나요.
빌드 스크립트를 통해 아키텍처에 대한 규칙을 강제하고, 그걸 팀의 공통 언어로 만든 경험이 이번 리팩토링에서 얻은 가장 큰 수확이라고 생각합니다. 말버릇 처럼 유지보수 유지보수를 하지만 정말로 유지보수에 좋은 구조인가? 라는 물음을 멈추지 않길 바랍니다.
'Android' 카테고리의 다른 글
| [Android] 앱을 배포해 봅시다. (0) | 2025.10.02 |
|---|---|
| [Android] Baseline Profile 적용기 feat. Fake 주입 실패 (0) | 2025.09.23 |
| [Android] 실용적인 멀티모듈 아키텍처 설계 실전기 (Domain 레이어와 build-logic) (0) | 2025.08.10 |