데이터 레이어
데이터 레어어는 0개부터 수많은 데이터 소스를 가진 Repository들로 이뤄져있습니다. 앱에서 각각의 다른 유형의 데이터를 다루기 위해 Repository를 생성해야 합니다.
Repository는 다음과 같은 역할을 합니다.
- 앱의 나머지 부분에 데이터 노출
- 데이터 변경사항을 한 곳에 집중
- 여러 데이터 소스 간의 충돌 해결
- 앱의 나머지 부분에서 데이터 소스 추상화
- 비즈니스 로직 포함
각 데이터 소스 클래스는 파일, 네트워크 소스, 로컬 데이터 베이스와 같은 하나의 데이터 소스만 사용해야 합니다.
계층 구조의 다른 레이어는 데이터 소스에 직접 액세스해서는 안 됩니다. 데이터 영역의 진입점은 항상 Repository입니다.
State holder(UI 레이어 가이드 참고) 또는 use case(도메인 레이어 가이드 참고)에는 데이터 소스가 직접 종속 항목으로 있어서는 안 됩니다.
이 레이어에서 노출된 데이터는 변경 불가능해야 합니다. 그래야 값을 일관되지 않은 상태로 만들 위험이 있는 다른 클래스에 의한 조작이 불가능해집니다. 또한 변경 불가능한 데이터는 여러 스레드에서 안전하게 처리될 수 있습니다.
API 노출
데이터 영역의 클래스는 일반적으로 One-shot 생성, 조회, 업데이트 및 삭제(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) { ... }
}
Threading
대표 비즈니스 모델
예를 들어 기사 정보뿐만 아니라 수정 기록, 사용자 댓글, 일부 메타데이터도 반환하는 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
)
화면에 가사 콘텐츠와 작성자에 관한 기본 정보만 표시하므로 앱은 가사에 관한 많은 정보를 필요로 하지 않습니다. 모델 클래스를 분리하고 저장소에서 계층 구조의 다른 레이어에 필요한 데이터만 노출하도록 하는 것이 좋습니다.
data class Article(
val id: Long,
val title: String,
val content: String,
val publicationDate: Date,
val authorName: String,
val readTimeMin: Int
)
모델 클래스를 분리하면 다음과 같은 이점이 있습니다.
- 필요한 수준으로 데이터를 줄여 앱 메모리를 절약합니다.
- 앱에서 사용하는 데이터 유형에 맞게 외부 데이터 유형을 조정합니다. 예를 들어 앱은 날짜를 나타내는 데 다른 데이터 유형을 사용할 수 있습니다.
- 이를 통해 관심사를 더 잘 분리할 수 있습니다. 예를 들어 모델 클래스가 미리 정의된 경우 대규모 팀원이 기능의 네트워크 레이어와 UI 레이어에서 개별적으로 작업할 수 있습니다.
최소한 데이터 소스가 앱의 나머지 부분에서 예상하는 데이터와 일치하지 않는 데이터를 수신하는 경우에는 새 모델을 만드는 것이 좋습니다.
일반적인 데이터 영역 작업들
네트워크 요청
네트워크 요청은 Android 앱에서 실행할 수 있는 가장 일반적인 작업 중 하나입니다. 네트워크 요청을 할 때 DataSource와 Repository를 만들게 됩니다.
DataSource
데이터 소스는 네트워크에서 최신 뉴스를 가져오는 기본 안정성을 갖춘 방법을 제공해야 합니다. 이 경우 작업을 실행항 CoroutineDispatcher 또는 Executor에 종속 항목을 가져와야 합니다.
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>
}
Repository
예시의 Repository는 추가 로직이 필요하지 않아서 네트워크 데이터 소스의 프록시 역할을 합니다.
// NewsRepository is consumed from other layers of the hierarchy.
class NewsRepository(
private val newsRemoteDataSource: NewsRemoteDataSource
) {
suspend fun fetchLatestNews(): List<ArticleHeadline> =
newsRemoteDataSource.fetchLatestNews()
}
제가 여기까지 정리하면서 들었던 의문은 오픈소스 예제들을 보면 Repository에서 withContext를 사용하던데 어떤 차이가 있는걸까? 였습니다.
우선 NowinAndroid를 보면 DefaultSearchContentsRepository에서 withContext를 활용하여 Dispatcher를 IO로 변경하는 작업을 진행합니다.
override suspend fun populateFtsData() {
withContext(ioDispatcher) {
newsResourceFtsDao.insertAll(
newsResourceDao.getNewsResources(
useFilterTopicIds = false,
useFilterNewsIds = false,
)
.first()
.map(PopulatedNewsResource::asFtsEntity),
)
topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() })
}
}
Pokedex에서도 Repository에서 Dispatchers.IO를 관리해주는 코드를 볼 수 있습니다.
이런 예시를 통해 안드로이드 공식 홈페이지에서 제공된 예시 코드는 DataSource의 코드를 그저 One-Shot으로 진행되는 Repository였기 때문에 DataSource에서 처리했을 것 같다는 게 제 생각이었습니다. 즉, 만약 Repository에서 백그라운드 스레드를 필요로 하지 않는 이상, 어디서 하든 큰 문제는 없지만 Dispatchers.IO로 적용시켜주는 것은 중요하다! 라고 볼 수도 있을 것 같습니다.
메모리 내 데이터 캐싱 구현
네트워크 요청 결과 캐시
여러 스레드에서 읽기 및 쓰기를 금지하기 위해 Mutex가 사용됩니다.
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 }
}
}
작업을 화면보다 길게 유지
네트워크 요청이 진행되는 동안 사용자가 화면을 벗어나면 취소되고 결과가 캐시되지 않습니다. 이럴 때는 호출자의 CoroutineScope를 사용해서는 안됩니다. 대신 Repository는 수명 주기에 연결된 CoroutineScope를 사용해야 합니다.
종속 항목 삽입 권장사항을 따르려면 Repository는 자체 CoroutineScope를 만드는 대신 생성자의 매개변수로 범위를 수신해야 합니다. 저장소는 대부분의 작업을 백그라운드 스레드에서 해야 하므로 CoroutineScope를 Dispatchers.Default 또는 자체 스레드 풀로 구성해야 합니다.
class NewsRepository(
...,
// This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).
private val externalScope: CoroutineScope
) { ... }
Repository는 외부 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를 사용
- JSON 객체와 같은 데이터 청크의 경우 파일을 사용
'Android' 카테고리의 다른 글
[Android] Kakao Login 구현하기1 - 기본 설정 (1) | 2023.09.30 |
---|---|
[Android] 도메인 레이어 (0) | 2023.09.30 |
[Android] UI 레이어 - 이벤트와 상태 (0) | 2023.09.29 |
[Android] 앱 아키텍처 UI레이어와 UiState (0) | 2023.09.28 |
[Android-Test] Android Compose Test 도입하기 - 실전 (0) | 2023.09.27 |
댓글