안드로이드 프로젝트를 진행하다 보면, 키-값을 저장해야 할 경우가 많이 생기는데 (Bearer Token
을 저장하고 읽어오는 유즈케이스 등), 이에 대해 그동안 주로 상대적으로 간편한 SharedPreferences
을 사용하곤 했습니다. 그런데 사실 공식문서를 살펴보면 다음과 같은 문구가 적혀있습니다.
약 1년 전, 해당 문서를 읽고 DataStore
에 대한 학습을 하였으나,
프로젝트를 진행할 때마다 관성적으로 SharedPreferences
를 사용하는 나 자신에 대한 의문을 품고, 좀 더 DataStore
에 애정을 갖고 프로젝트에 적용하고자, 본 포스팅을 시작했습니다.
우선 DataStore
는 프로토콜 버퍼를 사용하여 키-값 쌍 또는 입력된 객체로 데이터를 유지하는 Jetpack 라이브러리입니다.
DataStore
는 코틀린의 코루틴(Coroutines)
과 플로우(Flow)
를 기반으로 사용하여 기존의 SharedPreferences
를 대체하는 것을 목표로 합니다.
우선 DataStore
의 장점을 이해하기 전에 SharedPreferences API
의 한계점에 대해 알아야 합니다. SharedPreference
s는 API 레벨 1
부터 있었지만, 시간이 지나도 지속되는 한계점이 있습니다.
한계점은 다음과 같습니다.
SharedPreferences
를 호출하는 것은 UI 쓰레드를 차단하여 버벅거림을 유발할 수 있으므로 항상 안전한 것은 아닙니다.SharedPreferences
에서 오류를 알릴 방법이 없습니다. (parsing error 런타임 예외 제외)SharedPreferences
는 데이터 마이그레이션을 지원하지 않습니다. 데이터의 타입을 변경하려면 전체 로직을 수정해야 합니다.SharedPreferences
는 유형 안전성(type safety)
을 제공하지 않습니다. 동일한 키(key)
를 사용하여 Boolean
과 Integer
타입의 값들을 모두 저장하려고 해도, 앱이 그대로 컴파일됩니다.DataStore
는 유즈케이스에 따라 선택할 수 있는 두 가지 구현 타입을 제공합니다.
Preferences DataStore
: SharedPreferences
와 유사한 키-값 쌍으로 데이터를 저장합니다. 이를 사용하여 기본 데이터 유형을 저장하고 검색합니다.Proto DataStore
: 프로토콜 버퍼를 사용하여 사용자 정의 데이터 타입을 저장합니다. Proto DataStore
를 사용하는 경우 맞춤 데이터 타입에 대한 스키마(schema)
를 정의해야 합니다.SharedPreferences
는 XML
을 사용하여 데이터를 저장합니다.
따라서, 데이터 양이 증가함에 따라 파일 크기가 급격히 증가하고 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
라는 새 디렉토리를 만듭니다.
기존에 있던 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에 매개변수로 전달합니다.
이제 NotePrefs
가 DataStore
에 액세스할 수 있으므로 여기에 데이터를 쓰는 코드를 추가합니다.
DataStore
에서 데이터를 읽고 쓰려면 Preferences.Key<T>
의 인스턴스가 필요합니다. 여기서 T
는 읽고 쓰려는 데이터 타입입니다.
문자열 데이터와 상호 작용하려면 Preferences.Key<String>
의 인스턴스가 필요합니다. DataStore 라이브러리에는 이러한 키를 쉽게 만들 수 있는 stringPreferencesKey()
및 doublePreferencesKey()
와 같은 메서드도 포함되어 있습니다.
NotePrefs.kt
파일을 열고 아래의 코드를 companion object
블록 안에 넣어줍니다.
private val BACKGROUND_COLOR =
stringPreferencesKey("key_app_background_color")
위의 코드는 String 타입의 키를 생성하고 이름을 key_app_background_color
로 지정합니다.
이제 키를 생성했으므로 이를 사용하여 키에 해당하는 값을 쓸 수 있습니다. 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())
}
위의 코드에서 많은 일이 발생합니다.
lifecycleScope
는 Activity
의 생명 주기에 연결된 CoroutineScope
에 대한 액세스를 제공하는 확장 프로퍼티입니다. CoroutineScope
는 작업을 실행할 스레드와 취소할 수 있는 시기를 지정하는 데 도움이 됩니다.launchWhenStarted()
는 Activity
가 적어도 STARTED
상태일 때 lifecycleScope
에서 코루틴을 시작합니다.saveNoteBackgroundColor()
는 lifecycleScope
내부에서 호출됩니다.위의 코드는 Datastore
가 비동기적으로 업데이트되도록 합니다.
또한 showNoteBackgroundColorDialog()
에서 아래 코드를 제거합니다.
changeNotesBackgroundColor(getCurrentBackgroundColorInt())
DataStore를 사용하면 사용자가 원할 때마다 반응적으로(리액티브하게) UI를 업데이트하므로 더 이상 필요하지 않습니다.
이제 데이터 쓰기 메서드 작성을 완료했습니다.
다음은 이 데이터를 읽는 로직을 작성해보겠습니다.
SharedPreferences
와 달리 DataStore
는 데이터를 동기식으로 읽는 API를 제공하지 않습니다.
대신 Flow
를 사용하여 DataStore
내부의 데이터를 관찰하고 오류를 올바르게 처리하는 방법을 제공합니다.
Flow
는 값을 순차적으로 내보내는 비동기 데이터 스트림을 제공하는 Kotlin 코루틴 API입니다.
UserPreferences.kt
파일을 생성해주고 아래의 코드를 작성해줍니다.
data class UserPreferences(
val backgroundColor: AppBackgroundColor
)
위의 코드에서 UserPreferences
는 다양한 사용자 기본 preferences
를 그룹화하는 컨테이너 클래스 역할을 합니다. 지금은 배경색만 포함되어 있습니다.
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)
}
위의 코드는 몇 가지 중요한 작업을 수행합니다.
Preferences
타입의 Flow
를 반환하는 DataStore
의 data 프로퍼티를 사용하여 DataStore
내부의 데이터에 액세스합니다.Flow
에 발생한 모든 예외를 잡아줍니다. IOException
예외가 발생하면, emit()
을 사용하여 empty preferences
를 반환합니다. 그렇지 않으면 호출자에게 예외를 던집니다.BACKGROUND_COLOR
키를 사용하여 preferences
값을 읽습니다. 만약 preferences
가 해당 키에 해당하는 데이터를 갖고 있지 않다면 null을 반환합니다. 이 경우에는 DEFAULT_COLOR
를 선택합니다. 마지막으로 UserPreferences
인스턴스를 생성하고 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
에서 배경 색상값에 액세스합니다.
새로운 앱을 개발 중이라면 처음부터 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
값을 복사해야 합니다.
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 스레드 차단, 예외 처리 및 데이터 마이그레이션과 관련된 제한 사항이 있습니다.SharedPreferences API
의 한계점을 해결하기 위해 DataStore
를 도입했습니다.DataStore
에는 Preferences
와 Proto
의 두 가지 구현이 있습니다.preferencesDataStore()
를 사용하여 DataStore
를 생성하거나 이에 대한 액세스 권한을 얻을 수 있습니다.DataStore
인스턴스는 SharedPreferences
와 마찬가지로 고유한 이름을 가질 수 있습니다.DataStore
데이터는 데이터를 읽고 싶을 때 수집(collect)할 수 있는 Flow
로 노출됩니다.SharedPreferences
에서 DataStore
로 데이터를 마이그레이션할 수 있습니다.