UI 레이어에는 UI 관련 상태와 UI 로직이 포함되어 있는 반면, 데이터 레이어에는 애플리케이션 데이터와 비즈니스 로직이 포함되어 있음. 비즈니스 로직은 앱에 값을 부여하는 요소. 비즈니스 로직은 응용 프로그램 데이터를 생성, 저장 및 변경하는 방법을 결정하는 실제 비즈니스 규칙으로 구성됨.
관심사 분리를 통해 데이터 계층을 여러 화면에서 사용하고 앱의 여러 부분 사이에 정보를 공유하고 단위 테스트를 위해 UI 외부에서 비즈니스 로직을 재현할 수 있음.
데이터 계층은 각각 0~여러개의 데이터 소스를 포함할 수 있는 리포지토리로 구성됨. 앱에서 처리하는 다양한 데이터 유형마다 리포지토리 클래스를 만들어야함.
예를 들어 영화 관련 데이터에 대한 MoviesRepository 클래스를 생성하거나 결제 관련 데이터에 대한 PaymentsRepository 클래스를 생성할 수 있음.
⭐ 리포지토리 클래스가 담당하는 작업
각 데이터 소스 클래스는 파일, 네트워크 소스 또는 로컬 데이터베이스 등 하나의 데이터 소스에 대해서만 작업을 담당해야함.
데이터 소스 클래스는 데이터 작업을 위해 애플리케이션과 시스템 사이를 연결하는 다리.
계층구조의 다른 계층은 데이터 소스에 직접 액세스해서는 안됨.
데이터 계층의 진입점은 항상 리포지토리 클래스.
state holder 클래스 또는 use case 클래스는 데이터 소스를 직접 종속성으로 가져서는 안됨.
리포지토리 클래스를 진입점으로 사용하면 아키텍처의 다양한 계층을 독립적으로 확장할 수 있음.
의존성 삽입 권장사항에 따라 리포지토리는 데이터 소스를 생성자의 종속 항목으로 사용함.
class ExampleRepository(
private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
private val exampleLocalDataSource: ExampleLocalDataSource // database
) { /* ... */ }
데이터 계층의 클래스는 일반적으로 일회성 CRUD(생성, 읽기, 업데이트, 삭제) 호출을 수행하거나 시간에 따른 데이터 변경 사항에 대한 알림을 받는 함수를 노출함. 데이터 영역은 이러한 각 경우에 대해 다음을 노출해야함.
일회성 작업
데이터 영역은 코틀린에서 suspend 함수를 노출해야함.
자바의 경우 데이터 계층은 작업 결과를 알리는 콜백을 제공하는 함수나 RxJava Single, Maybe 또는 Completable 타입을 노출해야함.
시간 경과에 따른 데이터 변경 알림을 받으려면 데이터 영역이 코틀린 flow를 노출해야함.
자바의 경우 데이터 계층은 세 데이터를 내보내는 콜백이나 RxJava Observable 또는 Flowable 타입을 노출해야함.
class ExampleRepository(
private val exampleRemoteDataSource: ExampleRemoteDataSource, // network
private val exampleLocalDataSource: ExampleLocalDataSource // database
) {
val data: Flow<Example> = ...
suspend fun modifyData(example: Example) { ... }
}
더 복잡한 비즈니스 요구 사항과 관련된 일부 경우에는 리포지토리가 다른 리포지토리에 의존해야 할 수도 있음. 이는 관련된 데이터가 여러 데이터 소스에서 집계된 것이기 때문이거나 책임이 다른 리포지토리 클래스에 캡슐화되어야 하기 때문일 수 있음.
예를 들어 사용자 인증 데이터를 처리하는 저장소인 UserRepository는 요구 사항을 충족하기 위해 LoginRepository 및 RegistrationRepository와 같은 다른 저장소에 의존할 수 있음.
⭐ 참고: 흔히 다른 리포지토리 클래스 관리자에 의존하는 리포지토리 클래스 (예: UserRepository 대신 UserManager)를 호출함. 원하는 경우 이 명명 규칙을 사용할 수 있음.
각 리포지토리가 하나의 정보 소스를 정의하는 것이 중요함.
정보 소스는 항상 일관되고 정확하며 최신 상태인 데이터를 포함함.
실제로 리포지토리에 노출되는 데이터는 항상 정보 소스에서 직접 가져온 데이터여야 함.
정보 소스는 데이터 소스(예: 데이터베이스)이거나 리포지토리에 포함될 수 있는 메모리 내 캐시일 수도 있음.
리포지토리는 서로 다른 데이터 소스를 결합하고 데이터 소스 간의 잠재적 충돌을 해결하여 정기적으로 또는 사용자 입력 이벤트에 따라 정보 소스를 업데이트함.
앱 리포지토리마다 정보 소스가 다를 수 있음. 예를 들어 LoginRepository 클래스는 캐시를 정보 소스로 사용하고 PaymentsRepository 클래스는 네트워크 데이터 소스를 사용할 수 있음.
오프라인 우선 지원을 제공하려면 데이터베이스와 같은 로컬 데이터 소스를 정보 소스로 사용하는 것이 좋음.
데이터 소스와 리포지토리 호출은 기본 스레드에서 호출하기에 안전하도록 기본 안전성이 보장되어야함. 이러한 클래스는 장기 실행 차단 작업을 실행할 때 로직 실행을 적절한 스레드로 이동시킴.
예를 들어, 데이터 소스가 파일에서 읽을 수 있거나 리포지토리가 큰 목록에서 값비싼 필터링을 수행하려면 기본 안전성이 보장되어야함.
대부분의 데이터 소스는 이미 Room, Retrofit 또는 Ktor에서 제공하는 suspend 메서드 호출과 같은 기본 안전성을 갖춘 API를 제공. API를 사용할 수 있게 되면 리포지토리에서 API를 활용할 수 있음.
코틀린 사용자의 경우 코루틴을 사용하는 것이 좋음.
데이터 계층의 클래스 인스턴스는 일반적으로 앱의 다른 객체에서 참조되어 가비지 수집 루트에서 연결할 수 있는 한 메모리에 유지됨.
클래스에 메모리 내 데이터(예: 캐시)가 포함된 경우 특정 기간 동안 해당 클래스의 동일한 인스턴스를 재사용할 수 있음. 이를 클래스 인스턴스의 생명주기라고도 함.
클래스의 책임이 전체 애플리케이션에 중요한 경우 해당 클래스의 인스턴스 범위를 Application 클래스로 지정할 수 있음. 이렇게하면 인스턴스가 애플리케이션 생명 주기를 따르게 됨.
또는 앱의 특정 플로우(예: 등록 또는 로그인 플로우)에서만 동일한 인스턴스를 재사용해야 하는 경우 해당 플로우의 수명 주기를 소유하는 클래스로 인스턴스 범위를 지정해야함.
예를 들어 메모리 내 데이터가 포함된 RegistrationRepository의 범위를 RegistrationActivity 또는 registration 플로우의 네비게이션 그래프로 지정할 수 있음.
각 인스턴스의 수명 주기는 앱 내에서 종속성을 제공하는 방법을 결정하는 중요한 요소.
종속성을 관리하고 종속성 컨테이너로 범위를 지정할 수 있는 의존성 주입 모범 사례를 따르는 것이 좋음.
데이터 영역에서 공개하려는 데이터 모델은 다양한 데이터 소스에서 얻는 정보의 하위 집합일 수 있음.
이상적으로는 네트워크와 로컬 등 다양한 데이터 소스가 애플리케이션에 필요한 정보만 반환해야함.
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
)
앱은 기사의 내용과 작성자에 대한 기본 정보만 화면에 표시하기 때문에 기사에 대한 많은 정보가 필요하지 않음.
모델 클래스를 분리하고 저장소가 계층 구조의 다른 계층에 필요한 데이터만 노출하도록 하는 것이 좋음.
data class Article(
val id: Long,
val title: String,
val content: String,
val publicationDate: Date,
val authorName: String,
val readTimeMin: Int
)
⭐ 모델 클래스를 분리할 경우 이점
이 방법을 확장하여 앱 아키텍처의 다른 부분 (예: 데이터 소스 클래스 및 ViewModel)에서도 별도의 모델 클래스를 정의할 수 있음.
그러나 이를 위해서는 적절하게 문서화하고 테스트해야 하는 추가 클래스 및 로직을 정의해야함.
최소한 데이터 소스가 앱의 나머지 부분에서 예상하는 데이터와 일치하지 않는 데이터를 수신하는 경우에는 새 모델을 만드는 것이 좋음.
데이터 레이어에서 중요도에 따라 다양한 유형의 작업(예: UI 지향, 앱 지향, 비즈니스 지향 작업)을 처리할 수 있음.
UI 지향 작업
UI 지향 작업은 사용자가 특정 화면에 있을 때만 관련이 있고 사용자가 화면에서 멀어지면 취소됨. 예를 들어 데이터베이스에서 얻은 일부 데이터를 표시함.
UI 지향 작업은 일반적으로 UI 레이어에 의해 트리거되며 호출자의 수명 주기(예: ViewModel의 수명 주기)를 따름.
앱 지향 작업
앱 지향 작업은 앱이 열려 있는 한 관련이 있음. 앱이 닫히거나 프로세스가 종료되면 이러한 작업은 취소됨. 예를 들어 네트워크 요청의 결과를 필요에 따라 나중에 사용할 수 있도록 캐시하는 경우가 있음.
비즈니스 지향 작업
비즈니스 지향 작업은 취소할 수 없음. 프로세스 종료 후에도 유지됨. 예를 들어 사용자가 프로필에 게시하고 싶은 사진 업로드를 완료하는 작업이 있음.
리포지토리 및 데이터 소스와의 상호작용은 성공하거나 실패 시 예외를 발생시킬 수 있음.
코루틴과 flow의 경우 Kotlin의 기본 제공 오류 처리 메커니즘을 사용해야함.
suspend 함수에 의해 트리거될 수 있는 오류의 경우 적절한 경우 try/catch 블록을 사용하며 흐름에서는 catch 연산자를 사용함. 이 접근 방식을 사용하면 데이터 레이어를 호출할 때 UI 레이어가 예외를 처리해야함.
데이터 레이어는 다양한 유형의 오류를 이해하고 처리하며 맞춤 예외(예: UserNotAuthenticatedException)를 사용하여 이를 노출할 수 있음.
⭐ 참고: Result 클래스를 사용해서도 데이터 레이어와의 상호작용 결과를 모델링할 수 있음.
이 패턴은 결과 처리의 일환으로 발생할 수 있는 오류 및 기타 신호를 모델링함.
이 패턴에서 데이터 레이어는 T 대신 Result<T>
유형을 반환하여 특정 시나리오에서 발생할 수 있는 알려진 오류를 UI에 알림. 이는 적절한 예외 처리 기능이 없는 반응형 프로그래밍 API(예: LiveData)에 필요
네트워크 요청은 Android 앱에서 실행할 수 있는 가장 일반적인 작업 중 하나.
뉴스 앱은 네트워크에서 가져온 최신 뉴스를 사용자에게 표시해야함.
따라서 앱에는 네트워크 작업을 관리하기 위한 데이터 소스 클래스 NewsRemoteDataSource가 필요.
앱의 나머지 부분에 정보를 노출하기 위해 뉴스 데이터에 관한 작업을 처리하는 새로운 저장소 NewsRepository를 만듦.
요구사항은 사용자가 화면을 열 때 항상 최신 뉴스를 업데이트하도록 하는 것. 따라서 이는 UI 지향 작업임.
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 is consumed from other layers of the hierarchy.
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource
) {
suspend fun fetchLatestNews(): List<ArticleHeadline> =
newsRemoteDataSource.fetchLatestNews()
}
뉴스 앱에 새로운 요구사항이 도입되었다고 가정할 경우,
사용자가 화면을 열면 이전에 요청이 생성된 경우 캐시된 뉴스가 사용자에게 표시되어야함.
그러지 않으면 앱이 최신 뉴스를 가져오기 위해 네트워크 요청을 해야함.
새로운 요구사항이 있으므로 앱은 사용자가 앱을 열고 있는 동안 메모리에 최신 뉴스를 보존해야함. 따라서 이는 앱 지향 작업임.
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 }
}
}
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는 외부 scope에서 코루틴을 시작하는 데 사용됨.
네트워크 요청이 다시 발생하고 결과가 캐시에 저장될 때까지 정지하기 위해 await가 새 코루틴에서 호출됨.
그때 사용자가 여전히 화면에 있다면 최신 뉴스가 표시됨.
사용자가 화면에서 벗어나면 await가 취소되지만 async 내부의 로직은 계속 실행됨.
북마크한 뉴스와 사용자 환경설정과 같은 데이터를 저장하려 한다고 가정할 경우, 이러한 유형의 데이터는 사용자가 네트워크에 연결되어 있지 않더라도 프로세스가 종료된 후에도 남아 있어 액세스할 수 있어야함.
작업 중인 데이터가 프로세스 중단 후에도 유지되어야 하는 경우 다음 방법 중 하나로 데이터를 디스크에 저장해야함.
쿼리해야 하거나 참조 무결성이 필요하거나 부분 업데이트가 필요한 대규모 데이터 세트의 경우 Room 데이터베이스에 데이터를 저장함. 뉴스 앱 예시에서는 뉴스 기사나 작성자를 데이터베이스에 저장할 수 있음.
쿼리하거나 부분적으로 업데이트하지 않고 검색 및 설정해야 하는 소규모 데이터 세트에는 DataStore를 사용함. 뉴스 앱 예시에서 사용자의 기본 날짜 형식 또는 기타 표시 환경설정은 Datastore에 저장할 수 있음.
JSON 객체와 같은 데이터 청크의 경우 파일을 사용함.
각 데이터 소스는 하나의 소스에서만 작동하며 특정 데이터 유형(예: News, Authors, NewsAndAuthors, UserPreferences)에 대응함.
데이터 소스를 사용하는 클래스는 데이터가 저장되는 방식(예: 데이터베이스 또는 파일)을 알 수 없음.
📝 데이터 소스로 사용되는 Room
각 데이터 소스는 특정 유형의 데이터에 관해 하나의 소스만 사용해야 하므로, Room 데이터 소스는 데이터 액세스 객체(DAO) 또는 데이터베이스 자체를 매개변수로 수신할 수 있음.
예를 들어 NewsLocalDataSource는 NewsDao 인스턴스를 매개변수로 사용하고 AuthorsLocalDataSource는 AuthorsDao 인스턴스를 사용할 수 있음.
추가 로직이 필요하지 않은 경우 테스트에서 쉽게 대체할 수 있는 인터페이스이므로 DAO를 저장소에 직접 삽입할 수 있음.
📝 데이터 소스로 사용되는 Datastore
DataStore는 사용자 설정과 같은 키-값 쌍을 저장하는 데 적합함.
예를 들어 시간 형식, 알림 환경설정, 사용자가 뉴스 항목을 읽은 후 표시하거나 숨길지 여부 등.
Datastore는 유형이 지정된 객체를 프로토콜 버퍼와 함께 저장할 수도 있음.
다른 객체와 마찬가지로 Datastore가 지원하는 데이터 소스에는 특정 유형이나 앱의 특정 부분에 해당하는 데이터가 포함되어야함.
따라서 관련 환경설정을 동일한 Datastore에 저장해야함.
예를 들어 알림 관련 환경설정만 처리하는 NotificationsDataStore와 뉴스 화면 관련 환경설정만 처리하는 NewsPreferencesDataStore가 있을 수 있음. 이렇게 하면 업데이트 범위를 더 잘 지정할 수 있음.
newsScreenPreferencesDataStore.data 플로우가 화면과 관련된 환경설정이 변경될 때만 발생하기 때문.
또한 객체의 수명 주기는 뉴스 화면이 표시되어 있는 동안에만 표시될 수 있으므로 더 짧을 수 있음.
📝 데이터 소스로 사용되는 파일
JSON 객체나 비트맵과 같은 큰 객체로 작업할 때는 File 객체로 작업하고 스레드 전환을 처리해야함.
뉴스 앱에 또 다른 요구사항이 도입되었다고 가정.
따라서 비즈니스 지향 작업이 됨.
이렇게 하면 사용자가 앱을 열 때 기기가 연결되지 않아도 사용자가 최근 뉴스를 볼 수 있음.
WorkManager를 사용하면 신뢰할 수 있는 비동기 작업을 쉽게 예약할 수 있으며 제약 조건을 관리할 수 있음. 영구 작업에 권장되는 라이브러리.
위에 정의된 작업을 실행하기 위해 Worker 클래스인 RefreshLatestNewsWorker가 생성됨.
이 클래스는 최신 뉴스를 가져와서 디스크에 캐시하기 위해 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)
}
}
앱을 시작할 때 작업을 트리거해야 하는 경우 Initializer에서 저장소를 호출하는 앱 시작 라이브러리를 사용하여 WorkManager 요청을 트리거하는 것이 좋음.
의존성 주입 권장사항은 앱을 테스트할 때 유용. 또한 외부 리소스와 통신하는 클래스의 인터페이스에 의존하는 것이 유용함. 단위를 테스트할 때 종속 항목의 가짜 버전을 삽입하여 확정적이고 신뢰할 수 있는 테스트를 할 수 있음.
단위 테스트
데이터 레이어를 테스트할 때는 일반 테스트 안내가 적용됨. 단위 테스트의 경우 필요한 경우 실제 객체를 사용하고 파일에서 읽거나 네트워크에서 읽는 등 외부 소스에 연결되는 모든 종속 항목을 가짜로 만들어야 함.
통합 테스트
외부 소스에 액세스하는 통합 테스트는 실제 기기에서 실행되어야 하므로 확정성이 떨어짐. 통합 테스트의 안정성을 높이려면 통제된 환경에서 이러한 테스트를 실행하는 것이 좋음.
데이터베이스의 경우 Room에서는 테스트에서 완전히 제어할 수 있는 메모리 내 데이터베이스를 만들 수 있음.
네트워킹의 경우 HTTP 및 HTTPS 호출을 가짜로 만들고 요청이 정상적으로 이루어졌는지 확인할 수 있는 WireMock 또는 MockWebServer와 같은 인기 있는 라이브러리가 있음.