Preferences Datastore를 사용하여 작업하기

Datastore란?

Datastore는 개선된 신규 데이터 저장소 솔루션으로, SharedPreferences를 대체합니다. Kotlin 코루틴과 Flow를 기반으로 한 Datastore는 두 가지 구현, 즉 타입 객체를 저장하는 Proto Datastore(프로토콜 버퍼로 지원) 및 키-값 쌍을 저장하는 Preferences Datastore를 제공합니다. 비동기적이고 일관된 트랜잭션 방식으로 데이터를 저장하여 SharedPreferences의 단점을 일부 극복합니다.

학습할 내용

  • Datastore의 정의 및 Datastore를 사용해야 하는 이유
  • 프로젝트에 Datastore를 추가하는 방법
  • Preferences Datastore와 Proto Datastore의 차이점 및 각각의 장점
  • Preferences Datastore를 사용하는 방법
  • SharedPreferences에서 Preferences Datastore로 이전하는 방법

빌드할 항목

이 Codelab에서는 작업 목록을 표시하는 샘플 앱으로 시작합니다. 작업 목록은 완료 상태를 기준으로 필터링하고 우선순위와 기한을 기준으로 정렬할 수 있습니다.

fcb2ffa4e6b77f33.gif

Show completed tasks 필터의 부울 플래그가 메모리에 저장됩니다. 정렬 순서는 SharedPreferences 객체를 사용하여 디스크에 유지됩니다.

이 Codelab에서는 다음 작업을 완료하여 Preferences Datastore를 사용하는 방법을 알아봅니다.

  • Datastore에 완료 상태 필터를 유지합니다.
  • 정렬 순서를 SharedPreferences에서 Datastore로 이전합니다.

둘 사이의 차이를 더 잘 이해할 수 있도록 Proto Datastore Codelab도 살펴보는 것이 좋습니다.

필요한 항목

아키텍처 구성요소에 관한 소개는 뷰 Codelab이 있는 Room을 확인하세요. Flow에 관한 소개는 Kotlin Flow 및 LiveData Codelab을 사용한 고급 코루틴을 확인하세요.

이 단계에서는 전체 Codelab을 위한 코드를 다운로드한 후 간단한 예시 앱을 실행합니다.

Google에서 준비한 시작 프로젝트를 사용하면 신속하게 빌드할 수 있습니다.

git을 설치했다면 아래 명령어를 실행하면 됩니다. git이 설치되어 있는지 확인하려면 터미널이나 명령줄에 git --version을 입력하여 올바르게 실행되는지 확인합니다.

 git clone https://github.com/googlecodelabs/android-datastore

초기 상태는 마스터 분기에 있으며, 솔루션 코드는 preferences_datastore 분기에 있습니다.

git이 없는 경우 다음 버튼을 클릭하여 이 Codelab을 위한 모든 코드를 다운로드하세요.

소스 코드 다운로드

  1. 코드의 압축을 푼 후 Android 스튜디오 버전 3.6 이상에서 프로젝트를 엽니다.
  2. 기기 또는 에뮬레이터에서 app 실행 구성을 실행합니다.

b3c0dfdb92dfed77.png

앱이 실행되고 다음과 같은 작업 목록이 표시됩니다.

16eb4ceb800bf131.png

앱에서 작업 목록을 볼 수 있습니다. 각 작업에는 이름, 완료 상태, 우선순위, 기한과 같은 속성이 있습니다.

처리해야 하는 코드를 단순화하기 위해 개발자는 앱에서 다음 두 가지 작업만 할 수 있습니다.

  • 완료된 작업을 표시하도록 전환(완료된 작업은 기본적으로 숨겨짐)
  • 우선순위, 기한 또는 기한과 우선순위를 기준으로 작업 정렬

앱은 '앱 아키텍처 가이드'에서 권장하는 아키텍처를 따릅니다. 각 패키지에는 다음과 같은 항목이 포함됩니다.

data

  • Task 모델 클래스
  • TasksRepository 클래스: 작업을 제공합니다. 단순화하기 위해 하드코딩된 데이터를 반환하고 Flow를 통해 노출하여 더욱 현실적인 시나리오를 나타냅니다.
  • UserPreferencesRepository 클래스: enum으로 정의된 SortOrder를 포함합니다. 현재 정렬 순서가 SharedPreferences에 String으로(enum 값 이름 기반) 저장됩니다. 정렬 순서를 저장하고 가져오는 동기 메서드를 노출합니다.

ui

  • RecyclerView가 있는 Activity 표시와 관련된 클래스입니다.
  • TasksViewModel 클래스는 UI 로직을 담당합니다.

TasksViewModel: UI에 표시해야 할 데이터를 빌드하는 데 필요한 모든 요소를 포함합니다. 작업 목록, 완료된 작업 표시 플래그, 정렬 순서 플래그가 TasksUiModel 객체에 래핑되어 있습니다. 이러한 값 중 하나가 변경될 때마다 새 TasksUiModel을 재구성해야 합니다. 이를 위해 다음 세 가지 요소를 결합합니다.

  • Flow<List<Task>>: TasksRepository에서 가져옵니다.
  • MutableStateFlow<Boolean>: 메모리에만 보관되어 있는 완료된 작업 표시 플래그를 최신 상태로 유지합니다.
  • MutableStateFlow<SortOrder>: 최신 SortOrder 값을 유지합니다.

활동이 시작될 때만 UI를 올바르게 업데이트할 수 있도록 LiveData<TasksUiModel>을 노출합니다.

코드에 다음과 같은 문제가 있습니다.

  • UserPreferencesRepository.sortOrder를 초기화할 때 디스크 IO에서 UI 스레드를 차단합니다. 이로 인해 UI 버벅거림이 발생할 수 있습니다.
  • 완료된 작업 표시 플래그는 메모리에만 보관되므로 사용자가 앱을 열 때마다 재설정됩니다. SortOrder와 마찬가지로 앱을 닫은 후에도 플래그가 유지되어야 합니다.
  • 현재 SharedPreferences를 사용하여 데이터를 보존하지만, 메모리에 MutableStateFlow를 보관하여 직접 수정하며 변경 시 알림을 받을 수 있습니다. 이렇게 하면 값이 애플리케이션의 다른 위치에서 수정되는 경우 쉽게 중단됩니다.
  • UserPreferencesRepository에서는 정렬 순서를 업데이트하는 두 가지 메서드, 즉 enableSortByDeadline()enableSortByPriority()를 노출합니다. 두 메서드는 현재 정렬 순서 값에 의존하지만, 한 메서드가 완료되기 전에 다른 한 메서드가 호출되면 최종값이 잘못 생성됩니다. 또한 이러한 메서드는 UI 스레드에서 호출될 때 UI 버벅거림 및 엄격 모드 위반을 일으킬 수 있습니다.

완료된 작업 표시 플래그와 정렬 순서 플래그는 모두 사용자 환경설정이지만 지금은 두 개의 서로 다른 객체로 표시됩니다. Google의 목표 중 하나는 UserPreferences 클래스 아래에 이러한 두 플래그를 통합하는 것입니다.

Datastore를 사용하여 이러한 문제를 해결하는 방법을 알아보겠습니다.

소규모 또는 단순한 데이터 세트를 저장해야 하는 경우가 있을 수 있습니다. 이를 위해 이전에는 SharedPreferences를 사용했지만, 이 API에는 일련의 단점이 있습니다. Jetpack Datastore 라이브러리는 이러한 문제를 해결하고 데이터를 저장하는 간단하고 더 안전한 비동기 API를 만드는 것을 목표로 합니다. 이러한 목표는 다음과 같이 두 가지로 구현됩니다.

  • Preferences Datastore
  • Proto Datastore

기능

SharedPreferences

PreferencesDataStore

ProtoDataStore

비동기 API

✅(변경된 값을 읽는 용도로만, 리스너를 통해)

✅(Flow를 통해)

✅(Flow를 통해)

동기 API

✅(단, UI 스레드에서 호출하는 것은 안전하지 않음)

UI 스레드에서 호출하기 안전함

❌*

✅(작업은 내부에서 Dispatchers.IO로 이동됨)

✅(작업은 내부에서 Dispatchers.IO로 이동됨)

오류 신호 전송 가능

런타임 예외로부터 안전함

❌**

strong consistency가 보장되는 트랜잭션 API가 있음

데이터 이전 처리

✅(SharedPreferences에서)

✅(SharedPreferences에서)

유형 안전성

✅(프로토콜 버퍼 포함)

  • SharedPreferences에는 UI 스레드에서 호출하기에 안전해 보일 수 있지만 실제로는 디스크 I/O 작업을 하는 동기 API가 있습니다. 또한 apply()fsync()에서 UI 스레드를 차단합니다. 대기 중인 fsync() 호출은 서비스가 시작되거나 중지될 때마다, 그리고 애플리케이션에서 활동이 시작되거나 중지될 때마다 트리거됩니다. UI 스레드는 apply()에서 예약한 대기 중인 fsync() 호출에서 차단되며 흔히 ANR의 소스가 됩니다.

** SharedPreferences는 파싱 오류를 런타임 예외로 발생시킵니다.

Preferences Datastore와 Proto Datastore 비교

Preferences Datastore와 Proto Datastore에서는 모두 데이터 저장이 가능하지만 저장 방법이 서로 다릅니다.

  • Preference Datastore는 SharedPreferences와 마찬가지로 스키마를 먼저 정의하지 않은 상태에서 키를 기반으로 데이터에 액세스합니다.
  • Proto Datastore프로토콜 버퍼를 사용하여 스키마를 정의합니다. Protobufs를 사용하기 때문에 강타입(strongly typed) 데이터를 유지할 수 있습니다. 이러한 데이터는 XML 등 다른 유사한 데이터 형식보다 빠르고 작고 간결하며 덜 모호합니다. Proto Datastore를 사용하려면 새로운 직렬화 메커니즘을 배워야 하지만 Proto Datastore의 강타입 이점이 그만한 가치가 있습니다.

Room과 Datastore 비교

부분 업데이트, 참조 무결성 또는 대규모/복잡한 데이터 세트가 필요한 경우에는 Datastore 대신 Room을 사용하는 것이 좋습니다. Datastore는 소규모 또는 단순한 데이터 세트에 적합하며 부분 업데이트나 참조 무결성을 지원하지 않습니다.

Preferences Datastore API는 SharedPreferences와 비슷하지만 주목할 만한 다음과 같은 차이점이 있습니다.

  • 데이터 업데이트를 트랜잭션 방식으로 처리함
  • 데이터의 현재 상태를 나타내는 Flow를 노출함
  • 데이터 영구 메서드(apply(), commit())가 없음
  • 변경 가능한 참조를 내부 상태에 반환하지 않음
  • 타입 키가 있는 MapMutableMap과 유사한 API를 노출함

Preferences Datastore API를 프로젝트에 추가하고 SharedPreferences를 Datastore로 이전하는 방법을 살펴보겠습니다.

종속 항목 추가

build.gradle 파일을 업데이트하여 다음 Preferences Datastore 종속 항목을 추가합니다.

implementation "androidx.datastore:datastore-preferences:1.0.0-alpha06"

완료된 작업 표시 플래그와 정렬 순서 플래그는 모두 사용자 환경설정이지만 지금은 두 개의 서로 다른 객체로 표시됩니다. 그런 이유로 여기서 목표 중 하나는 두 플래그를 UserPreferences 클래스 아래에 통합하고 Datastore를 사용하여 이 클래스를 UserPreferencesRepository에 저장하는 것입니다. 완료된 작업 표시 플래그는 현재 메모리에 TasksViewModel에 보관됩니다.

먼저 UserPreferencesRepository에서 UserPreferences 데이터 클래스를 만들어 시작합니다. 지금은 showCompleted 필드 하나만 있습니다. 나중에 정렬 순서를 추가합니다.

data class UserPreferences(val showCompleted: Boolean)

Datastore 만들기

context.createDataStoreFactory() 메서드를 사용하여 UserPreferencesRepositoryDataStore<Preferences> 비공개 필드를 만들겠습니다. 필수 매개변수는 Preferences Datastore의 이름입니다.

private val dataStore: DataStore<Preferences> =
        context.createDataStore(name = "user")

Preferences Datastore에서 데이터 읽기

Preferences Datastore는 환경설정이 변경될 때마다 방출되는 Flow<Preferences>에 저장된 데이터를 노출합니다. 전체 Preferences 객체를 노출하지 않고 UserPreferences 객체를 노출하려고 합니다. 이렇게 하려면 Flow<Preferences>를 매핑하고 키를 기반으로 원하는 부울 값을 가져오고 UserPreferences 객체를 구성해야 합니다.

따라서 가장 먼저 해야 할 일은 show completed 키를 정의하는 것입니다. 이 키는 비공개 PreferencesKeys 객체에서 멤버로 선언할 수 있는 booleanPreferencesKey 값입니다.

private object PreferencesKeys {
  val SHOW_COMPLETED = booleanPreferencesKey("show_completed")
}

dataStore.data: Flow<Preferences>를 기반으로 구성된 userPreferencesFlow: Flow<UserPreferences>를 노출하겠습니다. 그런 다음, 매핑하여 올바른 환경설정을 가져옵니다.

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .map { preferences ->
        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED]?: false
        UserPreferences(showCompleted)
    }

데이터를 읽는 동안 예외 처리

Datastore가 파일에서 데이터를 읽을 때 데이터를 읽는 동안 오류가 발생하면 IOExceptions가 발생합니다. map() 앞에 catch() Flow 연산자를 사용하고 발생한 예외가 IOException인 경우 emptyPreferences()를 방출하면 처리할 수 있습니다. 다른 유형의 예외가 발생한다면 예외를 다시 발생시키는 편이 좋습니다.

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        // dataStore.data throws an IOException when an error is encountered when reading data
        if (exception is IOException) {
            emit(emptyPreferences())
        } else {
            throw exception
        }
    }.map { preferences ->
        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED]?: false
        UserPreferences(showCompleted)
    }

Preferences Datastore에 데이터 쓰기

데이터를 쓰기 위해 Datastore는 정지 함수 DataStore.edit(transform: suspend (MutablePreferences) -> Unit)를 제공합니다. 이 함수는 Datastore에서 트랜잭션 방식으로 상태를 업데이트할 수 있는 transform 블록을 허용합니다.

변환 블록에 전달된 MutablePreferences는 이전에 실행된 수정사항으로 최신 상태가 됩니다. transform 블록에서 MutablePreferences의 모든 변경사항은 transform이 완료된 후 edit이 완료되기 전에 디스크에 적용됩니다. MutablePreferences에서 하나의 값을 설정하면 다른 모든 환경설정이 변경되지 않고 유지됩니다.

참고: 변환 블록 외부에서 MutablePreferences를 수정하지 마세요.

UserPreferencesshowCompleted 속성을 업데이트할 수 있는 updateShowCompleted()라는 정지 함수를 만들겠습니다. 이 함수는 dataStore.edit()를 호출하고 새 값을 설정합니다.

suspend fun updateShowCompleted(showCompleted: Boolean) {
    dataStore.edit { preferences ->
        preferences[PreferencesKeys.SHOW_COMPLETED] = showCompleted
    }
}

edit()는 디스크를 읽거나 디스크에 쓰는 중에 오류가 발생하면 IOException을 발생시킬 수 있습니다. 변환 블록에서 생긴 다른 오류의 경우 edit()에 의해 발생합니다.

이 시점에서 앱을 컴파일해야 하지만 UserPreferencesRepository에서 방금 만든 기능은 사용되지 않습니다.

정렬 순서는 SharedPreferences에 저장됩니다. 정렬 순서를 Datastore로 옮기겠습니다. 이렇게 하려면 먼저, 정렬 순서도 저장하도록 UserPreferences를 업데이트합니다.

data class UserPreferences(
    val showCompleted: Boolean,
    val sortOrder: SortOrder
)

SharedPreferences에서 이전

Datastore로 이전하려면 SharedPreferencesMigration을 이전 목록에 전달하도록 Datastore 빌더를 업데이트해야 합니다. Datastore는 SharedPreferences에서 Datastore로 자동으로 이전할 수 있습니다. 이전은 Datastore에서 데이터 액세스가 발생하기 전에 실행됩니다. 즉, 먼저 성공적으로 이전해야 DataStore.data가 값을 방출하고 DataStore.edit()가 데이터를 업데이트할 수 있습니다.

참고: 키는 SharedPreferences에서 한 번만 이전되므로 코드가 Datastore로 이전된 후에는 기존 SharedPreferences 사용을 중단해야 합니다.

private val dataStore: DataStore<Preferences> =
    context.createDataStore(
        name = USER_PREFERENCES_NAME,
        migrations = listOf(SharedPreferencesMigration(context, USER_PREFERENCES_NAME))
    )

private object PreferencesKeys {
    ...
    // Note: this has the the same name that we used with SharedPreferences.
    val SORT_ORDER = stringPreferencesKey("sort_order")
}

모든 키가 Datastore로 이전되고 사용자 환경설정 SharedPreferences에서 삭제됩니다. 이제 Preferences에서 SORT_ORDER 키를 기반으로 SortOrder를 가져오고 업데이트할 수 있습니다.

Datastore에서 정렬 순서 읽기

map() 변환의 정렬 순서도 가져오도록 userPreferencesFlow를 업데이트하겠습니다.

val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
    .catch { exception ->
        if (exception is IOException) {
            emit(emptyPreferences())
        } else {
            throw exception
        }
    }.map { preferences ->
        // Get the sort order from preferences and convert it to a [SortOrder] object
        val sortOrder =
            SortOrder.valueOf(
                preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name)

        // Get our show completed value, defaulting to false if not set:
        val showCompleted = preferences[PreferencesKeys.SHOW_COMPLETED] ?: false
        UserPreferences(showCompleted, sortOrder)
    }

정렬 순서를 Datastore에 저장

현재 UserPreferencesRepository는 정렬 순서 플래그를 설정하는 동기 방식만 노출하며 동시 실행 문제가 있습니다. 정렬 순서를 업데이트하는 두 메서드, 즉 enableSortByDeadline()enableSortByPriority()를 노출합니다. 두 메서드는 현재 정렬 순서 값에 의존하지만, 한 메서드가 완료되기 전에 다른 메서드가 호출되면 최종값이 잘못 생성됩니다.

Datastore에서는 트랜잭션 방식의 데이터 업데이트 처리를 보장하므로 더 이상 이 문제가 발생하지 않습니다. 다음과 같이 변경하겠습니다.

  • enableSortByDeadline()enableSortByPriority()dataStore.edit()을 사용하는 suspend 함수가 되도록 업데이트합니다.
  • edit()의 변환 블록에서 currentOrder_sortOrderFlow 필드에서 검색하는 대신 환경설정 매개변수에서 가져옵니다.
  • updateSortOrder(newSortOrder)를 호출하는 대신 환경설정에서 정렬 순서를 직접 업데이트할 수 있습니다.

다음과 같이 구현됩니다.

suspend fun enableSortByDeadline(enable: Boolean) {
    // edit handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.edit { preferences ->
        // Get the current SortOrder as an enum
        val currentOrder = SortOrder.valueOf(
            preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name
        )

        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_PRIORITY) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_DEADLINE
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_PRIORITY
                } else {
                    SortOrder.NONE
                }
            }
        preferences[PreferencesKeys.SORT_ORDER] = newSortOrder.name
    }
}

suspend fun enableSortByPriority(enable: Boolean) {
    // edit handles data transactionally, ensuring that if the sort is updated at the same
    // time from another thread, we won't have conflicts
    dataStore.edit { preferences ->
        // Get the current SortOrder as an enum
        val currentOrder = SortOrder.valueOf(
            preferences[PreferencesKeys.SORT_ORDER] ?: SortOrder.NONE.name
        )

        val newSortOrder =
            if (enable) {
                if (currentOrder == SortOrder.BY_DEADLINE) {
                    SortOrder.BY_DEADLINE_AND_PRIORITY
                } else {
                    SortOrder.BY_PRIORITY
                }
            } else {
                if (currentOrder == SortOrder.BY_DEADLINE_AND_PRIORITY) {
                    SortOrder.BY_DEADLINE
                } else {
                    SortOrder.NONE
                }
            }
        preferences[PreferencesKeys.SORT_ORDER] = newSortOrder.name
    }
}

이제 UserPreferencesRepository가 Datastore에 완료된 작업 표시 플래그와 정렬 순서 플래그를 모두 저장하고 Flow<UserPreferences>를 노출하므로 이러한 항목을 사용하도록 TasksViewModel을 업데이트하겠습니다.

showCompletedFlowsortOrderFlow를 삭제하고, userPreferencesRepository.userPreferencesFlow로 초기화되는 userPreferencesFlow라는 값을 만듭니다.

private val userPreferencesFlow = userPreferencesRepository.userPreferencesFlow

tasksUiModelFlow 생성 시 showCompletedFlowsortOrderFlowuserPreferencesFlow로 바꿉니다. 그에 따라 매개변수를 바꿉니다.

filterSortTasks를 호출할 때 userPreferencesshowCompletedsortOrder를 전달합니다. 코드는 다음과 같이 표시됩니다.

private val tasksUiModelFlow = combine(
        repository.tasks,
        userPreferencesFlow
    ) { tasks: List<Task>, userPreferences: UserPreferences ->
        return@combine TasksUiModel(
            tasks = filterSortTasks(
                tasks,
                userPreferences.showCompleted,
                userPreferences.sortOrder
            ),
            showCompleted = userPreferences.showCompleted,
            sortOrder = userPreferences.sortOrder
        )
    }

이제 showCompletedTasks() 함수가 userPreferencesRepository.updateShowCompleted()를 호출하도록 업데이트됩니다. 이 함수는 정지 함수이므로 viewModelScope에서 새 코루틴을 만드세요.

fun showCompletedTasks(show: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.updateShowCompleted(show)
    }
}

이제 userPreferencesRepository 함수인 enableSortByDeadline()enableSortByPriority()가 정지 함수이므로 viewModelScope에서 실행되는 새 코루틴에서도 호출되어야 합니다.

fun enableSortByDeadline(enable: Boolean) {
    viewModelScope.launch {
       userPreferencesRepository.enableSortByDeadline(enable)
    }
}

fun enableSortByPriority(enable: Boolean) {
    viewModelScope.launch {
        userPreferencesRepository.enableSortByPriority(enable)
    }
}

UserPreferencesRepository 정리

더 이상 필요하지 않은 필드와 메서드를 삭제하겠습니다. 다음 항목을 삭제할 수 있습니다.

  • _sortOrderFlow
  • sortOrderFlow
  • updateSortOrder()
  • private val sortOrder: SortOrder

이제 앱이 성공적으로 컴파일됩니다. 완료된 작업 표시 플래그와 정렬 순서 플래그가 올바르게 저장되었는지 확인하기 위해 앱을 실행해 보겠습니다.

Codelab 저장소의 환경설정 분기를 확인하여 변경사항을 비교하세요.

Preferences Datastore로 이전을 완료했으니 배운 내용을 요약해 보겠습니다.

  • SharedPreferences는 UI 스레드에서 호출하기에 안전해 보일 수 있는 동기 API가 있고, 오류 신호를 보내는 메커니즘이 없고, 트랜잭션 API가 없다는 등 일련의 단점이 있습니다.
  • SharedPreferences를 대체하는 Datastore는 API의 거의 모든 단점을 해결합니다.
  • Datastore는 Kotlin 코루틴과 Flow를 사용하는 완전 비동기 API가 있으며, 데이터 이전을 처리하고, 데이터 일관성을 보장하고, 데이터 손상을 처리합니다.