Jetpack DataStore!

wonseok·2023년 1월 7일
0
post-custom-banner

안드로이드 프로젝트를 진행하다 보면, 키-값을 저장해야 할 경우가 많이 생기는데 (Bearer Token을 저장하고 읽어오는 유즈케이스 등), 이에 대해 그동안 주로 상대적으로 간편한 SharedPreferences을 사용하곤 했습니다. 그런데 사실 공식문서를 살펴보면 다음과 같은 문구가 적혀있습니다.

약 1년 전, 해당 문서를 읽고 DataStore에 대한 학습을 하였으나,
프로젝트를 진행할 때마다 관성적으로 SharedPreferences를 사용하는 나 자신에 대한 의문을 품고, 좀 더 DataStore에 애정을 갖고 프로젝트에 적용하고자, 본 포스팅을 시작했습니다.

우선 DataStore는 프로토콜 버퍼를 사용하여 키-값 쌍 또는 입력된 객체로 데이터를 유지하는 Jetpack 라이브러리입니다.

DataStore는 코틀린의 코루틴(Coroutines)플로우(Flow)를 기반으로 사용하여 기존의 SharedPreferences를 대체하는 것을 목표로 합니다.

SharedPreferences의 한계점

우선 DataStore의 장점을 이해하기 전에 SharedPreferences API의 한계점에 대해 알아야 합니다. SharedPreferences는 API 레벨 1부터 있었지만, 시간이 지나도 지속되는 한계점이 있습니다.
한계점은 다음과 같습니다.

  1. UI 쓰레드에서 SharedPreferences를 호출하는 것은 UI 쓰레드를 차단하여 버벅거림을 유발할 수 있으므로 항상 안전한 것은 아닙니다.
  2. SharedPreferences에서 오류를 알릴 방법이 없습니다. (parsing error 런타임 예외 제외)
  3. SharedPreferences는 데이터 마이그레이션을 지원하지 않습니다. 데이터의 타입을 변경하려면 전체 로직을 수정해야 합니다.
  4. SharedPreferences유형 안전성(type safety)을 제공하지 않습니다. 동일한 키(key)를 사용하여 BooleanInteger 타입의 값들을 모두 저장하려고 해도, 앱이 그대로 컴파일됩니다.

DataStore의 구현 타입

DataStore는 유즈케이스에 따라 선택할 수 있는 두 가지 구현 타입을 제공합니다.

  1. Preferences DataStore : SharedPreferences와 유사한 키-값 쌍으로 데이터를 저장합니다. 이를 사용하여 기본 데이터 유형을 저장하고 검색합니다.
  2. Proto DataStore : 프로토콜 버퍼를 사용하여 사용자 정의 데이터 타입을 저장합니다. Proto DataStore를 사용하는 경우 맞춤 데이터 타입에 대한 스키마(schema)를 정의해야 합니다.

SharedPreferencesXML을 사용하여 데이터를 저장합니다.
따라서, 데이터 양이 증가함에 따라 파일 크기가 급격히 증가하고 CPU가 파일을 읽는 데 더 많은 비용이 듭니다.

프로토콜 버퍼(Protocol buffers)XML보다 빠르고 크기가 작은 구조화된 데이터를 표현하는 새로운 방법입니다. 저장된 데이터의 읽기 시간이 앱의 성능에 영향을 미칠 때 유용합니다.

이를 사용하려면 .proto 파일을 사용하여 데이터 스키마를 정의합니다. 그런 다음 플러그인이 클래스를 생성합니다.

먼저 Preferences DataStore를 프로젝트에 적용해보겠습니다.

build.gradle 파일을 열고 다음을 추가합니다.

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

MainActivity.kt를 열고 다음 코드를 클래스 선언부 앞에 작성해줍니다.

private val Context.dataStore by preferencesDataStore(
  name = NotePrefs.PREFS_NAME
)

위의 코드에서 수신자 유형(receiver type)Context인 프로퍼티를 만듭니다.
그런 다음 그 값을 preferencesDataStore()에 위임합니다. (by 키워드 사용)
PreferencesDataStore()DataStore의 이름을 매개변수로 사용합니다.
이것은 이름을 사용하여 SharedPreferences 인스턴스를 생성하는 방법과 유사합니다.

이렇게 DataStore 인스턴스를 만들 때, Jetpack DataStore 라이브러리는 앱 내 files 디렉토리 안에 datastore라는 새 디렉토리를 만듭니다.

DataStore 접근하기

기존에 있던 NotePrefs 클래스의 생성자를 다음과 같이 변경해줍니다.

class NotePrefs(
  private val sharedPrefs: SharedPreferences,
  private val dataStore: DataStore<Preferences>
)

위 코드는 DataStore의 인스턴스임을 나타내는 DataStore<Preferences> 타입의 생성자 매개변수를 추가하는 코드입니다.

MainActivity.kt로 넘어가서, 새 생성자 매개변수를 포함하도록 notePrefs의 지연 할당(lazy assignment)을 변경합니다.

private val notePrefs: NotePrefs by lazy {
  NotePrefs(
applicationContext.getSharedPreferences(NotePrefs.PREFS_NAME, Context.MODE_PRIVATE),
	dataStore 
  )
}

위의 코드는 MainActivity에서 생성된 DataStore 인스턴스를 NotePrefs에 매개변수로 전달합니다.

이제 NotePrefsDataStore에 액세스할 수 있으므로 여기에 데이터를 쓰는 코드를 추가합니다.

DataStore 쓰기

DataStore에서 데이터를 읽고 쓰려면 Preferences.Key<T>의 인스턴스가 필요합니다. 여기서 T는 읽고 쓰려는 데이터 타입입니다.

key 생성하기

문자열 데이터와 상호 작용하려면 Preferences.Key<String>의 인스턴스가 필요합니다. DataStore 라이브러리에는 이러한 키를 쉽게 만들 수 있는 stringPreferencesKey()doublePreferencesKey()와 같은 메서드도 포함되어 있습니다.

NotePrefs.kt 파일을 열고 아래의 코드를 companion object 블록 안에 넣어줍니다.

private val BACKGROUND_COLOR =
stringPreferencesKey("key_app_background_color")

위의 코드는 String 타입의 키를 생성하고 이름을 key_app_background_color로 지정합니다.

key-value 쌍 쓰기

이제 키를 생성했으므로 이를 사용하여 키에 해당하는 값을 쓸 수 있습니다. saveNoteBackgroundColor()를 다음으로 바꿉니다.

suspend fun saveNoteBackgroundColor(noteBackgroundColor: String)
{
  dataStore.edit { preferences ->
    preferences[BACKGROUND_COLOR] = noteBackgroundColor
  }
}

위의 코드에서 edit() 메서드를 사용하여 DataStore 수정을 시작합니다.
edit() 메서드는 기본 preferecnes에 대한 액세스를 제공하는 람다를 사용합니다.
그런 다음 BACKGROUND_COLOR 키를 사용하여 새 색상을 저장합니다.

마지막으로 코루틴 내부의 MainActivity에서 saveNoteBackgroundColor()를 호출해야 합니다.

MainActivity.kt 파일을 열고 showNoteBackgroundColorDialog() 내부의 saveNoteBackgroundColor() 호출을 다음과 같이 변경합니다.

lifecycleScope.launchWhenStarted {
notePrefs.saveNoteBackgroundColor(selectedRadioButton.text.toString())
}

위의 코드에서 많은 일이 발생합니다.

  • lifecycleScopeActivity의 생명 주기에 연결된 CoroutineScope에 대한 액세스를 제공하는 확장 프로퍼티입니다. CoroutineScope는 작업을 실행할 스레드와 취소할 수 있는 시기를 지정하는 데 도움이 됩니다.
  • launchWhenStarted()Activity가 적어도 STARTED 상태일 때 lifecycleScope에서 코루틴을 시작합니다.
  • saveNoteBackgroundColor()lifecycleScope 내부에서 호출됩니다.

위의 코드는 Datastore가 비동기적으로 업데이트되도록 합니다.

또한 showNoteBackgroundColorDialog() 에서 아래 코드를 제거합니다.

changeNotesBackgroundColor(getCurrentBackgroundColorInt())

DataStore를 사용하면 사용자가 원할 때마다 반응적으로(리액티브하게) UI를 업데이트하므로 더 이상 필요하지 않습니다.

이제 데이터 쓰기 메서드 작성을 완료했습니다.

다음은 이 데이터를 읽는 로직을 작성해보겠습니다.

DataStore 읽기

SharedPreferences와 달리 DataStore는 데이터를 동기식으로 읽는 API를 제공하지 않습니다.
대신 Flow를 사용하여 DataStore 내부의 데이터를 관찰하고 오류를 올바르게 처리하는 방법을 제공합니다.

Flow는 값을 순차적으로 내보내는 비동기 데이터 스트림을 제공하는 Kotlin 코루틴 API입니다.

UserPreferences.kt 파일을 생성해주고 아래의 코드를 작성해줍니다.

data class UserPreferences(
  val backgroundColor: AppBackgroundColor
)

위의 코드에서 UserPreferences는 다양한 사용자 기본 preferences를 그룹화하는 컨테이너 클래스 역할을 합니다. 지금은 배경색만 포함되어 있습니다.

Flow 초기화하기

DataStore에서 읽은 데이터를 UserPreferences의 인스턴스로 변환하는 과정이 필요합니다.

다시 NotePrefs.kt를 열고 클래스 선언 바로 아래의 맨 위에 다음의 코드를 추가합니다.

// 1
val userPreferencesFlow: Flow<UserPreferences> = dataStore.data
  // 2
  .catch { exception ->
    if (exception is IOException) {
      emit(emptyPreferences())
    } else {
      throw exception
    }
  }.map { preferences ->
	// 3
    val backgroundColor =
AppBackgroundColor.getColorByName(preferences[BACKGROUND_COLOR]
?: DEFAULT_COLOR)
    UserPreferences(backgroundColor)
  }

위의 코드는 몇 가지 중요한 작업을 수행합니다.

  1. Preferences 타입의 Flow를 반환하는 DataStore의 data 프로퍼티를 사용하여 DataStore 내부의 데이터에 액세스합니다.
  2. Flow에 발생한 모든 예외를 잡아줍니다. IOException 예외가 발생하면, emit()을 사용하여 empty preferences를 반환합니다. 그렇지 않으면 호출자에게 예외를 던집니다.
  3. BACKGROUND_COLOR 키를 사용하여 preferences 값을 읽습니다. 만약 preferences가 해당 키에 해당하는 데이터를 갖고 있지 않다면 null을 반환합니다. 이 경우에는 DEFAULT_COLOR를 선택합니다. 마지막으로 UserPreferences 인스턴스를 생성하고 Flow에 전달합니다.

Flow 수집하기

이전 단계에서 DataStore 내부의 데이터에 액세스하여 UserPreferences에 매핑한 다음 Flow로 내보냈습니다.

이제 해당 값을 읽으려면 MainActivity에서 Flow수집(collect)해야 합니다. Flow를 수집하는 것은 Flow에서 내보낸 값을 읽는 것과 같습니다.

MainActivity.kt를 열고 onCreate() 내부의 changeNotesBackgroundColor() 호출을 다음 코드로 바꿉니다.

try {
  lifecycleScope.launchWhenStarted {
    notePrefs.userPreferencesFlow.collect { userPreferences ->
changeNotesBackgroundColor(userPreferences.backgroundColor.intCo
lor)
	} 
  }
} catch (e: Exception) {
  Log.e("MainActivity", e.localizedMessage)
}

위의 코드에서 Flow에서 collect()를 호출하고 UserPreferences 인스턴스에 대한 액세스 권한을 얻습니다.

그런 다음 backgroundColor 값을 추출하고 changeNotesBackgroundColor()를 호출합니다.

마지막으로, Flow에서 발생하는 모든 예외는 collect()에서도 다시 발생하므로 전체 collect() 호출을 try-catch 블록으로 래핑합니다.

동기식 호출 만들기

개발하고 있던 안드로이드 프로젝트를 SharedPreferences에서 DataStore로 마이그레이션할 때, 모든 동기식 읽기(synchronous reads)비동기식 읽기(asynchronous reads)로 바꾸는 것이 항상 가능한 것은 아닙니다.

NotePrefs.kt를 열고 getAppBackgroundColor()를 다음 코드로 바꿉니다.

fun getAppBackgroundColor(): AppBackgroundColor =
  runBlocking {
    AppBackgroundColor.getColorByName(dataStore.data.first()
[BACKGROUND_COLOR] ?: DEFAULT_COLOR)
}

위의 코드에서 runBlocking()은 코루틴이 실행되는 동안 현재 스레드를 차단하고 효과적으로 DataStore에서 동기식 읽기를 수행할 수 있도록 합니다.

DataStore가 값을 읽는 동안 잠재적으로 UI 스레드를 차단할 수 있으므로 정말 필요한 경우에만 runBlocking()을 사용해야 합니다.

DataStore.data.first()를 사용하여 Flow에서 내보낸 첫 번째 항목에 액세스한 다음 BACKGROUND_COLOR를 사용하여 preferences에서 배경 색상값에 액세스합니다.

SharedPreferences로부터 마이그레이션하기

새로운 앱을 개발 중이라면 처음부터 DataStore를 바로 사용할 수 있습니다.

그러나 대부분의 경우 SharedPreferences를 사용하는 기존 프로젝트에서 작업하게 됩니다. (제가 진행하고 있던 대부분의 프로젝트도 마찬가지였습니다.)

읽기/쓰기 메서드 마이그레이션

NotePrefs에는 SharedPreferences의 preferences를 유지하는 메서드가 포함되어 있습니다. 마이그레이션의 첫 번째 단계는 DataStore를 사용하도록 이러한 메서드를 다시 작성하는 것입니다.

NotePrefs.kt를 열고 아래의 코드를 companion object 블록 안에 추가해줍니다.

private val NOTE_SORT_ORDER =
stringPreferencesKey("note_sort_preference")
private val NOTE_PRIORITY_SET =
stringSetPreferencesKey("note_priority_set")

위의 코드에서 노트의 정렬 순서 및 우선 순위에 대한 키를 만들어줍니다.
이것은 배경색을 처리하는 방법과 유사합니다.

다음으로 saveNoteSortOrder(), getNoteSortOrder(), saveNotePriorityFilters()getNotePriorityFilters()를 다음 코드로 바꿔줍니다.

suspend fun saveNoteSortOrder(noteSortOrder: NoteSortOrder) {
  dataStore.edit { preferences ->
    preferences[NOTE_SORT_ORDER] = noteSortOrder.name
  }
}

fun getNoteSortOrder() = runBlocking {
  NoteSortOrder.valueOf(dataStore.data.first()
[NOTE_SORT_ORDER] ?: DEFAULT_SORT_ORDER)
}

suspend fun saveNotePriorityFilters(priorities: Set<String>) {
  dataStore.edit { preferences ->
    preferences[NOTE_PRIORITY_SET] = priorities
  }
}

fun getNotePriorityFilters() = runBlocking {
  dataStore.data.first()[NOTE_PRIORITY_SET] ?:
setOf(DEFAULT_PRIORITY_FILTER)
}

위의 코드는 배경색 기본 설정에 대해 이미 작성한 것과 유사합니다.

saveNoteSortOrder()saveNotePriorityFilters()에서 제공된 값을 특정 키에 할당하고 DataStore에 저장합니다.

getNotePriorityFilters()getNoteSortOrder()를 사용하여 저장된 값 또는 실제 값이 null인 경우 기본값을 검색합니다.

MainActivity.kt를 열고 다음과 같이 updateNoteSortOrder()updateNotePrioritiesFilter()를 교체하여 CoroutineScope를 사용하도록 합니다.

private fun updateNoteSortOrder(sortOrder: NoteSortOrder) {
  noteAdapter.updateNotesFilters(order = sortOrder)
  lifecycleScope.launchWhenStarted {
    notePrefs.saveNoteSortOrder(sortOrder)
  }
}
private fun updateNotePrioritiesFilter(priorities: Set<String>)
{
  noteAdapter.updateNotesFilters(priorities = priorities)
  lifecycleScope.launchWhenStarted {
    notePrefs.saveNotePriorityFilters(priorities)
  }
}

updateNoteSortOrder()는 제공된 sortOrder를 어댑터 내부의 메모 목록에 적용합니다.

그런 다음 saveNoteSortOrder()를 사용하여 현재 order 필터를 DataStore에 저장합니다.

updateNotePrioritiesFilter()도 유사한 작업을 수행합니다.

필터 우선순위에 대한 noteAdapter를 업데이트하고 우선순위를 DataStore에 저장합니다.

이렇게 DataStore에 값을 쓰고 읽는 메서드 작성을 완료했습니다.

이제 마지막 단계가 남아있습니다.

바로 SharedPreferences에서 DataStore로 사용자의 기존 preferences 값을 복사해야 합니다.

preferences 마이그레이션

SharedPreferences에서 데이터를 마이그레이션하려면 DataStore에 복사하려는 데이터가 있는 SharedPreferences 인스턴스의 이름을 알려야 합니다.
이를 위해 SharedPreferencesMigration을 사용합니다.

마이그레이션을 진행하기 위해, 다음 매개변수를 포함하도록 preferencesDataStore()를 변경합니다.

produceMigrations = { context ->
    listOf(SharedPreferencesMigration(context,
NotePrefs.PREFS_NAME))
  }

위의 코드에서 produceMigrations는 생성자 파라미터이며 (Context) -> List<DataMigration<Preferences>> 타입의 람다를 사용합니다.

Context 인스턴스와 기존 SharedPreferences의 이름을 사용하는 SharedPreferencesMigration 인스턴스를 만듭니다.

그런 다음 SharedPreferencesMigration 인스턴스로 리스트를 만들고 람다에서 반환합니다.

마찬가지로 여러 SharedPreferences를 하나의 DataStore로 마이그레이션할 수 있습니다.

요약

  • SharedPreferences에는 UI 스레드 차단, 예외 처리 및 데이터 마이그레이션과 관련된 제한 사항이 있습니다.
  • Google은 SharedPreferences API의 한계점을 해결하기 위해 DataStore를 도입했습니다.
  • DataStore에는 PreferencesProto의 두 가지 구현이 있습니다.
  • preferencesDataStore()를 사용하여 DataStore를 생성하거나 이에 대한 액세스 권한을 얻을 수 있습니다.
  • DataStore 인스턴스는 SharedPreferences와 마찬가지로 고유한 이름을 가질 수 있습니다.
  • DataStore 데이터는 데이터를 읽고 싶을 때 수집(collect)할 수 있는 Flow로 노출됩니다.
  • SharedPreferences에서 DataStore로 데이터를 마이그레이션할 수 있습니다.
post-custom-banner

0개의 댓글