반응형

개요

안드로이드 개발을 하다보면, 간단한 데이터에 대해 로컬에 저장하고 사용하고자 하는 니즈를 자주 마주하게 됩니다. 그럴 때마다 그동안에는 SharedPreferences 라는 라이브러리를 사용해왔는데요. SharedPreferences 는 key-value 의 형식의 데이터로 다뤄지며, xml 파일로 로컬 저장소에 저장됩니다.

 

얼마전, Jetpack DataStore 가 1.0.0 stable 버전이 정식 릴리즈 되었는데요. Android Developer 유튜브 채널에서 DataStore 를 소개하는 영상에서는 진행자가 아래와 같은 말로 소개를 했습니다.

 

"It aims to replace SharedPreferences"

 

즉, DataStore 라이브러리는 SharedPreferences 를 대체하기 위한 목적으로 만들어졌다고 볼 수 있는데요.

그렇다면 SharedPreferences 에 어떤 문제점이 있어서 DataStore 라는 별도의 라이브러리를 만들었을까요? 우리는 DataStore 를 사용하기에 앞서 이 부분을 먼저 짚고 넘어가도록 하겠습니다.

 

Developer 사이트에서도 Datastore 사용을 권장하고 있습니다.

 

SharedPreferences 의 문제점

1. 비동기(Asynchronous) API 를 제한적으로 지원

기존에 SharedPreferences 에서는 읽기(Read)에 대해 기존에 값을 읽어오는 것은 동기(sync) API 만을 제공하고, 값에 변화가 있을 때마다 비동기적으로 값을 가져오는 방법으로는 오직 OnSharedPreferenceChangeListener 를 통해서만 콜백을 받을 수 있게 지원하고 있었습니다.

또한 쓰기(Write)에 대해서는 Editor 를 통해 put 하고, commit() 이 아닌 apply() 라는 함수를 통해 비동기로 write 할 수 있게 제공하고 있었는데요. 이 apply() 도 사실 즉시 비동기 호출을 하는게 아니라 내부 코드를 보면 pending 시켜두었다가 서비스나 액티비티가 Start/Stop 되는 시점에 백그라운드에서 동작하게 하는데, 이때 실행되는 fsync() 라는 native 함수가 사실상 main thread 를 Block 하기 때문에 자칫 잘못하면 ANR 까지도 이어질 수 있다고 합니다.

 

2. Runtime Exception 에 취약함

SharedPreferences 는 기본적으로 Exception 에 대한 에러 핸들링을 제공하고 있지 않습니다. 따라서 SharedPreferences 때문에 발생하는 Exception 을 다루기에 어려움이 있습니다. 심지어 위에서 언급한대로 apply() 통해 pending 된 작업을 처리하다가 에러가 발생할 경우에는 해당 예외를 잡을 방법이 없이 크래쉬를 맞이할 수 밖에 없습니다.

 

3. UI Thread 에 안전하지 않음

SharedPreferenceImpl 내부 코드를 보면, Editor 를 통한 commit() 함수에서는 별도의 쓰레드가 아닌 호출된 Thread 에서 바로 File Write 를 하고 있습니다. 이는 파일에 쓰는 데이터가 많지 않으면 언뜻 보기에 문제 없어 보일 수 있지만 저사양 기기에서나, 데이터 양이 많아지면 Main Thread 를 오랫동안 Block 하면서 유저에게 버벅이는 경험을 주거나 ANR 까지도 이어질 수 있습니다.

 

DataStore

이번에는 DataStore 에 대해 알아보겠습니다. DataStore 의 주요 특징으로는 아래와 같이 정리할 수 있습니다.

  • 코루틴 Flow 사용하여 비동기적이고, 일관된 트랜잭션 방식으로 데이터 저장
  • 소규모 단순 데이터에 적합한 솔루션으로, 복잡한 데이터나 참조 무결성 등을 필요로 때는 여전히 Room 사용하는 것이 적합
  • key-value 방식의 Preferences DataStore Protocol buffer 사용한 타입이 지정된 객체를 저장할 있는 방식인 Proto DataStore 솔루션을 제공

그리고 SharedPreferences 와 비교한다면 아래와 같이 정리할 수 있습니다.

 

 

DataStore 는 코루틴 Flow 를 통해서만 제공되기 때문에 기본적으로는 비동기 API 만을 제공합니다. 하지만 first() 등을 활용한다면 동기로도 활용할 수 있습니다. 그리고 CorruptionHandler 이나 Flow 의 확장함수 중 catch() 등을 통해서 에러 핸들링을 잘 지원해주고 있고, 모든 무거운 작업은 Dispatchers.IO 에서 작업되기 때문에 UI Thread 에서 얼마든지 사용하더라도 안전하다 할 수 있습니다.

 

SharedPreferences 에서 DataStore 로 데이터 마이그레이션 하는 방법도 공식적으로 지원하고 있기 때문에 기존에 SharedPreferences 를 사용하고 있던 프로젝트에서도 보다 편하고 안전한 방법으로 마이그레이션을 할 수 있습니다.

 

Preferences Datastore

먼저 소개할 것은 Preferences DataStore 입니다. Preferences DataStore 는 이름에서 힌트를 얻을 수 있듯이, key-value 방식으로 데이터를 읽고 쓰는 방식입니다.

기본적으로 DataStore 를 다루기 위해서는 DataStore<T> 타입의 객체를 활용해야 하는데, 이때 Preferences DataStore 를 생성하는 방식은 2가지 방식을 제공하고 있습니다.

 

한 가지 방식은 preferencesDataStore() 라는 Delegate 함수이고, 다른 하나는 PreferenceDataStoreFactory.create() 팩토리 함수입니다. 사실 preferencesDataStore() 함수 내부적으로 PreferenceDataStoreFactory.create() 함수를 사용하고 있기 때문에 팩토리 통해서 객체를 생성한다고 아시면 되겠습니다.

 

개인적으로 Hilt 나 Dagger 등 의존성 주입 프레임워크를 사용한다면 직접 Factory 통해서 생성하는 것을 추천하고, 그렇지 않다면 File 의 top level 에서 Delegate 함수 통해 생성 후 싱글톤으로 관리하는 방식을 추천합니다.

 

// Delegate 방식
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = DATASTORE_NAME)

// Factory 방식
PreferenceDataStoreFactory.create(
    corruptionHandler = corruptionHandler,
    migrations = produceMigrations(applicationContext),
    scope = scope
) {
    applicationContext.preferencesDataStoreFile(name)
}

 

DataStore<Preferences> 객체를 생성했다면, 이번에는 간단하게 read & write 를 해보도록 하겠습니다.

오늘 예제 코드는 간단하게 이름과 나이를 갖고 있는 User 객체를 정의해서 사용해보겠습니다.

data class User(
    val name: String? = "",
    val age: Int? = 0
)

 

먼저 Key-Value 중 Key 를 정의해보겠습니다.

val NAME_KEY = stringPreferencesKey("name")
val AGE_KEY = intPreferencesKey("age")

Key 를 사용하는 것에서부터 SharedPreferences 와 큰 차이점이 있는데요.

SharedPreferences 에서는 Key 의 타입이 String 이었던 것과는 달리, DataStore 에서는 Preferences.Key<T> 타입이어야 합니다.

따라서 위와 같이 DataStore 패키지 안에 정의되어 있는 XXXPreferencesKey(String) 함수를 통해 원하는 타입에 맞는 키로 변환해주어야 합니다.

 

이렇게 구현했을 때의 장점은 Key 에 타입이 설정됨으로써 value 가 읽기/쓰기 과정에서 다른 타입으로 다뤄질 경우 컴파일 에러를 통해 개발자가 사전에 문제를 알 수 있게 합니다.

 

이제 Key 가 준비되었으니, 먼저 읽기를 해보겠습니다.

val userPreferencesFlow: Flow<User> = context.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 ->
        val name: String = preferences[NAME_KEY] ?: ""
        val age: Int = preferences[AGE_KEY] ?: 0
        User(name, age)
    }

보시면 DataStore<Preferences> 타입의 dataStore 변수의 data 프로퍼티를 통해 데이터를 읽어올 수 있고, catch() 를 통해 에러를 핸들링 하고, map() 을 통해 원하는 객체로 변환도 할 수 있습니다. 이외에도 Flow 와 관련된 다양한 함수를 통해 풍부하게 활용 가능하다고 할 수 있습니다.

 

참고로 개발을 하다보면, 캐싱된 값을 사용하거나 스냅샷을 활용하고자 하는 니즈가 있을 수 있습니다. 주의할 것은 이때 StateFlow 로 감싸기 보다는 first() 통해서 스냅샷을 뜨기를 권장합니다.

// Don’t
suspend fun fetchCachedPrefs(scope: CoroutineScope): StateFlow<Preferences> = dataStore.data.stateIn(scope)

// Do
suspend fun fetchInitialPreferences(): Preferences = dataStore.data.first().toPreferences()

 

이번에는 쓰기(Write)를 해보겠습니다.

데이터 쓰기는 edit() 함수를 통해 할 수 있습니다. 내부적으로 구현을 살펴보면 Immutable 한 Preferences 객체를 MutablePreferences 로 변환해서 write 하는데요. 이때 주의할 점은 데이터 쓰기는 edit() 을 통해 하기만을 권장합니다. 개발자가 임의로 toMutablePreferences() 로 변환해서 put 한다 해도 반영이 되지 않습니다.

참고로, edit() 함수는 suspend function 이기 때문에 CoroutineScope 내에서나 suspend function 내에서 호출되어야 합니다.

suspend fun updateUserName(name: String) {
    context.dataStore.edit { preferences ->
        preferences[NAME_KEY] = name
    }
}

이때 NAME_KEY 가 Key<String> 타입으로 선언되어있기 때문에 name 자리에 다른 타입(예를들어 Int)의 데이터가 들어올 경우에는 컴파일 에러가 발생합니다.

 

DataStore 의 Read, Modify, Write 작업은 모두 Atomic 하게 수행되기 때문에, 데이터 일관성을 보장하고 경쟁 상태(race condition)을 예방합니다.

 

Proto DataStore

이번에는 구조화된 데이터 타입(Typed Object)으로 데이터를 읽고 쓸 수 있는 Proto DataStore 를 알아보겠습니다.

Proto DataStore 를 사용하기 위해서는 Protocol Buffer 를 알아야 하는데, 잘 모르시는 분들은 문서를 참고하시면 되겠습니다.

 

먼저, main/proto 디렉토리에 *.proto 파일을 만들고 스키마를 정의합니다. (Enum 도 정의 가능합니다.)

// user_prefs.proto
syntax = "proto3";

option java_package = "com.reddy.datastoresample";

message UserData {
  string name = 1;
  int32 age = 2;
}

 

protobuf-gradle-plugin 을 사용하면 컴파일 단계에서 위에서 정의한 proto 파일의 스키마에 따라 클래스를 생성해줍니다.

개발자는 해당 클래스로 직렬화/역직렬화 할 수 있도록 Serializer 인터페이스 구현체를 정의해야 합니다.

object UserDataSerializer : Serializer<UserData> {
    override val defaultValue: UserData = UserData.newBuilder()
        .setName("reddy")
        .setAge(29)
        .build()

    @Suppress("BlockingMethodInNonBlockingContext")
    override suspend fun readFrom(input: InputStream): UserData {
        try {
            return UserPreferences.parseFrom(input)
        } catch (exception: InvalidProtocolBufferException) {
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    @Suppress("BlockingMethodInNonBlockingContext")
    override suspend fun writeTo(t: UserData, output: OutputStream) = t.writeTo(output)
}

이때 주의할 점은, protobuf 플러그인에서 생성된 객체(위 예에서는 UserData)는 생성자가 private 으로 정의되어 있고 불변 객체이므로 값을 변경하기 위해서는 newBuilder() 를 호출하여 빌더를 통해서 새로운 객체로 만들어주는 수 밖에 없습니다.

 

private val Context.userDataStore: DataStore<UserData> by dataStore(
    fileName = DATASTORE_NAME,
    serializer = UserPreferencesSerializer,
    produceMigrations = { context ->
        listOf(
            SharedPreferencesMigration(
                context = context,
                sharedPreferencesName = PREFERENCES_NAME
            ) { sharedPrefs: SharedPreferencesView, currentData: UserData ->
                val name = sharedPrefs.getString(NAME_KEY, "")
                val age = sharedPrefs.getInt(AGE_KEY, 0)
                if (!name.isNullOrBlank() && age > 0) {
                    currentData.toBuilder().setName(name).setAge(age).build()
                } else {
                    currentData
                }
            }
        )
    }
)

Proto DataStore 는 DataStore<UserData> 타입으로 선언할 수 있으며, 위임(Delegate) 함수인 dataStore() 통해서 생성할 수 있습니다. 이때 Preferences DataStore 와는 다르게 serializer 를 등록해줘야 합니다.

 

그리고 위 예시에서는 produceMigrations 파라미터를 통해 SharedPreferences 에서 DataStore 로 데이터 마이그레이션 하는 예시입니다. DataStore 는 위와 같이 produceMigrations 파라미터에 List<DataMigration<T>> 타입의 값을 넘기면서 다양한 형태의 마이그레이션 객체를 넘길 수 있는데요. DataStore 패키지에서는 대표적으로 SharedPreferencesMigration 을 제공하고 있는데, 이 객체는 SharedPreferences 에서 DataStore 로 마이그레이션을 방법을 제공합니다.

 

이제 Proto DataStore 의 데이터를 읽고 쓰는 예시를 살펴보겠습니다.

이번에는 read & write 를 한 번에 작성해보겠습니다.

class UserPreferencesRepository(private val userPreferencesStore: DataStore<UserPreferences>) {
    val userPreferencesFlow: Flow<UserPreferences> = userPreferencesStore.data
        .catch { exception ->
            // error handling
        }

    // Don't
    suspend fun fetchCachedPrefs(scope: CoroutineScope): StateFlow<UserPreferences> =
        userPreferencesStore.data.stateIn(scope)

    // Do
    suspend fun fetchInitialPreference() = userPreferencesStore.data.first()

    // edit() 을 사용하던 Preferences DataStore 와는 달리 updateData() 를 사용.
    suspend fun updateAge(age: Int) {
        userPreferencesStore.updateData { currentPreferences ->
            currentPreferences.toBuilder().setAge(30).build()
        }
    }
}

이미 Preferences DataStore 에서 보신 것과 크게 사용성이 다르지 않아 어렵지 않게 느껴지실텐데요.

눈여겨 볼 점이 있다면 쓰기(Write) 할 때 edit() 함수를 호출해야 했던 Preferences DataStore 와는 달리 updateData() 라는 이름의 함수를 사용해야 한다는 점입니다.

 

이렇게 해서 DataStore 의 등장 배경과 SharedPreferences 와 DataStore 의 비교, 그리고 DataStore 의 간단한 예시까지 살펴봤는데요. SQLite 가 Room 의 등장 이후 역사속으로 사라져가는 것처럼 SharedPreferences 도 점차 DataStore 가 안정화 됨에 따라 사라지지 않을까 싶습니다.

반응형
반응형