[Android] 컨벤션 플러그인을 사용해서 멀티 모듈을 효율적으로 관리해보자

신민준·2025년 8월 24일
1

안드로이드 빌드

목록 보기
1/2

들어가며

안드로이드 프로젝트를 멀티 모듈로 진행하다보면 필연적으로 똑같은 플러그인을 똑같이 적용하는 상황을 마주한다.
그럴 때마다 동일한 플러그인을 반복적으로 작성하는 비효율적이라고 느껴지는데 이것을 어떻게 해결할 수 있을까?

구글에 쳐보면 바로 알 수 있듯이 Convention Plugin을 통해 이 문제를 해결할 수 있다고 한다.

하지만 본인은 컨벤션 플러그인에 대해 여러 글을 보아도 이해가 잘 가지 않았다!

글마다 방식도 다르고 플러그인을 dependencies에 추가하라 하기에 따라 해도 해당 플러그인이 없다고 아래와 같이 에러가 발생했다.

또한 필자는 nowinandroid를 많이 참고하는 편인데 nowinandroid의 컨벤션 플러그인은 또 여타 레퍼런스들과는 사뭇 다르다!

그러다 보니 어떻게 해야하는지도 감도 안오고 복붙하니 되긴 되는데
어째서 되는 지 이해가 하나도 안 됐다...

그래서 아예 Gradle부터 하나씩 공부해서 싹다 뒤집어 까보기로 했고
그 과정을 기록하게 되었다.

이 글이 내가 공부하고 이해한 과정을 그대로 담았다.(그래서 틀릴 수도 있다...)
그렇기 때문에 나같은 사람이 또 있다면 이 글이 도움이 되길 바란다...

multi-project build

Convention Plugin에 대해 이야기하기 전에 먼저 Gradle의 multi-project build를 알아야 한다.

multi-project build란 무엇인가?

While some small projects and monolithic applications may contain a single build file and source tree, it is often more common for a project to have been split into smaller, interdependent modules. The word "interdependent" is vital, as you typically want to link the many modules together through a single build.
Gradle supports this scenario through multi-project builds
This is sometimes referred to as a multi-module project.

Gradle의 공식 문서에는 위와 같이 적혀있다. 내용은 아래와 같다.

프로젝트가 더 작고 상호의존적인 모듈로 분할되는 것이 일반적이고 Gradle은 multi-project builds로 그것을 지원한다
이것은 때때로 multi-module project라 불린다.

즉 멀티 프로젝트란 안드로이드에서의 멀티 모듈을 말한다.
아래의 예시로 좀더 살펴보자.

├── .gradle
│   └── ⋮
├── gradle
│   ├── libs.versions.toml
│   └── wrapper
├── gradlew
├── gradlew.bat
├── settings.gradle.kts 
├── sub-project-1
│   └── build.gradle.kts    
├── sub-project-2
│   └── build.gradle.kts    
└── sub-project-3
    └── build.gradle.kts

이것은 3개의 서브 프로젝트을 포함하는 멀티 프로젝트 빌드의 구조인데 어디선가 많이 본듯하다

바로 안드로이드 멀티 모듈 환경이다.

그렇다면 이 멀티 모듈의 빌드 구조를 알아보자

Multi-Project Build Standards

Gradle community는 멀티 모듈 빌드 구조에 대해서 2가지 표준을 갖고 있다.

1. Multi-Project Builds using buildSrc - buildSrc는 모든 빌드 로직을 포함하는 Gradle 프로젝트 루트의 하위 프로젝트와 같은 디렉토리
2. Composite Builds - build-logic이 재사용 가능한 빌드 로직을 포함하는 Gradle 프로젝트 루트의 빌드 디렉토리인 다른 빌드를 포함하는 빌드.

그중 Convention Plugin은 Composite Builds이므로
BuildSrc가 뭔지만 간단히 알고 넘어가자

BuildSrc란

멀티 모듈에서 공통 빌드 로직(플러그인 등)을 한 곳에 모아두고 여러 모듈에서 쉽게 재사용하기 위한 특별한 디렉토리로, Gradle이 자동으로 인식하고 빌드 로직을 모든 모듈에 제공한다.

라고 하는데 이렇게 보면 Composite Builds랑 무슨 차이인가 싶다.
애초에 목적은 똑같다.
그렇다면 우리는 차이점을 통해 BuildSrc를 알아보자.

  • BuildSrc는 단일 빌드의 일부다.
  • 그렇기에 메인 프로젝트와 생명주기를 같이 하여 결합도가 높다.
  • 내부의 코드 한 줄만 바뀌어도 전체 빌드 로직 캐시가 무효화되어 빌드 속도에 영향을 준다. -> 캐시 효율이 좋지 않다.

이 글에서는 이정도만 알아도 괜찮다.
용도가 같으니 Composite Builds를 이해할수록 BuildSrc도 더 이해가 갈 것이다.

Composite Builds

그렇다면 Composite Builds를 알아보자.
Gradle 공식문서에서는 Composite Builds를 아래와 같이 말하고 있다.

Composite Builds, also referred to as included builds, are best for sharing logic between builds (not subprojects) or isolating access to shared build logic (i.e., convention plugins).

Composite Builds는 빌드간 로직(서브 모듈간 X)을 공유하거나 공유 빌드 로직(즉, 컨벤션 플러그인)에 대한 액세스를 격리하는 데 가장 좋다고 한다.

.
├── gradle
├── gradlew
├── settings.gradle.kts
├── build-logic
│   ├── settings.gradle.kts
│   └── conventions
│       ├── build.gradle.kts
│       └── src/main/kotlin/shared-build-conventions.gradle.kts
├── mobile-app
│   └── build.gradle.kts
├── web-app
│   └── build.gradle.kts
├── api
│   └── build.gradle.kts
├── lib
│   └── build.gradle.kts
└── documentation
    └── build.gradle.kts

BuildSrc와 달리 플러그인은 build.gradle.ktssettings.gradle.kts와 함께 build-logic 자체 빌드로 이동한다.

일단 지금은 플러그인에 대해서는 넘어가고 빌드 구조만을 먼저 이해하자.

여기서 용어를 정리하고 가자면 빌드 != 모듈(프로젝트)이다.

  • 빌드(Build) : 전체 빌드 프로세스를 의미한다. 즉, gradle 명령어를 실행할 때 발생하는 모든 작업을 포괄하는 개념으로 하나의 빌드는 하나 이상의 프로젝트(Project)로 구성된다.

  • 프로젝트(Project) : 빌드의 기본 단위이다. 각 프로젝트는 자체적인 소스 코드, 리소스, 테스트 등을 가지고 있으며, build.gradle 또는 build.gradle.kts 파일로 프로젝트의 빌드 로직을 정의한다. 멀티 프로젝트 빌드에서는 여러 프로젝트가 계층적으로 구성될 수 있다.

안드로이드를 예시로 더 간단하게 생각하면 settings.gradle.kts가 있으면 빌드, 없으면 모듈이다.

그러므로 settings.gradle.kts가 루트 프로젝트에만 있는 일반적인 멀티 모듈 프로젝트들은 단일 빌드다.

my-composite
├── settings.gradle.kts
├── build.gradle.kts
├── my-app
│   ├── settings.gradle.kts
│   └── app
│       ├── build.gradle.kts
│       └── src/main/java/org/sample/my-app/Main.java
└── my-utils
    ├── settings.gradle.kts
    ├── number-utils
    │   ├── build.gradle.kts
    │   └── src/main/java/org/sample/numberutils/Numbers.java
    └── string-utils
        ├── build.gradle.kts
        └── src/main/java/org/sample/stringutils/Strings.java

이와 같이 독립적인 빌드 my-utilsmy-app이 있을 때
my-app이 저 두 라이브러리의 함수를 사용하고자 한다면 my-utils를 직접 의존하는 것이 아니라 각각을 아래와 같이 의존한다.

dependencies {
    implementation("org.sample:number-utils:1.0")
    implementation("org.sample:string-utils:1.0")
}

실제 안드로이드 환경에서도 각각의 독립적인 빌드가 다른 빌드의 모듈에 있는 함수나 클래스를 사용하려면 각 모듈을 추가해야 한다.

그 이유는 Gradle의 의존성 관리 메커니즘과 관련이 있는데
Gradle은 프로젝트 간의 의존성을 관리할 때, 프로젝트의 결과물(출력물) 을 기준으로 한다.

my-app프로젝트가 number-utils 프로젝트의 기능을 사용하고 싶을 때, my-appnumber-utils가 컴파일되어 생성된 라이브러리(JAR 파일)를 필요로 한다.

만약 빌드 자체에 의존하는 방식이라면, Gradle은 어떤 모듈의 결과물을 사용해야 할지 알 수 없다. 빌드는 여러 프로젝트(모듈)로 구성될 수 있으므로, my-utils 빌드에 의존한다는 것은 number-utilsstring-utils 중 어떤 것을 사용할지 명확하지 않기 때문이다.

그렇다면, my-utils빌드에 있는 모든 모듈(number-utils, string-utils)을 my-app에서 사용하고 싶을 때, 매번 개별적으로 의존성을 추가해야 할까? 이 문제를 해결하고, 독립된 여러 빌드를 통합적으로 관리하기 위해 등장한 것이 바로 Composite Builds다.

이 Composite Builds에 포함된 빌드를 included build라고 한다.

이러한 Composite Builds를 설정하는 방법은 간단하다. settings.gradle.kts 파일에 포함하고 싶은 빌드의 위치를 명시해주면 된다.

안드로이드를 예시로 들면 루트 프로젝트의 settings.gradle.kts에 다음과 같이 빌드를 includeBuild로 추가하면 된다.

pluginManagement {
	includeBuild("build-logic")
    repositories {
        google {
            content {
                includeGroupByRegex("com\\.android.*")
                includeGroupByRegex("com\\.google.*")
                includeGroupByRegex("androidx.*")
            }
        }
        mavenCentral()
        gradlePluginPortal()
    }
}

지금까지 Composite Builds를 통해 컨벤션 플러그인이 어떻게 동작하는지 기본적인 지식을 알았다.
물론 이것만으로는 아직 컨벤션 플러그인을 이해하기엔 부족하다.
이번에는 플러그인에 대해 알아보자.

Plugin

일단 플러그인이 무엇인가 살펴보면 Gradle에서는 다음과 같이 정의한다.

A plugin is a reusable piece of software that provides additional functionality to the Gradle build system.

한국어로 직역하면 Gradle 빌드 시스템에 추가적인 기능을 제공하는 재사용가능한 소프트웨어 조각이라고 한다.
플러그인은 크게 3종류가 있다.

  • Script Plugins
  • Precompiled Script Plugins
  • BinaryPlugins
    각각을 알아보자

Script Plugins

이것은 Gradle에서도 추천하지 않는 방식이다.
그 이유는 일단 재사용성이라는 이점으로 플러그인을 사용하는데 재사용성이 그리 좋지 않기 때문이다.
하지만 동작 방식을 아는 것은 중요한데 왜냐하면 우리가 최종적으로 수행할 방식과 유사한 부분이 있기 때문이다.

예시 코드를 살펴보자.

// build.gradle.kts
// Define a plugin
class HelloWorldPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.tasks.register("helloWorld") {
            group = "Example"
            description = "Prints 'Hello, World!' to the console"
            doLast {
                println("Hello, World!")
            }
        }
    }
}

// Apply the plugin
apply<HelloWorldPlugin>()

HelloWorldPlugin라는 직접 정의한 플러그인으로 Plugin<Project>를 구현한다.
그렇기 때문에 apply를 override해주어야한다.
내부는 간단하다.
그냥 helloWorld라는 task를 register로 등록해주는 것이 전부다.

./gradlew helloWorld

로 확인해보면 Hello World가 출력된다.
하지만 이것은 빌드 스크립트에 직접 적는 inline 방식으로 적용된다.

Precompiled Script Plugins

직접 적는 거면 굳이 쓸 필요가 있을까?
그렇기에 우리는 사전에 컴파일된 플러그인을 사용해야한다.
일단 Gradle에서 어떻게 설명하는지를 보자.

Precompiled script plugins are Groovy DSL or Kotlin DSL scripts compiled and distributed as Java class files packaged in some library.
They are meant to be consumed as a binary Gradle plugin, so they are applied to a project using the plugins {} block.

한국말로 번역하면 다음과 같다.

Precompiled script plugin은 .gradle.kts 또는 .gradle 확장자를 가진 Gradle 스크립트 파일을 미리 컴파일(Precompiled)하여, 다른 프로젝트에서 바이너리(binary) 라이브러리처럼 사용할 수 있도록 만든 Gradle 플러그인입니다.

예시 코드를 살펴보자

// plugin/src/main/kotlin/my-plugin.gradle.kts
// This script is automatically exposed to downstream consumers as the `my-plugin` plugin
tasks {
    register("myCopyTask", Copy::class) {
        group = "sample"
        from("build.gradle.kts")
        into("build/copy")
    }
}

위와 같이 플러그인을 정의한 후

// consumer/build.gradle.kts
plugins {
    id("my-plugin") version "1.0"
}

사용할 모듈의 build.gradle.ktsplugins.gradle.kts를 뺀 이름으로 추가하면 사용할 수 있다.

이렇게 보면 감이 잘 안 올수 있다.
조금 더 익숙한 예시를 보자.

// plugin/src/main/kotlin/my-plugin.gradle.kts
// 1. 어떤 플러그인들을 기반으로 할지 먼저 적용
plugins {
    id("com.android.library")
    id("org.jetbrains.kotlin.android")
}

// 2. 안드로이드 관련 공통 설정
android {
    compileSdk = 34
    
    defaultConfig {
        minSdk = 26
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
    
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

// 3. 공통으로 사용할 라이브러리 추가
dependencies {
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.appcompat:appcompat:1.7.0")
    testImplementation("junit:junit:4.13.2")
}

똑같이 자신이 사용할 플러그인을 정의한 후

// consumer/build.gradle.kts
plugins {
    id("my-plugin")
}

dependencies {
    // 이 모듈에만 필요한 특별한 라이브러리만 추가
    implementation("com.google.android.material:material:1.12.0")
}

사용할 모듈에 추가해주면 된다.

뭔가 슬슬 컨벤션 플러그인에 다가가는 게 느껴진다.
사실 이 방식으로도 컨벤션 플러그인을 구현할 수 있다.
실제로 Precompiled Plugin으로 build-logic을 구성하는 프로젝트도 쉽게 찾아볼 수 있기도 하고 말이다.

하지만 나는 신실한 nowinandroid 신봉자다.
nowinandroid는 마지막으로 설명할 방식으로 구현하였다.
그럼 플러그인의 남은 한 종류를 알아보자.

Binary Plugins

Binary plugins are compiled plugins typically written in Java or Kotlin DSL that are packaged as JAR files. They are applied to a project using the plugins {} block. They offer better performance and maintainability compared to script plugins or precompiled script plugins.

간단히 설명하면 다음과 같다.

Binary Plugins는 Java나 Kotlin DSL로 작성된 컴파일된 플러그인으로 script plugins이나 precompiled plugins보다 더 나은 성능과 유지보수성을 갖는다.

예제 코드로 좀더 살펴보자.

//plugin/src/main/kotlin/plugin/MyPlugin.kta
class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.run {
            tasks {
                register("myCopyTask", Copy::class) {
                    group = "sample"
                    from("build.gradle.kts")
                    into("build/copy")
                }
            }
        }
    }
}

익숙한 모양새이다. Script Plugins처럼 직접 플러그인을 정의한 후

//consumer/build.gradle.kts
plugins {
    id("my-plugin") version "1.0"
}

precompiled plugins처럼 추가해주면 된다.

그렇다면 Script Plugin과 Binary Plugin은 뭐가 다르길래 똑같이 생겨놓고 이런 차이가 생기는 걸까?

바로 build.gradle.kts에 직접 코드를 작성하는 것과, 별도의 프로젝트 폴더(src/main/kotlin)에 작성하는 것은 Gradle이 코드를 처리하는 방식 자체를 완전히 바꾸기 때문이다.

Gradle 공식문서에서는 차이점을 다음과 같이 서술한다.

  • A binary plugin is compiled into bytecode, and the bytecode is shared.
  • A script plugin is shared as source code, and it is compiled at the time of use.

즉, Binary Plugin은 사전에 바이트코드로 컴파일되고 그 바이트코드가 공유되며 필요한 곳에서는 바로 실행만 하면 되는 것이며 Script Plugin은 소스코드가 공유되고 사용될 때 그곳에서 즉석으로 컴파일된다는 차이가 있다.

Gradle에서도 최종적으로는 Binary Plugin을 사용하는 것을 권장한다.

지금까지 그러면 Composite Builds와 플러그인에 대해 알아보았다.

이제는 슬슬 컨벤션 플러그인을 어떻게 작성하면 될지 감이 올 것이다.
본격적으로 컨벤션 플러그인을 만들어보자.

컨벤션 플러그인

지금까지의 내용을 조합하면 컨벤션 플러그인은 Composite Build이고 사전에 만들어놓은 플러그인을 미리 컴파일한 뒤 그것을 공유함으로서 플러그인과 라이브러리 관리를 용이하게 하는 것이다.

build-logic 생성

그럼 가장 먼저 Composite Build 구조를 세팅하자.
Composite Buids에서의 예시코드와 동일하게 build-logic이라는 자체 빌드를 생성해준다.

이때는 Java or Kotlin 모듈로 생성해주는데 이때는 아직 별도의 빌드가 아닌 모듈이기에 settings.gradle.kts가 없다.
그렇기에 이것을 직접 추가해준다.
내용은 루트 프로젝트의 settings.gradle.kts를 복붙해주면 된다.
여기서 만약 build-logicgradle.properties를 별도로 설정하고 싶다면 추가해줘도 된다.

이렇게 하면 settings.gradle.kts에 Code insight unavailable이라고 Load Script Configurations을 누르라고 하는데 막상 눌러도 해결되지 않을 것이다.

이것은 처음 모듈을 만들 때 메인 프로젝트의 settings.gradle.kts에 모듈로서 추가되었기 때문이다.
하지만 우리는 이것을 별도의 빌드로서 includeBuild로 추가해주어야 한다.
그러니 본 프로젝트의 settings.gradle.kts에서 자동으로 추가된

include(":build-logic:convention")

을 제거하고
pluginManagementincludeBuild("build-logic")를 추가해준다.

커스텀 플러그인 생성

이제 본인이 만들고 싶은 커스텀 플러그인을 만들자.
이 글에서는 Hilt에 필요한 플러그인과 라이브러리를 한번에 관리할 수 있는 HiltConventionPlugin을 만들 것이다.

그전에 먼저 버전 카탈로그를 다른 빌드인 build-logic에서도 사용하기 위한 세팅을 해주자.
build-logicsettings.gradle.ktsdependencyResolutionManagement에 아래와 같이 버전 카탈로그를 설정해준다.

dependencyResolutionManagement {
    repositories {
        google {
            content {
                includeGroupByRegex("com\\.android.*")
                includeGroupByRegex("com\\.google.*")
                includeGroupByRegex("androidx.*")
            }
        }
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

이후 convention모듈의 build.gradle.kts에 다음과 같이 작성해준다.

plugins {
    `kotlin-dsl`
}

group = "com.example.conventionplugin.buildlogic"

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

kotlin {
    compilerOptions {
        jvmTarget = JvmTarget.JVM_17
    }
}

dependencies {
    compileOnly("com.android.tools.build:gradle:8.12.1") 
    //AGP 버전은 stable한 것으로 하자.
    //2025-08-24 기준 최신 버전은 
    //lint와의 충돌이슈가 있어 8.10.1을 사용하는 것을 권장한다.
}

그다음에 convention 모듈에 ProjectExtensions라는 파일을 추가한다.
사실 이름이 저럴 필요는 없지만 nowinandroid는 위의 이름으로 되어있다. nowinandroid를 따라서 나쁠 건 없지 않을까 싶다ㅎㅎ;;
ProejctExtensions에 아래와 같이 작성해준다.

import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalog
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.getByType

val Project.libs
    get(): VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")

이러면 이제 버전 카탈로그를 libs로 간단히 사용할 수 있다.
이제 HiltConventionPlugin을 작성해보자.

class HiltConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            apply(plugin = "com.google.devtools.ksp")
            apply(plugin = "dagger.hilt.android.plugin")

            dependencies {
                "ksp"(libs.findLibrary("hilt.compiler").get())
                "implementation"(libs.findLibrary("hilt.android").get())
            }
        }
    }
}

이건 nowinandroid를 기반으로 간단하게 작성한 것이다.
이것에 hilt를 완전히 전담하게 하고 싶다면

"implementation"(libs.findLibrary("androidx.hilt.navigation.compose").get())

이것도 추가해주면 된다.
nowinandroid는 별도의 feature용 플러그인에 추가한다.
본인의 프로젝트에 맞게 설정해주자.

플러그인 등록

이제 이렇게 작성한 플러그인을 등록해줘야 한다.
convention모듈의 build.gradle.kts의 맨 아래 자신의 플러그인을 등록해준다.

dependencies {
...
}
gradlePlugin {
    plugins {
        register("hilt") {
			id = libs.plugins.custom.hilt.get().pluginId
            implementationClass = "HiltConventionPlugin"
        }
    }
}
  • id : 등록할 플러그인의 아이디 설정, 버전 카탈로그에 본인이 사용할 아이디
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" }

custom-hilt = { id = "custom.hilt" }

본인이 하고 싶은 플러그인 아이디를 설정한다.

  • implementationClass : 등록할 플러그인 파일 이름

이제 생성과 등록을 모두 완료하였으니 사용하고 싶은 모듈에 추가해서 사용하면 된다.

// app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
    alias(libs.plugins.custom.hilt)
}

마무리

지금까지 컨벤션 플러그인을 만드는 방법을 알아보았다.

ProjectCommonExtension로 아예buildConfigcompileOptions등을 관리할 수도 있다.
이 부분은 다루지 않았지만 지금까지의 내용으로 다른 코드를 보고 자신에게 필요한 build-logic을 구성할 수 있을 것이다.

하지만 단순히 작성할 줄 아는 정도로는 조금 아쉽다.
다음으로는 Project를 비롯한 빌드에 관련된 것들을 좀 더 뒤집어 까볼 예정이다.

참고자료

https://docs.gradle.org/current/userguide/plugins.html
https://docs.gradle.org/current/userguide/sharing_build_logic_between_subprojects.html
https://docs.gradle.org/current/userguide/intro_multi_project_builds.html
https://docs.gradle.org/current/userguide/composite_builds.html#composite_builds

profile
안드로이드 외길

0개의 댓글