[Android] modularization patterns

chaeny·2024년 12월 25일
0

Gradle의 유연한 특정으로 인해 프로젝트를 구성하는 방법에는 제약이 거의 없고 모든 프로젝트에 맞는 하나의 모듈화 전략은 없다.

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

모듈식 코드베이스를 특정짓는 한 가지 방법은 결합력 및 응집력 속성을 사용하는 것이다.

결합력 ⬇

  • 모듈이 서로 종속된 정도
  • 결합력이 낮다는 것은 모듈이 최대한 서로 독립적이어야 한다
  • 한 모듈의 변경사항이 다른 모듈에 미치는 영향이 없거나 최소화된다.
  • 모듈은 다른 모듈의 내부 작동을 알 수 없어야 한다.

    각 모듈은 자신의 역할에만 집중하고, 다른 모듈의 세부 구현에 의존하지 않는다

응집력 ⬆

  • 단일 모듈의 요소가 기능적으로 관련된 방식
  • 응집력이 높다는 것은 모듈이 시스템 역할을 하는 코드 모음으로 구성되어야 한다.
  • 모듈은 맡은 일이 명확히 규정 되어 있고 특정 도메인 지식의 범위를 벗어나지 않아야 한다.
  • 샘플 eBook app을 예를 든다면, 동일한 모듈에 도서와 결제 관련 코드를 함께 사용하는 것은 부적절할 수 있다. 두 코드가 서로 다른 두개의 기능 도메인이기 때문이다

    모듈 내부의 모든 구성 요소가 서로 긴밀히 관련되어 있다
    모듈이 하나의 명확한 역할을 수행한다. 단일 책임 원칙을 따른다.

두 모듈이 서로에 관한 지식에 크게 의존하는 경우 두 모듈이 실제로는 하나의 시스템처럼 작동해야 함을 나타내는 유용한 신호일 수도 있다.
반대로 모듈의 두 부분이 서로 자주 상호작용하지 않는다면 별도의 모듈이어야 할 수 있다

모듈 유형

데이터 모듈

  • 데이터 모듈에는 repository, data source, model 클래스가 포함되어 있다

1 . 특정 도메인의 모든 데이터 및 비즈니스 로직 캡슐화

  • 각 데이터 모듈은 특정 도메인을 나타내는 데이터를 처리해야 한다.
  • 관련이 있는 데이터라면 다양한 유형의 데이터를 처리할 수 있다

2 . repository를 외부 API로 노출

  • 데이터 모듈의 공개 API는 데이터를 앱의 나머지 부분에 노출하는 일을 담당하기 때문에 repository여야 한다.

3 . 외부로부터 모든 구현 세부정보 및 데이터 소스 숨기기

  • 데이터 소스는 같은 모듈의 저장소에서만 액세스 가능해야 한다.
  • 외부에는 공개되지 않는다.
  • Kotlin의 private 또는 internal 공개 상태 키워드를 사용하여 데이터 소스를 숨길 수 있다.

데이터 모듈이란? 🤔
데이터 모듈은 앱의 데이터를 관리하고 처리하는 계층이다.
이 계층은 특정 도메인에 대한 데이터를 캡슐화하고, 이를 외부로 노출하거나 숨기는 역할을 한다

  1. 특정 도메인의 모든 데이터와 비즈니스 로직 캡슐화
  • 데이터 모듈은 특정 도메인(예: 책, 리뷰, 결제 등)의 데이터를 처리하는 모든 작업(읽기, 쓰기, 삭제 등)을 담당한다.
  • :data:books 모듈은 "책" 도메인의 데이터를 담당한다.
    이 모듈에는 로컬 데이터 소스(BooksLocalDataSource), 원격 데이터 소스(BooksRemoteDataSource), 데이터 모델(BookDbModel, BookApiModel)이 포함된다.
  • 각 데이터 모듈(:data:books, :data:reviews, :data:payments)은 자신만의 데이터 소스와 비즈니스 로직(repository?)을 캡슐화한다. 상위 기능 모듈(:feature:home, :feature:reviews, :feature:checkout)은 이 데이터 모듈에 의존하여 데이터를 요청하거나 처리한다.
  1. Repository를 외부 API로 노출
  • 데이터 모듈에서 외부로 노출되는 유일한 진입점은 Repository다.
  • Repository는 데이터를 가져오거나 저장하는 방법을 정의하며, 내부 데이터 소스(Local/Remote 등)는 숨긴다. 예를 들어, BooksRepository는 BooksLocalDataSource나 BooksRemoteDataSource를 활용해 데이터를 처리하지만, 외부에서는 이런 세부 사항을 알 필요가 없다.
  • 이미지의 Public 영역(BooksRepository, ReviewsRepository, PaymentRepository)은 외부 기능 모듈에서 접근 가능한 부분이다.
    기능 모듈은 Repository를 통해 데이터에 접근하며, 데이터 소스(Local/Remote)는 Internal로 숨겨져 있다.
  1. 외부로부터 모든 구현 세부정보 및 데이터 소스 숨기기
  • 데이터 모듈 내부의 세부 구현(Local, Remote 등)은 외부에서 접근할 수 없도록 숨겨야 한다.
  • Kotlin의 private 또는 internal 키워드를 사용해 데이터 소스를 캡슐화한다. 외부에서는 오직 Repository만 접근할 수 있다.
  • BooksLocalDataSource와 BooksRemoteDataSource는 :data:books 모듈 내부에서만 사용된다. BookApiModel 등도 외부로 노출되지 않고, 모듈 내에서만 사용된다.

기능 모듈

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

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

기능 모듈이란? 🤔 애플리케이션의 특정 기능을 독립적으로 캡슐화한 것
하나의 화면이나 밀접하게 연결된 화면들(ex. 결제 흐름, 로그인 흐름)을 하나의 기능으로 보고, 이를 별도의 모듈로 분리한다

  • 앱의 화면과 연결
    각 기능 모듈은 특정 화면(UI)이나 화면 그룹(ViewModel 및 UI 상태 관리 포함)을 담당한다. 예를 들어, 홈 화면(HomeScreen)이나 리뷰 화면(ReviewsScreen)이 각각 하나의 기능 모듈이 된다.
  • 로직과 상태 처리
    각 기능 모듈은 화면과 관련된 로직(ViewModel) 및 상태(UIState)를 관리한다. 이 상태는 UI에 표시될 데이터를 포함하거나, 화면의 동작을 제어한다.
  • 데이터 모듈에 종속
    기능 모듈은 데이터를 처리하기 위해 데이터 모듈에 의존한다. 데이터 모듈에서 필요한 정보를 가져오거나, 데이터를 저장한다. 데이터 모듈과의 협업을 통해 필요한 데이터만 가져와 로직과 UI를 처리한다

앱 모듈

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

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

앱모듈이란? 🤔
앱 실행 시 가장 먼저 로드되는 모듈이며, 애플리케이션의 전체 구조를 관리하고 다른 기능 모듈들을 통합한다

  • 기능 모듈에 종속됨 (모듈화된 확장성)
    여러 기능 모듈(:feature:home, :feature:reviews)을 종속적으로 연결하여 하나의 애플리케이션으로 묶어준다.
    각 기능 모듈은 독립적으로 개발되지만, 앱 모듈을 통해 하나의 완전한 앱이 된다.
    특정 기능만 빌드에 포함하거나 배포할 수 있으므로 불필요한 기능이나 데이터가 앱에 포함되지 않는다. (앱 크기 최적화 가능)
  • 루트 탐색 제공 (탐색 흐름을 관리)
    앱 모듈은 애플리케이션의 탐색 경로(네비게이션 흐름)를 설정한다.
    예를 들어, 앱의 홈 화면에서 리뷰 화면으로 이동하거나 책 데이터를 검색하는 흐름이 앱 모듈에서 정의된다.
  • 빌드 변형 가능 (여러 버전의 앱을 지원)
    빌드 변형(Build Variants): 하나의 앱 모듈을 사용해 다양한 버전의 앱(데모 버전, 전체 버전)을 생성할 수 있다.
    데모 앱은 기본 기능만 포함하고, full 앱은 모든 기능을 포함하는 방식으로 구성된다.
    ex) 게임에서 무료 버전(광고 포함)과 유료 버전(모든 기능 제공)으로 구분 가능

일반 모듈 (핵심 모듈)

다른 모듈에서 자주 사용하는 코드가 포함된다.
중복성을 줄이는 역할을 하며 앱 아키텍처의 특정 레이어를 나타내지는 않는다.

UI 모듈

  • 앱에서 맞춤 UI 요소를 사용하거나 정교한 브랜딩을 사용하는 경우 모든 기능을 재사용할 수 있도록 위젯 컬렉션을 하나의 모듈로 캡슐화하는 것이 좋다.
  • 이렇게 하면 서로 다른 기능에서 UI를 일관되게 만들 수 있다.
  • 예를 틀어 테마 설정이 일원화되어 있다면 리브랜딩이 발생할 때 골치 아픈 리책토링 작업을 피할 수 있다.

애널리틱스 모듈

  • 일반적으로 추적은 소프트웨어 아키텍처에 대한 고려 없이 비즈니스 요구사항에 따라 정해진다.
  • 애널리틱스 추적기를 서로 관련 없는 여러 구성요소에 사용하는 경우가 많으며, 이 경우 전용 애널리틱스 모듈을 사용하는 것이 좋다.

네트워크 모듈

  • 많은 모듈에 네트워크 연결이 필요한 경우 http 클라이언트 제공 전용 모듈을 사용하는 것이 좋다.
  • 이는 클라이언트 맞춤 구성이 필요할 때 유용하다.

유틸리티 모듈

  • 도우미라고도 하는 유틸리티는 일반적으로 애플리케이션 전체에서 재사용되는 작은 코드이다.
  • 예를 들면 테스트 도우미, 통화 형식 지정 함수, 이메일 검사기 또는 맞춤 연산자가 있다.

일만 모듈이란 ? 🤔
앱의 여러 부분에서 자주 사용하는 공통 코드를 모아놓은 모듈이다.
반복적으로 사용되는 기능이나 코드를 한 곳에 모아서 중복을 줄이고 유지보수를 쉽게 만드는 역할을 한다

  • UI 모듈
    UI(사용자 인터페이스) 요소를 모아놓은 모듈
    앱 전체에 적용되는 테마, 버튼, 다이얼로그 등을 이 모듈에 저장한다
    UI가 한 곳에서 관리되므로, 디자인 변경 시 한 번만 수정하면 전체 앱에 반영돤다.
  • 애널리틱스 모듈
    사용자의 행동 데이터를 추적하는 코드(로그, 이벤트 기록 등)를 모아놓은 모듈
    어떤 화면이 가장 자주 사용되는지 기록하거나, 사용자가 버튼을 클릭한 횟수를 추적하며 코드를 한곳에서 관리 할 수 있다
  • 네트워크 모듈
    네트워크 요청(예: 서버에 데이터를 보내거나 받는 코드)을 모아놓은 모듈
    네트워크 설정(예: API 키, 서버 주소)을 한 번에 관리할 수 있어서 유지보수가 쉽다.
    ex. HTTP 요청을 처리하는 코드를 중앙에서 관리
  • 유틸리티 모듈
    앱 전반에서 자주 사용되는 작은 기능들을 모아놓은 모듈
    같은 기능을 여러 번 작성할 필요 없이 재사용 가능하다

테스트 모듈

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

1. 테스트 코드 공유

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

2. 더 깔끔한 빌드 구성

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

3. 통합 테스트

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

4. 대규모 애플리케이션

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

:app 모듈의 androidTest에 테스트 코드가 포함된 형태

  • 모든 테스트 코드가 하나의 큰 모듈 안에 있어서 복잡도가 증가한다
  • 여러 모듈에서 테스트를 공유하려면 코드 중복이 발생할 가능성이 있다

:test:navigation이라는 테스트 모듈이 별도로 생성된 상태

  • :app, :feature:home, :data와 같은 모듈들이 공통으로 사용하는 테스트 코드를 분리하여 중복을 줄인다
  • 테스트 모듈 덕분에 각 기능 모듈의 테스트 코드가 간소화되고 유지보수가 쉬워진다

테스트 모듈이란 ? 🤔 앱의 품질을 보장하기 위한 전용 도구
앱을 테스트하기 위한 코드와 리소스만 따로 분리해서 관리하는 모듈
테스트 실행에만 필요하며, 실제 애플리케이션의 런타임에는 포함되지 않는다.

  • 테스트 코드 공유
    여러 모듈에서 동일한 테스트 코드가 필요할 때, 테스트 모듈에 공통 코드를 모아두면 중복을 줄일 수 있다
    JSON 응답을 시뮬레이션하는 테스트 데이터, 특정 조건을 검사하는 맞춤 함수
  • 더 깔끔한 빌드 구성
    테스트에만 필요한 설정(build.gradle)을 따로 분리해서 관리
  • 통합 테스트
    앱의 여러 부분(예: 화면, 네트워크, 데이터베이스 등)이 제대로 협력하는지 확인하기 위해 전체적인 테스트를 작성할 수 있다.
  • 대규모 애플리케이션 관리
    앱이 복잡하거나 모듈이 많은 경우, 테스트 코드를 모듈로 분리하면 구조화와 관리가 훨씬 쉬워진다.

모듈 간 통신

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

모듈화된 구조에서는 각 모듈이 서로 독립적이지만, 기능을 수행하기 위해 서로 정보를 주고받아야 하는 경우가 많다. 이때 모듈 간의 통신이 필요하다. 그러나 이 과정에서 결합력이 높아지면 유지보수가 어려워지고 코드의 복잡도가 증가한다. 따라서 결합력을 낮게 유지하면서 통신하는 것이 중요하다.

아래 상단의 그림과 같이 :feature:home 모듈과 :feature:checkout 모듈이 서로 직접적으로 통신하려고 하면, 순환 종속성 문제가 발생할 수 있다.

  • feature:home이 :feature:checkout을 알고, 반대로 :feature:checkout도 :feature:home을 알아야 한다
  • 두 모듈이 서로 지나치게 의존하게 되어 코드 변경 시 연쇄적인 수정이 필요하고 결합도가 높아져 코드 유지보수가 어려워진다.

:app 모듈을 통한 간접 통신

  • :app 모듈을 중재자로 사용해, 각 모듈이 :app 모듈과만 통신하게 만든다.
  • :feature:home → :app → :feature:checkout의 방식으로 데이터를 전달한다 반대로 :feature:checkout → :app → :feature:home도 가능하다.
onCheckout(bookId) 호출
:feature:home 모듈에서 사용자가 책을 선택하고 결제로 이동하려고 할 때, 
onCheckout(bookId)를 호출합니다. 이 이벤트는 :app 모듈로 전달됩니다.

navigate("checkout/$bookId") 호출
:app 모듈은 전달받은 bookId를 기반으로 :feature:checkout 모듈에 필요한 데이터를 전달합니다. 
:app 모듈은 Navigation 라이브러리를 사용해 화면을 전환할 수 있습니다.

onPaymentCanceled() 호출
만약 결제 과정에서 사용자가 취소를 선택하면, :feature:checkout 모듈이 onPaymentCanceled() 이벤트를 호출해 
:app 모듈에 알립니다. 이 정보를 :feature:home 모듈에 다시 전달해 홈 화면으로 돌아갑니다.

app 모듈을 통한 간접 통신 장점

  • 결합력 감소
    각 모듈이 서로 직접적으로 의존하지 않고, 중재자인 :app 모듈과만 연결되므로 변경이 발생해도 다른 모듈에 영향을 덜 미친다.
  • 유지보수 용이
    특정 모듈의 변경 사항이 다른 모듈에 연쇄적으로 영향을 미치지 않아 수정 및 확장이 쉽다.
  • 모듈화 강화
    각 모듈이 독립적으로 설계되고 동작하며, :app 모듈은 모듈 간의 데이터를 교환하는 허브 역할만 담당한다.

중재 모듈은 두 모듈의 매시지를 수신 대기하고 필요에 따라 메시지를 전달할 수 있다.
샘플 앱에서는 이벤트가 다른 기능에 속하는 별도의 화면에서 시작되었더라도 구매할 도서가 결제 화면에 인식되어야 한다. 이 경우 중재 모듈은 탐색 그래프를 소유한 모듈(일반적으로 앱모듈)이다.

예제에서는 Navigation 구성 요소를 사용하여 탐색을 통해 홈 기능의 데이터를 결제 기능으로 전달한다.
결제 대상은 도서 ID를 인수로 수신하고, 이를 도서 정보를 가져오는 데 사용한다.
개발자는 저장된 상태 핸들을 사용하여 대상 기능의 ViewModel 내부에서 탐색 인수를 검색할 수 있다.
객체(Book 객체)를 탐색 인수로 전달해서는 안된다. 대신 데이터 영역에서 원하는 리소스에 액세스하고 로드하기 위해 기능에서 사용할 수 있는 간단한 ID를 사용한다.
이렇게 하면 결합력은 낮게 유지하고 단일 소스 저장소 원칙을 위반하지 않는다.

navController.navigate("checkout/$bookId")
// bookId만 전달하며, 객체를 직접 전달하지 않는다
// navController는 :app 모듈의 Navigation 구성요소
class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // bookId를 기준으로 데이터 모듈에서 필요한 데이터를 가져옴
          bookRepository.getBook(bookId)
      }
      …
}

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

:feature:home과 :feature:checkout 모듈이 동일한 데이터 모듈(:data:books)에 의존한다.
이 방식은 중재자 모듈 (:app)이 전달해야 할 데이터 양을 최소화한다.
각 모듈은 객체 대신 ID를 기준으로 필요한 데이터를 직접 로드한다

종속 항목 역전

추상화가 구체적인 구현으로부터 분리되도록 코드를 구성하는 경우

추상화

  • 애플리케이션의 구성요소 또는 모듈이 서로 상호작용하는 방식을 정의하는 계약
  • 추상화 모듈은 시스템의 API를 정의하고 인터페이스와 모델을 포함한다.

구체적인 구현

  • 추상화 모듈에 종속되며 추상화의 동작을 구현하는 모델이다.

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

종속 항목 역전이란? 🤔
상위 모듈(High-level Module)이 하위 모듈(Low-level Module)에 직접 의존하지 않도록 설계하는 것이다.
대신, 추상화를 사용해 두 모듈을 간접적으로 연결한다.
Why? 상위 모듈이 하위 모듈에 직접 의존하면, 하위 모듈의 변경 사항이 상위 모듈에도 영향을 미친다.
이로 인해 코드가 변경에 민감해지고 유지보수가 어렵게 된다.

  • 일반적인 설계
    상위 모듈은 데이터베이스의 구체적인 구현(예: Room DB)이나 API 호출 방식(예: Retrofit)을 직접 알고 있어야 한다.
    만약 하위 모듈의 기술이 바뀌면(예: Room → Firestore), 상위 모듈도 수정이 필요하다.
  • 종속 항목 역전
    상위 모듈은 "무엇(What)"을 해야 하는지(인터페이스나 API 계약)를 알고, 하위 모듈은 "어떻게(How)" 그것을 수행할지를 정의한다. 상위와 하위 모듈이 서로 독립적이므로, 하위 모듈의 구현이 바뀌어도 상위 모듈에는 영향을 미치지 않는다.

    코드 재사용성, 유지보수성, 확장성 개선

Example

작동하려면 데이터베이스가 필요한 기능 모듈이 있다고 가정해 보겠다.
이 때 데이터베이스는 로컬 Room 데이터베이스든 원격 Firestore 인스턴스이든 구현 방식이 무엇이어도 상관없다.
데이터베이스가 애플리케이션 데이터를 저장하고 읽을 수 있기만 하면 된다.
이를 위해 기능 모듈은 특정 데이터베이스 구현이 아닌 추상화 모듈에 종속된다.
이 추상화는 앱의 데이터베이스 API를 정의한다. 즉, 데이터베이스와 상호작용하는 방법에 관한 규칙을 설정한다.
이로써 기능 모듈은 데이터베이스의 기본 구현 세부정보를 알 필요 없이 어떠한 데이터베이스도 사용할 수 있다.
구체적인 구현 모듈은 추상화 모듈에 정의된 API의 실제 구현을 제공한다. 이를 위해 구현 모듈도 추상화 모듈에 종속된다.

  • 앱에서 Room이나 Firestore 같은 데이터베이스 기술을 사용한다고 가정한다면 기능 모듈은 데이터 저장/읽기를 해야 하지만, 어떤 데이터베이스 기술을 사용하는지는 몰라도 된다.
  • 만약 기능 모듈이 직접 Room이나 Firestore 같은 데이터베이스 기술에 의존하면, 데이터베이스를 바꿀 때마다 기능 모듈의 코드도 수정해야 한다. 따라서 해결책으로 추상화 모듈을 사용한다.
  • 데이터베이스와 상호작용하는 공통 API(인터페이스)를 정의한다. DatabaseInterface라는 이름으로 getData(), saveData() 같은 메서드를 정의한다.
  • 기능 모듈은 이 추상화만 의존한다. 즉, DatabaseInterface를 호출하면 데이터를 가져오거나 저장할 수 있음을 알 뿐, 실제 구현은 모른다.
  • 실제 데이터베이스 동작은 구현 모듈이 담당한다. RoomDatabaseImpl은 DatabaseInterface를 구현하여 Room의 동작을 정의한다 FirestoreDatabaseImpl은 DatabaseInterface를 구현하여 Firestore의 동작을 정의한다.
  • 기능 모듈은 구체적인 데이터베이스를 직접 생성하거나 몰라도된다. 대신, 앱 모듈(상위 모듈)이 데이터베이스 구현체를 의존성으로 주입한다.

장점

  • 유연성
    데이터베이스를 Room에서 Firestore로 바꾸더라도 기능 모듈의 코드는 변경할 필요가 없다. 새로운 데이터베이스 기술이 추가되더라도 기존 기능 모듈에 영향을 주지 않는다.
  • 유지보수성
    데이터베이스와 기능 모듈 간의 결합도가 낮아져, 한쪽을 변경해도 다른 쪽에 미치는 영향이 적다.
  • 테스트 가능성
    테스트할 때 실제 데이터베이스를 사용하지 않고 모의(Mock) 구현체를 주입하여 테스트할 수 있다.

종속 항목 삽입

기능 모듈이 구현 모듈에 종목 항목 삽입을 통해 연결된다. 기능 모듈은 필요한 데이터베이스 인스턴스를 직접 만들지 않는다.
대신 어떤 종속 항목이 필요한지 지정한다. 이러한 종속 항목은 외부에서, 많은 경우에 앱 모듈에서 제공된다.

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

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

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

서로 다른 빌드 유형에 대해 다양한 종속 항목을 정의할 수 있다. 대상 예를 들어 출시 빌드는 Firestore 구현, 디버그 빌드는 로컬 Room 데이터베이스를 사용할 수 있고 계측 테스트는 모의 구현을 사용해야 한다.

기능 모듈은 필요한 데이터베이스가 뭐든 상관없고, 데이터만 가져오고 저장할 수 있으면 된다.
즉, 기능 모듈은 데이터베이스와 상호작용해야 한다.
그러나 기능 모듈은 데이터베이스 구현(Room, Firestore 등)을 직접 생성하거나 세부사항을 알 필요가 없다.
앱 모듈(상위 모듈)이 어떤 데이터베이스 구현을 사용할지 결정하고, 기능 모듈에 전달해주는 방식이 필요하다.

빌드 환경에 따라 필요한 데이터베이스를 유연하게 교체하는 데 유용하다

종속 항목 역전 이점

1. 상호 교환성

API와 구현 모듈이 명확히 분리되면 동일한 API의 여러 구현을 개발한 다음 API를 사용하는 코드를 변경하지 않고도 구현을 서로 전환할 수 있다.
이는 컨텍스트에 따라 서로 다른 기능이나 동작을 제공하려는 경우에 특히 유용하다.
일례로 테스트용 모의 구현과 프로덕선용 실제 구현이 있다.

2. 분리

추상화를 사용하는 모듈이 특정 기술에 종속되지 않는다.
나중에 데이터베이스를 Room에서 Firestore로 변경할 경우에도 작업을 담당하는 특정모듈(구현 모듈)만 변경하면 되고 데이터베이스의 API를 사용하는 다른 모듈은 영향을 받지 않는다.

3. 테스트 가능성

API와 API의 구현을 분리하면 테스트가 크게 용이해 진다.
테스트 사례를 API 계약에 대해 작성할 수 있고, 여러 구현을 사용하여 모의 구현을 비롯한 다양한 시나리오와 특수한 사례를 테스트할 수 있다.

4. 빌드 성능 개선

API와 API의 구현을 서로 다른 모듈로 분리하면 구현 모듈이 변경되어도 빌드 시스템이 API 모듈에 종속된 모듈을 재컴파일하지 않는다.
따라서 빌드 시간이 단축되고, 빌드 시간이 중요한 대규모 프로젝트의 생산성이 향상된다.

분리해야 하는 경우

1. 다양한 기능

시스템의 여러 부분을 다양한 방식으로 구현할 수 있는 경우, API를 명확하게 구현하면 여러 구현을 서로 전환할 수 있다.
그 예로 OpenGL 또는 Vulkan을 사용하는 렌더링 시스템이나 Play 또는 사내 결제 API를 사용하는 결제 시스템이 있다.

2. 여러 애플리케이션

여러 플랫폼에서 공통된 기능으로 작동하는 여러 애플리케이션을 개발하는 경우 공통 API를 정의하고 플랫폼별로 특정 구현을 개발할 수 있다.

3. 독립적인 팀

여러 개발자와 여로 팀이 코드베이스의 서로 다른 부분을 동시에 작업할 수 있다.
개발자는 API 계약을 이해하고 올바르게 사용하는 데 집중할 수 있고, 다른 모듈의 구현 세부정보에는 신경 쓸 필요가 없다.

4. 대규모 코드베이스

코드베이스가 크거나 복잡한 경우 API를 구현으로부터 분리하면 코드를 더 쉽게 관리할 수 있다.
이를 통해 코드베이스를 더 세분화되고 이해하기 쉬우며 유지보수가 용이한 유닛으로 세분화할 수 있다.

종속 항목 역전 구현 방법

1. 추상화 모듈 만들기

이 모듈은 기능의 동작을 정의하는 API(인터페이스 및 모델)를 포함해야 한다.

ex) 데이터베이스와 상호작용하는 공통 인터페이스를 정의
이 모듈은 구현 세부사항(Room, Mock 등)을 알지 못하며, 오직 "어떤 동작을 해야 하는가?"만 규정한다

2. 구현 모듈 만들기

구현 모듈은 API 모듈에 종속되고 추상화의 동작을 구현해야 한다.

추상화 모듈(database:api)에 정의된 동작을 실제로 구현합
ex) Room 데이터베이스 구현, 테스트를 위한 Mock 구현 등
이 모듈은 database:api에 종속되며 API를 구현한다.

3. 상위 모듈을 추상화 모듈에 종속

모듈이 특정 구현에 직접 종속되는 대신 추상화 모듈에 종속되도록 한다.
상위 모듈은 구현 세무정보를 알 필요가 없으며 계약(API)만 있으면 된다.

기능 모듈(feature:home)은 특정 구현(Room, Mock)에 종속되지 않고, database:api(추상화된 인터페이스)에만 종속된다.
기능 모듈은 "데이터베이스가 무엇인지 몰라도 되며, 그저 getData()와 saveData() 같은 기능을 호출하면 된다"고 가정한다.
이렇게 하면 기능 모듈은 어떤 데이터베이스 구현이 사용되더라도 수정 없이 그대로 동작할 수 있습니다.

4. 구현 모듈 제공

종속 항목의 실제 구현을 제공해야 한다.
구체적인 구현은 프로젝트 설정에 따라 다르지만, 일반적으로 앱 모듈에 구현하는 것이 좋다
구현을 제공하려면 구현을 선택한 빌드 변형 또는 테스트 소스 세트의 종속 항목으로 저장한다.

실제로 어떤 데이터베이스 구현(Room, Mock 등)을 사용할지는 앱 모듈(app)이 결정한다
앱 모듈(main)은 프로덕션 환경에서 database:implroom(Room 구현)을 주입
테스트 환경(androidTest)에서는 database:implmock(Mock 구현)을 주입
이렇게 환경에 따라 적절한 구현체를 선택해서 주입하는 것이 종속 항목 삽입(Dependency Injection)이다.

권장사항

1. 구성을 일관되게 유지

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

멀티 모듈 프로젝트에서는 각 모듈마다 Gradle 설정 파일(build.gradle 또는 build.gradle.kts)이 있다.
각 모듈에서 사용하는 종속 항목(라이브러리), 플러그인 버전, 공통 설정을 따로 정의하게되면 모든 모듈의 구성을 유지보수하기 어려워진다. 예를 들어, 모든 모듈에서 Retrofit을 사용한다고 가정한다면, 어떤 모듈은 2.9.0 버전, 다른 모듈은 2.8.0을 사용하는 경우 충돌될 수 있다
따라서, Gradle의 구성 관리 도구를 사용하여 공통 설정과 종속 항목을 중앙에서 관리하면, 이러한 문제를 해결할 수 있다.

Gradle에서 구성 중앙 집중화 방법

  • buildSrc 디렉터리 사용
    공통 종속 항목과 버전을 관리하는 클래스를 정의
    모든 모듈에서 이 클래스를 참조하여 버전과 라이브러리를 가져옴
  • version catalog (Gradle 7 이상)
    libs.versions.toml 파일을 사용하여 공통 버전을 관리
    각 모듈에서 이 파일을 참조하여 종속 항목을 사용

2. 가능한 한 노출 최소화

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

implementation으로 선언된 종속 항목은 외부 모듈에 노출되지 않으므로, 해당 종속 항목이 변경되어도 외부 모듈을 다시 빌드할 필요가 없다.

3. Kotlin 및 Java 모듈 선호

Android 스튜디오에서 지원하는 세 가지 필수 모듈 유형

앱 모듈

앱 모듈은 애플리케이션의 진입점으로 소스 코드, 리소스, 에셋 및 AndroidManifest.xml을 포함할 수 있다.
앱 모듈의 출력은 Android App Bundle(AAB) 또는 Android 애플리케이션 패키지(APK)다

앱을 빌드하고 실행하기 위해 필요한 모든 요소를 포함한다

라이브러리 모듈

라이브러리 모듈에는 앱 모듈과 동일한 콘텐츠가 포함되어 있다
라이브러리 모듈은 다른 Android 모듈에 종속 항목으로 사용된다.
라이브러리 모듈의 출력은 앱 모듈과 구조적으로 동일한 Android 보관 파일(AAR)이다.
하지만 이는 나중에 다른 모듈에서 종속 항목으로 사용할 수 있는 Android 보관 파일(AAR)로 컴파일 된다.
라이브러리 모듈을 사용하면 여러 앱 모듈 간에 동일한 로직과 리소스를 캡슐화하고 재사용할 수 있다.

재사용 가능한 코드와 리소스를 캡슐화하기 위한 모듈
여러 앱에서 동일한 기능이나 코드를 공유하고 싶을 때 유용한다
앱 모듈과 유사하게 코드, 리소스, 애셋을 포함할 수 있지만, 앱의 진입점 역할은 하지 않는다

Kotlin 및 Java 라이브러리

Kotlin 및 Java 라이브러리에는 Android 리소스, 애셋 또는 매니페스트 파일이 포함되지 않는다.

Android 관련 요소(리소스, 에셋, AndroidManifest.xml)가 포함되지 않은 순수 Kotlin 또는 Java 코드만 포함하는 모듈이다.
플랫폼 독립적인 순수 로직을 작성할 때 사용한다.
Android와 관계없이 재사용 가능한 비즈니스 로직이나 헬퍼 유틸리티를 개발할 때 유용하다.
ex) 날짜/시간 계산 로직, 문자열 처리 유틸리티 등을 작성할 때 사용

  • 순수 비즈니스 로직
    계산, 데이터 변환, 데이터 검증.
  • 공통 유틸리티
    날짜 포맷, 문자열 처리, JSON 파싱.

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

Android 모듈은 AAR 또는 APK 파일로 컴파일되고, 이 과정에서 추가적인 빌드 시간과 리소스 오버헤드가 발생한다.
반면 Kotlin 및 Java 모듈은 Android와 관련된 추가 작업이 없기 때문에 빌드 속도가 빠르고 오버헤드가 적다.
최대한 간단한 로직은 Kotlin/Java 모듈로 작성해 효율성과 재사용성을 높이는 것이 좋다.

https://developer.android.com/topic/modularization/patterns#cohesion-coupling

0개의 댓글