본문 바로가기

IT/Android

Data Store

728x90
반응형

 

출처 : https://medium.com/androiddevelopers/introduction-to-jetpack-datastore-3dc8d74139e7, https://medium.com/@jurajkunier/migrating-sharedpreferences-to-jetpack-datastore-9deb8259063, https://developer.android.com/topic/libraries/architecture/datastore

Introduction to Jetpack DataStore

DataStore 는 preference 또는 application state 같은 작은 양의 data 를 안전하고 일관되게 저장하기 위한 방법을 제공하는 Jetpack library 입니다. DataStore 는 asynchronous data 저장을 가능하게 하는 Kotlin coroutineFlow를 기반으로 합니다. Thread-safe 하고 non-blocking 이기 때문에 SharedPreferences 를 대체하는 것이 목적입니다. DataStore 는 두가지 다른 implementations 를 제공합니다. Proto DataStore는 typed objects(backed by protocol buffers) 저장하고, Preferences DataStore 는 key-value pairs 를 저장합니다. 이후로는 별도의 언급이 없는 한 DataStore 는 두 implementation 모두를 말합니다.

이 post 에서 DataStore 가 어떻게 동작하는지, 제공하는 implementation 과 개별적인 사례 등을 살펴보겠습니다. 또한, SharedPreference에 비해 어떤 이점과 개선점을 제공하는지, DataStore 를 사용할 가치가 있는 이유에 대해서도 살펴보겠습니다.

 

DataStore vs SharedPreferences

대부분 SharedPreference 를 앱 구현시 사용했을 것입니다. 또한 SharedPreference 를 사용하면서 재현하기 어려운 문제를 경험해봤을 수도 있습니다. 여기서 재현하기 어려운 문제는 uncaught exception 때문에 발생한 이상한 crash들을 analytics 에서 발견하거나, 호출할 때 UI thread 를 block 하거나 일관되지 않은 persisted data 가 발생하는 이런 문제들입니다. DataStore 는 이런 모든 이슈들을 해결하기 위해 만들어졌습니다.

SharedPreference 와 DataStore 의 직접적인 비교를 살펴봅시다.

 

SharedPreferences에 어떤 문제가 있습니까?

SharedPreferences 인터페이스는 XML 파일에 저장된 키-값 preference 의 작은 컬렉션을 관리하기 위한 API를 제공합니다. 키는 항상 문자열이고 값은 primitive 또는 문자열일 수 있습니다. API를 사용하면 다양한 get 메소드로 preference 을 읽고 Editor 클래스로 저장할 수 있습니다. Editor여기서 preference을 읽을 수 있으려면 Android가 실제 XML 파일을 열어야 하고 I/O 작업이므로 시간이 걸릴 수 있으므로 주의해야 합니다. 모든 get메서드는 동기식이며 preference 파일이 메모리에 로드될 때까지 기다려야 하므로 UI ​​스레드에서 호출되는 경우 잠재적으로 ANR 이 발생할 수 있습니다. 파일에 preference 을 다시 쓰기 위해 두 가지 방법 이 있습니다. apply() 는 비동기식이지만 작업이 성공하거나 실패하면 정보를 제공하지 않습니다.commit() 는 동기식이므로 UI ​​스레드에서 호출하면 안되지만 작업의 성공 또는 실패를 알리는 boolean 값을 반환합니다. 또 다른 함정은 SharedPreferences가 유형 안전성을 보장하지 않는다는 것입니다. 이는 오류가 발생하기 쉽고 구문 분석 오류 및 런타임 예외로 이어질 수 있습니다.

 

Jetpack DataStore의 멋진 점은 무엇입니까?

DataStore는 데이터 읽기 및 쓰기를 위한 비동기 코루틴 기반 데이터 스트림을 사용하는 멋진 인터페이스와 함께 제공됩니다. 모든 파일 작업은 기본적으로 I/O 스레드에서 실행되기 때문에 UI 스레드에서 사용하는 것이 완벽하게 안전합니다. 데이터는 더 이상 XML 형식이 아니라 Google의 바이너리 protobuf 형식으로 저장됩니다. 일반적인 텍스트 편집기로는 쉽게 읽을 수 없지만 다행히도 이 형식으로 저장된 데이터를 해독하고 읽는 데 도움이 되는 도구가 이미 많이 있습니다. 마지막으로 DataStore는 데이터 마이그레이션을 처리하고 데이터 일관성을 보장하며 데이터 손상을 처리합니다.

public fun preferencesDataStore(
    name: String,
    corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? = null,
    produceMigrations: (Context) -> List<DataMigration<Preferences>> = { listOf() },
    scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
): ReadOnlyProperty<Context, DataStore<Preferences>> {
    return PreferenceDataStoreSingletonDelegate(name, corruptionHandler, produceMigrations, scope)
}

기본적으로 DataStore 는 파일 로드 시에 IO 로 로드합니다.

 

 

Async API

대부분의 data storage API 들에서, data 가 수정되었을 때 자주 asynchronously 알림을 받을 필요가 있습니다. SharedPreference 일부 async 를 제공하지만, 오직 OnSharedPreferenceChangeListener 를 통해 바뀐 값에 대한 update 만 제공합니다. 하지만, 이러한 callback 은 여전히 main thread 에서 호출됩니다.마찬가지로, 만약 파일저장 작업을 background 로 넘기려면 SharedPreference 의 apply() 를 사용할 수 있지만, fsync() 에서 UI thread 가 block 되고 이는 잠재적으로 버벅거림 및 ANR 을 유발시킵니다. 이것은 언제든지 service 가 start 또는 stop되거나 activity 가 pause 또는 stop 될 때마다 발생할 수 있습니다. 이에 비해, DataStore 는 Kotlin coroutine 과 Flow 의 강력함을 사용하여, data 를 조회하고 저장하기 위한 완전한 asynchronous API 를 제공하여 , UI thread blocking 의 위험을 줄입니다. Kotlin FLow에 친숙하지 않은 사람들에게는 asynchronously 계산할 수 있는 값의 stream 일 뿐입니다.

 

 

FileUtils.java

    public static boolean sync(FileOutputStream stream) {
        try {
            if (stream != null) {
                stream.getFD().sync();
            }
            return true;
        } catch (IOException e) {
        }
        return false;
    }
    
FileDescriptor.java

public native void sync() throws SyncFailedException;

 

Synchronous work

SharedPreference API 는 즉시 사용 가능한 synchronous 작업을 지원합니다. 하지만, SharedPreference 의 persisted data 를 수정하기 위한 synchronous commit() UI thread 에서 호출하는 것이 안전해 보일 수 있지만, 실제로는 heavier I/O operations 을 수행합니다. 이것은 ANR 및 UI 버벅거림을 만드는 , 발생할 수 있고, 자주 발생하는 , 위험한 scenario 입니다. 이것을 막기 위해, DataStore 는 즉시 사용 가능한 synchronous 지원을 제공하지 않습니다. DataStore 는 preference 를 파일에 저장하고 별도로 지정하지 않는 한 내부적으로 Dispatchers.IO 에서 모든 data 작업을 수행하여 UI thread 를 unblock 상태로 유지합니다.

하지만, 나중에 살펴보겠지만 corotine builder 의 약간의 도움으로 DataStore 와 synchronous 작업을 결합하는 것이 가능합니다.

 

Error handling

SharedPreference 는 parsing error 를 runtime exception 으로 throw 하여 앱이 crash 에 취약해질 수 있습니다. 예를 들면, ClassCastException 은 잘못된 data type 이 API 에 요청되었을 때 발생하는 흔한 exception 입니다. DataStore 는 Flow 의 error signalling mechanism 에 의존하여 data 를 읽거나 쓸 때 발생하는 exception 을 catching 하는 방법을 제공합니다.

 

Type safety

data 를 저장하거나 가져오기 위해 Map 을 이용한 key-value pair 는 type safety protection 을 제공하지 않습니다. 하지만, Proto DataStore 를 사용하면 data model 에 대한 schema 를 미리 정의하고 full type safety(전체 유형 안전성) 의 추가 이점을 얻을 수 있습니다.

 

Data consistency

SharedPreferences의 atomicity(원자성) 보장이 부족하기 때문에 항상 어디서나 반영되는 data 수정에 의존할 수 없습니다. 특히 이 API의 요점은 영구적인 data 저장이기 때문에 위험할 수 있습니다. 이에 비해 DataStore의 완전한 트랜잭션 API는 원자적 읽기-수정-쓰기 작업으로 data가 update 되므로 강력한 ACID 보장을 제공합니다. (ACID : atomicity, consistency, isolation, durability) 또한 완료된 모든 update가 읽기 값에 반영된다는 사실을 반영하여 "쓰기 후 읽기" 일관성을 제공합니다.

 

Migration support

SharedPreferences에는 기본 제공 migration mechanism 이 없습니다 — 지루하고 오류가 발생하기 쉬운 값을 이전 저장소에서 새 저장소로 다시 매핑한 다음 정리하는 것은 사용자의 몫입니다. 이 모든 것은 data type 불일치 문제가 쉽게 발생할 수 있으므로 runtime exceptions 의 가능성을 높입니다. 그러나 DataStore는 SharedPreferences에서 DataStore로의 migration을 위해 제공된 구현과 함께 data 를 쉽게 migration 하는 방법을 제공합니다.

 

Preferences vs Proto DataStore

DataStore가 SharedPreferences보다 어떤 이점을 제공하는지 살펴보았으므로 Preferences와 Proto DataStore의 두 가지 구현 중에서 선택하는 방법에 대해 알아보겠습니다.

 

Preferences DataStore는 schema를 미리 정의하지 않고 key-value 쌍을 기반으로 data 를 읽고 씁니다. SharedPreferences와 유사하게 들릴 수 있지만 DataStore가 제공하는 위에서 언급한 모든 개선 사항을 염두에 두십시오. 이름에 "Preferences” 을 함께 사용하는 것에 속지 마십시오. 이들은 공통점이 없으며 완전히 별개의 두 API에서 제공됩니다.

 

Proto DataStore는 Protocol Buffers에 의해 backup 되는 typed objects를 저장하므로 type safety을 제공하고 keys가 필요하지 않습니다. (Protocol Buffers는 구조화된 data 를 직렬화하기 위한 언어 중립적, 플랫폼 중립적 확장 mechanism입니다). Protobufs는 XML 및 기타 유사한 데이터 형식보다 빠르고, 작고, 간단하고, 덜 모호합니다. 이전에 사용하지 않았다면 두려워하지 마십시오! 이것들은 배우기 매우 간단합니다. Proto DataStore를 사용하려면 새로운 직렬화 mechanism 을 배워야 하지만 그 이점, 특히 type safety 은 그만한 가치가 있다고 생각합니다.

 

 

 

둘 중 하나를 선택할 때 다음 사항을 고려해야 합니다:

  • data 읽기 및 쓰기를 위해 key-value 쌍으로 작업하고 있는 경우, 최소한의 변경만으로 SharedPreferences에서 빠르게 migration 하고 DataStore의 향상된 기능을 활용하며 type safety checks 없이도 충분히 안심할 수 있다면 Preferences DataStore를 사용할 수 있습니다.
  • 향상된 가독성의 이점을 추가로 얻기 위해 프로토콜 버퍼를 학습하고 data 가 enum 이나 lists 와 같은 보다 복잡한 classes 로 작업해야 하며 full type safety 지원을 받으려면 Proto DataStore를 사용해 볼 수 있습니다.

 

DataStore vs Room

"음, data를 저장하기 위해 Room을 사용하는 것이 어떨까요?"라고 물을 수 있습니다. 그리고 그것은 공정한 질문입니다! 그럼 이 모든 것에서 Room이 어디에 적합한지 봅시다.

 

수십 KB보다 큰 복잡한 datasets 로 작업해야 하는 경우 여러 data table 간에 부분 update 또는 참조 무결성이 필요할 수 있습니다. 그럴 경우에는 Room 사용을 고려해야 합니다.

하지만 preferences 나 app state 와 같은 더 작고 단순한 datasets 로 작업하므로 부분 update나 참조 무결성이 필요하지 않다면 DataStore를 선택해야 합니다.

 

 

설정

앱에서 Jetpack Datastore를 사용하려면 사용할 구현에 따라 다음을 Gradle 파일에 추가해야 합니다.

Preferences DataStore

// Preferences DataStore (SharedPreferences like APIs)
    dependencies {
        implementation("androidx.datastore:datastore-preferences:1.0.0")

        // optional - RxJava2 support
        implementation("androidx.datastore:datastore-preferences-rxjava2:1.0.0")

        // optional - RxJava3 support
        implementation("androidx.datastore:datastore-preferences-rxjava3:1.0.0")
    }

    // Alternatively - use the following artifact without an Android dependency.
    dependencies {
        implementation("androidx.datastore:datastore-preferences-core:1.0.0")
    }

 

Proto DataStore

    // Typed DataStore (Typed API surface, such as Proto)
    dependencies {
        implementation("androidx.datastore:datastore:1.0.0")

        // optional - RxJava2 support
        implementation("androidx.datastore:datastore-rxjava2:1.0.0")

        // optional - RxJava3 support
        implementation("androidx.datastore:datastore-rxjava3:1.0.0")
    }

    // Alternatively - use the following artifact without an Android dependency.
    dependencies {
        implementation("androidx.datastore:datastore-core:1.0.0")
    }

 

참고: Proguard와 함께 datastore-preferences-core 아티팩트를 사용한다면 Proguard 규칙을 proguard-rules.pro 파일에 직접 추가하여 필드가 삭제되지 않도록 해야 합니다. 필요한 규칙은 여기에서 확인할 수 있습니다.

 

Preferences Datastore로 키-값 쌍 저장

Preferences Datastore 구현은 DataStore 클래스와 Preferences 클래스를 사용하여 간단한 키-값 쌍을 디스크에 유지합니다.

Preferences Datastore 만들기

preferencesDataStore로 만든 속성 위임을 사용하여 Datastore<Preferences>의 인스턴스를 만듭니다. kotlin 파일의 최상위 수준에서 인스턴트를 한 번 호출한 후 애플리케이션의 나머지 부분에서는 이 속성을 통해 인스턴트에 액세스합니다. 이렇게 하면 더 간편하게 DataStore를 싱글톤으로 유지할 수 있습니다. 또는 RxJava를 사용하는 경우 RxPreferenceDataStoreBuilder를 사용합니다. 필수 name 매개변수는 Preferences Datastore의 이름입니다.

 

// At the top level of your kotlin file:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

 

Preferences Datastore에서 읽기

Preferences Datastore는 사전 정의된 스키마를 사용하지 않으므로 DataStore<Preferences> 인스턴스에 저장해야 하는 각 값의 키를 정의하려면 상응하는 키 유형 함수를 사용해야 합니다. 예를 들어 int 값의 키를 정의하려면 intPreferencesKey()를 사용합니다. 그런 다음 DataStore.data 속성을 사용하여 Flow를 사용한 적절한 저장 값을 노출합니다.

 

val EXAMPLE_COUNTER = intPreferencesKey("example_counter")
val exampleCounterFlow: Flow<Int> = context.dataStore.data
  .map { preferences ->
    // No type safety.
    preferences[EXAMPLE_COUNTER] ?: 0
}

 

Preferences Datastore에 쓰기

Preferences Datastore는 DataStore의 데이터를 트랜잭션 방식으로 업데이트하는 edit() 함수를 제공합니다. 함수의 transform 매개변수는 필요에 따라 값을 업데이트할 수 있는 코드 블록을 허용합니다. 변환 블록의 모든 코드는 단일 트랜잭션으로 취급됩니다.

suspend fun incrementCounter() {
  context.dataStore.edit { settings ->
    val currentCounterValue = settings[EXAMPLE_COUNTER] ?: 0
    settings[EXAMPLE_COUNTER] = currentCounterValue + 1
  }
}

Proto Datastore 에 대해서 만드는 방법과 사용방법은 이번 자료에서는 생략합니다.

 

동기 코드에서 Datastore 사용

주의: 가능하면 항상 Datastore 데이터의 읽기에서 스레드를 차단하지 마세요. UI 스레드를 차단하면 ANR 또는 UI 버벅거림이 발생할 수 있으며, 다른 스레드를 차단하면 교착 상태가 발생할 수 있습니다.

DataStore의 주요 이점 중 하나는 비동기 API이지만 주변 코드를 비동기로 변경하는 것이 항상 가능하지는 않을 수도 있습니다. 동기 디스크 I/O를 사용하는 기존 코드베이스로 작업하거나 비동기 API를 제공하지 않는 종속 항목이 있다면 이러한 상황이 발생할 수 있습니다.

Kotlin 코루틴은 runBlocking() 코루틴 빌더를 제공하여 동기 코드와 비동기 코드 간의 격차를 해소합니다. runBlocking()을 사용하여 Datastore에서 데이터를 동기식으로 읽을 수 있습니다. RxJava는 Flowable에서 차단 메서드를 제공합니다. 다음 코드는 Datastore가 데이터를 반환할 때까지 호출 스레드를 차단합니다.

 

val exampleData = runBlocking { context.dataStore.data.first() }

 

UI 스레드에서 동기 I/O 작업을 실행하면 ANR 또는 UI 버벅거림이 발생할 수 있습니다. Datastore에서 데이터를 비동기식으로 미리 로드하여 이 문제를 완화하세요.

override fun onCreate(savedInstanceState: Bundle?) {
    lifecycleScope.launch {
        context.dataStore.data.first()
        // You should also handle IOExceptions here.
    }
}

 

이렇게 하면 Datastore가 비동기식으로 데이터를 읽고 메모리에 캐시합니다. runBlocking()을 사용한 이후의 동기식 읽기는 초기 읽기가 완료된 경우 더 빠르거나 디스크 I/O 작업을 모두 피할 수 있습니다.

 

저장 경로

SharedPreference 가 생성되는 경로는 data/data/$app package/shared_prefs 에 이름.xml 파일로 생성됩니다.

Preference DataStore 는 data/data/$app package/files/datastore 에 $이름.preferences_pb 파일로 생성됩니다.

 

PreferenceDataStoreDelegate.kt

    override fun getValue(thisRef: Context, property: KProperty<*>): DataStore<Preferences> {
        return INSTANCE ?: synchronized(lock) {
            if (INSTANCE == null) {
                val applicationContext = thisRef.applicationContext

                INSTANCE = PreferenceDataStoreFactory.create(
                    corruptionHandler = corruptionHandler,
                    migrations = produceMigrations(applicationContext),
                    scope = scope
                ) {
                    applicationContext.preferencesDataStoreFile(name)
                }
            }
            INSTANCE!!
        }
    }
}

PreferenceDataStoreFile.kt

public fun Context.preferencesDataStoreFile(name: String): File =
    this.dataStoreFile("$name.preferences_pb")
    
DataStoreFile.kt

public fun Context.dataStoreFile(fileName: String): File =
    File(applicationContext.filesDir, "datastore/$fileName")    
728x90
반응형