[Android] 앱 아키텍처 가이드

유민국·2023년 8월 7일
0

모바일 앱 UX

사용자는 짧은 시간 내에 여러 앱과 상호작용할 때도 많다는 점을 고려해야 하기 때문에 앱은 사용자 중심의 다양한 워크플로 및 작업에 맞게 조정될 수 있어야 한다.

앱 구성요소는 개별적이고 비순차적으로 실행될 수 있으며, 운영체제나 사용자가 언제든지 앱 구성요소를 소멸시킬 수 있다. 이러한 이벤트는 직접 제어할 수 없기 때문에 앱 구성요소에 애플리케이션 데이터나 상태를 저장해서는 안 되며 앱 구성요소가 서로 종속되지 않도록 만든다.

아키텍처의 이점

  • 앱의 전반적인 유지관리성, 품질, 견고성이 개선된다.
  • 앱을 확장할 수 있다. 코드 충돌이 최소화되어 더 많은 인력과 팀이 동일한 코드베이스에 기여할 수 있다.
  • 아키텍처는 프로젝트에 일관성을 부여하므로 새로운 팀원이 빠르게 업무를 시작하고 짧은 시간에 효율을 높일 수 있다.
  • 테스트하기 쉬워진다. 좋은 아키텍처는 테스트하기가 더 쉬운 간단한 유형을 사용하도록 지원한다.
  • 잘 정의된 프로세스를 사용하여 버그를 체계적으로 조사할 수 있다.

아키텍처는 효율적으로 앱을 관리할 수 있도록 만들 수 있기 때문에 앱의 안정성이 향상되고 많은 기능을 적용시킬 수 있지만, 초기에 시간이 많이 소비될 수 있다는 점이 있다.

고품질 앱 제공하기

일반적인 아키텍처 원칙

데이터와 상태를 저장하는 데 앱 구성요소를 사용할 수 없다면 어떻게 설계해야 하는지에 대한 원칙들이 있다
앱 확장, 앱의 견고성, 더 쉽게 테스트 하기 위해 아키텍처를 정의하는 것은 중요하다
앱 아키텍처는 앱의 각 부분에 필요한 기능 간의 경계를 정의한다.

Separation of concerns(관심사 분리)

가장 중요한 원칙 중 하나
Activity나 Fragment에 모든 코드를 작성하지 말도록 한다. UI 기반의 클래스는 UI 및 운영체제 상호작용을 처리하는 로직만 포함해야 한다. 이러한 클래스를 최대한 가볍게 유지한다면 구성요소 생명 주기와 관련된 많은 문제들을 피할 수 있게된다.
Activity 및 fragment는 언제든지 소멸될 수 있기 때문에 만족스러운 사용자 환경과 앱 관리 환경을 제공하려면 의존성을 최소화하는 것이 좋다.

데이터 모델에서 UI 도출하기

지속적인 데이터 모델을 권장한다.

  • OS에서 앱을 제거해도 사용자 데이터가 삭제되지 않는다.
  • 네트워크 연결이 취약하거나 연결되어 있지 않아도 앱이 계속 작동된다.

데이터 모델은 앱의 UI 요소 및 기타 구성요소로부터 독립되어 있기 때문에 UI 및 앱 구성요소의 생명 주기와는 관련이 없다.
데이터 모델도 OS가 메모리에서 앱의 프로세스를 삭제한다면 데이터 모델도 삭제 된다.
데이터 모델 클래스를 기반으로 앱 아키텍처를 구축하면 앱의 테스트 가능성과 견고성이 더 높아진다.

단일 소스 저장소(SSOT-Single source of truth)

앱에서 새로운 데이터 유형을 정의할 때는 데이터 유형에 SSOT를 할당해야 한다.
SSOT는 데이터의 소유자이며, SSOT만 데이터를 수정하거나 변경할 수 있다. SSOT는 이를 위해 불변 유형을 사용하여 데이터를 노출하고, 다른 유형이 호출 할 수 있는 이벤트를 수신하거나 함수를 노출하여 데이터를 수정한다.

장점

  • 특정 유형 데이터의 모든 변경사항을 한 곳에서 관리할 수 있다.
  • 다른 유형이 조작할 수 없도록 데이터를 보호한다.
  • 데이터 변경사항을 쉽게 추척할 수 있게 하여 버그를 발견하기 쉬워진다.

추가

  • 오프라인 중심 앱 데이터 정보 소스는 주로 데이터베이스이다. 정보 소스가 ViewModel이거나 UI인 경우도 있다.

단방향 데이터 흐름(UDF)

SSOT 원칙은 종종 UDF 패턴과 함께 사용된다.
UDF에서 state는 한 방향으로만 흐르며 데이터를 수정하는 이벤트는 반대 방향으로 흐른다.
Android에서 상태 또는 데이터는 일반적으로 계층 구조의 상위 범위 유형에서 하위 범위 유형으로 흐른다.
이벤트는 보통 하위 범위 유형에서 트리거되어 상응하는 데이터 유형의 SSOT에 도달한다.
예를 들어 앱 데이터는 보통 데이터 소스에서 UI로 흐르고, 버튼 클릭과 같은 사용자 이벤트는 UI에서 SSOT로 흐르며, SSOT에서는 앱 데이터가 불변 유형으로 수정 및 노출 된다.
UDF 패턴은 데이터 일관성을 강화하고, 오류를 발생할 확률을 줄여 주며, 디버그 하기 쉽고, SSOT 패턴의 모든 이점을 제공한다.

권장하는 앱 아키텍처

일반적은 아키텍처 원칙에 따라 각 앱에는 최소한 두 가지 레이터가 포함되어야 한다.

  • 화면에 앱 데이터를 표시하는 UI 레이어
  • 앱의 비즈니스 로직을 포함하고 데이터를 출하는 데이터 레이어
  • 추가 : UI와 데이터 레어이 간의 상호작용을 간소화하고 재사용하기 위한 도메인 레이어

    다이어그램의 화살표는 클래스 간의 종속성을 나타낸다. 예를 들어 도메인 레이어는 데이터 레이어 클래스에 종속된다.

최신 앱 아키텍처

최신 앱 아키텍처에서는 다음 기법을 사용하도록 권장한다.

  • 반응형 및 계층형 아키텍처
  • 앱의 모든 레이어에서의 단방향 데이터 흐름(UDF)
  • 상태 홀더가 있는 UI 레이어로 UI의 복잡성 관리
  • 코루틴 및 flows
  • 종속 항목 삽입 권장사항
    자세한 내용: Android 아키텍처 권장사항

UI 레이어

자세한 내용 : UI레이어
UI 레이어는 다음 두 가지로 구성된다.

  • 화면에 데이터를 렌더링 하는 UI 요소. 이러한 요소는 뷰 또는 Jetpack Compose 함수를 사용하여 빌드할 수 있다.
  • 데이터를 보유하고 이를 UI에 노출하며 로직을 처리하는 State holders(Ex. ViewModel 클래스)

UI의 역할은 화면에 애플리케이션 데이터를 표시하고 사용자 상호작용을 위한 기능을 제공한다. 사용자 상호작용 또는 외부 입력(네트워크 응답)으로 인해 데이터가 변경될 때마다 변경사항을 반영하도록 UI가 업데이트되어야 한다.

  • UI는 데이터 레이어에서 가져온 앱 상태를 시각적으로 나타내는 역할
  • UI 레이어는 애플리케이션 데이터 변경사항을 UI가 표시할 수 있는 형식으로 변환한 후에 표시하는 파이프라인이다.

UI 레이어 아키텍처

  1. 앱 데이터를 사용하여 UI에서 쉽게 렌더링할 수 있는 데이터로 변환한다.
  2. UI 렌더링 가능한 데이터를 사용하여 사용자에게 제공하기 위한 UI 요소로 변환한다.
  3. 조합된 UI 요소의 사용자 입력 이벤트를 사용하고 그 결과를 필요에 따라 UI 데이터에 반영한다.
  4. 1~3단계를 필요한 만큼 반복한다.

위의 단계를 예시를 보면서 UI 레이어를 구현하는 방법을 설명하고, 다음과 같은 개념을 설명한다.

  • UI 상태를 정의하는 방법
  • UI 상태를 생성하고 관리하기 위한 UDF(단방향 데이터 흐름)
  • UDF 원칙에 따라 관찰 가능한 데이터 유형으로 UI 상태를 노출하는 방법
  • 관찰 가능한 UI 상태를 사용하는 UI를 구현하는 방법

예시

사용자가 읽을 수 있도록 뉴스 기사를 가져오는 앱
기능

  • 읽을 수 있는 기사 보기
  • 카테고리별로 기사 둘러보기
  • 로그인하여 특정 기사 북마크
  • 자격이 있는 경우 일부 프리미엄 기능에 액세스

UI 상태 정의

UI에는 각 기사의 일부 메타데이터와 함께 기사 목록이 표시된다. 앱에서 사용자에게 표시하는 정보가 UI 상태이다.
사용자가 보는 항목이 UI라면 UI 상태는 앱에서 사용자가 봐야 한다고 지정하는 항목이다. UI는 UI 상태를 시각적으로 나타내며 UI 상태가 변경되면 변경사항이 즉시 UI에 반영된다.

예시를 보면 뉴스 앱의 요구사항을 충족하기 위해 UI를 완전히 렌더링 하는 데 필요한 정보를 다음과 같이 정의된 NewsUiState 데이터 클래스에 캡슐화할 수 있다.
데이터 클래스의 변수들이 val로 선언된 것에 주목하도록 하자

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

불변성

위 예에서 UI 상태 정의는 변경할 수 없다. 불변성의 주요 이점은 변경 불가능한 객체가 순간의 애플리케이션 상태를 보장한다는 점이다. 덕분에 UI는 상태를 읽고 이에 따라 UI 요소를 업데이트하는 한 가지 역할에 집중할 수 있다. 불변성 원칙을 위반하면 동일한 정보가 여러 정보 소스에서 비롯되어 데이터 불일치와 미세한 버그가 발생 한다.

이해한 내용 : UI 상태 클래스를 만든 후 불변성을 위해 모든 변수는 val로 만들어 관리한다.
데이터를 받아와 UI객체를 만들면 UI객체는 변경되지 않도록 코딩해야한다.

핵심 사항 : 데이터의 소스나 소유자만 노출 데이터를 업데이트할 수 있도록 한다.

단방향 데이터 흐름으로 상태 관리

기본적으로 UI 상태는 불변성의 원칙을 따라야 한다. 하지만 사용자 상호작용이나 기타 이벤트로 인하여 앱 데티어의 동적 특성에 따라 상태가 변경될 수 있는데 이런 경우 어떻게 처리해야하는지 살펴 보자

이 챕터에서는 중재 요소가 각 이벤트에 적용할 로직을 정의하고 UI 상태를 만들기 위해 지원 데이터 소스에 필요한 변환을 실행하여 상호작용을 처리하는 방법을 설명한다. 만약 UI 자체에 상호작용을 위한 로직이 포함된다면 UI 이상의 역할(데이터 소유자, 생성자, 변환자 등)을 담당하는 것을 최대한 지양하도록 한다. 궁극적으로는 UI가 담당하는 역할은 UI 상태를 사용 및 표시하는 것 이외 부담은 최대한 줄이도록 해야한다.

상태 홀더

UI 상태를 생성하는 역할을 담당하고 생성 작업에 필요한 로직을 포함하는 클래스이다.
상태 홀더는 단일 위젯부터 전체화면이나 탐색 대상에 이르기까지 UI 요소의 범위에 따라 다양하다.

  1. 상태 홀더의 범위가 전체 화면이나 탐색 대상일 경우
    일반적인 구현은 ViewModel 인스턴스이지만 애플리케이션의 요구사항에 따라 간단한 클래스로도 충분할 수 있다. 예시를 보면 NewsViewModel 클래스를 상태 홀더로 사용하여 색션에 표시되는 화면의 UI 상태를 생선ㅇ한다.

ViewModel 유형은 데이터 레이어에 엑세스할 권한이 있는 화면 수준 UI 상태를 관리하는데 권장되는 구현이다. 또한 구성이 변경되어도 자동으로 유지된다. ViewModel 클래스는 앱의 이벤트에 적용할 로직을 정의하고 결과로 업데이트 되는 상태를 생성한다.

UI와 ViewModel 클래스 사이의 상호작용은 이벤트 input과 input의 후속 상태인 output으로 간주될 수 있다.

위의 그림은 앱 아키텍처에서 UDF의 작동 방식을 보여주는 다이어그램이다.

상태가 아래로 향하고 이벤트는 위로 향하는 패턴을 단방향 데이터 흐름(UDF)라고 한다. 이 패턴이 앱 아키텍처에 미치는 영향은 다음과 같다.

  • ViewModel이 UI에 사용될 상태를 보유하고 노출한다. UI 상태는 ViewModel에 의해 변환된 애플리케이션 데이터이다.
  • UI가 ViewModel에 사용자 이벤트를 알린다.
  • ViewModel이 사용자 작업을 처리하고 상태를 업데이트한다.
  • 업데이트된 상태가 렌더링할 UI에 다시 제공된다.
  • 상태 변경을 야기하는 모든 이벤트에 위의 작업이 반복된다.
  1. 상태홀더의 범위가 탐색 대상이나 화면의 경우
    ViewModel은 Repository와 함께 작동하여 데이터를 가져와 UI 상태로 변환하는 동시에 상태 변경을 야기할 수 잇는 이벤트 효과를 통합한다. 예시를 보면 기사 목록이 해당되며 각 기사의 제목, 설명, 출처, 작성자 이름, 게시일, 북마크 여부가 표시된다.

사용자의 기사 북마크 요청은 상태 변경을 야기할 수 있는 이벤트이다. 상태 생성자의 경우 UI 상태의 모든 필드를 채우고 UI가 완전히 렌더링 되는 데 필요한 이벤트를 처리하기 위해 모든 필수 로직을 정의하는 역할은 ViewModel이 담당한다.

UDF의 이벤트 및 데이터 주기를 보여주는 다이어그램

UDF를 사용하는 이유

  • 데이터 일관성 : UI용 정보 소스가 하나이다.
  • 테스트 가능성 : 상태 소스가 분리되므로 UI와 별개로 테스트할 수 있다.
  • 유지 관리성 : 상태 변경은 잘 정의된 패턴을 따른다. 즉, 변경은 사용자 이벤트 및 데이터를 가져온 소스 모두의 영향을 받는다.

UI 상태 노출

UI 상태를 정의하고 관리할 방법을 결정한 후에는 생성된 상태를 UI에 표시하는 단계이다.

ViewModel에서 데이터를 직접 가져오지 않고도 UI가 상태 변경사항에 반응할 수 있도록 LiveData 또는 StateFlow와 같이 관찰 가능한 데이터 홀더에 UI 상태를 노출해야 한다.
항상 최신 버전의 UI 상태를 캐시한다는 이점도 있고, 이는 구성 변경 후 빠른 상태 복원에 유용하다.

참고 : Jetpack Compose 앱에서는 Compose의 관찰 가능한 상태 API(예: UI 상태 노출용 mutableStateOf 또는 snapshotFlow)를 사용할 수 있습니다. 이 가이드에 나온 StateFlow 또는 LiveData 등 모든 유형의 관찰 가능한 데이터 홀더는 적절한 확장 프로그램을 사용하여 Compose에서 쉽게 사용할 수 있습니다.

추가 고려사항

UI 상태 객체는 서로 관련성 있는 상태를 처리해야 합니다.
불일치가 줄어들고 코드를 이해하기가 더 쉽워진다.
뉴스 항목 목록과 북마크 수를 서로 다른 두 스트림에 노출하면 한 스트림이 업데이트되고 다른 스트림은 업데이트되지 않은 상황이 발생할 수 있다.
단일 스트림을 사용하면 두 요소가 모두 최신 상태로 유지된다.
예를 들어 로그인한 상태인 동시에 프리미엄 뉴스 서비스의 구독자인 사용자에게만 북마크 버튼을 표시해야 한다면 다음과 같이 UI 상태 클래스를 정의하면 된다.

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf()
)

val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium

이 선언에서 북마크 버튼의 표시 여부는 다른 두 속성의 파생 속성이다. 비즈니스 로직이 더 복잡해짐에 따라 모든 속성을 즉시 사용할 수 있는 단일 UiState 클래스의 중요성이 점차 커진다.

UI 상태 사용

UI에서 UiState 객체의 스트림을 사용하려면 사용 중인 관찰 가능한 데이터 유형에 터미널 연산자를 사용한다. 예를 들어 LiveData의 경우 observe() 메서드를 사용하고 Kotlin flows의 경우 collect() 메서드를 사용한다.
Kotlin flows
UI에서 관찰 가능한 데이터 홀더를 사용할 때는 UI의 수명 주기를 고려해야 한다.
그 이유는 사용자에게 뷰가 표시되지 않을 때 UI가 UI 상태를 관찰해서는 안 되기 때문이다.
자세한 내용 : 블로그 게시물 참고
LiveData를 사용하면 LifecycleOwner가 수명 주기 문제를 암시적으로 처리합니다. kotlin flows를 사용할 때는 적절한 코루틴 범위와 repeatOnLifecycle API로 처리하는 것이 가장 좋다.

데이터 레이어

자세한 내용 : 데이터 레이어
앱의 데이터 레이어에는 비즈니스 로직이 포함되어 있다. 비즈니스 로직은 앱에 가치를 부여하는 요소로, 앱의 데이터 생성, 저장, 변경 방식을 결정하는 규칙으로 구성된다.

데이터 레이어는 0개부터 여러 개의 데이터 소스를 각각 포함할 수 있는 저장소로 구성된다.
영화관련 데이터로 MoviesRepository 클래스, 결제 관련 데이터로 PaymentsRepository 클래스를 만드는 것 처럼 앱에서 처리하는 다양한 유형의 데이터별로 저장소 클래스를 만들어야 한다.

Repository에서 담당하는 작업

  • 앱의 나머지 부분에 데이터 노출
  • 데이터 변경사항을 한 곳에 집중
  • 여러 데이터 소스 간의 충돌 해결
  • 앱의 나머지 부분에서 데이터 소스 추상화
  • 비즈니스 로직 포함

각 데이터 소스 클래스는 파일, 네트워크 소수, 로컬 데이터베이스와 같은 하나의 데이터 소스만 사용해야 한다.

계층 구조의 다른 레이어는 데이터 소스에 직접 액세스해서는 안 됩니다. 데이터 영역의 진입점은 항상 저장소 클래스여야 한다.
상태 홀더 클래스 또는 사용 사례 클래스에는 데이터 소스가 직접 종속 항목으로 있어서는 안 된다.
저장소 클래스를 진입점으로 사용하면 아키텍처의 다양한 레이어를 독립적으로 확장할 수 있다.

이 레이어에서 노출된 데이터는 변경 불가능해야 한다.
값을 일관되지 않은 상태로 만들 위험이 있는 다른 클래스에 의한 조작을 불가능하게 만들 수 있다.
변경 불가능한 데이터는 여러 스레드에서 안전하게 처리될 수 있다.

이름 지정 규칙

이 가이드에서 저장소 클래스의 이름은 담당하는 데이터의 이름을 따라 지정된다.

데이터 유형 + 저장소

예: NewsRepository, MoviesRepository 또는 PaymentsRepository

데이터 소스 클래스의 이름은 담당하는 데이터와 사용하는 소스의 이름을 따라 지정된다.

데이터 유형 + 소스 유형 + DataSource

데이터 유형의 경우 구현이 변경될 수 있으므로 좀 더 일반적인 Remote 또는 Local을 사용합니다. 예를 들면 NewsRemoteDataSource 또는 NewsLocalDataSource가 있습니다. 소스가 중요한 경우를 좀 더 구체적으로 지정하려면 소스 유형을 사용합니다. 예를 들면 NewsNetworkDataSource 또는 NewsDiskDataSource가 있습니다.

여러 수준의 저장소

더 복잡한 비즈니스 요구사항이 포함된 일부 경우에는 저장소가 다른 저장소에 종속되게 만들 수 있다.

예를 들어 사용자 인증 데이터를 처리하는 저장소인 UserRepository는 요구사항을 충족하기 위해 LoginRepository 및 RegistrationRepository와 같은 다른 저장소에 종속될 수 있습니다.

정보 소스

각 저장소가 하나의 정보 소스를 정의하는 것이 중요하다.
정보 소스는 항상 일관되고 정확하며 최신 상태인 데이터를 포함하며 실제로 저장소에서 노출되는 데이터는 항상 정보 소스에서 직접 가져온 데이터여야 한다.

정보 소스는 데이터 소스(예: 데이터베이스)이거나 저장소에 포함될 수 있는 메모리 내 캐시일 수도 있다. 저장소는 서로 다른 데이터 소스를 결합하고 데이터 소스 간의 잠재적인 충돌을 해결하여 정기적으로 또는 사용자 입력 이벤트에 따라 정보 소스를 업데이트한다.

앱의 저장소마다 정보 소스가 다를 수 있다. 예를 들어 LoginRepository 클래스는 캐시를 정보 소스로 사용하고 PaymentsRepository 클래스는 네트워크 데이터 소스를 사용할 수 있다.

오프라인 우선 지원을 제공하려면 데이터베이스와 같은 로컬 데이터 소스를 정보 소스로 사용하는 것이 좋다.

스레딩

데이터 소스와 저장소 호출은 기본 스레드에서 안전성이 보장되어야 한다.
데이터 소스와 저장소 클래스는 장기 실행 차단 작업을 실행할 때 로직 실행을 적절한 스레드에서 실행시키도록 해야한다.
예를 들어 데이터 소스가 파일에서 읽거나 저장소가 큰 목록에서 비용이 많이 드는 필터링을 수행할 때 기본 안전성이 보장되어야 한다.

수명 주기

데이터 영역의 클래스 인스턴스는 가비지 컬렉션 루트에서 연결할 수 있는 한 메모리에 남아 있다.

클래스에 메모리 내 데이터가 포함된 경우(예: 캐시) 특정 기간 동안 해당 클래스의 동일한 인스턴스를 재사용하고자 할 수 있다. 이를 클래스 인스턴스의 수명 주기라고도 합니다.

데이터 클래스가 전체 애플리케이션에 중요한 경우 해당 클래스의 인스턴스 범위를 Application 클래스로 지정할 수 있다. 이렇게 하면 인스턴스가 애플리케이션의 수명 주기를 따르게 할 수 있다. 또는 앱의 특정 흐름(예: 등록 또는 로그인 흐름)에서만 동일한 인스턴스를 재사용해야 하는 경우 흐름의 수명 주기를 소유한 클래스로 인스턴스 범위를 지정해야 합니다. 예를 들어 메모리 내 데이터가 포함된 RegistrationRepository 범위를 RegistrationActivity 또는 등록 흐름의 탐색 그래프로 지정할 수 있다.

대표 비즈니스 모델

데이터 영역에서 노출하려는 데이터 모델은 다양한 데이터 소스에서 가져오는 정보의 하위 집합일 수 있다. 네트워크 및 로컬의 다양한 데이터 소스가 애플리케이션에 필요한 정보만 반환하는 것이 좋으나 실제 이런 경우는 많지 않다.

예를 들어 기사 정보뿐만 아니라 수정 기록, 사용자 댓글, 일부 메타데이터도 반환하는 News API 서버가 있다고 하면,

data class ArticleApiModel(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val modifications: Array<ArticleApiModel>,
    val comments: Array<CommentApiModel>,
    val lastModificationDate: Date,
    val authorId: Long,
    val authorName: String,
    val authorDateOfBirth: Date,
    val readTimeMin: Int
)

화면에 기사 콘텐츠와 작성자에 관한 기본 정보만 표시하므로 앱은 기사에 관한 많은 정보를 필요로 하지 않다. 모델 클래스를 분리하고 저장소에서 계층 구조의 다른 레이어에 필요한 데이터만 노출하도록 하는 것이 좋다. 예를 들어 다음은 Article 모델 클래스를 도메인 및 UI 레이어에 노출하기 위해 네트워크에서 ArticleApiModel을 다듬는 방법이다.

data class Article(
    val id: Long,
    val title: String,
    val content: String,
    val publicationDate: Date,
    val authorName: String,
    val readTimeMin: Int
)

모델 클래스를 분리했을때의 이점

  • 필요한 수준으로 데이터를 줄여 앱 메모리를 절약한다.
  • 앱에서 사용하는 데이터 유형에 맞게 외부 데이터 유형을 조정할 수 있다. 예를 들어 앱은 날짜를 나타내는 데 다른 데이터 유형을 사용할 수 있다.
  • 관심사를 더 잘 분리할 수 있습니다. 예를 들어 모델 클래스가 미리 정의된 경우 대규모 팀원이 기능의 네트워크 레이어와 UI 레이어에서 개별적으로 작업할 수 있다.
  • 이 방식을 확장하고 앱 아키텍처의 다른 부분(예: 데이터 소스 클래스 및 ViewModel)에서도 별도의 모델 클래스를 정의할 수 있다. 그러나 이를 위해서는 적절하게 문서화하고 테스트해야 하는 추가 클래스 및 로직을 정의해야 한다. 최소한 데이터 소스가 앱의 나머지 부분에서 예상하는 데이터와 일치하지 않는 데이터를 수신하는 경우에는 새 모델을 만드는 것이 좋다.

일반적인 작업

네트워크 요청

네트워크 요청은 Android 앱에서 실행할 수 있는 가장 일반적인 작업 중 하나이다. 뉴스 앱은 네트워크에서 가져온 최신 뉴스를 사용자에게 표시해야 하기 때문에 앱에는 네트워크 작업을 관리하기 위한 데이터 소스 클래스 NewsRemoteDataSource가 필요하다.
앱의 나머지 부분에 정보를 노출하기 위해 뉴스 데이터에 관한 작업을 처리하는 새로운 저장소 NewsRepository를 생성하면 된다.

요구사항은 사용자가 화면을 열 때 항상 최신 뉴스를 업데이트하도록 하는 이다. 이 경우에 UI 지향 작업이라고 한다.

데이터 소스 만들기

데이터 소스는 최신 뉴스를 반환하는 함수, 즉 ArticleHeadline 인스턴스 목록을 노출해야 한다. 데이터 소스는 네트워크에서 최신 뉴스를 가져오는 기본 안정성을 갖춘 방법을 제공해야 한다. 이 경우 작업을 실행할 CoroutineDispatcher 또는 Executor에 종속 항목을 가져오도록 한다.

네트워크 요청은 새로운 fetchLatestNews() 메서드에서 처리되는 원샷 호출이다.

class NewsRemoteDataSource(
  private val newsApi: NewsApi,
  private val ioDispatcher: CoroutineDispatcher
) {
    /**
     * Fetches the latest news from the network and returns the result.
     * This executes on an IO-optimized thread pool, the function is main-safe.
     */
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        // Move the execution to an IO-optimized thread since the ApiService
        // doesn't support coroutines and makes synchronous requests.
        withContext(ioDispatcher) {
            newsApi.fetchLatestNews()
        }
    }
}

// Makes news-related network synchronous requests.
interface NewsApi {
    fun fetchLatestNews(): List<ArticleHeadline>
}

저장소 만들기

이 작업의 저장소 클래스에는 추가 로직이 필요하지 않으므로 NewsRepository는 네트워크 데이터 소스의 프록시 역할을 한다.

메모리 내 데이터 캐싱 구현

뉴스 앱에 새로운 요구사항이 도입되었다고 가정해보자. 사용자가 화면을 열면 이전에 요청이 생성된 경우 캐시된 뉴스가 사용자에게 표시되어야 한다. 그러지 않으면 앱이 최신 뉴스를 가져오기 위해 네트워크 요청을 해야 한다.

새로운 요구사항이 있으므로 앱은 사용자가 앱을 열고 있는 동안 메모리에 최신 뉴스를 보존해야 하기 때문에 이는 앱 지향 작업이다.

캐시

사용자가 앱에 있는 동안 메모리 내 데이터 캐싱을 추가하여 데이터를 보존할 수 있다. 캐시는 사용자가 앱에 있는 한 특정 시간 동안 메모리에 일부 정보를 저장하기 위해 실행된다. 캐시 구현은 다양한 형태를 취할 수 있다. 간단한 변경 가능 변수부터 여러 스레드에서 읽기/쓰기 작업을 금지하는 더욱 정교한 클래스에 이르기까지 다양할 수 있다. 사용 사례에 따라 저장소 또는 데이터 소스 클래스 내에 캐싱을 구현할 수 있다.

네트워크 요청 결과 캐시

편의상 NewsRepository는 변경 가능한 변수를 사용하여 최신 뉴스를 캐시한다. 여러 스레드에서 읽기 및 쓰기를 금지하기 위해 Mutex가 사용된다.

  • Mutex(Mutual Exclusion) : 공유자원에 변경이 일어나는 순간에 적절한 block을 통해 race condition의 발생을 막는 동기화 제어 기법

작업을 화면보다 길게 유지

네트워크 요청이 진행되는 동안 사용자가 화면에서 벗어나면 취소되고 결과가 캐시되지 않는다. NewsRepository는 이 로직을 실행하는 데 호출자의 CoroutineScope를 사용해서는 안 된다. 대신 NewsRepository는 수명 주기에 연결된 CoroutineScope를 사용하도록 한다. 최신 뉴스를 가져오는 작업은 앱 지향 작업이어야 한다.

종속 항목 삽입 권장사항을 따르려면 NewsRepository는 자체 CoroutineScope를 만드는 대신 생성자의 매개변수로 범위를 수신해야 한다. 저장소는 대부분의 작업을 백그라운드 스레드에서 해야 하므로 CoroutineScope를 Dispatchers.Default 또는 자체 스레드 풀로 구성해야 한다.

class NewsRepository(
    ...,
    // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
    private val externalScope: CoroutineScope
) { ... }

NewsRepository는 외부 CoroutineScope를 사용하여 앱 지향 작업을 실행할 준비가 되어 있으므로 데이터 소스 호출을 실행하고 그 범위에서 시작된 새 코루틴으로 결과를 저장해야 한다

class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource,
    private val externalScope: CoroutineScope
) {
    /* ... */

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        return if (refresh) {
            externalScope.async {
                newsRemoteDataSource.fetchLatestNews().also { networkResult ->
                    // Thread-safe write to latestNews.
                    latestNewsMutex.withLock {
                        latestNews = networkResult
                    }
                }
            }.await()
        } else {
            return latestNewsMutex.withLock { this.latestNews }
        }
    }
}

async는 외부 범위에서 코루틴을 시작하는 데 사용된다. 네트워크 요청이 다시 발생하고 결과가 캐시에 저장될 때까지 정지하기 위해 await가 새 코루틴에서 호출된다. 그때 사용자가 여전히 화면에 있다면 최신 뉴스가 표시된다. 사용자가 화면에서 벗어나면 await가 취소되지만 async 내부의 로직은 계속 실행된다.

데이터 저장 및 디스크에서 가져오기

북마크한 뉴스와 사용자 환경설정과 같은 데이터를 저장하려 한다고 가정해 보자. 이러한 유형의 데이터는 사용자가 네트워크에 연결되어 있지 않더라도 프로세스가 종료된 후에도 남아 있어 액세스할 수 있어야 한다.

작업 중인 데이터가 프로세스 중단 후에도 유지되어야 하는 경우 다음 방법 중 하나로 데이터를 디스크에 저장해야 한다.

  • 쿼리해야 하거나 참조 무결성이 필요하거나 부분 업데이트가 필요한 대규모 데이터 세트의 경우 Room 데이터베이스에 데이터를 저장하는 방법이 있다. 뉴스 앱 예시에서는 뉴스 기사나 작성자를 데이터베이스에 저장할 수 있다.
  • 쿼리하거나 부분적으로 업데이트하지 않고 검색 및 설정해야 하는 소규모 데이터 세트에는 DataStore를 사용합니다. 뉴스 앱 예시에서 사용자의 기본 날짜 형식 또는 기타 표시 환경설정은 Datastore에 저장할 수 있다.
  • JSON 객체와 같은 데이터 청크의 경우 파일을 사용한다.

정보 소스 섹션에서 언급했듯이 각 데이터 소스는 하나의 소스에서만 작동하며 특정 데이터 유형(예: News, Authors, NewsAndAuthors, UserPreferences)에 대응한다. 데이터 소스를 사용하는 클래스는 데이터가 저장되는 방식(예: 데이터베이스 또는 파일)을 알 수 없다.

데이터 소스로 사용되는 Room

각 데이터 소스는 특정 유형의 데이터에 관해 하나의 소스만 사용해야 하므로, Room 데이터 소스는 데이터 액세스 객체(DAO) 또는 데이터베이스 자체를 매개변수로 수신할 수 있다. 예를 들어 NewsLocalDataSource는 NewsDao 인스턴스를 매개변수로 사용하고 AuthorsLocalDataSource는 AuthorsDao 인스턴스를 사용할 수 있다.

추가 로직이 필요하지 않은 경우 테스트에서 쉽게 대체할 수 있는 인터페이스이므로 DAO를 저장소에 직접 삽입할 수 있다.

Room API 작업에 관한 자세한 내용 : Room 가이드를 참고

데이터 소스로 사용되는 Datastore

DataStore는 사용자 설정과 같은 키-값 쌍을 저장하는 데 적합하다. 예를 들어 시간 형식, 알림 환경설정, 사용자가 뉴스 항목을 읽은 후 표시하거나 숨길지 여부 등이 있다. Datastore는 유형이 지정된 객체를 Protocol Buffers와 함께 저장할 수도 있습니다.

다른 객체와 마찬가지로 Datastore가 지원하는 데이터 소스에는 특정 유형이나 앱의 특정 부분에 해당하는 데이터가 포함되어야 한다(DataStore 읽기는 값이 업데이트될 때마다 방출되는 흐름으로 노출되므로) 따라서 관련 환경설정을 동일한 Datastore에 저장해야 한다.

예를 들어 알림 관련 환경설정만 처리하는 NotificationsDataStore와 뉴스 화면 관련 환경설정만 처리하는 NewsPreferencesDataStore가 있을 수 있다. 이렇게 하면 업데이트 범위를 더 잘 지정할 수 있다. newsScreenPreferencesDataStore.data 흐름이 화면과 관련된 환경설정이 변경될 때만 발생하기 때문이다. 또한 객체의 수명 주기는 뉴스 화면이 표시되어 있는 동안에만 표시될 수 있으므로 더 짧을 수 있다.

DataStore API 작업에 대한 자세한 내용 : DataStore 가이드를 참고

데이터 소스로 사용되는 파일

JSON 객체나 비트맵과 같은 큰 객체로 작업할 때는 File 객체로 작업하고 스레드 전환을 처리해야 한다.

파일 스토리지 작업에 관한 자세한 내용 : 스토리지 개요 페이지를 참고

데이터 영역 : 오프라인 우선

참고 링크

도메인 레이어

자세한 내용 : 도메인 레이어
도메인 레이어는 UI 레이어와 데이터 레이어 사이에 있는 선택적 레이어이다.
도메인 레이어는 복잡한 비즈니스 로직이나 여러 ViewModel에서 재사용되는 간단한 비즈니스 로직의 캡슐화를 담당하고 모든 앱에 이런 요구사항이 있는 것은 아니므로 선택사항이다 따라서 복잡성을 처리하거나 재사용성을 선호하는 등 필요한 경우에만 도메인 레이어를 사용해야 한다.

이 레이어의 클래스는 일반적으로 사용 사례 또는 상호작용자라고 한다. 각 사용 사례는 하나의 기능을 담당해야 한다. 예를 들어 여러 ViewModel에서 시간대를 사용하여 화면에 적절한 메시지를 표시하는 경우 앱에는 GetTimeZoneUseCase 클래스가 있다

도메인 레이어 이름 지정 규칙

UseCase의 이름은 각각 담당하고 있는 단일 작업에 따라 지정할 수 있다.

현재 시제의 동사 + 명사/대상(선택사항) + UseCase.

예를 들면 FormatDateUseCase, LogOutUserUseCase, GetLatestNewsWithAuthorsUseCase, MakeLoginRequestUseCase가 있다.

보편적인 권장사항

앱 구성요소에 데이터를 저장한다.

  • Activity, Service, broadcast receiver와 같은 앱의 진입점을 데이터 소스로 지정하지 말도록 한다. 진입점과 관련된 데이터 일부만 가져오도록 다른 구성요소에 맞춰 조정해야 한다. 각 앱 구성요소는 사용자와 기기의 상호작용 및 시스템의 전반적인 현재 상태에 따라 단기간만 지속된다는 점을 명시하도록 한다.

Android 클래스의 종속 항목을 줄인다.

  • 앱 구성요소는 Android 프레임워크 SDK API(Ex. Context, Toast)를 사용하는 유일한 클래스여야 한다.
  • 앱 구성요소와 별도로 앱의 다른 클래스를 추상화하면 테스트 가능성은 높이고 앱 내의 결합도는 줄일 수 있다.

앱의 다양한 모듈 간 책임이 잘 정의된 경계를 만든다.

  • 예를들어 네트워크에서 데이터를 로드하는 코드를 코드베이스의 여러 클래스나 패키지 전체에 분산하면 안 된다.

각 모듈에서 가능하면 적게 노출한다.

  • 모듈의 내부 구현 세부정보를 노출시키지 않도록 한다. 단기적으로 편하게 작업할 수 있을지 몰라도 코드베이스가 발전함에 따라 기술적 문제가 발생할 수 있다.

다른 앱과 차별되도록 앱의 고유한 핵심에 초점을 맞춘다.

  • 앱의 차별화위해 시간과 에너지를 쏟고, 반복적인 상용구는 Jetpact 라이브러리와 기타 권장 라이브러리가 처리하도록 만든다.

앱의 각 부분을 독립적으로 테스트할 수 있도록 한다.

  • 예를 들어 네트워크에서 데이터를 가져오기 위한 API를 잘 정의하면 더 쉽게 테스트 할 수 있다. 만약 두 모듈의 로직을 한곳에 혼합하거나, 네트워크 코드를 전체 코드베이스에 분산하면 테스트하기 훨씬 더 어려워진다.

Type은 concurrency policy를 담당한다.

  • Type은 기본 안전성을 갖춰야 한다. 즉 기본 스레드에서 차단 없이 안전하게 호출될 수 있어야 한다.

가능한 관련성이 높은 최신 데이터를 보존하도록 한다.

  • 이렇게 하면 기기가 오프라인 모드일 때도 사용자가 앱의 기능을 이용할 수 있다.
  • 모바일 디바이스에서는 네트워크 상태가 안정적이지 못하기 때문에 항상 수신 상태를 신경써야 한다.
profile
안녕하세요 😊

0개의 댓글