안드로이드 멀티 모듈(multi module) 관리는 어떻게 하면 좋을까?



개인 광고 영역

최근 안드로이드 개발은 모듈에 대한 고민을 많이 하는 추세이다. 하나의 app 모듈에서 package를 분리하여 개발하던 방식에서 모듈 단위로의 분리를 시도하고 있다.

모듈 단위로 분리하여 개발하면 얻을 수 있는 이득은 무엇일까?

  • 모듈 간 디펜던시 최소화로 아키텍처 적용의 용의
  • DI 적용으로 유연한 개발 가능
  • 단독 화면으로 테스트 가능성

등등 다양한 이유가 있을 것이다.

적절한 모듈 분리도 필요하고, 모듈을 분리하는 조건도 정해야 할 것이다.

  • feature 단위로 모듈을 구분한다.
  • feature 단위의 데이터 역시 함께 구분한다.
  • Architecture에 따라 모듈화를 구성한다.(View-ViewModel + Model(+ Clean Architecture) + core + ui system 등)

다행히 DI가 있으니 이런 모듈이 여러 개여도 app/main 화면에서 쉽게 엮어 줄 수 있다.

이 글에서는 모듈 분리 기법을 소개하지는 않고, 모듈을 분리 시 어떤 식으로 관리하면 좋을지 소개한다.

폴더 구조를 include 하는 방식은 두 가지가 있다.

  • include(“:모듈이름”)
  • include(“:상위폴더:모듈이름”)

여기서는 상위 폴더 위치는 신경쓰지 않는 방식에 대해서 소개한다.


이 글에서 알아볼 내용

  • 폴더 구조의 모듈을 정리해보자.
  • 폴더 구조 작업 시 설정 부분을 살펴본다.
  • 폴더 구조 작업 시 Android 카테고리에서도 볼 수 있는 방법 추가
  • build.gradle plugin 관리 방법을 소개한다.
  • 모듈 나누는 방법을 소개하지는 않는다.


몇 개의 모듈까지 개발해 보았나?

작성하는 기준 약 200개의 모듈을 구성하여 개발에 활용하고 있다. 200개란 말은 폴더가 200개가 보인다는 말과도 동일하다.

참고로 200개의 모듈 빌드에 걸리는 시간은 대략적으로

  • M1 Pro 클린 빌드시 약 5분, M1 Max 클린 빌드시 약 3~4분 소요된다. 리빌드는 약 1분 미만의 시간을 필요로 한다.

폴더 구조 역시 중요하고, build.gradle의 코드 중복성도 중요하다. 당연히 UnitTest에 대한 검증도 함께 고민하면 좋다.


폴더 구조

기본 형태에서 폴더 구조를 잡을 순 없다.(부가적인 코드를 필요로 하는데 부가적인 코드를 아래에서 소개하겠다.)

현재 직접 관리하고 있는 모듈은 크게 4가지 형태로 분리하여 활용 중이다. 여기에 샘플 코드도 함께 관리되고 있는데, 그래서 5개의 큰 폴더가 있고, app 모듈을 di만을 연결하여 활용한다.

  • app-core : 앱의 UI와 같은 작업하는 앱의 디펜던시 모듈이라고 생각하면 되겠다.
  • library(core) : UI를 제외한 다른 앱에서도 활용 가능한 모듈이다.
  • UI(feature) : Router 기반의 화면 단위 이동이 가능한 Activity 기준으로 모듈이 나누어져 있다.
  • app 모듈 : 위의 모든 코드를 Dagger 기반으로 앱을 실행할 수 있는 형태로 만들었다.
  • sample 모듈 : 샘플 앱을 작성하기 위한(디자인 시스템) 모듈을 별도로 관리하고 있다.

di 작성 양이 많아지는데, hilt-android를 활용한다면 좀 더 쉽게 접근 가능할 것이다.

하지만 hilt를 활용하고 있지 않기 때문에 dagger module을 모두 작성해 줘야 한다. 그래서 이를 ksp를 활용해 자동화하고, app 모듈에서도 이를 자동으로 include 할 수 있도록 개발하였다.

이 코드 기반은 정승욱 님이 올려둔 android-alatan/LifecycleComponents 코드를 기반하고 있다.


모듈의 형태

필자가 작업하는 모듈의 형태는 android-alatan/LifecycleComponents 기반이고, 다음의 형태를 가지고 있으며, 모두 UnitTest 가능하도록 설계하였다.

  • ~api : interface 정의
  • ~impl : interface 정의에 대한 구현체 모듈
  • ~Assertions : interface에 대한 목 테스트 모듈

모든 모듈은 interface와 테스트 모듈을 활용하여 개발하여 디펜던시를 끊어주고, app에서 합쳐지는 형태이다.


모듈을 나눠 보자

Now in andorid 기반 모듈을 나눈다고 가정한다면 다음과 같다.

image_01

Now in android는 별도 폴더를 분리하고 있지 않다. 하지만 모듈의 양은 점점 많아지고, 작업 모듈 찾기가 힘들어진다. 우선 Now in android의 종류를 구분하여보자.

  • core : 앱에서 활용할 네트워크, Repository, UI를 core로 분리하였다.
  • feature : 여기서는 Navigation을 활용하고 있고, 탭 하나에 feature 하나씩을 다루고 있다.
  • app : main 화면을 포함하며, core, feature를 합쳐 화면을 구성한다. di를 활용하고 있다.

core에는 Network에 Response 모델이 포함되어 있고, 모든 데이터(entity)는 data model에 포함되어 있다. 이를 포함한 Now in android의 대략적인 설계는 아래와 같다.

image_02


폴더 구조를 적용해 보자. - 모듈 이름 만 적용 할 경우

Now in Android를 기반으로 폴더 구조를 적용해 보았다.

참고로 이 폴더 구조의 include는 include(":모듈") 형태로 사용하고 있을 경우에 해당한다.

  • _core : 코어 폴더는 기존 core를 담고 있으며, 여기서 한 단계 더 분리하여, UI와 model 폴더도 구분하였다.
  • _feature : feature들을 폴더 하위로 이동시켰다.
  • app : app 모듈은 기존과 동일하다.

여기서 _을 붙인 건 좀 더 찾기 편하도록 하기 위해 붙인 prefix 일뿐 별다른 의미는 없다.

이를 정리하면 다음과 같은 형태일 수 있다.

image_03


참고로 폴더 구조를 Android Studio의 android에도 노출하도록 하려면? - 모듈 이름 앞에 폴더 지정

!! 내용 추가 : 친구가 다음의 방법으로도 폴더 관리 가능하다고 알려주어 내용을 추가한다.

위에서 적은 부분은 단순히 폴더 구조를 정리하고, project 부분에서 쉽게 접근하기 위함으로 소개하였다.

android 방식에서도 폴더 별로 보려면 다음과 같이 include를 추가할 수 있다.

include(":상위폴더:모듈이름")

위에서 소개한 방법과는 다르게 모든 모듈 이름 앞에 :상위폴더를 명시해주어야 한다. 대신 이용할 때도 :상위폴더:모듈이름을 적어줘야 한다.

implementation(projects.core.sync)

// 또는
implementation(project(":core:sync"))

와 같은 형태로 적어줘야 한다. 상위 폴더의 이동이 많지 않다면 이 방법이 위에서 소개한 방법보다 좋을 수 있다. 대신 _ 형태의 prefix 활용은 불가능하다.


폴더 구조 활용을 위한 필수 소스 코드

now in android 폴더 구조는 다음 소스코드에서 확인할 수 있다. 참고 - now in android - directory

안드로이드 프로젝트는 하위 폴더 구조 활용을 제공하지 않기 때문에 다음의 추가 코드 작성이 필요하다.

이 코드는 settings.gradle.kts(코틀린) 코드 기반이니 참고하시길

// 찾은 모듈을 저장하기 위한 HashMap 설정
val modules = hashMapOf<String, String>()

// rootProject의 projectDir을 찾고, findSubProjects를 동작한다.
rootProject.projectDir.listFiles()
    ?.forEach {
        findSubProjects(it)
    }

// 하위 디렉토리 구조를 찾기 위함
fun findSubProjects(file: File) {
    if (file.name.startsWith(".")) {
        return
    }

    // build.gradle.kts와 build.gradle을 찾기 위함
    if (file.name == "build.gradle.kts" || file.name == "build.gradle") {
        modules[file.parentFile.name] = file.parentFile.path
        return
    }

    // 재기 처리
    if (file.isDirectory) {
        file.listFiles()
            ?.forEach {
                findSubProjects(it)
            }
    }
}

// 모든 폴더 구조를 projectDir에 포함시켜 하위 폴더 모두를 포함시켜준다.
for (project in rootProject.children) {
    if (modules.containsKey(project.name)) {
        val directory = modules[project.name] ?: continue
        project.projectDir = File(directory)
    }
}

위와 같이 settings.gradle.kts에 정의해 주어야 하위 디렉토리 형태의 모듈 구조를 가질 수 있다.


build.gradle.kts 관리

모듈이 증가하면 동일한 코드라도 모든 모듈에 동일하게 들어가야 한다. 이를 줄이기 위해 plugin을 개발할 수 있다.

plugin 개발을 해두면 아래와 같이 플러그인 이름을 지정하여 활용할 수 있게 된다.

plugins {
  id("xxxx")
}

중복 코드를 최소화시킬 수도 있고, 관리 포인트를 줄일 수 있다는 장점이 있다. 이런 플러그인 개발은 2가지 방법이 있다.

buildSrc에 kotlin-dsl-precompiled-script-plugins을 활용하는 방법과 nowinandroid에 포함되어 있는 build-logic을 활용하는 방법이다.

이 둘의 차이는 특별한 건 없지만 다음과 같다.

  • buildSrc는 kotlin, gradle 문법 모두 활용이 가능하지만 상수를 포함하지 않는 게 좋다.(const value)
  • build-logic는 kotlin 문법을 활용할 수 있다.

상수를 포함하지 않으려면 보통 버전 관리를 할 수 있는 gradle version catalog를 활용하여야 한다.

특별히 다르지 않으니 원하는 걸 선택하여 개발하면 되겠다.

참고로 buildSrc가 7초 동안 빌드 과정을 거치면 build-logic 역시 7초의 빌드 과정을 거치게 된다. 결국 동일하다는 이야기이니 편한 방법을 선택하시길


여기서는 buildSrc의 script 방법을 소개한다.

build-logic은 now in android에 포함되어 있으니 이를 참고하시길

nowinandroid - build-logic

이 글에서는 buildSrc 활용 방법을 소개하는데, 이미 buildSrc를 활용 중이라면 아래 코드를 추가해 주면 되겠다.

참고 - buildSrc/build.gradle.kts

plugins {
    `kotlin-dsl`
    `kotlin-dsl-precompiled-script-plugins` // < 이 코드 추가
}

plugin precompiled을 위한 플러그인을 추가한다.

그리고 다음과 같이 원하는 plugin 형태를 파일로 추가한다.

참고 - 전체 코드 andorid-library.plugin

이 코드는 com.andorid.library를 포함하고 있는데, 모든 모듈에서 기본으로 활용할 코드를 담고 있다.

중요한 부분은 이 파일에는 package name을 명시하지 않아야 한다. 이는 build-logic도 동일하다.

plugins {
    id("com.android.library")
    id("lib-tasks")
    id("code-quality")
    kotlin("android")
}

// VersionCatalog를 활용할 경우 이름을 library 정보를 가져오기 위함
val versionCatalog = project.extensions.getByType<VersionCatalogsExtension>()
val libs = versionCatalog.named("libs")


android {
    // 컴파일 sdk의 버전 정보를 가져온다.
    compileSdk = libs.findVersion("compileSdk").get().requiredVersion.toInt()
    buildToolsVersion = libs.findVersion("buildToolsVersion").get().requiredVersion

    defaultConfig {
        minSdk = libs.findVersion("minSdk").get().requiredVersion.toInt()
        targetSdk = libs.findVersion("targetSdk").get().requiredVersion.toInt()

    }

    kotlinOptions {
        jvmTarget = "11"
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
}

dependencies {
    // 위의 extensions을 통해 테스트 모듈 정보를 가져와 import 한다.
    testImplementation(libs.findLibrary("test.mockito").get())
    testImplementation(libs.findLibrary("test.mockitoKotlin").get())

    testImplementation(libs.findLibrary("test.junit5").get())
    testRuntimeOnly(libs.findLibrary("test.junit5.engine").get())
    testRuntimeOnly(libs.findLibrary("test.junit5.vintage").get())
}

이 코드가 precompiled 되고 나면 아래와 같이 활용할 수 있다.

plugins {
  id("lib-kotlin-android")
}

// 생략


build-logic

build-logic는 내부 코드에서 알 수 있지만 plugin 명칭을 별도 매핑해줘야하는 부분이 있는데, build.gradle.kts 파일에서 확인할 수 있다.

코드 참고 - nowinandroid convention

gradlePlugin {
    plugins {
        register("androidLibrary") {
            id = "nowinandroid.android.library"
            implementationClass = "AndroidLibraryConventionPlugin"
        }
    }
}

이와 같이 추가 매핑 작업을 해줘야 필요한 plugin 활용이 가능해진다.


plugins 활용 시 참고

plugins을 너무 많이 만들면 buildSrc의 빌드 시간이 길어진다. 이상하게도 아무런 수정을 하지 않더라도 처음 7초가 걸렸다면 이후에도 기본 7초를 소모하고 있다.

이는 build-logic도 동일하다.

그러니 적당한 plugin을 개발할 필요가 있다.


추가로 version catalog 활용 시 참고 사항

version catalog는 const value 활용에 많은 이점이 생긴다. buildSrc는 상수 표현이 있을 경우 이를 inline 하는 형태로 동작한다.

그러다 보니 상수 값 수정 시 모든 plugin 코드를 리빌드한다. 그래서 상수를 활용하지 않는 방법을 활용해야 하는데, 기존 buildSrc를 활용하여 버전 관리 시에는 kotlin의 상수를 활용한 버전 관리를 했었다.

하지만 이는 값의 하나의 수정으로 모두 리빌드가 일어나기 때문에 비효율 적일 수 있다. version catalog의 경우 함수 호출의 형태라 상수 값의 변경 여부는 무관하기 때문에 단순 버전 변경이 일어난다고 해도 리빌드를 하지 않으니 그만큼 시간 절약에 도움이 될 수 있다.

version catalog 문서

version catalog에서 생성한 파일의 위치를 잡아야 하는데, 꼭! gradle 폴더 하위에 둬야 한다.

buildSrc 활용 시에는 다음의 코드를 settings.gradle.kts에 포함해 주면 buildSrc와 rootProject에서도 이를 활용할 수 있다.(build-logic 역시 동일)

@Suppress("UnstableApiUsage")
dependencyResolutionManagement {
    versionCatalogs {
        create("libs") { // catalog를 불러올때 활용할 이름
            from(files("../gradle/libs.versions.toml")) // 파일의 위치
        }
    }
}

여기서 중요한 부분은 gradle 폴더 하위에 libs.versions.toml 파일을 위치시켜야 한다는 점이다. 그렇지 않다면 rootProject에서는 libs 접근이 불가능하니 꼭 정확한 위치에 포함하길 바란다.


GDG 송도 - 안드로이드 Module 발표 자료


마무리

이 글에서 모듈을 나누고 폴더 구조와 buildSrc 활용 방법을 소개해 보았다.

모듈 형태를 개발하더라도, 빌드가 월등히 빨라지는 것은 아니다. 다만 최초 빌드 이후 수정에서 얼마나 달라진 환경에서 개발할 수 있느냐도 중요하다고 생각한다.

모듈의 수정 사항이 없다면 당연히 증분 빌드가 일어나기에 수정하지 않으면 리빌드하지 않는 점도 중요하다.



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments