[Android 클린 아키텍처 멀티모듈 제작기] 5편 - Convention Plugin 직접 만들어보기

김보현·2026년 4월 9일

android

목록 보기
8/12
post-thumbnail

지난 편에서 어디까지 했더라?

4편에서는 build-logic 모듈을 생성하고, AGP(Android Gradle Plugin)와 Kotlin 플러그인 의존성을 build.gradle.ktscompileOnly로 연결하는 것까지 마쳤다. 솔직히 그때까지는 "여기에 뭔가를 작성하면 되는구나" 정도의 느낌이었고, Convention Plugin 자체를 실제로 구현하는 건 이번 편에서 처음 해봤다.

이번 편 목표는 하나다. Convention Plugin 3개를 실제로 작성하고, 각 모듈에 적용한 뒤, 빌드가 성공하는 것까지 확인하기.


Convention Plugin이 왜 필요한가? Before / After로 보면 바로 이해된다

처음에 nowinandroid 코드를 보면서 "왜 이렇게 복잡하게 플러그인을 만들지?" 싶었는데, Before/After를 나란히 놓으면 바로 납득이 됐다.

Before: 플러그인 없을 때 각 모듈 build.gradle.kts

// presentation/build.gradle.kts
plugins {
    id("com.android.library")
    id("org.jetbrains.kotlin.android")
}

android {
    compileSdk = 36
    defaultConfig {
        minSdk = 24
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
}

// domain/build.gradle.kts - 똑같은 내용 반복
// data/build.gradle.kts - 똑같은 내용 또 반복

모듈이 3개면 compileSdk, minSdk, compileOptions, kotlinOptions... 이 설정들이 3번 복붙된다. 나중에 compileSdk = 37로 올려야 하면? 모든 모듈을 하나하나 열어서 바꿔야 한다. 이게 10개, 20개 모듈이 되면 유지보수 악몽이 된다.

After: Convention Plugin 적용 후

// presentation/build.gradle.kts
plugins {
    id("com.dantariun.buildlogic.library")
}

// domain/build.gradle.kts
plugins {
    id("com.dantariun.buildlogic.library")
}

// data/build.gradle.kts
plugins {
    id("com.dantariun.buildlogic.library")
}

플러그인 ID 한 줄이 나머지 설정 전부를 대신한다. SDK 버전 변경이 필요하면 플러그인 파일 하나만 수정하면 된다. 이 구조를 이해하고 나서 nowinandroid가 왜 그렇게 설계됐는지 비로소 납득이 됐다.


구현 과정

1단계: SDK 버전과 VersionCatalog 접근 유틸리티 만들기

먼저 build-logic/convention/src/main/kotlin/com/dantariun/buildlogic/ 경로에 ProjectExtensions.kt를 만들었다.

package com.dantariun.buildlogic

import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension

val Project.libs: VersionCatalog
    get() = extensions.getByType(VersionCatalogsExtension::class.java).named("libs")

object AndroidSdkVersions {
    const val COMPILE_SDK = 36
    const val MIN_SDK = 24
    const val TARGET_SDK = 36
}

libs 확장 프로퍼티는 libs.versions.toml에 선언된 Version Catalog에 접근하기 위한 것이다. Convention Plugin 내부에서 의존성을 추가할 때 쓸 수 있다. AndroidSdkVersions는 단순히 SDK 버전을 한 곳에서 관리하기 위한 object다. 이제 이 값을 플러그인들이 참조한다.


2단계: AndroidApplicationConventionPlugin 구현

앱 모듈(:app)에 적용할 플러그인이다.

class AndroidApplicationConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.application")
                apply("org.jetbrains.kotlin.android")
            }
            extensions.configure<ApplicationExtension> {
                compileSdk = AndroidSdkVersions.COMPILE_SDK
                defaultConfig {
                    minSdk = AndroidSdkVersions.MIN_SDK
                    targetSdk = AndroidSdkVersions.TARGET_SDK
                    versionCode = 1
                    versionName = "1.0"
                    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
                }
                compileOptions {
                    sourceCompatibility = JavaVersion.VERSION_11
                    targetCompatibility = JavaVersion.VERSION_11
                }
            }
            extensions.configure<KotlinAndroidProjectExtension> {
                compilerOptions {
                    jvmTarget.set(JvmTarget.JVM_11)
                }
            }
        }
    }
}

extensions.configure<ApplicationExtension>이 처음엔 낯설었는데, 결국 기존 build.gradle.kts에서 android { ... } 블록에 쓰던 것과 동일한 내용이다. 코드로 설정을 주입하는 방식으로 바뀐 것뿐이다.


3단계: AndroidLibraryConventionPlugin 구현

라이브러리 모듈(:presentation, :domain, :data)에 공통 적용할 플러그인이다.

class AndroidLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
            }
            extensions.configure<LibraryExtension> {
                compileSdk = AndroidSdkVersions.COMPILE_SDK
                defaultConfig {
                    minSdk = AndroidSdkVersions.MIN_SDK
                    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
                }
                compileOptions {
                    sourceCompatibility = JavaVersion.VERSION_11
                    targetCompatibility = JavaVersion.VERSION_11
                }
                buildFeatures {
                    buildConfig = false  // 불필요한 BuildConfig 비활성화
                }
            }
        }
    }
}

라이브러리 모듈에는 targetSdkversionCode 같은 앱 전용 설정이 필요 없다. 또 buildConfig = false로 기본 설정해두면 불필요한 빌드 파일 생성을 막을 수 있다. nowinandroid에서 본 패턴인데, 작은 최적화지만 챙길 수 있는 건 챙기는 게 맞다고 생각해서 적용했다.


4단계: AndroidLibraryComposeConventionPlugin 구현

Compose가 필요한 모듈에만 선택적으로 적용하는 플러그인이다. 이 플러그인의 핵심은 라이브러리 플러그인을 상속한다는 점이다.

class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply("com.dantariun.buildlogic.library")  // 라이브러리 플러그인 포함
            pluginManager.apply("org.jetbrains.kotlin.plugin.compose")
            extensions.configure<LibraryExtension> {
                buildFeatures {
                    compose = true
                }
            }
        }
    }
}

com.dantariun.buildlogic.library를 먼저 apply하기 때문에, Compose 플러그인을 적용한 모듈은 라이브러리 기본 설정까지 자동으로 가져간다. 나중에 :presentation에 Compose를 붙일 때 이 플러그인 ID 하나면 된다.


5단계: build.gradle.kts에 플러그인 등록

작성한 플러그인 클래스들을 Gradle이 인식하려면 gradlePlugin 블록에 등록해야 한다.

// build-logic/convention/build.gradle.kts
gradlePlugin {
    plugins {
        register("androidApplication") {
            id = "com.dantariun.buildlogic.application"
            implementationClass = "AndroidApplicationConventionPlugin"
        }
        register("androidLibrary") {
            id = "com.dantariun.buildlogic.library"
            implementationClass = "AndroidLibraryConventionPlugin"
        }
        register("androidLibraryCompose") {
            id = "com.dantariun.buildlogic.library.compose"
            implementationClass = "AndroidLibraryComposeConventionPlugin"
        }
    }
}

id는 다른 모듈에서 plugins { id("...") } 형태로 참조하는 이름이고, implementationClass는 실제 Kotlin 클래스 이름을 문자열로 넣는다. 처음엔 이 두 개의 역할이 헷갈렸는데, id는 외부에 노출되는 이름, implementationClass는 내부 구현체 연결이라고 이해하면 된다.


Convention Plugin 동작 원리 전체 흐름


각 모듈에 적용하기

플러그인 등록이 끝나면 각 모듈의 build.gradle.kts가 눈에 띄게 단순해진다.

// app/build.gradle.kts
plugins {
    id("com.dantariun.buildlogic.application")
}

dependencies {
    // 기존 의존성 유지
}
// presentation/build.gradle.kts, domain/build.gradle.kts, data/build.gradle.kts
plugins {
    id("com.dantariun.buildlogic.library")
}

dependencies {
    // 기존 의존성 유지
}

android { ... } 블록 전체가 사라졌다. 의존성 설정만 남아있으니 각 모듈이 무엇에 의존하는지 한눈에 파악된다.


모듈 의존성 구조

현재 프로젝트의 전체 모듈 구조는 아래와 같다.

클린 아키텍처 원칙대로 :domain은 다른 모듈에 의존하지 않고, :presentation:data 모두 :domain을 바라보는 구조다.


빌드 검증 결과

플러그인 작성 후 가장 먼저 컴파일 단계를 확인했다.

./gradlew :build-logic:convention:compileKotlin
BUILD SUCCESSFUL in 14s

그리고 빌드 결과물을 보면 플러그인 Descriptor 파일이 생성된 걸 확인할 수 있다.

build/pluginDescriptors/
├── com.dantariun.buildlogic.application.properties
├── com.dantariun.buildlogic.library.properties
└── com.dantariun.buildlogic.library.compose.properties

.properties 파일이 생겼다는 건 Gradle이 플러그인을 정상적으로 인식했다는 의미다. 각 모듈에서 플러그인 ID를 선언했을 때도 "Unresolved reference" 같은 에러 없이 정상 resolve 됐다.


nowinandroid와 비교해보면

nowinandroid에는 AndroidFeatureConventionPlugin, AndroidHiltConventionPlugin, AndroidRoomConventionPlugin 등 훨씬 많은 플러그인이 있다. 처음에 그걸 보면서 압도당했는데, 결국 패턴은 똑같다. "이 기술 스택을 쓰는 모듈에 공통으로 필요한 설정이 있다면 플러그인으로 추출한다" 는 원칙 하나다.

morphview는 현재 단계에서 Hilt도 없고 Room도 없다. 그래서 플러그인 3개로 충분하다. 나중에 Hilt를 도입하면 AndroidHiltConventionPlugin을 추가하면 된다. 이게 Convention Plugin 구조의 장점이기도 하다. 점진적으로 확장이 가능하다.

VersionCatalog를 통한 SDK 버전 관리 패턴은 nowinandroid와 동일하게 가져갔다. 버전이 올라가도 AndroidSdkVersions 오브젝트 하나만 수정하면 된다.


마치며: 이번 편 소감

솔직히 처음에 Convention Plugin 개념을 접했을 때 "그냥 buildSrc 쓰면 되는 거 아닌가?" 싶었다. 근데 실제로 만들어보니 플러그인으로 추출해두는 게 얼마나 편한지 바로 체감이 됐다. 특히 모듈이 늘어날수록 이 차이가 극명해질 것 같다.

다음 편에서는 실제 클린 아키텍처 레이어별 구조를 구현하기 시작할 예정이다. Domain 레이어의 UseCase와 Entity 설계부터 시작해서, Repository 인터페이스가 어떻게 Data 레이어와 연결되는지 코드로 풀어볼 생각이다.


다음 편: 6편 - Domain 레이어 설계: UseCase와 Entity, Repository 인터페이스

profile
Android Developer

0개의 댓글