Android

Multi Module과 Custom Plugin을 사용해서 프로젝트를 관리해보자 feat. build-logic

chattymin 2024. 12. 31. 20:51
728x90

 

요즘 안드로이드 프로젝트들을 보다보면 뭐가 되게 많다.

사실 내가 만든거임ㅋ

 

처음 프로젝트를 만들면 app 모듈 하나만 있는데 이사람들은 도대체 어디서 이렇게 많은 모듈을 만들었는지 모르겠다.

그래서. 해봤다.

 

내가 직접 구현한 프로젝트 링크이니 참고한다면 이해하는데 도움을 줄 수 있을것 같아 첨부한다.

https://github.com/Team-Hankki/hankki-android

 

GitHub - Team-Hankki/hankki-android: 한끼익....

한끼익.... Contribute to Team-Hankki/hankki-android development by creating an account on GitHub.

github.com

 

예시는 여기있고, 자세한 설명은 이제 시작해보겠다.

 

 

 

Multi Module을 왜 해야하는데?

코드가 점점 많아지다보면 시간이 지나며 확장성, 가독성 등 전반적인 코드 품질이 떨어지는 경우가 상당히 많다. 

코드베이스 규모가 커지지만, 유지관리 담당자가 이를 쉽게 유지관리할 수 있는 구조를 적용하기 위한 조치를 하지 않기 때문이다.

그렇기에 이를 해결하기 위해 모듈화를 한다.

 

모듈화는 코드베이스를 각각의 모듈로 만들어 독립적인 부분으로 구성하는 방법이다.

그렇기에 각 모듈들은 독립적이며 명확한 역할을 한다.

공식문서 예시

 

모듈화의 이점은 많지만 모두 코드베이스의 유지관리 가능성과 전반적인 품질을 개선하는데에 초점을 둔다.

 

 

재사용성

모듈화를 사용하면 코드를 공유하고 동일한 기반을 토대로 여러 앱을 빌드할 수 있다.

모듈은 사실상 각각의 구성요소인 것이고, 이를 모아서 앱이 만들어 진다. 특정 모듈에서 지원하는 기능은 특정 앱에서 사용되거나 사용되지 않을 수 있다. 

예를 들어 :feature:news가 전체 버전과 Wear앱에는 있고, 데모버전에는 없을 수 있다.

 

엄격한 공개 상태 제어(접근 제한)

모듈화를 사용하면 코드베이스의 다른 부분에 노출할 내용을 쉽게 제어할 수 있다.

internal, private 등 접근제한자를 사용하여 모듈 밖에서 접근할 수 없도록 설정하기 쉽다.

 

맞춤설정 가능한 전송

Play Feature Delivery는 App Bundle의 고급 기능을 사용하여 앱의 특정 기능을 조건부로 또는 주문형으로 전송할 수 있도록 한다.

 

 

위 이점들은 모듈식에서만 얻을 수 있는 이점이다. 아래 후술할 이점은 다른 기술을 사용해도 얻을 수 있지만, 모듈화를 사용하면 더욱 극대화 할 수 있다.

 


확장성

적절히 모듈화된 프로젝트는 관심사 분리가 적용되어 결합을 제한한다. 그렇기에 더 많은 곳에서 확장되어 사용될 수 있다.

 

소유권

모듈은 자율성을 보장하는 것 외에 책임성을 부여하는 데에도 사용할 수 있다. 모듈에는 코드 유지관리, 버그 수정, 테스트 코드 등을 담당하는 전용 소유자를 둘 수 있다.

 

캡슐화

캡슐화는 코드의 각 부분이 다른 부분에 관한 지식을 최소한으로 갖고 있어야 함을 의미한다. 분리된 코드가 이해하기 쉽다.

 

테스트 가능성

모듈화 되었다면 각각 분리되어있는 코드이기에 테스트 가능성이 높다. 즉 테스터블하다.

 

빌드 시간

증분 빌드, 빌드 캐시 또는 병렬 빌드와 같은 일부 Gradle 기능은 모듈성을 활용하여 빌드 성능을 개선할 수 있다.

 

 

 

그럼 Multi Module을 무조건 해야하나요?

아래와 같은 단점들도 있으니 잘 생각해보고 판단 후 도입하는 것이 좋을 것 같다.

 


너무 세분화됨

빌드 복잡성과 상용구 코드가 늘어나며 모든 모듈에 일정량의 오버헤드가 발생한다. 복잡한 빌드 구성으로 인해 모듈간 일관된 구성을 유지하기 힘들다. 그렇기에 일부는 합치는게 좋을 수 있다.

 

너무 대략적임

하나의 모듈이 너무 커진다면,  그 모듈이 또 하나의 모놀리식이 될 수 있다. 그렇다면 모듈성이 제공하는 이점을 놓칠 수 있다.

예를 들어 작은 프로젝트라면 data 모듈을 단일 모듈로 해도 좋지만, 크기가 커진다면 독립형 모듈로 분리해야 할 수 있다.

 

너무 복잡함

프로젝트를 모듈화 하는 것이 항상 적합한 것은 아니다. 결정적 요소는 코드베이스의 크기이다. 프로젝트가 특정 기준점 이상으로 확장될 것 같지 않으면 확장성 및 빌드 시간 면의 이점은 누릴 수 없다.

 

 

 

 

모듈은 어떻게 만드나요?

모듈을 만들고 싶은 곳에서 마우스 우클릭 -> New -> Module을 누르면 새 모듈을 만들 수 있다.

여기서 일반적인 앱에서 모듈을 만들 때 사용하는 3개의 타입에 대해서 알아보자.

 

 

Phone & Tablet

 

우리가 처음 프로젝트를 생성하면 보이는 app 모듈과 같은 모양을 하고 있다.

 

 

Android Library

android 종속성을 가지는 모듈에서 사용하면 된다.

ex) feature, data ...

 

 

Java or Kotlin Library

Android 종속성이 없으니 manifest도 없고 res도 없다.

kotlin만 가져야 하는 domain 모듈에서 주로 사용된다.

 

 

 

일반적으로 어떻게 모듈을 만들까?

🗃️app

🗃️core
┗ 📂기능 별 패키징 or 모듈

🗃️data
 ┗ 📂기능 별 패키징 or 모듈
 
🗃️domain
 ┗ 📂기능 별 패키징 or 모듈
 
🗃️feature
 ┗ 📂기능 별 패키징 or 모듈



app 모듈을 진입점으로 잡은 후 data, domain, feature로 모듈을 분리한다.

그 후 data, domain, feature를 필요에 따라 단일 모듈 혹은 내부에 여러 모듈을 둔다

 

그 후 필요에 따라 core모듈 등을 추가하면 된다.

 

 

 

이렇게 모듈 여러개 만들면 끝?

이렇게 해도 실행하는데는 문제가 없다.

 

그런데 모듈 내부에 모듈을 넣는 방식으로 코드를 작성했다면 build.gradle이 상당히 많아질거다.

으아~

 

build.gradle 내부를 보면 중복되는 코드들이 상당히 많다.

minSdk, targetSdk 등 중복되는 코드들이 상당히 많다.

 

이 외에도 공통적으로 들어가야하는 종속성이라거나 공통 로직들을 모든 build.gradle에 넣어주는 것이 힘들다.

 

 

물론 이걸 하나하나 적어주고 프로젝트를 실행시킨다고 해서 문제가 되는 것은 아니다. 정상적으로 잘 실행된다.

 

하지만 이 뿐만 아니라 proguard-rules.pro 파일 또한 엄청나게 많이 생겨나게 된다.

이것도 하나하나 복사하고 붙여넣기 한다면 의도대로 잘 돌아가겠지만, 하나 수정된다면 모든 파일에 복사 붙여넣기를 해줘야 한다.

 

이런 불편한 일을 줄이고, 안전하게, 공통으로 관리하기 위한 다양한 방법론이 나왔다.

 

대표적으로 buildSrc, build-logic이 있다.

buildSrc의 경우 가장 많이 사용되었지만, 빌드시간증가, 범용성과 캡슐화 부족이라는 문제점이 존재하였고 이를 build-logic으로 해결하려 했다.

 

build-logic을 구성하는 여러 Plugin 선언법이 존재하지만 우선 Custom Plugin에 대해 설명하려 한다.

 

 

 

Plugin이란?

Plugin이란 Task의 집합이다.

Task란 Gradle이 다양한 작업을 수행하는 단위이다.

 

여러 Task가 있다면 이를 하나로 묶을 수 있는 것이 Plugin이라고 한다.

task(1번 업무)
task(2번 업무)        ->        plugin(통합 업무)
task(3번 업무)

 

Plugin을 만든다면 재사용이 쉽다.

task 3개를 각각 gradle에서 선언하는 것 보다 하나의 플러그인으로 작성해서 넣어준다면 재사용하기 쉽다.

 

사용자가 내부로직(task)를 알지 못해도 사용할 수 있는 점에서 Plugin은 추상화와 닮아있다. 
그래서 다른사람에게 내부로직을 설명하지 않아도 쉽게 배포가 가능하다.

 

하지만, 플러그인이 많아지면 많아질수록, task를 선언했을때보다 더 느려진다는 단점이 있다.

 

 

 

 

Custom Plugin

원하는 기능들을 모아서  Plugin을 직접 만들어서 사용 하는 것이 Custom Plugin이다.

독립적인 플러그인 클래스를 정의 후 모듈별 공통 설정을 관리할 수 있고, 이 플러그인을 모듈간 공유하여 사용할 수 있다.

 

자세한 만드는 법은 모듈 셋팅부터 이야기 한 후 필요한 곳에서 후술하겠다.

 

 

 

build-logic 모듈

Java or Kotlin Library로 모듈을 만들어 준다.

 

이때, build-logic 모듈에는 Custom Plugin을 만들어야 하는데 이를 Convention Plugin이라고 표현하기도 한다.

그래서 모듈을 build-logic 내부에 convention모듈을 만드는 경우도 많다.

하지만 나는 굳이 만들 이유를 못느껴 그냥 build-logic 내부에 바로 넣었다.

 

그 후 필수적으로 settings.gradle에 가서 includeBuild("build-logic")을 작성해준다.

enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
    includeBuild("build-logic")
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

...

이게 존재해야 빌드로직을 이용해서 빌드를 진행한다.

 

 

setting.gradle.kts(build-logic)

versionCatalogs 파일의 경로를 입력해준다.

enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

 

 

build.gradle.kts(build-logic)

plugins {
    `kotlin-dsl`
    `kotlin-dsl-precompiled-script-plugins`
}

dependencies {
    implementation(libs.android.gradlePlugin)
    implementation(libs.kotlin.gradlePlugin)
    compileOnly(libs.compose.compiler.gradle.plugin)
}

Kotlin DSL을 사용해서 Convention Plugin을 만들기 때문에 플러그인데 kotlin-dsl을 추가했다.

 

precompiled-script-plugins을 통해 스크립트로 만들어 여러 모듈에서 쉽게 적용할 수 있게 했다.

또한 프리컴파일 해둔다면 빌드 성능이 개선된다.

 

 

예를 들어 프리컴파일 스크립트를 만들어두지 않는다면 이렇게 필요한것들을 하나씩 하나씩 넣어줘야 하지만

plugins {
    sopt("feature")
    sopt("compose")
    sopt("test")
    sopt("deeplink")
}

 

여러개를 모은 후 하나의 플러그인만 추가하면 되도록 할 수 있다.

plugins {
    alias(libs.plugins.hankki.feature)
}

 

 

물론 이것 말고도 많은 이점이 있다. 그 부분은 아래에서 추가 설명하겠다.

 

 

어떤 커스텀 플러그인을 만들까?

이 프로그램에서 어떠한 것들을 사용하고 있는지가 중요하다.

또한 개인적으로 단순한 dependencies만 추가한다면 그것과 관련된 플러그인을 만드는 것을 선호하지 않는다.

Hilt처럼 플러그인도 추가하고, dependencies도 추가하는 그런 코드에서 custom plugin이 빛을 발한다고 생각한다.

 

그렇기에 아래와 같은 파일을 구상했다.

 

커스텀 플러그인

ComposeAndroid

CoroutineAndroid

KotlinAndroid

SerializationAndroid

 

익스텐션

Extension

AppNameExtension

 

각각의 기능별로 묶어서 플러그인들을 만들고, 이를 묶어서 precompiled script로 만드는 방법을 사용했다.

 

 

커스텀 플러그인

예를 들어 ComposeAndroid 플러그인을 보여주겠다.

package com.hankki.build_logic

import com.android.build.gradle.BaseExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension

internal fun Project.configureComposeAndroid() {
    with(plugins) {
        apply("org.jetbrains.kotlin.plugin.compose")
    }

    extensions.getByType<BaseExtension>().apply {
        buildFeatures.apply {
            compose = true
        }
    }

    val libs = extensions.libs
    androidExtension.apply {
        dependencies {
            val bom = libs.findLibrary("androidx-compose-bom").get()
            add("implementation", platform(bom))

            add("implementation", libs.findLibrary("androidx.compose.material3").get())
            add("implementation", libs.findLibrary("androidx.compose.ui").get())
            add("implementation", libs.findLibrary("androidx.compose.ui.tooling.preview").get())
            add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
            add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
            add("debugImplementation", libs.findLibrary("androidx.compose.ui.tooling").get())
            add("debugImplementation", libs.findLibrary("androidx.compose.ui.testManifest").get())
        }
    }

    extensions.getByType<ComposeCompilerGradlePluginExtension>().apply {
        enableStrongSkippingMode.set(true)
        includeSourceInformation.set(true)
    }
}

 

모든 Compose를 사용하는 곳에서 사용하는 모든 내용을 묶어둔다고 생각하면 된다.

플러그인, 익스텐션 등을 설정해준다.

 

그래서 내가 만든 이 플러그인을 설정한다면 Compose를 사용하기 위한 모든 설정이 한방에 되도록 구성하는 것이다.

 

 

이 외에도 Hilt 플러그인을 만든다면 힐트를 사용하는데 필요한 모든 것들을 적어두면 된다.

hilt에 대한 플러그인이 필요할거고, ksp를 위한 플러그인이 필요하다.

그리고 hilt에 대한 dependencies를 추가하면 hilt를 사용하기 위한 모든게 끝난다.

package com.hankki.build_logic

import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies

internal fun Project.configureHiltAndroid() {
    with(pluginManager) {
        apply("dagger.hilt.android.plugin")
        apply("com.google.devtools.ksp")
    }

    val libs = extensions.libs
    dependencies {
        "implementation"(libs.findLibrary("hilt.android").get())
        "ksp"(libs.findLibrary("hilt.android.compiler").get())
        "kspAndroidTest"(libs.findLibrary("hilt.android.compiler").get())
    }
}

이렇게.

 

익스텐션?

internal val Project.applicationExtension: CommonExtension<*, *, *, *, *, *>
    get() = extensions.getByType<ApplicationExtension>()

internal val Project.libraryExtension: CommonExtension<*, *, *, *, *, *>
    get() = extensions.getByType<LibraryExtension>()

internal val Project.androidExtension: CommonExtension<*, *, *, *, *, *>
    get() = runCatching { libraryExtension }
        .recoverCatching { applicationExtension }
        .onFailure { println("Could not find Library or Application extension from this project") }
        .getOrThrow()

internal val ExtensionContainer.libs: VersionCatalog
    get() = getByType<VersionCatalogsExtension>().named("libs")

익스텐션이라는 파일에서는 이렇게 다양한 extension을 만들었다.

이렇게 만들어두면 플러그인을 만들 때 조금 더 쉽게 개발을 할 수 있다.

 

 

또한 nameSpace를 그래들마다 써줘야 하는데 앞부분은 모든 모듈이 동일하다.

하지만 이걸 매번 직접 작성한다면 휴먼에러가 발생할 가능성이 있다고 생각하기에 이를 위한 익스텐션도 만들었다.

package com.hankki.build_logic

import org.gradle.api.Project

fun Project.setNamespace(name: String) {
    androidExtension.apply {
        namespace = "com.hankki.$name"
    }
}

아래처럼 작성한다면 자동으로  "com.hankki.feature.login"으로 된다.

android {
    setNamespace("feature.login")
}

 

 

 

 

커스텀 플러그인을 적용해보자

위에 만들어둔 커스텀 플러그인을 precompiled-script방법으로 적용해보자.

 

우선 나는 아래와 같이 사용처에 따라 나누어 두었다.

각각의 그래들 파일 안에서 내가 만들어둔 플러그인을 호출하면 끝이다.

 

예를 들어 kotlin 그래들의 경우 Project의 확장함수로 만들어둔 kotlin 옵션을 호출하면 된다.

import com.hankki.build_logic.configureKotlin

plugins {
    kotlin("jvm")
}

kotlin {
    jvmToolchain(17)
}

configureKotlin()

 

 

그 외 feature의 경우 조금 더 다양한 것들을 할 수있다.

import com.hankki.build_logic.configureHiltAndroid
import com.hankki.build_logic.configureSerializationAndroid
import com.hankki.build_logic.libs

plugins {
    id("hankki.android.library")
    id("hankki.android.compose")
}

android {
    packaging {
        resources {
            excludes.add("META-INF/**")
        }
    }
}

configureHiltAndroid()
configureSerializationAndroid()

dependencies {
    val libs = project.extensions.libs

    // modules
    implementation(project(":core:common"))
    implementation(project(":core:designsystem"))
    implementation(project(":core:navigation"))

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

    // lifecycle
    implementation(libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
    implementation(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())

    // timber
    implementation(libs.findLibrary("timber").get())

    // immutable
    implementation(libs.findLibrary("kotlinx.immutable").get())
}

 

내 앱의 구조상 모든 feature는 core에 있는 여러 모듈들을 참조하고 있다. 그렇기에 각 모듈의 gradle에서 써주는 것이 아니라 여기서 선언해주면 자동으로 모든 feature모듈은 core에 있는 여러 모듈과 연결이 된다.

 

또한 주로 사용하는 dependencies들도 여기 적어준다면 각 모듈마다 귀찮게 적어주지 않아도 된다.

 

 

이렇게 만든 gradle파일을 직접 plugin에 적어준다면 이 또한 휴먼에러를 유발할 수 있다.

 

 

 

그렇기에 versionCatalog에 플러그인으로 적어준다면 typeSafety하게 사용할 수 있다.

plugins {
    alias(libs.plugins.hankki.feature)
}

 

 

 

 

공통 로직 추출이라는 것은 정말 좋은 단어이지만, 위험한 단어이기도 하다.

공통이라는 것의 명확한 판정기준이 있어야 할 것이고, 이것을 설계하는 것이야 말로 정말 어려운 것이라고 생각한다.

 

그렇기에 지금 내 코드도 분명 많은 단점이 있을 것이고, 사용하다보면 그 단점을 느끼게 될 것이라 생각한다.

728x90