데이터 레이어 빌드

1. 시작하기 전에

이 Codelab에서는 데이터 레이어와 데이터 레이어가 전체 앱 아키텍처에 적용되는 방법을 알아봅니다.

도메인 레이어와 UI 레이어 아래에 있는 하단 레이어인 데이터 레이어

그림 1. 도메인 레이어와 UI 레이어가 의존하는 레이어인 데이터 레이어를 보여주는 다이어그램

작업 관리 앱의 데이터 레이어를 빌드합니다. 로컬 데이터베이스 및 네트워크 서비스의 데이터 소스와 데이터를 노출, 업데이트, 동기화하는 저장소를 만듭니다.

기본 요건

학습할 내용

이 Codelab에서는 다음 내용을 학습합니다.

  • 효과적이고 확장 가능한 데이터 관리를 위해 저장소와 데이터 소스, 데이터 모델을 만드는 방법
  • 다른 아키텍처 레이어에 데이터를 노출하는 방법
  • 비동기 데이터 업데이트 및 복잡한 작업이나 장기 실행 작업을 처리하는 방법
  • 여러 데이터 소스 간에 데이터를 동기화하는 방법
  • 저장소와 데이터 소스의 동작을 확인하는 테스트를 만드는 방법

빌드할 항목

작업을 추가하고 완료됨으로 표시할 수 있는 작업 관리 앱을 빌드합니다.

앱을 처음부터 작성하지는 않습니다. 대신 UI 레이어가 이미 있는 앱으로 작업합니다. 이 앱의 UI 레이어에는 ViewModel을 사용하여 구현된 화면과 화면 수준 상태 홀더가 포함되어 있습니다.

Codelab을 진행하면서 데이터 레이어를 추가하고 기존 UI 레이어에 연결하여 앱이 완전히 작동하도록 합니다.

작업 목록 화면

작업 세부정보 화면

그림 2. 작업 목록 화면 스크린샷

그림 3. 작업 세부정보 화면 스크린샷

2. 설정

  1. 다음 코드를 다운로드하세요.

https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip

  1. 또는 코드에 관한 GitHub 저장소를 클론해도 됩니다.
git clone https://github.com/android/architecture-samples.git
git checkout data-codelab-start
  1. Android 스튜디오를 열고 architecture-samples 프로젝트를 로드합니다.

폴더 구조

  • Android 뷰에서 Project 탐색기를 엽니다.

java/com.example.android.architecture.blueprints.todoapp 폴더 아래에 여러 폴더가 있습니다.

Android 스튜디오의 Android 뷰에서 Project 탐색기 창

그림 4. Android 스튜디오의 Android 뷰에서 Project 탐색기 창을 보여주는 스크린샷

  • <root>에는 탐색, 기본 활동, 애플리케이션 클래스와 같은 앱 수준 클래스가 포함되어 있습니다.
  • addedittask에는 사용자가 작업을 추가하고 수정할 수 있는 UI 기능이 포함되어 있습니다.
  • data에는 데이터 레이어가 포함되어 있습니다. 이 폴더에서 주로 작업합니다.
  • di에는 종속 항목 삽입을 위한 Hilt 모듈이 포함되어 있습니다.
  • tasks에는 사용자가 작업 목록을 보고 업데이트할 수 있는 UI 기능이 포함되어 있습니다.
  • util에는 유틸리티 클래스가 포함되어 있습니다.

두 개의 테스트 폴더도 있으며 폴더 이름 끝에 있는 괄호 안의 텍스트로 표시되어 있습니다.

  • androidTest<root>와 같은 구조를 따르지만 계측 테스트를 포함합니다.
  • test<root>와 같은 구조를 따르지만 로컬 테스트를 포함합니다.

프로젝트 실행

  • 상단 툴바에서 녹색 재생 아이콘을 클릭합니다.

Android 스튜디오 실행 구성, 대상 기기, 실행 버튼

그림 5. Android 스튜디오 실행 구성, 대상 기기, 실행 버튼을 보여주는 스크린샷

로딩 스피너가 사라지지 않는 Task List 화면이 표시되어야 합니다.

무한정 계속되는 로딩 스피너가 있는 시작 상태의 앱

그림 6. 무한정 계속되는 로딩 스피너가 있는 시작 상태의 앱 스크린샷

Codelab을 마치면 작업 목록이 이 화면에 표시됩니다.

data-codelab-final 브랜치에서 이 Codelab의 최종 코드를 확인할 수 있습니다.

git checkout data-codelab-final

변경사항을 먼저 저장해 두세요.

3. 데이터 레이어 알아보기

이 Codelab에서는 앱의 데이터 레이어를 빌드합니다.

데이터 레이어는 이름에서 알 수 있듯 애플리케이션 데이터를 관리하는 아키텍처 레이어입니다. 애플리케이션 데이터를 만들고 저장하고 수정하는 방법을 결정하는 실제 비즈니스 규칙인 비즈니스 로직도 포함하고 있습니다. 이러한 관심사 분리는 데이터 레이어를 재사용할 수 있게 하여 여러 화면에 데이터 레이어를 표시하고, 앱의 여러 부분 간에 정보를 공유하고, 단위 테스트를 위해 UI 외부에서 비즈니스 로직을 재현할 수 있습니다.

데이터 레이어를 구성하는 주요 구성요소 유형은 데이터 모델과 데이터 소스, 저장소입니다.

데이터 모델, 데이터 소스, 저장소 간 종속 항목을 비롯한 데이터 레이어의 구성요소 유형

그림 7. 데이터 모델, 데이터 소스, 저장소 간 종속 항목을 비롯한 데이터 레이어의 구성요소 유형을 보여주는 다이어그램

데이터 모델

애플리케이션 데이터는 일반적으로 데이터 모델로 표현됩니다. 이는 데이터의 메모리 내 표현입니다.

이 앱은 작업 관리 앱이므로 작업에 관한 데이터 모델이 있어야 합니다. 다음은 Task 클래스입니다.

data class Task(
    val id: String
    val title: String = "",
    val description: String = "",
    val isCompleted: Boolean = false,
) { ... }

이 모델의 핵심은 변경 불가능하다는 점입니다. 다른 레이어는 작업 속성을 변경할 수 없습니다. 작업을 변경하려면 데이터 레이어를 사용해야 합니다.

내부 및 외부 데이터 모델

Task는 외부 데이터 모델의 예입니다. 데이터 레이어에 의해 외부로 노출되고 다른 레이어에서 액세스할 수 있습니다. 나중에 데이터 레이어 안에서만 사용되는 내부 데이터 모델을 정의합니다.

비즈니스 모델 표현마다 데이터 모델을 정의하는 것이 좋습니다. 이 앱에는 데이터 모델이 세 개 있습니다.

모델명

데이터 레이어 외부 또는 내부?

표현

연결된 데이터 소스

Task

외부

앱 어디서나 사용할 수 있는 작업이며 메모리에만 또는 애플리케이션을 저장할 때만 저장됩니다.

해당 사항 없음

LocalTask

내부

로컬 데이터베이스에 저장되는 작업입니다.

TaskDao

NetworkTask

내부

네트워크 서버에서 가져온 작업입니다.

NetworkTaskDataSource

데이터 소스

데이터 소스는 데이터베이스 또는 네트워크 서비스와 같은 단일 소스에서 데이터를 읽고 쓰는 작업을 담당하는 클래스입니다.

이 앱에는 데이터 소스가 두 개 있습니다.

  • TaskDao는 데이터베이스를 읽고 데이터베이스에 쓰는 로컬 데이터 소스입니다.
  • NetworkTaskDataSource는 네트워크 서버를 읽고 서버에 쓰는 네트워크 데이터 소스입니다.

저장소

저장소는 단일 데이터 모델을 관리해야 합니다. 이 앱에서는 Task 모델을 관리하는 저장소를 만듭니다. 저장소는 다음 작업을 합니다.

  • Task 모델 목록 노출
  • Task 모델을 생성하고 업데이트하는 메서드 제공
  • 각 작업의 고유 ID 생성과 같은 비즈니스 로직 실행
  • 데이터 소스의 내부 데이터 모델을 Task 모델로 결합 또는 매핑
  • 데이터 소스 동기화

코드 작성 준비

  • Android 뷰로 전환하고 com.example.android.architecture.blueprints.todoapp.data 패키지를 확장합니다.

폴더와 파일을 보여주는 Project 탐색기 창

그림 8. 폴더와 파일을 보여주는 Project 탐색기 창

앱의 나머지 부분이 컴파일되도록 Task 클래스는 이미 생성되어 있습니다. 지금부터는 제공된 빈 .kt 파일에 구현을 추가하여 대부분의 데이터 레이어 클래스를 처음부터 만듭니다.

4. 로컬에 데이터 저장

이 단계에서는 작업을 기기에 로컬로 저장하는 Room 데이터베이스를 위한 데이터 소스데이터 모델을 만듭니다.

작업 저장소, 모델, 데이터 소스, 데이터베이스 간의 관계

그림 9. 작업 저장소, 모델, 데이터 소스, 데이터베이스 간의 관계를 보여주는 다이어그램

데이터 모델 만들기

Room 데이터베이스에 데이터를 저장하려면 데이터베이스 항목을 만들어야 합니다.

  • data/source/local 내에서 LocalTask.kt 파일을 열고 다음 코드를 추가합니다.
@Entity(
    tableName = "task"
)
data class LocalTask(
    @PrimaryKey val id: String,
    var title: String,
    var description: String,
    var isCompleted: Boolean,
)

LocalTask 클래스는 Room 데이터베이스에 있는 task 표에 저장된 데이터를 나타냅니다. Room에 강력하게 결합되어 있으며 DataStore와 같은 다른 데이터 소스에는 사용하면 안 됩니다.

클래스 이름의 Local 접두사는 이 데이터가 로컬에 저장되어 있음을 나타내는 데 사용합니다. 또한 앱의 다른 레이어에 노출되는 Task 데이터 모델과 이 클래스를 구별하는 데도 사용합니다. 즉, LocalTask는 데이터 레이어 내부에 있고 Task는 데이터 레이어 외부에 있습니다.

데이터 소스 만들기

이제 데이터 모델이 있으므로 데이터 소스를 만들어 LocalTask 모델을 생성하고 읽고 업데이트하고 삭제(CRUD)합니다. Room을 사용하고 있으므로 데이터 액세스 객체​LINK 2(@Dao 주석)를 로컬 데이터 소스로 사용할 수 있습니다.

  • TaskDao.kt 파일에서 새 Kotlin 인터페이스를 만듭니다.
@Dao
interface TaskDao {

    @Query("SELECT * FROM task")
    fun observeAll(): Flow<List<LocalTask>>

    @Upsert
    suspend fun upsert(task: LocalTask)

    @Upsert
    suspend fun upsertAll(tasks: List<LocalTask>)

    @Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId")
    suspend fun updateCompleted(taskId: String, completed: Boolean)

    @Query("DELETE FROM task")
    suspend fun deleteAll()
}

데이터 읽기 메서드는 observe가 접두사로 붙으며 Flow를 반환하는 정지되지 않은 함수입니다. 기본 데이터가 변경될 때마다 새 항목이 스트림으로 내보내집니다. Room 라이브러리(및 다른 여러 데이터 저장소 라이브러리)의 이 유용한 기능을 통해 데이터베이스에서 새 데이터를 폴링하는 대신 데이터 변경사항을 수신 대기할 수 있습니다.

데이터 쓰기 메서드는 정지 함수입니다. I/O 작업을 실행하기 때문입니다.

데이터베이스 스키마 업데이트

이제 LocalTask 모델을 저장하도록 데이터베이스를 업데이트해야 합니다.

  1. ToDoDatabase.kt를 열고 BlankEntityLocalTask로 변경합니다.
  2. BlankEntity 및 중복 import 문을 모두 삭제합니다.
  3. taskDao라는 DAO를 반환하는 메서드를 추가합니다.

업데이트된 클래스는 다음과 같이 표시됩니다.

@Database(entities = [LocalTask::class], version = 1, exportSchema = false)
abstract class ToDoDatabase : RoomDatabase() {

    abstract fun taskDao(): TaskDao
}

Hilt 구성 업데이트

이 프로젝트에서는 종속 항목 삽입에 Hilt를 사용합니다. Hilt는 TaskDao를 사용하는 클래스에 삽입할 수 있도록 TaskDao를 만드는 방법을 알아야 합니다.

  • di/DataModules.kt를 열고 다음 메서드를 DatabaseModule에 추가합니다.
    @Provides
    fun provideTaskDao(database: ToDoDatabase) : TaskDao = database.taskDao()

이제 로컬 데이터베이스에서 작업을 읽고 쓰는 데 필요한 준비를 마쳤습니다.

5. 로컬 데이터 소스 테스트

이전 단계에서 코드를 많이 작성했는데 이 코드가 제대로 작동하는지는 어떻게 알 수 있을까요? TaskDao에 SQL 쿼리가 많이 사용되었으니, 실수가 나오기 쉽습니다. TaskDao가 의도대로 동작하는지 확인하는 테스트를 만듭니다.

테스트는 앱의 일부가 아니므로 다른 폴더에 있어야 합니다. 테스트 폴더는 두 개가 있으며 패키지 이름 끝의 괄호 안 텍스트로 표시되어 있습니다.

Project 탐색기의 test 및 androidTest 폴더

그림 10. Project 탐색기의 test 및 androidTest 폴더를 보여주는 스크린샷

TaskDao에는 Room 데이터베이스(Android 기기에서만 생성될 수 있음)가 필요합니다. 따라서 이를 테스트하려면 계측 테스트를 만들어야 합니다.

테스트 클래스 만들기

  • androidTest 폴더를 확장하여 TaskDaoTest.kt를 엽니다. 파일 안에서 TaskDaoTest라는 빈 클래스를 만듭니다.
class TaskDaoTest {

}

테스트 데이터베이스 추가

  • ToDoDatabase를 추가하고 각 테스트 전에 초기화합니다.
    private lateinit var database: ToDoDatabase

    @Before
    fun initDb() {
        database = Room.inMemoryDatabaseBuilder(
            getApplicationContext(),
            ToDoDatabase::class.java
        ).allowMainThreadQueries().build()
    }

이렇게 하면 각 테스트 전에 메모리 내 데이터베이스가 만들어집니다. 메모리 내 데이터베이스는 디스크 기반 데이터베이스보다 훨씬 빠릅니다. 따라서 데이터가 테스트보다 오래 유지되지 않아도 되는 자동화된 테스트에 적합합니다.

테스트 추가

LocalTask를 삽입할 수 있고 동일한 LocalTaskTaskDao를 사용하여 읽을 수 있는지 확인하는 테스트를 추가합니다.

이 Codelab의 테스트는 모두 given, when, then 구조를 따릅니다.

Given

빈 데이터베이스입니다.

When

작업을 삽입하고 작업 스트림을 관찰하기 시작합니다.

Then

작업 스트림의 첫 항목이 삽입된 작업과 일치합니다.

  1. 실패 테스트를 먼저 만듭니다. 이는 실제로 테스트가 실행되어 올바른 객체와 그 종속 항목이 테스트되는지 확인합니다.
@Test
fun insertTaskAndGetTasks() = runTest {

    val task = LocalTask(
        title = "title",
        description = "description",
        id = "id",
        isCompleted = false,
    )
    database.taskDao().upsert(task)

    val tasks = database.taskDao().observeAll().first()

    assertEquals(0, tasks.size)
}
  1. 여백의 테스트 옆에 있는 Play를 클릭하여 테스트를 실행합니다.

코드 편집기 여백에 있는 테스트 Play 버튼

그림 11. 코드 편집기 여백에 있는 테스트 Play 버튼을 보여주는 스크린샷

테스트 결과 창에서 expected:<0> but was:<1> 메시지와 함께 테스트 실패가 표시됩니다. 이는 데이터베이스의 작업 수가 0이 아닌 1이므로 예상된 결과입니다.

실패 테스트

그림 12. 실패 테스트를 보여주는 스크린샷

  1. 기존 assertEquals 문을 삭제합니다.
  2. 데이터 소스에서 작업이 하나만 제공되고 이 작업이 삽입된 작업과 동일한지 테스트하는 코드를 추가합니다.

assertEquals에 대한 매개변수 순서는 항상 예상값 다음에 실제값이 와야 합니다**.**

assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
  1. 테스트를 다시 실행합니다. 테스트 결과 창에서 테스트 통과를 확인할 수 있습니다.

통과 테스트

그림 13. 통과 테스트를 보여주는 스크린샷

6. 네트워크 데이터 소스 만들기

작업을 기기에 로컬로 저장할 수 있는 것은 좋지만 이러한 작업을 네트워크 서비스에도 저장하고 로드하려면 어떻게 해야 할까요? Android 앱은 사용자가 TODO 목록에 작업을 추가할 수 있는 한 가지 방법일 수 있습니다. 웹사이트나 데스크톱 애플리케이션을 통해서도 작업을 관리할 수 있습니다. 또는 사용자가 기기를 바꾸더라도 앱 데이터를 복원할 수 있도록 온라인 데이터 백업만 제공할 수 있습니다.

이러한 시나리오에서는 일반적으로 Android 앱을 비롯한 모든 클라이언트가 데이터를 로드하고 저장하는 데 사용할 수 있는 네트워크 기반 서비스가 있습니다.

이 다음 단계에서는 데이터 소스를 만들어 이 네트워크 서비스와 통신합니다. 이 Codelab의 목적상 이는 실제 네트워크 서비스에 연결되지 않는 시뮬레이션된 서비스지만 실제 앱에서 구현될 수 있는 방식을 보여줍니다.

네트워크 서비스 정보

이 예에서 네트워크 API는 매우 간단합니다. 두 가지 작업만 실행합니다.

  • 모든 작업을 저장하여 이전에 작성된 데이터를 모두 덮어씁니다.
  • 모든 작업을 로드하여 네트워크 서비스에 현재 저장된 모든 작업의 목록을 제공합니다.

네트워크 데이터 모델링

네트워크 API에서 데이터를 가져올 때는 해당 데이터가 로컬에서와 다르게 표현되는 것이 일반적입니다. 작업의 네트워크 표현에는 추가 필드가 있거나, 다른 유형이나 필드 이름을 사용하여 동일한 값을 나타낼 수 있습니다.

이러한 차이를 고려하려면 네트워크에 특정한 데이터 모델을 만드세요.

  • data/source/network에 있는 NetworkTask.kt 파일을 열고 필드를 나타내는 다음 코드를 추가합니다.
data class NetworkTask(
    val id: String,
    val title: String,
    val shortDescription: String,
    val priority: Int? = null,
    val status: TaskStatus = TaskStatus.ACTIVE
) {
    enum class TaskStatus {
        ACTIVE,
        COMPLETE
    }
}

LocalTaskNetworkTask의 차이점은 다음과 같습니다.

  • 작업 설명의 이름이 description이 아닌 shortDescription입니다.
  • isCompleted 필드가 가능한 값 두 개(ACTIVECOMPLETE)를 보유한 status enum으로 표시됩니다.
  • 정수인 추가 priority 필드가 포함되어 있습니다.

네트워크 데이터 소스 만들기

  • TaskNetworkDataSource.kt를 열고 다음 콘텐츠가 있는 TaskNetworkDataSource 클래스를 만듭니다.
class TaskNetworkDataSource @Inject constructor() {

    // A mutex is used to ensure that reads and writes are thread-safe.
    private val accessMutex = Mutex()
    private var tasks = listOf(
        NetworkTask(
            id = "PISA",
            title = "Build tower in Pisa",
            shortDescription = "Ground looks good, no foundation work required."
        ),
        NetworkTask(
            id = "TACOMA",
            title = "Finish bridge in Tacoma",
            shortDescription = "Found awesome girders at half the cost!"
        )
    )

    suspend fun loadTasks(): List<NetworkTask> = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        return tasks
    }

    suspend fun saveTasks(newTasks: List<NetworkTask>) = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        tasks = newTasks
    }
}

private const val SERVICE_LATENCY_IN_MILLIS = 2000L

이 객체는 loadTasks 또는 saveTasks가 호출될 때마다 2초 지연을 시뮬레이션하는 등 서버와의 상호작용을 시뮬레이션합니다. 이는 네트워크 또는 서버 응답 지연 시간을 나타낼 수 있습니다.

또한 나중에 작업이 네트워크에서 성공적으로 로드될 수 있는지 확인하는 데 사용하는 테스트 데이터를 포함합니다.

실제 서버 API에서 HTTP를 사용한다면 Ktor 또는 Retrofit 같은 라이브러리를 사용하여 네트워크 데이터 소스를 빌드하는 것이 좋습니다.

7. 작업 저장소 만들기

이제 작업 저장소를 만들어 봅니다.

DefaultTaskRepository의 종속 항목

그림 14. DefaultTaskRepository의 종속 항목을 보여주는 다이어그램

데이터 소스가 두 개 있습니다. 하나는 로컬 데이터용이고(TaskDao) 하나는 네트워크 데이터용입니다(TaskNetworkDataSource). 각각 읽기와 쓰기를 허용하고 자체 작업 표현(각각 LocalTask, NetworkTask)이 있습니다.

이제 이러한 데이터 소스를 사용하고 다른 아키텍처 레이어에서 이 작업 데이터에 액세스할 수 있도록 API를 제공하는 저장소를 만들어 보겠습니다.

데이터 노출

  1. data 패키지에서 DefaultTaskRepository.kt를 열고 TaskDaoTaskNetworkDataSource를 종속 항목으로 사용하는 DefaultTaskRepository 클래스를 만듭니다.
class DefaultTaskRepository @Inject constructor(
    private val localDataSource: TaskDao,
    private val networkDataSource: TaskNetworkDataSource,
) {

}

데이터는 흐름을 사용하여 노출해야 합니다. 이렇게 하면 호출자가 시간 경과에 따른 해당 데이터의 변경사항에 관해 알림을 받을 수 있습니다.

  1. Flow를 사용하여 Task 모델 스트림을 반환하는 observeAll 메서드를 추가합니다.
fun observeAll() : Flow<List<Task>> {
    // TODO add code to retrieve Tasks
}

저장소는 단일 정보 소스의 데이터를 노출해야 합니다. 즉, 데이터의 출처는 단 하나의 데이터 소스여야 합니다. 메모리 내 캐시이거나 원격 서버일 수 있고 여기서는 로컬 데이터베이스일 수 있습니다.

로컬 데이터베이스의 작업에는 흐름을 쉽게 반환하는 TaskDao.observeAll을 사용하여 액세스할 수 있습니다. 하지만 이것은 LocalTask 모델의 흐름으로, 여기서 LocalTask는 다른 아키텍처 레이어에 노출해서는 안 되는 내부 모델입니다.

LocalTaskTask로 변환해야 합니다. Task는 데이터 레이어 API의 일부를 형성하는 외부 모델입니다.

내부 모델을 외부 모델에 매핑

이 변환을 실행하려면 LocalTask의 필드를 Task의 필드로 매핑해야 합니다.

  1. LocalTask에서 이를 위한 확장 함수를 만듭니다.
// Convert a LocalTask to a Task
fun LocalTask.toExternal() = Task(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)

// Convenience function which converts a list of LocalTasks to a list of Tasks
fun List<LocalTask>.toExternal() = map(LocalTask::toExternal) // Equivalent to map { it.toExternal() }

이제 LocalTaskTask로 변환해야 할 때마다 toExternal을 호출하기만 하면 됩니다.

  1. observeAll 내에서 새로 만든 toExternal 함수를 사용합니다.
fun observeAll(): Flow<List<Task>> {
    return localDataSource.observeAll().map { tasks ->
        tasks.toExternal()
    }
}

로컬 데이터베이스에서 작업 데이터가 변경될 때마다 LocalTask 모델의 새 목록이 흐름으로 내보내집니다. 그러면 각 LocalTaskTask에 매핑됩니다.

좋습니다. 이제 다른 레이어가 로컬 데이터베이스에서 모든 Task 모델을 가져오고, 이러한 Task 모델이 변경될 때마다 알림을 받기 위해 observeAll을 사용할 수 있습니다.

데이터 업데이트

TODO 앱은 작업을 만들고 업데이트할 수 없으면 별 쓸모가 없습니다. 이제 이러한 작업을 하는 메서드를 추가합니다.

데이터를 만들거나 업데이트하거나 삭제하는 메서드는 원샷 작업이며 suspend 함수를 사용하여 구현해야 합니다.

  1. titledescription을 매개변수로 사용하고 새로 만든 작업의 ID를 반환하는 메서드 create를 추가합니다.
suspend fun create(title: String, description: String): String {
}

데이터 레이어 API는 Task가 아닌 개별 매개변수를 허용하는 create 메서드만 제공하여 다른 레이어에서 Task를 만들지 못하도록 합니다. 이 접근 방식은 다음을 캡슐화합니다.

  • 고유한 작업 ID를 만드는 비즈니스 로직
  • 초기 생성 후 작업이 저장되는 위치
  1. 작업 ID를 만드는 메서드를 추가합니다.
// This method might be computationally expensive
private fun createTaskId() : String {
    return UUID.randomUUID().toString()
}
  1. 새로 추가된 createTaskId 메서드를 사용하여 작업 ID를 만듭니다.
suspend fun create(title: String, description: String): String {
    val taskId = createTaskId()
}

기본 스레드를 차단하지 마세요

하지만 작업 ID 생성에 계산 비용이 많이 들면 어떻게 하나요? 암호화를 사용하여 ID의 해시 키를 만들 수 있으며 여기에는 몇 초가 걸립니다. 이는 기본 스레드에서 호출된 경우 UI 버벅거림이 발생할 수 있습니다.

데이터 레이어는 장기 실행 작업이나 복잡한 작업이 기본 스레드를 차단하지 않도록 할 책임이 있습니다.

이 문제를 해결하려면 이러한 명령을 실행하는 데 사용할 코루틴 디스패처를 지정하세요.

  1. 먼저 DefaultTaskRepositoryCoroutineDispatcher를 종속 항목으로 추가합니다. 이미 만든 @DefaultDispatcher 한정자(di/CoroutinesModule.kt에서 정의됨)를 사용하여 Hilt에 Dispatchers.Default와 함께 이 종속 항목을 삽입하라고 지시합니다. Default 디스패처는 CPU 집약적인 작업에 최적화되어 있으므로 지정됩니다. 코루틴 디스패처 자세히 알아보기
class DefaultTaskRepository @Inject constructor(
   private val localDataSource: TaskDao,
   private val networkDataSource: TaskNetworkDataSource,
   @DefaultDispatcher private val dispatcher: CoroutineDispatcher,
)
  1. 이제 withContext 블록 안에 UUID.randomUUID().toString() 호출을 배치합니다.
val taskId = withContext(dispatcher) {
    createTaskId()
}

데이터 레이어의 스레딩 자세히 알아보기

작업 만들기 및 저장

  1. 이제 작업 ID가 있으므로 제공된 매개변수와 함께 사용하여 새 Task를 만듭니다.
suspend fun create(title: String, description: String): String {
    val taskId = withContext(dispatcher) {
        createTaskId()
    }
    val task = Task(
        title = title,
        description = description,
        id = taskId,
    )
}

작업을 로컬 데이터 소스에 삽입하기 전에 LocalTask에 매핑해야 합니다.

  1. 다음 확장 함수를 LocalTask 끝에 추가합니다. 이 함수는 앞서 만든 LocalTask.toExternal의 역 매핑 함수입니다.
fun Task.toLocal() = LocalTask(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)
  1. create에서 이를 사용하여 작업을 로컬 데이터 소스에 삽입하고 taskId를 반환합니다.
suspend fun create(title: String, description: String): Task {
    ...
    localDataSource.upsert(task.toLocal())
    return taskId
}

작업 완료

  • Task를 완료됨으로 표시하는 추가 메서드 complete를 만듭니다.
suspend fun complete(taskId: String) {
    localDataSource.updateCompleted(taskId, true)
}

이제 작업 생성 및 완료에 관한 유용한 메서드가 있습니다.

데이터 동기화

이 앱에서 네트워크 데이터 소스는 데이터가 로컬에 쓰여질 때마다 업데이트되는 온라인 백업으로 사용됩니다. 데이터는 사용자가 새로고침을 요청할 때마다 네트워크에서 로드됩니다.

다음 다이어그램은 각 작업 유형의 동작을 간략히 보여줍니다.

작업 유형

저장소 메서드

단계

데이터 이동

로드

observeAll

로컬 데이터베이스에서 데이터를 로드합니다.

로컬 데이터 소스에서 작업 저장소로의 데이터 흐름그림 15. 로컬 데이터 소스에서 작업 저장소로의 데이터 흐름을 보여주는 다이어그램

저장

createcomplete

1. 로컬 데이터베이스에 데이터를 씁니다. 2. 모든 데이터를 네트위크에 복사하여 모든 것을 덮어씁니다.

작업 저장소에서 로컬 데이터 소스로, 그리고 네트워크 데이터 소스로의 데이터 흐름그림 16. 작업 저장소에서 로컬 데이터 소스로, 그리고 네트워크 데이터 소스로의 데이터 흐름을 보여주는 다이어그램

새로고침

refresh

1. 네트워크에서 데이터를 로드합니다. 2. 로컬 데이터베이스에 이를 복사하여 모든 것을 덮어씁니다.

네트워크 데이터 소스에서 로컬 데이터 소스로, 그리고 작업 저장소로의 데이터 흐름그림 17. 네트워크 데이터 소스에서 로컬 데이터 소스로, 그리고 작업 저장소로의 데이터 흐름을 보여주는 다이어그램

네트워크 데이터 저장 및 새로고침

저장소는 이미 로컬 데이터 소스에서 작업을 로드합니다. 동기화 알고리즘을 완료하려면 네트워크 데이터 소스에서 데이터를 저장하고 새로고침하는 메서드를 만들어야 합니다.

  1. 먼저 NetworkTask.kt 안에서 LocalTask에서 NetworkTask로 또는 그 반대로의 매핑 함수를 만듭니다. LocalTask.kt 내에 함수를 배치하는 것과 동일하게 유효합니다.
fun NetworkTask.toLocal() = LocalTask(
    id = id,
    title = title,
    description = shortDescription,
    isCompleted = (status == NetworkTask.TaskStatus.COMPLETE),
)

fun List<NetworkTask>.toLocal() = map(NetworkTask::toLocal)

fun LocalTask.toNetwork() = NetworkTask(
    id = id,
    title = title,
    shortDescription = description,
    status = if (isCompleted) { NetworkTask.TaskStatus.COMPLETE } else { NetworkTask.TaskStatus.ACTIVE }
)

fun List<LocalTask>.toNetwork() = map(LocalTask::toNetwork)

여기에서 각 데이터 소스에 별도의 모델을 보유하는 이점을 확인할 수 있습니다. 한 데이터 유형을 다른 데이터 유형에 매핑하는 작업이 별도의 함수로 캡슐화됩니다.

  1. DefaultTaskRepository의 끝에 refresh 메서드를 추가합니다.
suspend fun refresh() {
    val networkTasks = networkDataSource.loadTasks()
    localDataSource.deleteAll()
    val localTasks = withContext(dispatcher) {
        networkTasks.toLocal()
    }
    localDataSource.upsertAll(networkTasks.toLocal())
}

이렇게 하면 모든 로컬 작업이 네트워크의 작업으로 대체됩니다. withContext는 대량 toLocal 작업에 사용됩니다. 작업의 수를 알 수 없고 각 매핑 작업은 계산 비용이 많이 들 수 있기 때문입니다.

  1. DefaultTaskRepository의 끝에 saveTasksToNetwork 메서드를 추가합니다.
private suspend fun saveTasksToNetwork() {
    val localTasks = localDataSource.observeAll().first()
    val networkTasks = withContext(dispatcher) {
        localTasks.toNetwork()
    }
    networkDataSource.saveTasks(networkTasks)
}

이렇게 하면 모든 네트워크 작업이 로컬 데이터 소스의 작업으로 대체됩니다.

  1. 이제 로컬 데이터가 변경될 때 네트워크에 저장되도록 createcomplete 작업을 업데이트하는 기존 메서드를 업데이트합니다.
    suspend fun create(title: String, description: String): String {
        ...
        saveTasksToNetwork()
        return taskId
    }

     suspend fun complete(taskId: String) {
        localDataSource.updateCompleted(taskId, true)
        saveTasksToNetwork()
    }

호출자를 기다리게 하지 마세요

이 코드를 실행하면 saveTasksToNetwork가 차단되는 것을 확인할 수 있습니다. 즉, createcomplete 호출자가 작업이 완료되었는지 확인하기 전에 네트워크에 데이터가 저장될 때까지 강제로 기다려야 합니다. 시뮬레이션된 네트워크 데이터 소스에서는 이 시간이 2초일 뿐이지만 실제 앱에서는 훨씬 길 수도 있고 네트워크 연결이 없으면 아예 저장이 안 됩니다.

이는 불필요한 제한사항이고 사용자 환경에 부정적 영향을 미칠 수 있습니다. 아무도 작업을 만들 때 기다리길 원하지 않습니다. 바쁠 때는 더 그렇죠.

더 나은 해결책은 데이터를 네트워크에 저장하기 위해 다른 코루틴 범위를 사용하는 것입니다. 이렇게 하면 호출자가 결과를 기다리게 하지 않고도 작업이 백그라운드에서 완료될 수 있습니다.

  1. 코루틴 범위를 매개변수로 DefaultTaskRepository에 추가합니다.
class DefaultTaskRepository @Inject constructor(
    // ...other parameters...
    @ApplicationScope private val scope: CoroutineScope,
)

Hilt 한정자 @ApplicationScope(di/CoroutinesModule.kt에서 정의됨)는 앱의 수명 주기를 따르는 범위를 삽입하는 데 사용됩니다.

  1. saveTasksToNetwork 내의 코드를 scope.launch로 래핑합니다.
    private fun saveTasksToNetwork() {
        scope.launch {
            val localTasks = localDataSource.observeAll().first()
            val networkTasks = withContext(dispatcher) {
                localTasks.toNetwork()
            }
            networkDataSource.saveTasks(networkTasks)
        }
    }

이제 saveTasksToNetwork가 즉시 반환되고 작업이 백그라운드에서 네트워크에 저장됩니다.

8. 작업 저장소 테스트

정말 많은 기능을 데이터 레이어에 추가했습니다. 이제 DefaultTaskRepository의 단위 테스트를 만들어 이 모든 것이 작동하는지 확인해 보겠습니다.

로컬 및 네트워크 데이터 소스의 테스트 종속 항목을 사용하여 테스트 대상(DefaultTaskRepository)을 인스턴스화해야 합니다. 먼저 이러한 종속 항목을 만들어야 합니다.

  1. Project 탐색기 창에서 (test) 폴더를 확장하고 source.local 폴더를 확장한 후 FakeTaskDao.kt.를 엽니다.

Project 폴더 구조에 있는 FakeTaskDao.kt 파일

그림 18. Project 폴더 구조에 있는 FakeTaskDao.kt 파일을 보여주는 스크린샷

  1. 다음 콘텐츠를 추가합니다.
class FakeTaskDao(initialTasks: List<LocalTask>) : TaskDao {

    private val _tasks = initialTasks.toMutableList()
    private val tasksStream = MutableStateFlow(_tasks.toList())

    override fun observeAll(): Flow<List<LocalTask>> = tasksStream

    override suspend fun upsert(task: LocalTask) {
        _tasks.removeIf { it.id == task.id }
        _tasks.add(task)
        tasksStream.emit(_tasks)
    }

    override suspend fun upsertAll(tasks: List<LocalTask>) {
        val newTaskIds = tasks.map { it.id }
        _tasks.removeIf { newTaskIds.contains(it.id) }
        _tasks.addAll(tasks)
    }

    override suspend fun updateCompleted(taskId: String, completed: Boolean) {
        _tasks.firstOrNull { it.id == taskId }?.let { it.isCompleted = completed }
        tasksStream.emit(_tasks)
    }

    override suspend fun deleteAll() {
        _tasks.clear()
        tasksStream.emit(_tasks)
    }
}

실제 앱에서는 TaskNetworkDataSource를 대체할 가짜 종속 항목도 만들지만(가짜 객체와 진짜 객체가 공통의 인터페이스를 구현하도록 하여) 이 Codelab에서는 직접 이를 사용합니다.

  1. DefaultTaskRepositoryTest 내에서 다음을 추가합니다.

기본 디스패처가 모든 테스트에서 사용되도록 설정하는 규칙

테스트 데이터

로컬 및 네트워크 데이터 소스의 테스트 종속 항목

테스트 대상: DefaultTaskRepository

class DefaultTaskRepositoryTest {

    private var testDispatcher = UnconfinedTestDispatcher()
    private var testScope = TestScope(testDispatcher)

    private val localTasks = listOf(
        LocalTask(id = "1", title = "title1", description = "description1", isCompleted = false),
        LocalTask(id = "2", title = "title2", description = "description2", isCompleted = true),
    )

    private val localDataSource = FakeTaskDao(localTasks)
    private val networkDataSource = TaskNetworkDataSource()
    private val taskRepository = DefaultTaskRepository(
        localDataSource = localDataSource,
        networkDataSource = networkDataSource,
        dispatcher = testDispatcher,
        scope = testScope
    )
}

좋습니다. 이제 단위 테스트를 작성할 수 있습니다. 테스트해야 하는 세 가지 주요 영역은 읽기, 쓰기, 데이터 동기화입니다.

노출된 데이터 테스트

다음은 저장소에서 데이터를 올바르게 노출하는지 테스트하는 방법입니다. 테스트는 given, when, then 구조로 제공됩니다. 예를 들면 다음과 같습니다.

Given

로컬 데이터 소스에는 기존 작업이 있습니다.

When

작업 스트림은 observeAll을 사용하여 저장소에서 가져옵니다.

Then

작업 스트림의 첫 항목은 로컬 데이터 소스에 있는 작업의 외부 표현과 일치합니다.

  • 다음 콘텐츠로 observeAll_exposesLocalData라는 테스트를 만듭니다.
@Test
fun observeAll_exposesLocalData() = runTest {
    val tasks = taskRepository.observeAll().first()
    assertEquals(localTasks.toExternal(), tasks)
}

first 함수를 사용하여 작업 스트림에서 첫 항목을 가져옵니다.

데이터 업데이트 테스트

이제 작업이 생성되고 네트워크 데이터 소스에 저장되는지 확인하는 테스트를 작성합니다.

Given

빈 데이터베이스입니다.

When

create를 호출하여 작업을 만듭니다.

Then

로컬 데이터 소스와 네트워크 데이터 소스에서 모두 작업이 만들어집니다.

  1. onTaskCreation_localAndNetworkAreUpdated라는 테스트를 만듭니다.
@Test
    fun onTaskCreation_localAndNetworkAreUpdated() = testScope.runTest {
        val newTaskId = taskRepository.create(
            localTasks[0].title,
            localTasks[0].description
        )

        val localTasks = localDataSource.observeAll().first()
        assertEquals(true, localTasks.map { it.id }.contains(newTaskId))

        val networkTasks = networkDataSource.loadTasks()
        assertEquals(true, networkTasks.map { it.id }.contains(newTaskId))
    }

작업이 완료될 때 로컬 데이터 소스에 올바르게 기록되고 네트워크 데이터 소스에 저장되는지 확인합니다.

Given

로컬 데이터 소스는 작업을 포함합니다.

When

해당 작업은 complete를 호출하여 완료됩니다.

Then

로컬 데이터와 네트워크 데이터도 업데이트됩니다.

  1. onTaskCompletion_localAndNetworkAreUpdated라는 테스트를 만듭니다.
    @Test
    fun onTaskCompletion_localAndNetworkAreUpdated() = testScope.runTest {
        taskRepository.complete("1")

        val localTasks = localDataSource.observeAll().first()
        val isLocalTaskComplete = localTasks.firstOrNull { it.id == "1" } ?.isCompleted
        assertEquals(true, isLocalTaskComplete)

        val networkTasks = networkDataSource.loadTasks()
        val isNetworkTaskComplete =
            networkTasks.firstOrNull { it.id == "1"} ?.status == NetworkTask.TaskStatus.COMPLETE
        assertEquals(true, isNetworkTaskComplete)
    }

데이터 새로고침 테스트

마지막으로 새로고침 작업이 성공적인지 테스트합니다.

Given

네트워크 데이터 소스는 데이터를 포함합니다.

When

refresh가 호출됩니다.

Then

로컬 데이터가 네트워크 데이터와 동일합니다.

  • onRefresh_localIsEqualToNetwork라는 테스트를 만듭니다.
@Test
    fun onRefresh_localIsEqualToNetwork() = runTest {
        val networkTasks = listOf(
            NetworkTask(id = "3", title = "title3", shortDescription = "desc3"),
            NetworkTask(id = "4", title = "title4", shortDescription = "desc4"),
        )
        networkDataSource.saveTasks(networkTasks)

        taskRepository.refresh()

        assertEquals(networkTasks.toLocal(), localDataSource.observeAll().first())
    }

이제 완료됐습니다. 테스트를 실행하면 모두 통과됩니다.

9. UI 레이어 업데이트

이제 데이터 레이어가 작동하는 것을 알게 됐으므로 이를 UI 레이어에 연결해 보겠습니다.

작업 목록 화면의 뷰 모델 업데이트

TasksViewModel로 시작합니다. 이는 앱에서 첫 화면(현재 활성화된 모든 작업 목록)을 표시하는 뷰 모델입니다.

  1. 이 클래스를 열고 생성자 매개변수로 DefaultTaskRepository를 추가합니다.
@HiltViewModel
class TasksViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
  1. 저장소를 사용하여 tasksStream 변수를 초기화합니다.
private val tasksStream = taskRepository.observeAll()

이제 코드 한 줄로 뷰 모델이 저장소에서 제공된 모든 작업에 액세스할 수 있고 데이터가 변경될 때마다 새 작업 목록을 수신하게 됩니다.

  1. 남은 작업은 사용자 작업을 저장소에 있는 상응하는 메서드에 연결하는 것 뿐입니다. complete 메서드를 찾아 다음과 같이 업데이트합니다.
fun complete(task: Task, completed: Boolean) {
    viewModelScope.launch {
        if (completed) {
            taskRepository.complete(task.id)
            showSnackbarMessage(R.string.task_marked_complete)
        } else {
            ...
       }
    }
}
  1. refresh로도 같은 작업을 실행합니다.
    fun refresh() {
        _isLoading.value = true
        viewModelScope.launch {
            taskRepository.refresh()
            _isLoading.value = false
        }
    }

작업 추가 화면의 뷰 모델 업데이트

  1. AddEditTaskViewModel을 열고 이전 단계와 같이 생성자 매개변수로 DefaultTaskRepository를 추가합니다.
class AddEditTaskViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
)
  1. create 메서드를 다음과 같이 업데이트합니다.
    private fun createNewTask() = viewModelScope.launch {
        taskRepository.create(uiState.value.title, uiState.value.description)
        _uiState.update {
            it.copy(isTaskSaved = true)
        }
    }

앱 실행

  1. 이제 앱을 실행해 보겠습니다. You have no tasks!가 표시된 화면이 나타납니다.

작업이 없을 때 앱의 작업 화면

그림 19. 작업이 없을 때 앱의 작업 화면 스크린샷

  1. 오른쪽 상단에서 점 3개를 탭하고 Refresh를 누릅니다.

작업 메뉴가 표시된 앱의 작업 화면

그림 20. 작업 메뉴가 표시된 앱의 작업 화면 스크린샷

2초간 로딩 스피너가 표시되고 앞서 추가한 테스트 작업이 표시됩니다.

작업이 두 개 표시된 앱의 작업 화면

그림 21. 작업이 두 개 표시된 앱의 작업 화면 스크린샷

  1. 이제 새 작업을 추가하려면 오른쪽 하단에서 더하기 기호를 탭합니다. 제목과 설명 필드를 작성합니다.

앱의 작업 추가 화면

그림 22. 앱의 작업 추가 화면 스크린샷

  1. 작업을 저장하려면 오른쪽 하단에서 틱 버튼을 탭합니다.

작업 추가 후 앱의 작업 화면

그림 23. 작업 추가 후 앱의 작업 화면 스크린샷

  1. 작업을 완료됨으로 표시하려면 작업 옆의 체크박스를 선택합니다.

완료된 작업을 보여주는 앱의 작업 화면

그림 24. 완료된 작업을 보여주는 앱의 작업 화면 스크린샷

10. 축하합니다

앱의 데이터 레이어를 만들었습니다.

데이터 레이어는 애플리케이션 아키텍처에서 중요한 부분을 형성합니다. 다른 레이어를 빌드할 수 있는 기반이므로 제대로 만들면 앱을 사용자와 비즈니스의 요구에 따라 확장할 수 있습니다.

학습한 내용

  • Android 앱 아키텍처에서 데이터 레이어의 역할
  • 데이터 소스 및 모델을 만드는 방법
  • 저장소의 역할과 저장소가 데이터를 노출하고, 데이터를 업데이트하는 일회성 메서드를 제공하는 방법
  • 코루틴 디스패처를 변경하는 시점과 이렇게 하는 것이 중요한 이유
  • 여러 데이터 소스를 사용한 데이터 동기화
  • 일반적인 데이터 레이어 클래스의 단위 테스트와 계측 테스트를 만드는 방법

추가 도전과제

또 다른 도전과제를 원한다면 다음 기능을 구현하세요.

  • 완료됨으로 표시된 후에 작업을 다시 활성화합니다.
  • 탭하여 작업의 제목과 설명을 수정합니다.

안내는 제공되지 않습니다. 직접 해 보시기 바랍니다. 문제가 생기면 main 브랜치에서 완전히 작동하는 앱을 살펴보세요.

git checkout main

다음 단계

데이터 레이어에 관한 자세한 내용은 공식 문서 및 오프라인 우선 앱 가이드를 참고하세요. 다른 아키텍처 레이어(UI 레이어, 도메인 레이어)에 관해서도 알아볼 수 있습니다.

좀 더 복잡한 실제 샘플은 Now in Android 앱을 살펴보세요.