Friday, August 18, 2023

Android 모듈화, 성공적인 앱 아키텍처의 청사진

소프트웨어 개발의 세계에서 '모놀리식(Monolithic)'이라는 단어는 종종 경고의 의미를 담고 있습니다. 단일 코드베이스에 모든 기능이 얽혀 있는 애플리케이션은 초기 개발 속도는 빠를 수 있지만, 프로젝트가 성장함에 따라 거대한 기술 부채의 덩어리로 변모하기 쉽습니다. 안드로이드 애플리케이션 개발도 예외는 아닙니다. 수십, 수백 명의 개발자가 하나의 거대한 :app 모듈에 코드를 쏟아붓는 상황을 상상해 보십시오. 작은 수정 하나가 예상치 못한 곳에서 버그를 유발하고, 코드 한 줄을 바꾸고 빌드하는 데 커피 한 잔을 다 마실 시간이 걸리며, 새로운 팀원이 프로젝트 구조를 파악하는 데 몇 주가 소요됩니다. 이는 단순히 비효율을 넘어 프로젝트의 지속 가능성을 위협하는 심각한 문제입니다.

이러한 혼돈 속에서 '모듈화(Modularization)'는 단순한 기술적 선택이 아닌, 복잡성을 제어하고 확장 가능한 아키텍처를 구축하기 위한 필수 전략으로 떠오릅니다. 모듈화는 거대한 애플리케이션을 기능, 계층 또는 책임에 따라 여러 개의 작고 독립적인 구성 요소, 즉 '모듈(Module)'로 분리하는 설계 방식입니다. 각 모듈은 독립적으로 개발, 테스트, 빌드될 수 있으며 명확하게 정의된 인터페이스를 통해 서로 통신합니다. 이 글에서는 안드로이드 모듈화가 왜 필요한지에 대한 근본적인 이유부터 시작하여, 다양한 모듈의 종류와 역할, 효과적인 모듈화 아키텍처 설계 방법, 그리고 실제 구현 과정에서 마주할 수 있는 기술적 과제와 해결책까지 심도 있게 탐구합니다.

1. 왜 모듈화인가? 거대한 혼돈에서 질서로

모듈화 도입을 결정하기 전에, 그것이 제공하는 구체적인 가치를 이해하는 것이 중요합니다. 모듈화는 단순히 코드를 여러 폴더로 나누는 행위가 아닙니다. 개발 문화와 프로세스 전반에 긍정적인 영향을 미치는 구조적 변화입니다.

1.1. 빌드 시간의 혁신적인 단축

모놀리식 앱의 가장 큰 고통 중 하나는 바로 끝없이 길어지는 빌드 시간입니다. 코드베이스가 커질수록 Gradle은 전체 코드를 다시 컴파일하고 처리해야 하므로, 간단한 변경 사항을 확인하는 데도 수 분이 소요될 수 있습니다. 이는 개발자의 집중력을 저하시키고 생산성을 심각하게 떨어뜨립니다.

모듈화는 Gradle의 강력한 최적화 기능을 최대한 활용할 수 있는 환경을 제공합니다.

  • 점진적 빌드(Incremental Build): 코드가 수정되었을 때, Gradle은 변경된 모듈과 해당 모듈에 직접적으로 의존하는 모듈들만 다시 빌드합니다. 예를 들어, :feature:login 모듈의 UI 로직만 수정했다면, 관련 없는 :feature:home이나 :feature:profile 모듈은 재빌드되지 않습니다.
  • 빌드 캐시(Build Cache): Gradle은 각 모듈의 빌드 결과물(출력)을 캐시에 저장합니다. 만약 특정 모듈의 코드가 변경되지 않았다면, Gradle은 컴파일을 다시 수행하는 대신 캐시된 결과물을 즉시 재사용합니다. 이는 팀 동료가 이미 빌드한 결과물을 CI 서버 등을 통해 공유받아 로컬 빌드 시간을 더욱 단축시키는 효과로 이어집니다.
  • 병렬 빌드(Parallel Build): 서로 의존성이 없는 모듈들은 동시에 병렬로 빌드될 수 있습니다. 프로젝트에 독립적인 기능 모듈이 많을수록 병렬 처리의 이점을 극대화하여 전체 빌드 시간을 줄일 수 있습니다.

이러한 최적화 덕분에 개발자는 몇십 초 내에 변경 사항을 확인하고 빠르게 개발 사이클을 반복할 수 있게 됩니다. 이는 단순한 시간 절약을 넘어, 개발 경험의 질을 근본적으로 향상시킵니다.

1.2. 코드 소유권과 팀 확장성

프로젝트 규모가 커지고 팀이 성장하면 코드 소유권 문제가 발생합니다. 모놀리식 구조에서는 모든 코드가 한곳에 섞여 있어 책임 소재가 불분명하고, 여러 팀이 동일한 파일을 동시에 수정하면서 발생하는 충돌(Conflict)을 해결하는 데 많은 시간을 허비하게 됩니다.

모듈화는 각 기능 또는 계층을 명확한 경계를 가진 모듈로 분리함으로써, 특정 모듈에 대한 '소유권(Ownership)'을 특정 팀이나 개발자에게 부여할 수 있게 합니다.

  • '로그인 팀'은 :feature:login 모듈을 책임지고, '홈 팀'은 :feature:home 모듈을 책임집니다. 각 팀은 자신들의 모듈 내에서는 자율성을 가지고 독립적으로 개발을 진행할 수 있습니다.
  • 다른 팀의 코드에 미치는 영향을 최소화합니다. 로그인 팀이 내부 구현을 변경하더라도, 공개된 API(인터페이스)만 변경하지 않는다면 홈 팀의 코드는 전혀 영향을 받지 않습니다. 이는 사이드 이펙트에 대한 두려움을 줄여주고, 더 과감하고 신속한 리팩토링을 가능하게 합니다.
  • 신규 입사자의 적응을 돕습니다. 새로운 팀원은 전체 프로젝트의 복잡성에 압도당하는 대신, 자신이 맡은 작은 모듈의 코드부터 분석하며 점진적으로 시스템 전체에 대한 이해를 넓혀갈 수 있습니다.

1.3. 재사용성과 일관성을 통한 코드 품질 향상

모놀리식 앱에서는 공통 유틸리티 클래스나 UI 컴포넌트가 중복으로 생성되거나, 여러 곳에 흩어져 일관성을 잃기 쉽습니다. 모듈화는 공통의 기능을 별도의 모듈로 추출하여 재사용성을 극대화하고 프로젝트 전체의 일관성을 유지하는 강력한 도구입니다.

예를 들어, :core:designsystem 모듈을 만들어 앱 전체에서 사용되는 버튼, 텍스트 필드, 색상, 타이포그래피 등을 정의할 수 있습니다. 모든 기능 모듈은 이 :designsystem 모듈에 의존하여 UI를 구성함으로써, 앱 전체가 일관된 디자인 언어를 갖게 됩니다. 마찬가지로, 네트워크 통신, 데이터베이스 접근 로직, 공통 유틸리티 함수 등을 :core:network, :core:database, :core:common과 같은 모듈로 분리하면 중복 코드를 제거하고 해당 기능의 품질을 중앙에서 관리할 수 있습니다.

1.4. 동적 기능 모듈(Dynamic Feature Module)을 통한 앱 경량화

앱의 기능이 많아질수록 APK/AAB 파일의 크기도 커집니다. 이는 사용자의 설치 장벽을 높이고, 특히 저장 공간이 부족한 기기에서는 앱 설치를 포기하게 만드는 요인이 됩니다. Android App Bundle과 함께 제공되는 동적 기능 모듈은 이 문제를 해결하는 혁신적인 방법을 제공합니다.

핵심 기능이 아닌 특정 기능(예: 비디오 편집, AR 필터, 특정 게임 모드 등)을 동적 기능 모듈로 분리하면, 사용자는 기본 앱만 설치하고 해당 기능이 필요할 때 네트워크를 통해 동적으로 다운로드하여 사용할 수 있습니다. 이는 초기 설치 크기를 획기적으로 줄여 설치 전환율을 높이고, 사용자에게 더 나은 경험을 제공합니다.

2. 안드로이드 모듈의 종류와 역할: 아키텍처의 구성 요소들

효과적인 모듈화를 위해서는 안드로이드 프로젝트에서 사용되는 다양한 모듈 유형과 각각의 역할을 명확히 이해해야 합니다. 모듈은 단순히 코드를 담는 폴더가 아니라, 뚜렷한 목적과 책임을 가진 아키텍처의 빌딩 블록입니다.

2.1. 애플리케이션 모듈 (Application Module - :app)

모든 안드로이드 프로젝트에는 단 하나의 애플리케이션 모듈이 존재합니다. 이 모듈은 com.android.application 플러그인을 사용하며, 최종적으로 사용자가 설치하는 APK 또는 AAB 파일을 생성하는 역할을 합니다.

이상적인 모듈화 아키텍처에서 :app 모듈의 역할은 최소화되어야 합니다. 직접적인 비즈니스 로직이나 UI 코드를 거의 포함하지 않고, 여러 기능 모듈과 코어 모듈들을 조립하여 하나의 완전한 애플리케이션으로 묶어주는 '조립자(Assembler)' 또는 '통합자(Integrator)'의 역할을 수행해야 합니다. 예를 들어, 모듈 간의 네비게이션 그래프를 정의하거나, 전체 애플리케이션 범위의 의존성 주입(Dependency Injection) 설정을 초기화하는 등의 작업을 담당합니다.

2.2. 라이브러리 모듈 (Library Module - :library)

라이브러리 모듈은 com.android.library 또는 org.jetbrains.kotlin.jvm 플러그인을 사용하며, 안드로이드 모듈화의 핵심적인 역할을 담당합니다. 재사용 가능한 코드와 리소스를 캡슐화하며, 다른 모듈(:app 모듈 포함)에서 의존성으로 추가하여 사용할 수 있습니다. 라이브러리 모듈은 그 역할과 책임에 따라 다음과 같이 세분화할 수 있습니다.

2.2.1. 기능 모듈 (Feature Module)

기능 모듈은 사용자에게 드러나는 특정 기능 단위를 캡슐화합니다. 예를 들어, :feature:login, :feature:home, :feature:search, :feature:profile 등이 여기에 해당합니다.

  • 각 기능 모듈은 해당 기능에 필요한 모든 구성 요소(Activity, Fragment, ViewModel, Repository, UI 리소스 등)를 포함하여 독립적으로 작동할 수 있도록 설계되는 것이 이상적입니다.
  • 기능 모듈 간에는 직접적인 의존성을 갖지 않는 것을 원칙으로 합니다. :feature:login 모듈이 :feature:home 모듈의 특정 클래스를 직접 참조해서는 안 됩니다. 모듈 간의 전환(Navigation)은 상위 계층인 :app 모듈이나 별도의 네비게이션 모듈을 통해 처리되어야 합니다.

2.2.2. 공유 모듈 (Shared/Core Module)

공유 모듈은 여러 기능 모듈에서 공통적으로 사용되는 코드와 리소스를 모아놓은 모듈입니다. 이는 코드 중복을 방지하고 일관성을 유지하는 데 필수적입니다.

  • :core:common (또는 :shared:utils): 확장 함수(Extension Functions), 로깅 유틸리티, 상수 등 프로젝트 전반에서 사용되는 순수한 Kotlin/Java 코드를 포함합니다.
  • :core:ui (또는 :core:designsystem): 커스텀 뷰, 공통 UI 컴포넌트(버튼, 다이얼로그), 테마, 스타일, 색상, 문자열 등 UI 관련 리소스를 포함합니다.
  • :core:model (또는 :shared:model): 여러 기능에서 공통으로 사용되는 데이터 모델(DTO, Entity 등)을 정의합니다.

2.2.3. 데이터 계층 모듈 (Data Layer Module)

애플리케이션의 데이터 소스(네트워크, 데이터베이스 등)에 대한 접근을 책임지는 모듈입니다.

  • :data: Repository 인터페이스의 구현체, Retrofit/Ktor와 같은 네트워크 API 정의, Room 데이터베이스 DAO 및 엔티티 등을 포함합니다. 이 모듈은 데이터 소스의 구체적인 기술(어떤 DB를 쓰는지, REST API인지 GraphQL인지 등)을 다른 모듈로부터 숨기는 역할을 합니다.

2.2.4. 도메인 계층 모듈 (Domain Layer Module)

도메인 계층은 순수한 비즈니스 로직을 담고 있는 모듈입니다. 이 모듈은 안드로이드 프레임워크에 대한 의존성이 없는 순수 Kotlin/Java 모듈(org.jetbrains.kotlin.jvm 플러그인 사용)로 만드는 것이 이상적입니다.

  • :domain: UseCase(또는 Interactor), 비즈니스 규칙, 그리고 프레임워크에 독립적인 도메인 모델을 포함합니다. 또한, 데이터 계층이 구현해야 할 Repository 인터페이스를 이곳에 정의함으로써 의존성 역전 원칙(DIP)을 적용합니다.

2.3. 동적 기능 모듈 (Dynamic Feature Module)

앞서 언급했듯이, com.android.dynamic-feature 플러그인을 사용하는 이 모듈은 앱의 핵심 기능이 아니며, 사용자가 필요할 때 다운로드하여 사용할 수 있는 기능을 캡슐화합니다. 이 모듈은 :app 모듈에 의존하며, :app 모듈의 코드와 리소스에 접근할 수 있습니다.

3. 모듈화 아키텍처 설계: 클린 아키텍처와 의존성 규칙

단순히 모듈을 나누는 것만으로는 충분하지 않습니다. 모듈들이 서로 어떻게 관계를 맺고 통신할 것인지에 대한 명확한 규칙과 구조, 즉 아키텍처가 필요합니다. 현대 안드로이드 개발에서 가장 널리 채택되는 모듈화 아키텍처는 클린 아키텍처(Clean Architecture)의 원칙을 기반으로 합니다.

3.1. 의존성 규칙 (The Dependency Rule)

클린 아키텍처의 핵심은 '의존성 규칙'입니다. 소스 코드의 의존성은 오직 안쪽을 향해야 한다. 즉, 바깥쪽 원(계층)의 어떤 것도 안쪽 원에 대해 알지 못해야 합니다.

이를 안드로이드 모듈화에 적용하면 다음과 같은 의존성 방향 그래프를 그릴 수 있습니다.

:app:feature:*:domain:data

  • Feature Layer (:feature:*): 사용자와 직접 상호작용하는 UI와 상태 관리 로직(ViewModel)이 위치합니다. 이 계층은 도메인 계층(:domain)에 의존하여 비즈니스 로직을 실행하고 데이터를 요청합니다. 하지만 데이터 계층(:data)의 존재를 전혀 알지 못합니다.
  • Domain Layer (:domain): 애플리케이션의 핵심 비즈니스 로직(UseCase)과 도메인 모델을 포함합니다. 이 계층은 다른 어떤 계층에도 의존하지 않는 가장 순수하고 안정적인 영역입니다. 안드로이드 프레임워크와도 독립적이어야 합니다. 데이터가 어떻게 저장되고 어디서 오는지(네트워크, DB 등)에 관심이 없으며, 오직 '데이터를 가져오는 행위'를 정의한 추상적인 인터페이스(e.g., UserRepository)만을 알고 있습니다.
  • Data Layer (:data): 도메인 계층에 정의된 인터페이스를 구현합니다. Retrofit을 사용하여 서버 API를 호출하거나, Room을 사용하여 로컬 데이터베이스에 접근하는 등의 구체적인 데이터 처리 작업을 수행합니다. 데이터 계층은 도메인 계층에 의존하여 어떤 인터페이스를 구현해야 하는지 알게 됩니다. 이것이 바로 '의존성 역전(Dependency Inversion)'입니다.

이러한 단방향 의존성 구조는 각 계층을 강력하게 분리(Decoupling)시켜줍니다. 예를 들어, 나중에 네트워크 라이브러리를 Retrofit에서 Ktor로 변경하더라도 :data 모듈의 내부 구현만 수정하면 되며, :domain이나 :feature 모듈은 전혀 영향을 받지 않습니다. 마찬가지로, UI 프레임워크를 Jetpack Compose로 전환하더라도 :feature 모듈만 수정하면 됩니다.

3.2. 샘플 프로젝트 구조

위 아키텍처를 기반으로 한 일반적인 프로젝트의 디렉토리 구조는 다음과 같습니다.


MyApplication/
├── app/                  # Application Module
├── buildSrc/             # Gradle Convention Plugins
├── core/
│   ├── common/           # Common utilities, extensions
│   ├── designsystem/     # UI components, themes, resources
│   └── navigation/       # Navigation interfaces/contracts
├── data/                 # Data Layer: Repository impl, Retrofit, Room
├── domain/               # Domain Layer: UseCases, Repository interfaces
├── feature/
│   ├── login/            # Login Feature Module
│   ├── home/             # Home Feature Module
│   └── profile/          # Profile Feature Module
└── gradle/
    └── libs.versions.toml # Version Catalog

4. 실제 구현: Gradle, DI, 그리고 모듈 간 통신

아키텍처 설계가 끝났다면, 이제 실제로 코드를 작성하고 모듈을 연결할 차례입니다. 이 과정에서는 Gradle 설정, 의존성 주입, 모듈 간 통신이라는 세 가지 핵심 기술이 중요하게 작용합니다.

4.1. Gradle을 통한 의존성 관리의 기술

모듈화된 프로젝트에서 Gradle 설정은 복잡해지기 쉽습니다. 수십 개의 모듈에 동일한 의존성과 설정을 반복적으로 복사-붙여넣기 하는 것은 비효율적이며 오류를 유발하기 쉽습니다. 이를 해결하기 위한 현대적인 Gradle 기법들이 있습니다.

4.1.1. `implementation` vs `api`

모듈 간 의존성을 선언할 때, implementationapi 키워드의 차이를 명확히 이해해야 합니다.

  • implementation: 가장 일반적으로 사용해야 하는 방식입니다. 의존성을 모듈 내부 구현의 일부로 숨깁니다. 예를 들어, :feature:homeimplementation project(":core:common")을 사용하면, :app 모듈은 :feature:home을 통해 :core:common의 클래스에 접근할 수 없습니다. 이는 불필요한 의존성 전파를 막아 빌드 시간을 최적화하고 결합도를 낮춥니다.
  • api: 의존성을 외부에 노출(transitive dependency)합니다. 모듈이 자신의 공개 API에서 다른 모듈의 타입을 직접 사용하거나 반환해야 할 때만 제한적으로 사용해야 합니다. 예를 들어, :core:ui 모듈의 함수가 :core:modelUser 객체를 반환한다면, :core:uiapi project(":core:model")로 의존성을 선언해야 합니다.
결론: 항상 implementation을 기본으로 사용하고, 꼭 필요한 경우에만 api를 사용하십시오.

4.1.2. 버전 카탈로그 (Version Catalogs)

libs.versions.toml 파일을 사용하여 프로젝트 전체의 라이브러리 의존성 버전과 정보를 중앙에서 관리할 수 있습니다.

[versions]
kotlin = "1.9.20"
coroutines = "1.7.3"
hilt = "2.48"

[libraries]
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }

[bundles]
coroutines = ["coroutines-core", "coroutines-android"]
이렇게 하면 각 모듈의 build.gradle.kts 파일에서는 타입-세이프한 방식으로 의존성을 추가할 수 있습니다.
dependencies {
    implementation(libs.coroutines.core)
    implementation(libs.hilt.android)
    kapt(libs.hilt.compiler)
}
이를 통해 버전 충돌을 방지하고 모든 모듈이 일관된 버전의 라이브러리를 사용하도록 강제할 수 있습니다.

4.1.3. 컨벤션 플러그인 (Convention Plugins)

buildSrc나 복합 빌드(Composite Build)를 사용하여 공통 Gradle 설정을 재사용 가능한 플러그인으로 만들 수 있습니다. 예를 들어, 모든 안드로이드 라이브러리 모듈에 공통적으로 적용되어야 할 `compileSdk`, `minSdk`, `testOptions` 등의 설정을 `android-library-convention.gradle.kts`와 같은 파일에 정의할 수 있습니다.

// buildSrc/src/main/kotlin/android-library-convention.gradle.kts
plugins {
    id("com.android.library")
    id("org.jetbrains.kotlin.android")
}

android {
    compileSdk = 34
    defaultConfig {
        minSdk = 24
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
    // ... other common configurations
}
그리고 각 라이브러리 모듈에서는 이 플러그인을 간단하게 적용하기만 하면 됩니다.
// feature/login/build.gradle.kts
plugins {
    id("android-library-convention")
}
// ... module specific dependencies

4.2. Hilt를 이용한 의존성 주입

모듈화 환경에서 의존성 주입(DI)은 필수적입니다. Hilt는 모듈화된 프로젝트의 DI를 매우 간단하게 만들어줍니다. 각 모듈은 필요한 의존성을 스스로 정의하고, Hilt는 빌드 시점에 이 의존성들을 올바르게 연결해줍니다.

// data/src/main/kotlin/di/DataModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    @Provides
    @Singleton
    fun provideUserRepository(apiService: ApiService): UserRepository {
        return UserRepositoryImpl(apiService)
    }
}

// feature/login/src/main/kotlin/LoginViewModel.kt
@HiltViewModel
class LoginViewModel @Inject constructor(
    private val loginUseCase: LoginUseCase
) : ViewModel() {
    // ...
}
:data 모듈에서 제공한 UserRepository:domain 모듈의 LoginUseCase에 주입되고, 이 UseCase는 다시 :feature:login 모듈의 LoginViewModel에 주입됩니다. 각 모듈은 구체적인 구현을 알 필요 없이 오직 자신이 필요로 하는 인터페이스에만 의존하게 되며, Hilt가 이 모든 연결을 자동으로 처리해줍니다.

4.3. 모듈 간 통신 및 화면 전환 (Navigation)

기능 모듈 간에는 직접적인 의존성이 없어야 한다는 원칙을 지키면서 어떻게 화면을 전환할 수 있을까요? :feature:login에서 로그인 성공 후 :feature:home의 홈 화면으로 이동해야 하는 상황을 예로 들어보겠습니다.

방법 1: 딥링크 (Deep Link) / Navigation Component

가장 일반적이고 권장되는 방법입니다. 각 기능의 진입점(Entry Point)에 고유한 URI를 부여하고, 이 URI를 통해 화면 전환을 요청합니다.

// In feature:login, after successful login
findNavController().navigate(Uri.parse("myapp://home"))
실제 URI와 특정 Fragment/Activity를 연결하는 내비게이션 그래프(Navigation Graph)는 모든 기능 모듈을 알고 있는 최상위 계층인 :app 모듈에 정의됩니다. 이를 통해 기능 모듈들은 서로를 전혀 알지 못한 채 통신할 수 있습니다.

방법 2: 인터페이스를 통한 추상화

공유 모듈(예: :core:navigation)에 내비게이션을 위한 인터페이스를 정의합니다.

// in :core:navigation
interface HomeNavigator {
    fun navigateToHome(context: Context)
}
:app 모듈은 이 인터페이스의 구현체를 제공합니다.
// in :app
class HomeNavigatorImpl @Inject constructor() : HomeNavigator {
    override fun navigateToHome(context: Context) {
        // Start HomeActivity from :feature:home
        context.startActivity(Intent(context, HomeActivity::class.java))
    }
}
그리고 :feature:login 모듈은 이 인터페이스를 주입받아 사용합니다.
// in :feature:login ViewModel
class LoginViewModel @Inject constructor(private val homeNavigator: HomeNavigator) {
    fun onLoginSuccess() {
        // ...
        homeNavigator.navigateToHome(context)
    }
}
이 방식은 컴파일 타임에 타입 체크가 가능하다는 장점이 있지만, 새로운 내비게이션 경로가 추가될 때마다 인터페이스와 구현체를 수정해야 하는 번거로움이 있습니다.

5. 주의사항 및 고급 전략

모듈화는 수많은 이점을 제공하지만, '은총알'은 아닙니다. 잘못된 모듈화는 오히려 프로젝트를 더 복잡하게 만들 수 있습니다. 성공적인 모듈화를 위해 몇 가지 주의사항과 전략을 알아두어야 합니다.

5.1. 순환 의존성(Circular Dependency)의 함정

모듈 A가 모듈 B를 의존하고, 동시에 모듈 B가 모듈 A를 의존하는 상황을 순환 의존성이라고 합니다. 이는 Gradle 빌드를 실패시키는 치명적인 문제입니다. 순환 의존성이 발생했다는 것은 모듈 분리가 잘못되었다는 신호입니다. 해결책은 두 모듈이 공통으로 필요로 하는 기능을 새로운 제3의 모듈로 추출하고, 두 모듈이 모두 이 새로운 모듈을 의존하도록 구조를 변경하는 것입니다.

5.2. '갓 모듈(God Module)'을 경계하라

모든 공통 기능을 :core:common과 같은 단일 모듈에 몰아넣다 보면, 이 모듈이 점점 비대해져 그 자체로 또 다른 모놀리식이 되는 '갓 모듈' 현상이 발생할 수 있습니다. :core 모듈이 너무 커진다면, 그 책임을 더 작은 단위로 분리하는 것을 고려해야 합니다. (예: :core -> :core:ui, :core:network, :core:testing)

5.3. 모놀리식 프로젝트의 점진적 리팩토링

이미 거대해진 모놀리식 프로젝트를 한 번에 모듈화하는 것은 매우 위험하고 비용이 큰 작업입니다. 대신 점진적인 접근 방식을 취해야 합니다.

  1. 1단계: 유틸리티 추출 - 가장 먼저, 프로젝트 전반에서 사용되는 유틸리티 클래스나 확장 함수들을 :core:common 모듈로 분리합니다. 이는 의존성 트리의 가장 아래에 위치하므로 다른 코드에 미치는 영향이 적습니다.
  2. 2단계: 데이터 계층 분리 - Repository, API 서비스, DB 관련 코드들을 :data 모듈로 분리합니다.
  3. 3단계: 기능 단위 분리 - 가장 독립적인 기능 하나를 선택하여 (예: 설정 화면) 새로운 :feature:settings 모듈로 분리하는 작업을 시작합니다. 이 과정에서 필요한 의존성을 식별하고, 모듈 간 통신 방법을 정립하는 경험을 쌓습니다.
  4. 4단계: 반복 - 하나의 기능 분리가 성공하면, 다른 기능들도 동일한 방식으로 점진적으로 분리해 나갑니다.

결론: 모듈화는 여정이다

안드로이드 모듈화는 단순히 코드를 분리하는 기술적인 작업을 넘어, 프로젝트의 성장과 변화에 유연하게 대응할 수 있는 견고한 구조를 설계하는 과정입니다. 이는 개발 속도, 코드 품질, 팀의 협업 방식에 근본적인 개선을 가져다줍니다. 물론, 초기 설정의 복잡성이나 모듈 분리 기준에 대한 고민과 같은 단기적인 비용이 발생할 수 있습니다. 하지만 장기적인 관점에서 볼 때, 잘 설계된 모듈화 아키텍처는 애플리케이션의 수명을 연장하고 지속적인 성공을 가능하게 하는 가장 확실한 투자일 것입니다. 모듈화는 한 번에 끝나는 목표가 아니라, 프로젝트가 진화함에 따라 끊임없이 다듬고 개선해 나가야 하는 여정임을 기억해야 합니다.


0 개의 댓글:

Post a Comment