데이터 영역

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

UI 레이어에는 UI 관련 상태 및 UI 로직이 포함되지만 데이터 영역에는 애플리케이션 데이터비즈니스 로직이 포함됩니다. 비즈니스 로직은 앱에 가치를 부여하는 요소로, 애플리케이션의 데이터 생성, 저장, 변경 방식을 결정하는 실제 비즈니스 규칙으로 구성됩니다.

이렇게 관심사를 분리하면 데이터 영역을 여러 화면에서 사용하고, 앱의 여러 부분 간에 정보를 공유하고, 단위 테스트를 위해 UI 외부에서 비즈니스 로직을 재현할 수 있습니다. 데이터 영역의 이점에 관한 자세한 내용은 아키텍처 개요 페이지를 참고하세요.

데이터 영역 아키텍처

데이터 영역은 0개부터 여러 개의 데이터 소스를 각각 포함할 수 있는 저장소로 구성됩니다. 앱에서 처리하는 다양한 유형의 데이터별로 저장소 클래스를 만들어야 합니다. 예를 들어 영화 관련 데이터에는 MoviesRepository 클래스를 만들거나 결제 관련 데이터에는 PaymentsRepository 클래스를 만들 수 있습니다.

일반적인 아키텍처에서 데이터 영역의 저장소는 앱의 나머지 부분에 데이터를 제공하고 데이터 소스에 종속됩니다.
그림 1. 앱 아키텍처에서 UI 레이어의 역할

저장소 클래스에서 담당하는 작업은 다음과 같습니다.

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

각 데이터 소스 클래스는 파일, 네트워크 소스, 로컬 데이터베이스와 같은 하나의 데이터 소스만 사용해야 합니다. 데이터 소스 클래스는 데이터 작업을 위해 애플리케이션과 시스템 간의 가교 역할을 합니다.

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

이 레이어에서 노출된 데이터는 변경 불가능해야 합니다. 그래야 값을 일관되지 않은 상태로 만들 위험이 있는 다른 클래스에 의한 조작이 불가능해집니다. 또한 변경 불가능한 데이터는 여러 스레드에서 안전하게 처리될 수 있습니다. 자세한 내용은 스레딩 섹션을 참고하세요.

종속 항목 삽입 권장사항에 따라 저장소는 데이터 소스를 생성자의 종속 항목으로 사용합니다.

class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }

API 노출

데이터 영역의 클래스는 일반적으로 원샷 생성, 조회, 업데이트 및 삭제(CRUD) 호출을 실행하거나 시간 경과에 따른 데이터 변경사항에 관해 알림을 받는 함수를 노출합니다. 데이터 영역은 다음과 같은 경우에 각 항목을 노출해야 합니다.

  • 원샷 작업: 데이터 영역에서 Kotlin의 정지 함수를 노출해야 합니다. 자바 프로그래밍 언어의 경우 데이터 영역에서 작업 결과 또는 RxJava Single, Maybe 또는 Completable 유형에 대한 콜백을 제공하는 함수를 노출해야 합니다.
  • 시간 경과에 따른 데이터 변경사항에 관해 알림을 받으려면: 데이터 영역에서 Kotlin의 흐름을 노출해야 합니다. 자바 프로그래밍 언어의 경우 데이터 영역에서 새 데이터 또는 RxJava Observable 또는 Flowable 유형을 내보내는 콜백을 노출해야 합니다.
class ExampleRepository(
    private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
    private val exampleLocalDataSource: ExampleLocalDataSource // database
) {

    val data: Flow<Example> = ...

    suspend fun modifyData(example: Example) { ... }
}

이 가이드의 이름 지정 규칙

이 가이드에서 저장소 클래스의 이름은 담당하는 데이터의 이름을 따라 지정됩니다. 규칙은 다음과 같습니다.

데이터 유형 + 저장소

예: NewsRepository, MoviesRepository 또는 PaymentsRepository

데이터 소스 클래스의 이름은 담당하는 데이터와 사용하는 소스의 이름을 따라 지정됩니다. 규칙은 다음과 같습니다.

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

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

구현 세부정보에 따라 데이터 소스의 이름을 지정하지 마세요(예: UserSharedPreferencesDataSource). 해당 데이터 소스를 사용하는 저장소가 데이터 저장 방법을 알 수 없습니다. 이 규칙을 따르면 데이터 소스의 구현을 변경(예: SharedPreferences에서 DataStore로 이전)하면서도 해당 소스를 호출하는 레이어에 영향을 주지 않을 수 있습니다.

여러 수준의 저장소

더 복잡한 비즈니스 요구사항이 포함된 일부 경우에는 저장소가 다른 저장소에 종속되어야 할 수 있습니다. 관련된 데이터가 여러 데이터 소스의 집계이거나 책임이 다른 저장소 클래스에 캡슐화되어야 하기 때문일 수 있습니다.

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

이 예에서 UserRepository는 다른 두 가지 저장소 클래스(다른 로그인 데이터 소스에 종속되는 LoginRepository 및 다른 등록 데이터 소스에 종속되는 RegistrationRepository)에 종속됩니다.
그림 2. 다른 저장소에 종속된 저장소의 종속 항목 그래프

정보 소스

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

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

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

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

스레딩

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

대부분의 데이터 소스는 이미 Room 또는 Retrofit에서 제공하는 정지 메서드 호출과 같은 기본 안전성을 갖춘 API를 제공합니다. API를 사용할 수 있게 되면 저장소에서 API를 활용할 수 있습니다.

스레딩에 관한 자세한 내용은 백그라운드 처리 가이드를 참고하세요. Kotlin 사용자의 경우 코루틴을 사용하는 것이 좋습니다. 자바 프로그래밍 언어에 권장되는 옵션에 관해서는 백그라운드 스레드에서 Android 작업 실행을 참고하세요.

수명 주기

데이터 영역의 클래스 인스턴스는 가비지 컬렉션 루트에서 연결할 수 있는 한 메모리에 남아 있습니다. 이는 대개 앱의 다른 객체에서 참조됩니다.

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

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

각 인스턴스의 수명 주기는 앱 내에서 종속 항목을 제공하는 방법을 결정할 때 중요한 요소입니다. 종속 항목이 관리되고 종속 항목 컨테이너로 범위가 지정될 수 있는 종속 항목 삽입 권장사항을 따르는 것이 좋습니다. Android의 범위 지정에 관한 자세한 내용은 Android 및 Hilt 내 범위 지정 블로그 게시물을 참고하세요.

대표 비즈니스 모델

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

예를 들어 기사 정보뿐만 아니라 수정 기록, 사용자 댓글, 일부 메타데이터도 반환하는 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)에서도 별도의 모델 클래스를 정의할 수 있습니다. 그러나 이를 위해서는 적절하게 문서화하고 테스트해야 하는 추가 클래스 및 로직을 정의해야 합니다. 최소한 데이터 소스가 앱의 나머지 부분에서 예상하는 데이터와 일치하지 않는 데이터를 수신하는 경우에는 새 모델을 만드는 것이 좋습니다.

데이터 작업 유형

데이터 영역에서 중요도에 따라 다양한 유형의 작업(예: UI 지향, 앱 지향, 비즈니스 지향 작업)을 처리할 수 있습니다.

UI 지향 작업

UI 지향 작업은 사용자가 특정 화면에 있을 때만 관련이 있고 사용자가 화면에서 멀어지면 취소됩니다. 예를 들어 데이터베이스에서 얻은 일부 데이터를 표시합니다.

UI 지향 작업은 일반적으로 UI 레이어에 의해 트리거되며 호출자의 수명 주기(예: ViewModel의 수명 주기)를 따릅니다. UI 지향 작업의 예는 네트워크 요청 실행 섹션을 참고하세요.

앱 지향 작업

앱 지향 작업은 앱이 열려 있는 한 관련이 있습니다. 앱이 닫히거나 프로세스가 종료되면 이러한 작업은 취소됩니다. 예를 들어 네트워크 요청의 결과를 필요에 따라 나중에 사용할 수 있도록 캐시하는 경우가 있습니다. 자세한 내용은 메모리 내 데이터 캐싱 구현 섹션을 참고하세요.

이러한 작업은 일반적으로 Application 클래스 또는 데이터 영역의 수명 주기를 따릅니다. 예는 작업을 화면보다 길게 유지 섹션을 참고하세요.

비즈니스 지향 작업

비즈니스 지향 작업은 취소할 수 없습니다. 프로세스 종료 후에도 유지됩니다. 예를 들어 사용자가 프로필에 게시하고 싶은 사진 업로드를 완료하는 작업이 있습니다.

비즈니스 지향 작업의 경우 WorkManager를 사용하는 것이 좋습니다. 자세한 내용은 WorkManager를 사용하여 작업 예약 섹션을 참고하세요.

오류 노출

저장소 및 데이터 소스와의 상호작용은 성공하거나 실패 시 예외를 발생시킬 수 있습니다. 코루틴과 흐름의 경우 Kotlin의 기본 제공 오류 처리 메커니즘을 사용해야 합니다. 정지 함수에 의해 트리거될 수 있는 오류의 경우 적절한 경우 try/catch 블록을 사용하며 흐름에서는 catch 연산자를 사용합니다. 이 접근 방식을 사용하면 데이터 영역을 호출할 때 UI 레이어가 예외를 처리해야 합니다.

데이터 영역은 다양한 유형의 오류를 이해하고 처리하며 커스텀 예외(예: UserNotAuthenticatedException)를 사용하여 이를 노출할 수 있습니다.

코루틴의 오류에 관한 자세한 내용은 코루틴 예외 블로그 게시물을 참고하세요.

일반적인 작업

다음 섹션에서는 Android 앱에서 일반적으로 사용되는 특정 작업을 실행하기 위해 데이터 영역을 사용하고 설계하는 방법의 예를 보여줍니다. 예시는 가이드 앞부분에서 언급된 일반적인 뉴스 앱을 기반으로 합니다.

네트워크 요청

네트워크 요청은 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>
}

NewsApi 인터페이스는 네트워크 API 클라이언트의 구현을 숨깁니다. 인터페이스가 Retrofit 또는 HttpURLConnection의 지원을 받는지에 따라 달라지지 않습니다. 인터페이스에 의존하면 앱에서 API 구현을 교체할 수 있습니다.

저장소 만들기

이 작업의 저장소 클래스에는 추가 로직이 필요하지 않으므로 NewsRepository는 네트워크 데이터 소스의 프록시 역할을 합니다. 이렇게 추가 추상화 계층을 추가할 때의 이점은 메모리 내 캐싱 섹션에 설명되어 있습니다.

// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
    private val newsRemoteDataSource: NewsRemoteDataSource
) {
    suspend fun fetchLatestNews(): List<ArticleHeadline> =
        newsRemoteDataSource.fetchLatestNews()
}

UI 레이어에서 직접 저장소 클래스를 사용하는 방법을 알아보려면 UI 레이어 가이드를 참고하세요.

메모리 내 데이터 캐싱 구현

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

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

캐시

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

네트워크 요청 결과 캐시

편의상 NewsRepository는 변경 가능한 변수를 사용하여 최신 뉴스를 캐시합니다. 여러 스레드에서 읽기 및 쓰기를 금지하기 위해 Mutex가 사용됩니다. 변경 가능하며 변경된 상태와 동시 실행에 관한 자세한 내용은 Kotlin 문서를 참고하세요.

다음 구현은 Mutex로 쓰기가 금지된 저장소의 변수에 최신 뉴스 정보를 캐시합니다. 네트워크 요청 결과가 성공하면 데이터가 latestNews 변수에 할당됩니다.

class NewsRepository(
  private val newsRemoteDataSource: NewsRemoteDataSource
) {
    // Mutex to make writes to cached values thread-safe.
    private val latestNewsMutex = Mutex()

    // Cache of the latest news got from the network.
    private var latestNews: List<ArticleHeadline> = emptyList()

    suspend fun getLatestNews(refresh: Boolean = false): List<ArticleHeadline> {
        if (refresh || latestNews.isEmpty()) {
            val networkResult = newsRemoteDataSource.fetchLatestNews()
            // Thread-safe write to latestNews
            latestNewsMutex.withLock {
                this.latestNews = networkResult
            }
        }

        return latestNewsMutex.withLock { this.latestNews }
    }
}

작업을 화면보다 길게 유지

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

종속 항목 삽입 권장사항을 따르려면 NewsRepository는 자체 CoroutineScope를 만드는 대신 생성자의 매개변수로 범위를 수신해야 합니다. 저장소는 대부분의 작업을 백그라운드 스레드에서 해야 하므로 CoroutineScopeDispatchers.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 내부의 로직은 계속 실행됩니다.

CoroutineScope의 패턴을 자세히 알아보려면 이 블로그 게시물을 참고하세요.

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

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

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

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

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

데이터 소스로 사용되는 Room

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

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

Room API 작업에 관한 자세한 내용은 Room 가이드를 참고하세요.

데이터 소스로 사용되는 Datastore

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

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

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

DataStore API 작업에 대한 자세한 내용은 DataStore 가이드를 참고하세요.

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

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

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

WorkManager를 사용하여 작업 예약

뉴스 앱에 또 다른 요구사항이 도입되었다고 가정해 보겠습니다. 이 앱은 기기가 충전되고 무제한 네트워크에 연결되어 있는 한 최신 뉴스를 정기적으로 자동으로 가져오는 옵션을 사용자에게 제공해야 합니다. 따라서 비즈니스 지향 작업이 됩니다. 이렇게 하면 사용자가 앱을 열 때 기기가 연결되지 않아도 사용자가 최근 뉴스를 볼 수 있습니다.

WorkManager를 사용하면 신뢰할 수 있는 비동기 작업을 쉽게 예약할 수 있으며 제약 조건을 관리할 수 있습니다. 영구 작업에 권장되는 라이브러리입니다. 위에 정의된 작업을 실행하기 위해 Worker 클래스인 FetchLatestNewsWorker가 생성됩니다. 이 클래스는 최신 뉴스를 가져와서 디스크에 캐시하기 위해 NewsRepository를 종속 항목으로 사용합니다.

class RefreshLatestNewsWorker(
    private val newsRepository: NewsRepository,
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result = try {
        newsRepository.refreshLatestNews()
        Result.success()
    } catch (error: Throwable) {
        Result.failure()
    }
}

이 작업 유형의 비즈니스 로직은 자체 클래스에 캡슐화되고 별도의 데이터 소스로 처리되어야 합니다. 그러면 WorkManager는 모든 제약 조건이 충족될 때 작업이 백그라운드 스레드에서 실행되도록 해야 합니다. 이 패턴을 준수하면 필요에 따라 다른 환경의 구현을 신속하게 교체할 수 있습니다.

이 예시에서는 뉴스 관련 작업이 NewsRepository에서 호출되어야 합니다. 그럼 새 데이터 소스를 NewsTasksDataSource 종속 항목으로 삼게 되며 다음과 같이 구현됩니다.

private const val REFRESH_RATE_HOURS = 4L
private const val FETCH_LATEST_NEWS_TASK = "FetchLatestNewsTask"
private const val TAG_FETCH_LATEST_NEWS = "FetchLatestNewsTaskTag"

class NewsTasksDataSource(
    private val workManager: WorkManager
) {
    fun fetchNewsPeriodically() {
        val fetchNewsRequest = PeriodicWorkRequestBuilder<RefreshLatestNewsWorker>(
            REFRESH_RATE_HOURS, TimeUnit.HOURS
        ).setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.TEMPORARILY_UNMETERED)
                .setRequiresCharging(true)
                .build()
        )
            .addTag(TAG_FETCH_LATEST_NEWS)

        workManager.enqueueUniquePeriodicWork(
            FETCH_LATEST_NEWS_TASK,
            ExistingPeriodicWorkPolicy.KEEP,
            fetchNewsRequest.build()
        )
    }

    fun cancelFetchingNewsPeriodically() {
        workManager.cancelAllWorkByTag(TAG_FETCH_LATEST_NEWS)
    }
}

이러한 유형의 클래스는 NewsTasksDataSource 또는 PaymentsTasksDataSource와 같이 책임이 있는 데이터에 따라 이름이 지정됩니다. 특정 데이터 유형과 관련된 모든 작업은 동일한 클래스에 캡슐화되어야 합니다.

앱을 시작할 때 작업을 트리거해야 하는 경우 Initializer에서 저장소를 호출하는 앱 시작 라이브러리를 사용하여 WorkManager 요청을 트리거하는 것이 좋습니다.

WorkManager API 작업에 관한 자세한 내용은 WorkManager 가이드를 참고하세요.

테스트

종속 항목 삽입 권장사항은 앱을 테스트할 때 유용합니다. 또한 외부 리소스와 통신하는 클래스의 인터페이스에 의존하는 것이 유용합니다. 단위를 테스트할 때 종속 항목의 가짜 버전을 삽입하여 확정적이고 신뢰할 수 있는 테스트를 할 수 있습니다.

단위 테스트

데이터 영역을 테스트할 때는 일반 테스트 안내가 적용됩니다. 단위 테스트의 경우 필요한 경우 실제 객체를 사용하고 파일에서 읽거나 네트워크에서 읽는 등 외부 소스에 연결되는 모든 종속 항목을 가짜로 만들어야 합니다.

통합 테스트

외부 소스에 액세스하는 통합 테스트는 실제 기기에서 실행되어야 하므로 확정성이 떨어집니다. 통합 테스트의 안정성을 높이려면 통제된 환경에서 이러한 테스트를 실행하는 것이 좋습니다.

데이터베이스의 경우 Room에서는 테스트에서 완전히 제어할 수 있는 메모리 내 데이터베이스를 만들 수 있습니다. 자세한 내용은 데이터베이스 테스트 및 디버깅 페이지를 참고하세요.

네트워킹의 경우 HTTP 및 HTTPS 호출을 가짜로 만들고 요청이 정상적으로 이루어졌는지 확인할 수 있는 WireMock 또는 MockWebServer와 같은 인기 있는 라이브러리가 있습니다.