일반적인 모듈화 패턴

모든 프로젝트에 맞는 하나의 모듈화 전략은 없습니다. Gradle의 유연한 특성으로 인해 프로젝트를 구성하는 방법에는 제약이 거의 없습니다. 이 페이지에서는 다중 모듈 Android 앱을 개발할 때 사용할 수 있는 일반적인 규칙과 공통 패턴을 간략히 설명합니다.

높은 응집력 및 낮은 결합력 원칙

모듈식 코드베이스를 특징짓는 한 가지 방법은 결합력응집력 속성을 사용하는 것입니다. 결합력은 모듈이 서로 종속된 정도를 측정합니다. 이 맥락에서 응집력은 단일 모듈의 요소가 기능적으로 관련된 방식을 측정합니다. 일반적으로 결합력은 낮추고 응집력은 높여야 합니다.

  • 결합력이 낮다는 것은 모듈이 최대한 서로 독립적이어야 한다는 의미입니다. 그러면 한 모듈의 변경사항이 다른 모듈에 미치는 영향이 없거나 최소화됩니다. 모듈은 다른 모듈의 내부 작동을 알 수 없어야 합니다.
  • 응집력이 높다는 것은 모듈이 시스템 역할을 하는 코드 모음으로 구성되어야 함을 의미합니다. 모듈은 맡은 일이 명확히 규정되어 있고 특정 도메인 지식의 범위를 벗어나지 않아야 합니다. 샘플 eBook 애플리케이션을 생각해 보세요. 동일한 모듈에 도서와 결제 관련 코드를 함께 사용하는 것은 부적절할 수 있습니다. 두 코드가 서로 다른 두 개의 기능 도메인이기 때문입니다.

모듈 유형

모듈을 구성하는 방법은 주로 앱 아키텍처에 따라 다릅니다. 다음은 권장 앱 아키텍처를 따르면서 앱에 도입할 수 있는 일반적 유형의 모듈입니다.

데이터 모듈

데이터 모듈에는 일반적으로 저장소, 데이터 소스, 모델 클래스가 포함되어 있습니다. 데이터 모듈의 세 가지 주된 역할은 다음과 같습니다.

  1. 특정 도메인의 모든 데이터 및 비즈니스 로직 캡슐화: 각 데이터 모듈은 특정 도메인을 나타내는 데이터를 처리해야 합니다. 관련이 있는 데이터라면 다양한 유형의 데이터를 처리할 수 있습니다.
  2. 저장소를 외부 API로 노출: 데이터 모듈의 공개 API는 데이터를 앱의 나머지 부분에 노출하는 일을 담당하기 때문에 저장소여야 합니다.
  3. 외부로부터 모든 구현 세부정보 및 데이터 소스 숨기기: 데이터 소스는 같은 모듈의 저장소에서만 액세스 가능해야 합니다. 외부에는 공개되지 않습니다. Kotlin의 private 또는 internal 공개 상태 키워드를 사용하여 데이터 소스를 숨길 수 있습니다.
그림 1. 샘플 데이터 모듈 및 콘텐츠

기능 모듈

기능은 일반적으로 화면 또는 밀접하게 관련된 일련의 화면(예: 가입 또는 결제 흐름)에 해당하는 독립적인 앱 기능을 의미합니다. 앱에 하단 탐색 메뉴가 있는 경우 각 대상이 기능일 가능성이 높습니다.

그림 2. 이 애플리케이션의 각 탭은 기능으로 정의할 수 있음

기능은 앱의 화면 또는 대상과 연결됩니다. 따라서 로직과 상태를 처리하기 위한 UI와 ViewModel이 연결될 가능성이 높습니다. 단일 기능이 단일 보기나 단일 탐색 대상으로 제한될 필요는 없습니다. 기능 모듈은 데이터 모듈에 종속됩니다.

그림 3. 샘플 기능 모듈 및 콘텐츠

앱 모듈

앱 모듈은 애플리케이션의 진입점입니다. 앱 모듈은 기능 모듈에 종속되며 일반적으로 루트 탐색을 제공합니다. 빌드 변형을 사용하면 단일 앱 모듈을 다양한 바이너리로 컴파일할 수 있습니다.

그림 4. *데모(demo)* 및 *전체(full)* 제품 버전 모듈의 종속 항목 그래프

앱이 자동차, 웨어러블 기기 또는 TV와 같은 여러 기기 유형을 타겟팅하는 경우 기기별로 앱 모듈을 정의하세요. 이는 플랫폼별 종속 항목을 구분하는 데 도움이 됩니다.

그림 5. Wear 앱 종속 항목 그래프

일반 모듈

일반 모듈(핵심 모듈이라고도 함)에는 다른 모듈에서 자주 사용하는 코드가 포함됩니다. 일반 모듈은 중복성을 줄이는 역할을 하며, 앱 아키텍처의 특정 레이어를 나타내지는 않습니다. 다음은 일반적인 모듈의 예입니다.

  • UI 모듈: 앱에서 맞춤 UI 요소를 사용하거나 정교한 브랜딩을 사용하는 경우 모든 기능을 재사용할 수 있도록 위젯 컬렉션을 하나의 모듈로 캡슐화하는 것이 좋습니다. 이렇게 하면 서로 다른 기능에서 UI를 일관되게 만들 수 있습니다. 예를 들어 테마 설정이 일원화되어 있다면 리브랜딩이 발생할 때 골치 아픈 리팩터링 작업을 피할 수 있습니다.
  • 애널리틱스 모듈: 일반적으로 추적은 소프트웨어 아키텍처에 대한 고려 없이 비즈니스 요구사항에 따라 정해집니다. 애널리틱스 추적기를 서로 관련 없는 여러 구성요소에 사용하는 경우가 많으며, 이 경우 전용 애널리틱스 모듈을 사용하는 것이 좋습니다.
  • 네트워크 모듈: 많은 모듈에 네트워크 연결이 필요한 경우 http 클라이언트 제공 전용 모듈을 사용하는 것이 좋습니다. 이는 클라이언트에 맞춤 구성이 필요할 때 특히 유용합니다.
  • 유틸리티 모듈: 도우미라고도 하는 유틸리티는 일반적으로 애플리케이션 전체에서 재사용되는 작은 코드입니다. 유틸리티의 예로는 테스트 도우미, 통화 형식 지정 함수, 이메일 검사기 또는 맞춤 연산자가 있습니다.

테스트 모듈

테스트 모듈은 테스트용으로만 사용되는 Android 모듈입니다. 테스트 모듈에는 테스트 실행에만 필요하고 애플리케이션 런타임에는 필요하지 않은 테스트 코드, 테스트 리소스, 테스트 종속 항목이 포함됩니다. 테스트 모듈은 기본 애플리케이션과 테스트용 코드가 분리되도록 생성되므로 모듈 코드를 더 쉽게 관리하고 유지할 수 있습니다.

테스트 모듈 사용 사례

다음 예는 테스트 모듈을 구현하는 것이 좋은 상황을 보여줍니다.

  • 테스트 코드 공유: 프로젝트에 여러 모듈이 있고 일부 테스트 코드가 둘 이상의 모듈에 적용되는 경우, 테스트 모듈을 만들어서 코드를 공유할 수 있습니다. 이렇게 하면 중복을 줄이고 테스트 코드를 더 쉽게 유지할 수 있습니다. 공유된 테스트 코드에는 맞춤 어설션이나 매처와 같은 유틸리티 클래스 또는 함수와 시뮬레이션된 JSON 응답과 같은 테스트 데이터가 포함될 수 있습니다.

  • 더 깔끔한 빌드 구성: 테스트 모듈은 자체 build.gradle 파일을 가질 수 있기 때문에 빌드 구성이 더 깔끔해집니다. 테스트에만 관련된 구성으로 앱 모듈의 build.gradle 파일을 복잡하게 만들지 않아도 됩니다.

  • 통합 테스트: 테스트 모듈은 사용자 인터페이스, 비즈니스 로직, 네트워크 요청, 데이터베이스 쿼리 등 앱의 여러 부분 간의 상호작용을 테스트하는 데 사용되는 통합 테스트를 저장하는 용도로 사용할 수 있습니다.

  • 대규모 애플리케이션: 테스트 모듈은 특히 코드베이스가 복잡하고 모듈이 여러 개 있는 대규모 애플리케이션에서 유용합니다. 이때 테스트 모듈을 사용하면 코드 구성 및 관리 용이성을 개선하는 데 도움이 됩니다.

그림 6. 테스트 모듈은 서로 종속되는 모듈을 격리하는 데 사용할 수 있음

모듈 간 통신

모듈은 완전히 분리된 경우는 거의 없으며 다른 모듈에 의존해 서로 통신하는 경우가 많습니다. 모듈이 함께 작동하고 정보를 자주 교환하는 경우에도 결합력을 낮게 유지하는 것이 중요합니다. 경우에 따라 두 가지 모듈 간의 직접 통신은 아키텍처 제약 조건의 경우에서처럼 바람직하지 않습니다. 두 가지 모듈 간의 직접 통신은 순환 종속 항목 등으로 인해 불가능할 수도 있습니다.

그림 7. 모듈 간의 직접적인 양방향 통신은 순환 종속 항목 때문에 불가능함. 다른 두 독립 모듈 간의 데이터 흐름을 조정하려면 중재 모듈 필요

이 문제를 극복하기 위해 두 개의 다른 모듈 간을 중재하는 세 번째 모듈을 둘 수 있습니다. 중재 모듈은 두 모듈의 메시지를 수신 대기하고 필요에 따라 메시지를 전달할 수 있습니다. 샘플 앱에서는 이벤트가 다른 기능에 속하는 별도의 화면에서 시작되었더라도 구매할 도서가 결제 화면에 인식되어야 합니다. 이 경우 중재 모듈은 탐색 그래프를 소유한 모듈(일반적으로 앱 모듈)입니다. 이 예에서는 Navigation 구성요소를 사용하여 탐색을 통해 홈 기능의 데이터를 결제 기능으로 전달합니다.

navController.navigate("checkout/$bookId")

결제 대상은 도서 ID를 인수로 수신하고, 이를 도서 정보를 가져오는 데 사용합니다. 개발자는 저장된 상태 핸들을 사용하여 대상 기능의 ViewModel 내부에서 탐색 인수를 검색할 수 있습니다.

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

객체를 탐색 인수로 전달해서는 안 됩니다. 대신 데이터 영역에서 원하는 리소스에 액세스하고 로드하기 위해 기능에서 사용할 수 있는 간단한 ID를 사용합니다. 이렇게 하면 결합력은 낮게 유지하고 단일 소스 저장소 원칙을 위반하지 않습니다.

아래 예에서 두 기능 모듈은 동일한 데이터 모듈에 종속됩니다. 이렇게 하면 중재자 모듈이 전달해야 하는 데이터 양을 최소화하고 모듈 간의 결합력을 낮게 유지할 수 있습니다. 모듈은 객체를 전달하는 대신 기본 ID를 교환하고 공유 데이터 모듈에서 리소스를 로드해야 합니다.

그림 8. 공유 데이터 모듈을 사용하는 두 개의 기능 모듈

종속 항목 역전

종속 항목 역전이란 추상화가 구체적인 구현으로부터 분리되도록 코드를 구성하는 경우를 가리킵니다.

  • 추상화: 애플리케이션의 구성요소 또는 모듈이 서로 상호작용하는 방식을 정의하는 계약입니다. 추상화 모듈은 시스템의 API를 정의하고 인터페이스와 모델을 포함합니다.
  • 구체적인 구현: 추상화 모듈에 종속되며 추상화의 동작을 구현하는 모듈입니다.

추상화 모듈에 정의된 동작을 사용하는 모듈은 특정 구현이 아닌 추상화 자체에만 종속되어야 합니다.

그림 9. 상위 모듈이 하위 모듈에 직접 종속되는 대신 상위 모듈과 구현 모듈이 추상화 모듈에 종속됩니다.

작동하려면 데이터베이스가 필요한 기능 모듈이 있다고 가정해 보겠습니다. 이때 데이터베이스는 로컬 Room 데이터베이스이든 원격 Firestore 인스턴스이든 구현 방식이 무엇이어도 상관없습니다. 데이터베이스가 애플리케이션 데이터를 저장하고 읽을 수 있기만 하면 됩니다.

이를 위해 기능 모듈은 특정 데이터베이스 구현이 아닌 추상화 모듈에 종속됩니다. 이 추상화는 앱의 데이터베이스 API를 정의합니다. 즉, 데이터베이스와 상호작용하는 방법에 관한 규칙을 설정합니다. 이로써 기능 모듈은 데이터베이스의 기본 구현 세부정보를 알 필요 없이 어떠한 데이터베이스도 사용할 수 있습니다.

구체적인 구현 모듈은 추상화 모듈에 정의된 API의 실제 구현을 제공합니다. 이를 위해 구현 모듈도 추상화 모듈에 종속됩니다.

종속 항목 삽입

기능 모듈이 구현 모듈에 어떻게 연결되는지 궁금하실 텐데요. 정답은 바로 종속 항목 삽입입니다. 기능 모듈은 필요한 데이터베이스 인스턴스를 직접 만들지 않습니다. 대신 어떤 종속 항목이 필요한지 지정합니다. 이러한 종속 항목은 외부에서, 많은 경우에 앱 모듈에서 제공됩니다.

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

이점

API와 API의 구현을 분리하는 방식에는 다음과 같은 이점이 있습니다.

  • 상호 교환성: API와 구현 모듈이 명확히 분리되면 동일한 API의 여러 구현을 개발한 다음 API를 사용하는 코드를 변경하지 않고도 구현을 서로 전환할 수 있습니다. 이는 컨텍스트에 따라 서로 다른 기능이나 동작을 제공하려는 경우에 특히 유용합니다. 일례로 테스트용 모의 구현과 프로덕션용 실제 구현을 들 수 있습니다.
  • 분리: 추상화를 사용하는 모듈이 특정 기술에 종속되지 않습니다. 나중에 데이터베이스를 Room에서 Firestore로 변경할 경우에도 작업을 담당하는 특정 모듈(구현 모듈)만 변경하면 되고 데이터베이스의 API를 사용하는 다른 모듈은 영향을 받지 않습니다.
  • 테스트 가능성: API와 API의 구현을 분리하면 테스트가 크게 용이해집니다. 테스트 사례를 API 계약에 대해 작성할 수 있고, 여러 구현을 사용하여 모의 구현을 비롯한 다양한 시나리오와 특수한 사례를 테스트할 수 있습니다.
  • 빌드 성능 개선: API와 API의 구현을 서로 다른 모듈로 분리하면 구현 모듈이 변경되어도 빌드 시스템이 API 모듈에 종속된 모듈을 재컴파일하지 않습니다. 따라서 빌드 시간이 단축되고, 빌드 시간이 중요한 대규모 프로젝트의 생산성이 향상됩니다.

분리해야 하는 경우

다음과 같은 경우에 API와 API의 구현을 분리하는 것이 좋습니다.

  • 다양한 기능: 시스템의 여러 부분을 다양한 방식으로 구현할 수 있는 경우, API를 명확하게 구현하면 여러 구현을 서로 전환할 수 있습니다. 그 예로 OpenGL 또는 Vulkan을 사용하는 렌더링 시스템이나 Play 또는 사내 결제 API를 사용하는 결제 시스템을 들 수 있습니다.
  • 여러 애플리케이션: 여러 플랫폼에서 공통된 기능으로 작동하는 여러 애플리케이션을 개발하는 경우 공통 API를 정의하고 플랫폼별로 특정 구현을 개발할 수 있습니다.
  • 독립적인 팀: 여러 개발자와 여러 팀이 코드베이스의 서로 다른 부분을 동시에 작업할 수 있습니다. 개발자는 API 계약을 이해하고 올바르게 사용하는 데 집중할 수 있고, 다른 모듈의 구현 세부정보에는 신경 쓸 필요가 없습니다.
  • 대규모 코드베이스: 코드베이스가 크거나 복잡한 경우 API를 구현으로부터 분리하면 코드를 더 쉽게 관리할 수 있습니다. 이를 통해 코드베이스를 더 세분화되고 이해하기 쉬우며 유지보수가 용이한 유닛으로 세분화할 수 있습니다.

구현 방법

종속 항목 역전을 구현하려면 다음 단계를 따르세요.

  1. 추상화 모듈 만들기: 이 모듈은 기능의 동작을 정의하는 API(인터페이스 및 모델)를 포함해야 합니다.
  2. 구현 모듈 만들기: 구현 모듈은 API 모듈에 종속되고 추상화의 동작을 구현해야 합니다.
    상위 모듈이 하위 모듈에 직접 종속되는 대신 상위 모듈과 구현 모듈이 추상화 모듈에 종속됩니다.
    그림 10. 구현 모듈은 추상화 모듈에 종속됩니다.
  3. 상위 모듈을 추상화 모듈에 종속: 모듈이 특정 구현에 직접 종속되는 대신 추상화 모듈에 종속되도록 합니다. 상위 모듈은 구현 세부정보를 알 필요가 없으며 계약(API)만 있으면 됩니다.
    상위 모듈은 구현이 아닌 추상화에 종속됩니다.
    그림 11. 상위 모듈은 구현이 아닌 추상화에 종속됩니다.
  4. 구현 모듈 제공: 마지막으로, 종속 항목의 실제 구현을 제공해야 합니다. 구체적인 구현은 프로젝트 설정에 따라 다르지만, 일반적으로 앱 모듈에 구현하는 것이 좋습니다. 구현을 제공하려면 구현을 선택한 빌드 변형 또는 테스트 소스 세트의 종속 항목으로 지정합니다.
    앱 모듈은 실제 구현을 제공합니다.
    그림 12. 앱 모듈은 실제 구현을 제공합니다.

일반 권장사항

처음에 언급했듯이 다중 모듈 앱을 개발할 수 있는 하나의 올바른 방법은 없습니다. 많은 소프트웨어 아키텍처가 있는 것처럼 앱을 모듈화하는 방법도 다양합니다. 그렇지만 다음과 같은 일반 권장사항은 코드의 판독과 유지관리 및 테스트 가능성을 높여주는 데 도움이 됩니다.

구성을 일관되게 유지

모든 모듈에는 구성 오버헤드가 발생합니다. 모듈 수가 특정 기준점에 도달하면 일관된 구성을 관리하기가 어렵습니다. 예를 들어 모듈에서 동일한 버전의 종속 항목을 사용하는 것이 중요합니다. 단지 종속 항목 버전을 늘리기 위해 많은 수의 모듈을 업데이트해야 하는 경우 많은 노력이 들어갈 뿐 아니라 실수의 가능성도 발생합니다. 이 문제를 해결하려면 Gradle 도구 중 하나를 사용하여 구성을 중앙 집중화하면 됩니다.

  • 버전 카탈로그는 동기화 중에 Gradle에서 생성된 종속 항목의 유형 안전 목록입니다. 이는 모든 종속 항목을 선언할 수 있는 중앙 위치로, 프로젝트의 모든 모듈에서 사용 가능합니다.
  • 규칙 플러그인을 사용하여 모듈 간에 빌드 로직을 공유합니다.

가능한 한 노출 최소화

모듈의 공개 인터페이스는 최소화하고 필수 부분만 노출해야 합니다. 구현 세부정보가 외부에 유출되면 안 됩니다. 모든 범위를 가능한 한 최소 수준으로 지정합니다. Kotlin의 private 또는 internal 공개 상태 범위를 사용하여 선언을 모듈 비공개로 설정합니다. 모듈에서 종속 항목을 선언할 때는 api보다 implementation을 사용하는 것이 좋습니다. 전자는 모듈 소비자에게 전이 종속 항목을 노출합니다. 구현을 사용하면 다시 빌드해야 하는 모듈의 수가 줄어들기 때문에 빌드 시간을 개선할 수 있습니다.

Kotlin 및 Java 모듈 선호

Android 스튜디오에서 지원하는 세 가지 필수 모듈 유형은 다음과 같습니다.

  • 앱 모듈은 애플리케이션의 진입점입니다. 앱 모듈은 소스 코드, 리소스, 애셋 및 AndroidManifest.xml을 포함할 수 있습니다. 앱 모듈의 출력은 Android App Bundle(AAB) 또는 Android 애플리케이션 패키지(APK)입니다.
  • 라이브러리 모듈에는 앱 모듈과 동일한 콘텐츠가 포함되어 있습니다. 라이브러리 모듈은 다른 Android 모듈에 종속 항목으로 사용됩니다. 라이브러리 모듈의 출력은 앱 모듈과 구조적으로 동일한 Android 보관 파일(AAR)입니다. 하지만 이는 나중에 다른 모듈에서 종속 항목으로 사용할 수 있는 Android 보관 파일(AAR)로 컴파일됩니다. 라이브러리 모듈을 사용하면 여러 앱 모듈 간에 동일한 로직과 리소스를 캡슐화하고 재사용할 수 있습니다.
  • Kotlin 및 Java 라이브러리에는 Android 리소스, 애셋 또는 매니페스트 파일이 포함되지 않습니다.

Android 모듈에는 오버헤드가 발생하므로 가능하면 Kotlin 또는 Java 종류를 사용하는 것이 좋습니다.