Nia 02. Architecture

동훈이·2023년 2월 24일
1

nia

목록 보기
2/3
post-thumbnail

두번째 Nia 분석으로는 가장 관심을 가질만한 아키텍쳐 대해서 이야기를 나누어 보려고 합니다.

기본적인 안내는 아래 두 링크에서 Nia 에서 어떤 기준으로 architecture 를 어떻게 구성하였는지에 대해서 서술하고 있고 본 게시글에서는 간단한 저의 의견과 함께 이야기를 나눠보려고 합니다.
https://github.com/android/nowinandroid/blob/main/docs/ArchitectureLearningJourney.md

Architecture

다들 프로젝트를 구성하면서 또는 안드로이드를 공부하면서 아키텍쳐에 대해서 한번쯤은 관심을 가지고 도입을 하고 계실꺼라고 생각합니다. MVC 를 쓰고 계실 수 있고, MVP 를 도입 하신분이나 MVVM 또는 이 모두를 섞어서 사용하시는 분들도 계실 것 같습니다.

위와 같은 패턴을 기반으로 Clean Architecture 를 하시는 분도 계실꺼고 Google Recommand Architecture 를 하시는 분도 계실텐데 이 중 Nia 에서는 Google Recommand Architecture 로 프로젝트를 구성하여 개발하고 있습니다. Google Recommand Architecture 는 우리가 흔히 아는 MVVM 을 기반으로 하여 데이터와 UI의 분리를 강조하는 아키텍쳐 입니다.

우선 Google Recommanded Architecture 에 대해 간단하게 소개하면 해당 아키텍쳐의 핵심 개념은 단방향 데이터 흐름이 있는 반응형 프로그래밍 모델이고 아래 2가지의 핵심 개념을 가지고 구성합니다.

  • 상위 계층은 하위 게층의 변화에 반응한다.
  • 이벤트는 아래로 흐르고, 데이터는 위로 흐른다.

예를 들어 사용자가 버튼을 클릭 하였을때 데이터를 가져온다고 하면 아래와 같은 플로우로 흐르게 될 것 입니다.

버튼 클릭 (UI) -> 데이터 요청 (ViewModel) -> 데이터 가져오기 (Domain or Data) -> 가져온 데이터 전달 (Domain or Data) -> 가져온 데이터 전달 (ViewModel) -> 가져온 데이터 화면에 업데이트 (UI)

"사용자가 버튼을 누르고 데이터를 요청하는 이벤트 그리고 요청을 통해 가져온 데이터 전달"

우리가 아키텍처를 공부하면서 Clean 과 Google Recommanded 를 배우게 되는데 이에 대한 차이점은 다음 게시글에서 소개해보도록 하겠습니다. 지금은 Google Recommanded 는 각 레이어는 다음 레이어에 대해서만 알고 있다! 라고 생각하시면 좋을 것 같습니다!

본격적으로 프로젝트의 아키텍쳐를 파헤쳐 볼텐데 UI -> Domain -> Data 순으로 살펴보도록 하겠습니다.

UI Layer 는 어떻게 구성하였을까?

nia 프로젝트가 안드로이드 최신기술의 집합체인 만큼 UI는 Compose 를 활용하여 구성되었습니다. 실제로 컴포즈를 활용하니 기존보다 파일수가 적고 좀 더 동적인 화면 구성을 할 수 있는 장점이 있는거 같습니다. 또한 Ui-State 를 활용하여 Compose Preview 기능을 통해 각 상태에 따른 뷰를 미리 볼 수 있다는 장점이 좀 크게 와닿았던 부분 같습니다.

UI 의 상태는 기존에 하고 있던 방식과 동일하게 Android ViewModel 를 통해 기본적인 비즈니스 로직이 돌고 해당 상태를 관찰하여 UI 에 업데이트 하는 구조로 구성되어 있습니다.

UI Layer 에서 가장 관심있게 볼 부분은 아무래도 Ui-State Modeling 이 아닐 수 없을 것 같습니다. 현재 많은 예시들이나 프로젝트에서 각자 서로 다른 Ui-State 를 구성하고 있는 상태에서 과연 nia 는 어떻게 구성을 했는지 알아볼까요?

기본적으로 nia 에서는 다음 2개는 보장을 합니다.

  • UI 상태는 항상 기본 앱 데이터를 나타내고, 신뢰할 수 있는 데이터 입니다
  • UI 요소는 가능한 모든 상태를 처리합니다.

예시로 For You 라는 뉴스 피드 화면을 들어보겠습니다.

sealed interface NewsFeedUiState {
    object Loading : NewsFeedUiState
    data class Success(
        val feed: List<UserNewsResource>,
    ) : NewsFeedUiState
}

Loading 은 데이터가 로드 중임을 나타내고, Success 는 데이터가 성공적으로 로드되었음을 나타냄과 동시에 뉴스 목록을 같이 반환합니다. 매우 간단하게 앱 상태를 나타내고 있고, 해당 상태 클래스만 보더라도 유아이에서 어떠한 상태들이 있는지를 한눈에 파악할 수 있는 장점이 있습니다.

실제로 회사 프로젝트에서 Ui-State 를 구현할때 로딩의 경우는 isLoading 이라는 프로퍼티를 만들어서 구현하였지만 위의 소스처럼 sealed 클래스로 묶어서 사용하였을때? 어떠한 이점들이 있는지 확인을 해볼 필요는 있을 것 같습니다.

또한 Ui-State 의 Error 인 케이스에 대해서 TODO() 로 처리 해놓은 부분도 있어서 이게 정답이다! 라고 확신은 하기 어렵고 아직도 이러한 부분은 개발적으로 풀 수 있지만 디자인적으로 풀 수 있는 부분이기에 이러면 어떨까? 라는 생각이 드는 소스 들이였습니다.

그 외에도 Compose 를 어떻게 활용했고 어떠한 State 들을 가졌는지에 대해서 관심있게 살펴볼 수 있던 기회였던거 같습니다. 이번 게시물은 아키텍처에 관한 내용임으로 Compose 에 대해서는 다음시간에 다뤄보도록 하겠습니다.

Domain Layer 는 어떻게 구성하였을까?

Nia 프로젝트에서는 유저가 북마크 또는 팔로우한 데이터를 포함한 뉴스 데이터를 반환해주는 로직에 대해 UseCase 를 작성하였습니다. UserNewsResource 에 선언되어 있는 mapToUserNewsResources 함수를 통해 UserNewsResource 를 구성할때, 유저데이터가 있으면 isFollowed 또는 isSaved 에 대한 플래그를 변경해서 반환해주는 작업을 하였습니다.

class GetUserNewsResourcesUseCase @Inject constructor(
    private val newsRepository: NewsRepository,
    private val userDataRepository: UserDataRepository,
) {
    operator fun invoke(
        query: NewsResourceQuery = NewsResourceQuery(),
    ): Flow<List<UserNewsResource>> =
        newsRepository.getNewsResources(
            query = query,
        ).mapToUserNewsResources(userDataRepository.userData)
}

private fun Flow<List<NewsResource>>.mapToUserNewsResources(
    userDataStream: Flow<UserData>,
): Flow<List<UserNewsResource>> =
    filterNot { it.isEmpty() }
        .combine(userDataStream) { newsResources, userData ->
            newsResources.mapToUserNewsResources(userData)
        }
        
        

Domain Layer 에서는 Clean 이나 Google Recommened 에서 항상 이야기가 나오는 유즈케이스에 대해 가장 유심있게 보았습니다. Nia 에서 모든 비즈니스 로직에 대해서 UseCase 를 작성한건 아닙니다. 기본적으로 Google Recommended Architecture 에서 Domain 은 Optional 로 권장하고 있고, 필요할 때만 사용 사례를 추가하는 것 을 권장하고 있습니다.

그래서 실제 ViewModel 의 생성자들을 살펴보면 Repository 와 Usecase 를 같이 사용 하고있습니다.

class BookmarksViewModel @Inject constructor(
    private val userDataRepository: UserDataRepository,
    getSaveableNewsResources: GetUserNewsResourcesUseCase,
) : ViewModel()

확실히 유즈케이스를 모두 사용하였을때 보다는 통일성이나 가독성이 떨어지는 느낌이 들지만 일제 개발 공수를 따져보면 나쁘지 않은 방법인거 같아서 다음 프로젝트에도 다시한번 고민을 해볼 것 같은 방식이였습니다.

Data Layer 는 어떻게 구성하였을까?

데이터 레이어의 구성은 어느앱과 다르지 않게 비슷하게 구성은 되었으나 사용자의 오프라인 상태를 고려하여 서버로부터 받아온 데이터를 캐싱하는 과정에 대해서는 유심히 봐볼만한 소스였습니다.

사진과 동일하게 유저에 대한 정보는 DataStore 에 캐싱을 하고 로컬에 있는 뉴스 데이터와 서버로 부터 받아온 뉴스 데이터를 활용하여 유저에게 보내주고 새로 업데이트 된 내용들은 캐싱하는 과정을 담아내었습니다.

한가지 관심을 가지고 보고 싶었던 부분은 데이터의 반환 과정입니다. 실제로 뷰모델을 살펴보면 runCatching 이나 try-catch 를 사용하지 않습니다. 대신 Repository 에서 데이터를 반환할때 거의 모든 데이터가 Flow 로 반환되고 있는 모습을 볼 수 있었습니다.

sealed interface Result<out T> {
    data class Success<T>(val data: T) : Result<T>
    data class Error(val exception: Throwable? = null) : Result<Nothing>
    object Loading : Result<Nothing>
}

fun <T> Flow<T>.asResult(): Flow<Result<T>> {
    return this
        .map<T, Result<T>> {
            Result.Success(it)
        }
        .onStart { emit(Result.Loading) }
        .catch { emit(Result.Error(it)) }
}

또한 반환과 동시에 asResult() 를 통해 Result 로 뷰모델에서 처리하는 모습을 볼 수 있었습니다.
아무래도 Room 사용과 동시에 DataStore 를 사용하다 보니 대부분의 데이터가 Flow 로 반환되어서 요러한 방식을 사용 할 것 같다는 생각이 들었습니다.

이러한 앱의 특수성 때문인지 실제로 대부분의 앱에서는 받아온 데이터의 캐싱은 잘 하지 않고 서버로 부터 들어온 데이터를 바로 뿌려주기에 과연 우리가 작성하는 프로젝트에서 어떻게 적용하면 좋을지? 고민을 해보게 하는 시간이였던거 같습니다.

항상 아키텍처에 대해 고민을 하고 권장 아키텍처를 숙지하였지만 어떻게 구현해야 하는지 고민이 많았던 시간동안 어느정도의 길잡이를 해준 시간이였다고 생각이 들었습니다. 물론 회사나 팀에 따라서 사용하는 아키텍처나 구현 방법이 다르기에 이것을 써야한다! 라고 확정은 할 순 없지만? 앞으로 사이드 프로젝트에 대해서는 위 아키텍처를 기반으로 제안을 해볼만 할 것 같다는 생각이 들었습니다.

profile
안드로이드 개발자

0개의 댓글