Android

안드로이드 [Kotlin] - Clean Architecture / 모듈화(1)

🤖 Play with Android 🤖 2023. 7. 1. 18:59
728x90



01. 소프트웨어 아키텍처

image
  • 소프트웨어가 제공하는 가치는 '기능'과 '구조', 두 가지가 있다.
  • 이 중 아키텍처는 구조에 해당한다.

image
  • 보통 우리는 소프트웨어를 개발할 때 기능에 집중한다.
  • 하지만 로버트 C.마틴은 소프트웨어에서 기능보가 구조가 더 중요하다고 말한다.

처음 프로젝트를 시작할 때는 프로젝트를 생성하고 신규 기능을 구현하는 코드를 작성하는 일만 하기 때문에 구조가 크게 필요하지 않다. 하지만 프로젝트가 어느정도 안정화되고 유지보수가 필요한 시점이 오게되면 이야기가 달라진다.

우리는 프로그램에 더 정확히, 더 빠르게. 더 많은 일을 시키기 위해 코드를 읽고, 이해하고, 수정 및 추가하는 작업을 하게 된다. 이를 앱을 유지보수 한다고 말할 수 있다. 이러한 유지보수를 수월하게 하기 위해서 소프트웨어의 구조는 매우 중요한 역할을 한다.

로버트 C.마틴은 소프트웨어 아키텍처의 목표를 다음과 같이 말했다.

필요한 시스템을 만들고 유지보수하는 데 투입되는 인력을 최소화하는 것


02. 클린 아키텍처

  • 좋은 아키텍처는 유즈케이스를 그 중심에 두기 때문에, 프레임워크나 도구, 환경에 전혀 구애 받지 않으면서 유즈케이스를 지원해야 한다.
  • 그렇기 때문에 안드로이드 프레임워크와 거리를 두고, 모든 유즈케이스에 대해 유닛 테스트를 할 수 있으면 좋은 아키텍처라고 볼 수 있다.

지난 수년간 안드로이드 진영에서 여러가지 아키텍처 또는 디자인패턴을 도입하려는 시도가 있었고, 이들은 세부적인 면에서는 다소 차이가 있더라도 그 핵심적인 내용은 같았다. 바로 관심사의 분리(Separation of concerns) 다.

아키텍처가 추구하는 내용들은 다음과 같은 특징을 갖는다.

  • UI, 데이터베이스, 프레임워크 등의 독립성 (결합도 down)
  • 기능 변경 및 확장의 용이성 (유지보수의 용이성)
  • 테스트 용이성

02-1. 클린 아키텍처 다이어그램

image
  • Entitiy(엔티티)
    • 엔티티는 핵심 업무 규칙을 캡슐화 한다.
    • 가장 변하지 않으며 외부로부터 영향을 받지 않는 영역이다.
  • Use Case(유즈케이스)
    • 애플리케이션에 특화된 업무 규칙을 포함한다.
    • 유즈케이스는 엔티티로 들어오고 나가는 데이터의 흐름을 조정하며, 엔티티가 자신의 핵심 업무 규칙을 사용해서 유즈케이스의 목적을 달성하도록 한다.
    • 변경사항이 엔터티에 영향을 줘서는 안되며, UI 또는 프레임워크 등과 같은 외부요소에서 발생한 변경이 이 계층에 영향을 줘서도 안된다.
  • Interface Adpater(인터페이스 어댑터)
    • 인터페이스 어댑터 계층은 일련의 어댑터들로 구성된다.
    • 여기서 어댑터가 하는 역할은 상대적으로 상위 계층인 유즈케이스 그리고 프레임워크와 같은 하위 계층간의 중간다리 역할을 하며 서로가 가장 편리한 형식으로 변환을 한다.
    • 흔히 MVP, MVVM 같은 아키텍처가 여기에 속한다.
  • Framework(프레임워크)
    • 시스템의 핵심 업무와는 관련 없는 세부 사항이다.
    • 즉 일반적인 앱 개발자는 이 영역에서 작성할 코드는 거의 없다.
    • 프레임워크나, 데이터베이스, 웹 서버 등이 여기에 해당한다.
    • 하지만, 구글이 새로운 안드로이드 프레임워크를 배포할 때 변경사항이 발생하므로 우리는 안드로이드 프레임워크를 가장 외부에 위치시켜서 피해를 최소화해야 한다.

이 때 클린 아키텍처의 설계 원칙은 다음과 같다.

  • 구성 요소를 계층적으로 분리한다.
  • 코드 변동성은 원 바깥으로 향할수록 크다.
  • 의존성은 원 바깥쪽에서 원 안쪽으로 향한다.

소프트웨어 아키텍처는 선을 긋는 기술이며, 나는 이러한 선을 경계(boundary)라고 부른다.
경계는 소프트웨어 요소를 서로 분리하고, 경계 한편에 있는 요소가 반대편에 있는 요소을 알지 못하도록 막는다.
-Robert C. Martin, Clean Architecture

이렇게 아키텍처는 내부에 있는 업무 규칙, 애플리케이션의 기능을 보호하는 방향으로 구현되어야 한다.


02-2. 계층간 의존성 규칙

image
  • 각각의 동심원은 서로 다른 영역을 표현하고 있다.
  • 그림에 있는 화살표 방향으로 알 수 있듯이 바깥원에서 안쪽원으로 의존하는 규칙을 갖는다.
  • 즉, 안쪽원은 바깥쪽원에 대한 정보를 몰라야 하고 외부원에 위치한 어떤 것도 내부의 원에 영향을 주지 않아야 한다.

안드로이드에서는 이러한 원칙을 지키기 위해서는 계층별로 모듈을 분리하고 의존관계를 설정할 수 있다.


image image

첫 번째 그림은 클린 아키텍처 다이어그램에 안드로이드 모듈별로 선을 그어 구획을 구분한 것이고 두 번째 그림은 클린 아키텍처에 맞게 구성한 안드로이드 프로젝트를 형상화 한것이다.

  • 프리젠테이션(Presentatoin) 계층
    • 뷰(View): 직접적으로 플랫폼 의존적인 구현, 즉 UI 화면 표시와 사용자 입력을 담당한다. 단순하게 프레젠터가 명령하는 일만 수행하게 된다.
    • 프레젠터(Presenter): MVVM의 ViewModel과 같이, 사용자 입력이 왔을 때 어떤 반응을 해야 하는지에 대한 판단을 하는 영역이다. 무엇을 그려야 할지도 알고 있다.
  • 도메인(Domain) 계층
    • 유즈 케이스(Use Case): 비즈니스 로직이 들어 있는 영역이다.
    • 모델(Entity): 앱의 실질적인 데이터라고 할 수 있다. 최대한 외부의 변경으로부터 보호한다.
  • 데이터(Data) 계층
    • 리포지터리(Repository): 유즈 케이스가 필요로 하는 데이터의 저장 및 수정 등의 기능을 제공하는 영역으로, 데이터 소스를 인터페이스로 참조하여, 로컬 DB와 네트워크 통신을 자유롭게 할 수 있다.
    • 데이터 소스(Data Source): 실제 데이터의 입출력이 여기서 실행됩니다.

각 계층에 대한 간략한 설명은 위와 같고 한 계층씩 자세히 살펴보자.


02-2-1. presentation 모듈

presentation 모듈은 domain, data 모듈에 의존한다. 프레젠테이션 계층은 UI와 관련된 코드를 캡슐화한다. 모든 UI와 관련된 컴포넌트 혹은 안드로이드 프레임워크와 관련된 코드들을 이 계층에서 다루게 된다.

UI / UX는 비즈니스 로직에 비해 상대적으로 변경할 일이 많다. 또한 UI와 관련된 유닛테스트는 어렵기 때문에 UI와 관련된 내용은 다른 코드에서 의존성이 없도록 최대한 독립적으로 만들어야 한다. UI 코드를 한 곳에서 관리함으로써 비즈니스 로직을 보호하고, 테스트도 쉽게 만든다.


02-2-2. domain 모듈

비즈니스 로직을 격리하기 위한 좋은 방법이 바로 domain 모듈을 구현하는 것이다. domain 모듈은 앱의 중심부로써 이 계층에 포함된 비즈니스 로직은 앱을 구성하고 있는 것 중 매우 중요한 부분이라고 할 수 있다. 그래서 비즈니스 로직을 망쳐서는 안되기 때문에 domain 계층은 어떠한 계층에도 의존하지 않는다.

domain 모듈은 다음과 같은 코드를 포함한다.

  • Entity : 특정 영역을 표현하는 객체. ex) Pojo, DTO 등
  • UseCase : Entity와 함께 비즈니스 로직을 수행한다.
  • Repository Interface : 데이터베이스, 원격 서버와 같은 데이터 소스에 접근한다.

domain 모듈은 비즈니스 로직들을 한 계층에서 관리하는데 초점을 맞춘다. 이를 통해 코드를 깨끗하게 관리하고, 단일 책임 원칙(SRP;Single Responsibility Principle)에 부합하는 코드를 작성하기가 쉬워진다.

UI 또는 프레임워크 코드는 빈번히 변경될 수 있고, 비즈니스 로직과 관련이 없는 내용이므로 domain 모듈 분리는 매우 중요하다. 코드의 가독성도 좋아지므로, 팀 프로젝트에 누군가 새로 온보딩 할 때 앱이 동작하는 방식에 대해 이해하기도 쉬울 것이다.


02-2-3. data 모듈

data 모듈은 데이터 소스(DB, 서버 등)와 상호작용을 담당하는 코드가 포함되는 곳이다. data 모듈은 domain 모듈에 의존한다.

앱은 아마 여러가지 데이터 소스를 사용할 텐데, 데이터 소스 또한 시간이 지남에 따라 변경될 수 있다. 예를 들면 REST 서버를 GraphQL 서버로 마이그레이션 하거나 또는 Realm DB를 RoomDB로 변경해야하는 경우를 말한다. 이러한 변경사항은 오로지 데이터를 처리하는데 관련된 로직일 뿐 데이터를 필요로 하는 코드에는 영향을 미치지 않아야 한다.

data 모듈은 다음과 같은 두 가지 책임을 갖는다.

  • 데이터 입출력 코드를 하나의 계층에서 관리한다.
  • 데이터 소스들과 데이터를 소비하는 다른 계층과의 경계를 둔다.

data 모듈에서는 domain 모듈에서 정의한 Repository 인터페이스를 구현한다. 이게 구글에서 권장하는 Repository 패턴이다.

image

그림을 보면 domain과 data간의 분리가 이루어져 있기 때문에, 혹시나 데이터 소스를 변경해도 domain 모듈에는 영향이 없기 때문에 비즈니스 로직은 피해없이 안전하다.


03. 제어의 흐름

image
  • 위 그림을 보면 목적이 같은 코드들끼리 계층적으로 그룹화 한 것을 확인할 수 있다. (관심사의 분리)
  • 안드로이드로 말하자면 시스템 이벤트 또는 사용자의 입력을 받아 유즈케이스를 통과한 후 처리한 결과를 View에 렌더링하며 마무리 한다고 볼 수 있다.

하지만 위 그림과 같이 직접적으로 계층을 참조하는 경우 문제점이 있다.

연쇄적인 참조 관계
프레젠테이션 계층의 UI는 도메인 계층의 업무로직을 참조하고 도메인 계층의 업무로직은 데이터 계층의 데이터를 참조한다. 이 경우 결국 프레젠테이션 계층의 UI가 데이터 계층의 데이터를 직접적으로 참조하게 되어버린다. 결과적으로 데이터 레이어의 데이터를 변경하면 그 변경의 영향이 업무로직에 영향을 미치고 다시 UI의 변경까지 영향을 미치게 된다. 이러한 것을 의존성을 갖는다 라고 부른다.

이렇게 의존성을 갖는 경우 코드 변경에 어려움을 갖는다. 연쇄 참조의 경우 한 곳이 변경되면 그 곳을 참조하고 있던 다른 곳에도 변경이 전파되기 때문이다. 또한 3개의 계층중 업무의 핵심 로직을 담고 있는 도메인 계층의 업무로직이 수시로 UI와 데이터 변경에 영향을 받게 된다는 것도 큰 문제점이라고 할 수 있다.


해결방법

image

의존성 역전이란?
객체 지향 프로그래밍에서 의존 관계 역전 원칙은 소프트웨어 모듈들을 분리하는 특정 형식을 지칭한다. 이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존 관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다.

계층을 횡단 하면서 데이터를 전달할 때, 데이터는 항상 내부의 원에서 사용하기에 가장 편리한 형태를 가져야만 한다. 예를 들어 서버에 저장된 사용자 정보를 갱신하기 위해 다음과 같이 가정해보자.

  1. 사용자 정보를 갱신하기 위해 API를 호출한다.
  2. domain에서 사용자 정보는 User라는 형태로 표현한다.
  3. presentation에서 View에 사용자 정보를 나타낼 때는 UserUiModel로 표현한다.
  4. data에서 서버로 부터 응답받은 사용자 정보는 UserDTO로 표현한다.

사용자 정보 갱신 API 호출을 위해 Activity에서 유즈케이스를 호출할 때 UserUiModel은 User로 변환되어야 한다. 또한 Retrofit을 통해 API요청에 따른 응답 UserDTO를 받았고, 이를 다시 유즈케이스로 넘기기 위해서는 User로 변경해야 한다. 흐름이 반대 방향일 때도 마찬가지로 변경이 필요하다.

UserUiModel <-> User <-> UserDTO

이렇게 경계를 횡단할 때 데이터의 모습이 변경될 수 있으며, 데이터의 형태를 변경할 때 접미어로 Mapper라고 하는 객체를 임의로 만들어 사용한다.


04. 코드 예제

04-1. presentation 계층

View

binding.include.tvRight.setOnClickListener {
    //ViewModel로 버튼 클릭 감지 보내기
    postViewModel.insertLifePost()
}
  • View의 역할은 보여주기와 인터렉션 감지뿐이다.

Presenter

@HiltViewModel
class LifePostViewModel @Inject constructor(
    private val insertDailyPostBaseUseCase: InsertDailyPostBaseUseCase
) : ViewModel() {

    private val _eventFlow = MutableEventFlow<Event>()
    val eventFlow = _eventFlow.asEventFlow()

    private fun event(event: Event) {
        viewModelScope.launch {
            _eventFlow.emit(event)
        }
    }

    fun insertLifePost(text: String, content: String) = viewModelScope.launch {
        insertDailyPostBaseUseCase(repo).collect { uiState ->
            event(Event.UiEvent(uiState))
        }
    }
  • 어떤 반응을 해야 하는지 판단하는 영역이고, 무엇을 그려야 하는지 알고 있는 영역이다.
  • 해당 ViewModel에서는 UseCase를 호출하고 있다.

04-2. domain 계층

UseCase

class InsertDailyPostUseCase @Inject constructor(
    private val repo: DailyRepository
) : InsertDailyPostBaseUseCase {

    override suspend fun invoke(lifePost: DailyPost) = flow {
        emit(UiState.Loding)
        runCatching {
            repo.insertDailyPost(lifePost)
        }.onSuccess { result ->
            emit(UiState.Success(result))
        }.onFailure {
            emit(UiState.Error(it))
        }
    }
}
  • 비즈니스 로직이 들어 있는 영역이다.
  • 여기에서 중요한 것은 참조하고 있는 것이 도메인 계층의 인터페이스로 이루어진 리포지터리이며, 구현체는 데이터 계층에 속해 있다는 것이다.

Repository Interface

// domain 계층 
interface DailyRepository {
    suspend fun insertDailyPost(postEntity: DailyPost): DailyPost
}
  • 레포지토리로써 네트워크와 통신하거나, 로컬 DB로 가져올지 선택할 수 있다.

04-3. data 계층

Repository Implement

class DailyRepositoryImp @Inject constructor(private val dailyRemoteSource: DailyRemoteSource) : DailyRepository {
    override suspend fun insertDailyPost(dailyPost: DailyPost): DailyPost =
        dailyRemoteSource.insert(dailyPost.toMapper()).toDomain()
}

DataSource

interface BookSearchDataSource {

    suspend fun searchBooks(
        query: String,
        sort: String,
        page: Int,
        size: Int
    ): Response<BookSearchResponseDTO>

    suspend fun saveSortMode(mode: String)

    suspend fun getSortMode(): Flow<String>

    suspend fun saveCacheDeleteMode(mode: Boolean)

    suspend fun getCacheDeleteMode(): Flow<Boolean>

    fun searchBooksPaging(query: String, sort: String): Flow<PagingData<Documents>>
}

@Singleton
class BookSearchDataSourceImpl @Inject constructor(
    private val dataStore: DataStore<Preferences>,
    private val api: BookSearchApi
): BookSearchDataSource {

    override suspend fun searchBooks(
            query: String,
            sort: String,
            page: Int,
            size: Int
        ): Response<BookSearchResponseDTO> {
            return api.searchBooks(query, sort, page, size)
        }
    ...
    ...
}
  • 실제 데이터의 입출력이 여기서 실행된다.

Api

interface BookSearchApi {

    @Headers("Authorization: KakaoAK $API_KEY")
    @GET("v3/search/book")
    suspend fun searchBooks(
        @Query("query") query: String,
        @Query("sort") sort: String,
        @Query("page") page: Int,
        @Query("size") size: Int
    ): Response<BookSearchResponseDTO>
}
  • Api 통신을 통해 DTO를 가져오는 부분
  • 데이터 소스(DB, 서버 등)와 직접적인 상호작용을 담당하는 코드가 포함되는 곳이다.

최종적인 앱 플로우

image

05. 클린 아키텍처에서 Paging 라이브러리 사용하기 - domain 계층의 의존성 문제

  • 만약 프로젝트를 진행하며 Jetpack Paging을 사용해야 하는 경우가 생기면 어떻게 할까?
image image
  • 구글의 Paging 라이브러리 공식 문서에는 PagingSource를 Repository에서 생성한 뒤, Pager를 통해 PagingData를 가져오게끔 되어 있다.
  • 이를 프로젝트에 적용한다고 하면, PagingSource는 data 계층에서 구현한 뒤, domain 계층의 인터페이스를 통해 presentation 계층의 ViewModel로 값을 넘겨주게 된다.
implementation "androidx.paging:paging-runtime:$paging_version"
  • Paging 라이브러리의 의존성을 추가하는 과정에서 문제가 발생한다.
  • domain 모듈은 순수 Kotlin 코드로만 되어 있기 때문에 안드로이드에 대한 의존성을 추가할 수 없다.
Could not resolve androidx.paging:paging-runtime-ktx:3.0.0.

Possible solution:
 - Declare repository providing the artifact, see the documentation at https://docs.gradle.org/current/userguide/declaring_repositories.html
  • domain 모듈의 Paging 의존성을 추가하려고 하면 위의 오류가 발생하게 된다.
  • 하지만 domain 계층의 interface를 통해 PagingData를 반환해주기 위해서는 Paging에 대한 의존성을 가지고 있어야 한다.

05-1. 해결방법


첫 번째 잘못된 방법
domain 모듈을 안드로이드 라이브러리로 만든다. -> 클린 아키텍처에 맞지 않는 구성이다.


두 번째 잘못된 방법
PagingSource를 presentation 모듈에서 구현한다. -> PagingSource는 엄연히 데이터를 가져오는 동작을 수행한다. Paging 공식 문서에서 제공하는 아키텍처에도 맞지 않는다.


의존성 추가

// 안드로이드에 대한 종속성이 없어서 sync가 가능하다.
// alternatively - without Android dependencies for tests
testImplementation "androidx.paging:paging-common:$paging_version"
  • testImplementation 을 implementaion 하면된다.