MAD Skills 앱 아키텍쳐 - Data 레이어

Architecture: The data layer - MAD Skills (영상, 문서)

💡개념이 헷갈리거나 제가 잘 모르거나 많은 사람들이 잘 모를 것 같은 것 위주로 정리

Data 레이어

레이어드 구조

  • UI Layer
  • Domain Layer
  • Data Layer
    • Repositories
      • RemoteDataSources
      • LocalDataSources

data-layer

역할

  • 앱의 영속적 데이터를 보유하고 관리한다.
  • 영속적 데이터의 접근통로를 제공한다.

Source of truth

Data 레이어에 Source of truth를 적용하려면 여러 데이터공급처(RemoteDataSource, LocalDataSource)가 있더라도 오직 한 곳으로부터 데이터를 공급받을 수 있도록 만들어주어야 한다.

Repository

역할

Data 레이어의 데이터 접근 로직을 구현한다.

이점

  • 데이터 접근 위치를 한 곳으로 모아준다.
  • 데이터의 변화 시 DataSource들 간 관계와 얽혀있는 동작을 신경 쓸 필요 없게 만들어준다.
class MoviesRepository(
    databaseSource: Database, // LocalDataSource
    apiSource: APiDataSource, // RemoteDataSource
) {
    ...
}

데이터 접근 메소드 정의

class MoviesRepository(...) {
    // Write
    suspend fun markMovieAsFavorite(...) { ... }

    // Read : fetchXxxx()
    suspend fun fetchMovies(): List<Movie> { ... }
    
    // Read as Flow
    val movies: Flow<Movie> = ...
}

관심사 분리

Repository에는 데이터 접근을 위한 많은 메소드가 있으므로 데이터 타입에 따라 Repository를 분류하면 관심사 분리에 도움이 된다.

class MoviesRepository(...)

class PaymentsRepository(...)

멀티레벨 Repositories

상위 Repository 안에 하위 Repository를 두어 관심사를 추상화하는 것도 관심사 분리에 도움이 된다. multi-level-repositories

동작 흐름

class NewsRepository(
    val localNewsDataSource: LocalNewsDataSource,
    val remoteNewsDataSource: RemoteNewsDataSource,
) {
    suspend fun fetchNews() : List<Article> {
        try {
            val news = remoteNewsDataSource.fetchNews()
            localNewsDataSource.updateNews(news)
        } catch (exception: RemoteDataSourceNotAvailableException) {
            Log.d("NewsRepository", "Connection failed.")
        }
        return localNewsDataSource.fetchNews()
    }
}
  1. NewsRepository.fetchNews()가 호출된다.
  2. NewsRepositoryRemoteNewsDataSource로부터 뉴스 목록을 가져와서 LocalNewsDataSource로 업데이트한다. (=로컬 캐싱)
  3. 그리고 LocalNewsDataSource로부터 뉴스 목록을 가져와 반환한다.

외부 Scope 사용

API 호출자의 CoroutineScope를 따르면 화면이 닫히는 등의 이벤트가 발생할 때 호출자 수명주기가 끝나며 Scope도 종료된다. 호출자 수명주기와 상관없이 요청된 작업을 마무리 하게 만들려면 외부 Scope를 사용해야 한다.

class NewsRepository(
  private val remoteNewsDataSource: RemoteNewsDataSource,        
  // This could be CoroutineScope(SupervisorJob() + Dispatchers.Default).    
  private val externalScope: CoroutineScope,    
) {
  ...
  
  suspend fun fetchNews() : List<Article> {
    return if (refresh) {
      externalScope.async {
        latestNews = remoteNewsDataSource.fetchLatestNews()
      }.await()
    } else {
      latestNews 
    }
  }
}

사용자가 화면에서 벗어나면 await()가 취소되지만 async 내부의 로직은 계속 실행된다.

DataSource

관심사 분리

DataSource는 데이터 타입별, 데이터 위치(Local/Remote)별로 분류한다.

class RemoteArticleDataSource(...)

class LocalUsersDataSource(
  val userDao : UserDao,
  val userDataStore : UserDataStore,
)

class RemoteMoviesDataSource(...)

RemoteDataSource에서 백그라운드 처리

네트워크 레이턴시가 발생하는 등의 작업은 백그라운드 스레드에서 실행되도록 한다.

class RemoteArticleDataSource(...) {
  suspend fun fetchNews() {
    withContext(Dispatchers.IO) {
      ...
    }
  }
}

LocalDataSource에서 저장위치 선택하기

  • Room : 쿼리해야 하거나 참조 무결성이 필요하거나 부분 업데이트가 필요한 대규모 데이터 세트의 경우. ex) 뉴스기사
  • DataStore : 소규모 데이터 세트. ex) 앱 환경설정

Model

Thread safe한 데이터 접근

데이터를 수정하면 데이터를 참조하는 여러 위치에서 영향을 받는다. 데이터가 Immutable이면 데이터를 수정하는 방법은 오직 복사 뿐이다. 복사된 데이터를 다른 위치에 넘기는 것은 수정에 의한 영향을 없애준다.

멀티 스레드 환경에서 안전한 Immutable 데이터를 정의하기 위해 Model을 data class로 정의하는 것을 권장한다.

Data Model과 UI Model의 차이

Data 레이어의 Model은 DataSource에서 사용하는 모델스펙을 반영하므로 속성이 좀 더 많을 수 있다. data-models

반면에 UI 레이어의 Model은 필요한 속성이 더 적거나 가공된 자료형일 수 있다. ui-models

주기적인 데이터 최신화

WorkManager를 사용하여 작업 예약

데이터를 주기적으로 자동으로 가져오게 만들면 사용자가 앱을 열었을 때 바로 최신 데이터를 사용할 수 있다.

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()
    }
}

앱 시작 시 Worker를 트리거해야 할 땐 AppStartUp 라이브러리를 활용한다.

에러 핸들링

suspend 함수 에러 핸들링

try {
    moviesRepository.setFavorite(id, isFavorite)
} catch (exception: Exception) {
    // handle exception
}

flow 에러 핸들링

movies.catch { exception ->
    // handle exception
}.collect {
    // collect data
}

테스트

개인적으로는

  • FakeDataSource를 만들어서 Repository에서 Source of truth가 잘 동작하는지 테스트 하는 것
  • FakeRepository를 만들어서 Domain 레이어의 도메인 로직을 테스트 하는 것

두 가지가 도움이 많이 됐다. Data 레이어는 DataSource들을 wrapping하는 코드가 대부분이고 Domain 레이어에 크리티컬한 도메인 로직이 들어있기 때문이다.

멀티레벨 Repository 유닛테스트

하위 Repository 자리에 FakeRepository로 대체한다. testing-repositories

DataSource 유닛테스트

Db 또는 Api 객체의 자리에 FakeDb 또는 FakeApi로 대체한다. testing-datasources

UI 테스트 big-test