이번 프로젝트에서 로컬 저장소로 Encrypted Shared Preference를 사용하다 Proto DataStore로 Migration을 하게 되었다. 이번 포스팅에서는 Proto DataStore와 Shared Preference의 비교, Proto DataStore 소개, 전반적인 구현 방법에 대해서 소개한다.
Proto DataStore
Jetpack Datastore는 프로토콜 버퍼를 사용하여 키-값 쌍 또는 유형이 지정된 객체를 저장할 수 있는 데이터 저장 방법이다. Datastore는 Coroutines, Flow를 사용하여 비동기적이고 일관된 트랜잭션 방식으로 데이터를 저장한다. 따로 공식문서에서도 Shared Preference 대신에 DataStore를 쓰라고 권장하고 있다.
DataStore의 차별점
해당 표를 통해 SharedPreference와 DataStore Preference 그리고 Proto DataStore의 차이점을 알 수 있다. 하나씩 살펴보도록 하겠다.
- 기존 Listener와 Ui Thread에서 비동기를 지원했던 Shared Preference와 다르게, DataStore는 별도의 스레드에서 코루틴을 사용하여 데이터를 저장하고, 가져오는 과정 모두 비동기를 지원한다.
- Shared Prefrence에서 호출한 Coroutines이 응답이 없으면, Ui Thread가 Blocking되어 ANR(어플리케이션 응답 없음.)이 발생하는 이슈가 있었다.
- DataStore는 동기적 처리를 허용하지 않는다. 이렇게 함으로써, Ui Thread를 막을 수 있는 동작을 직접적으로 피할 수 있다.
- DataStore는 Flow의 내부 오류 시그널 메커니즘에 의존하여, 데이터를 읽거나 쓸 때 예외를 안전하게 감지하고 처리할 수 있다.
- DataStore는 강한 일관성을 유지한다. atomic read-modify-write operation에서 데이터 업데이트를 안전하게 처리하여 강력한 ACID 보장한다.
- DataStore는 Data Migration이 쉽고 간편하다.
- Shared Preference는 Type Safety를 제공하지 않아. 데이터를 읽을 때 Type Converting을 해야 했다. 이에 반해 DataStore는 Type Safety를 지원한다.
사전 지식 : Protocol Buffers
Protocol Buffers는 구조화된 데이터를 직렬화 하는 방식이다. Protocol Buffers는 작고, 빠르고, 간편하고, XML과 같은 대부분의 데이터 구조체보다 직관적이다.
Protocol Buffers에 데이터 구조화 방법에 대한 스키마를 정의하고 코드 생성에 사용할 언어와 같은 옵션을 지정할 수 있다. 그러면 컴파일러가 자동적으로 작성한 파일에 따라서 클래스를 생성해준다. 이를 통해 다양한 데이터 스트림에서 구조화된 데이터를 쉽게 쓰고 읽을 수 있으며, 코틀린, 혹은 다른 언어를 사용하여 서로 다른 플랫폼 간에 공유할 수 있다.
실제 예제를 통해 자세하게 알아보자.
Proto DataStore setup
1. Version Catalog, build.gradle 종속성 및 플러그인 추가
libs.version.toml
[versions]
google-protobuf-plugin = "0.9.3"
google-protobuf = "3.23.0"
[libraries]
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore"}
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "google-protobuf" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "google-protobuf" }
[bundles]
datastore = ["androidx-datastore", "protobuf-kotlin-lite"]
[plugins]
google-protobuf = { id = "com.google.protobuf", version.ref = "google-protobuf-plugin" }
이전에 작성했던 Version Catalog를 기반으로 datastore 모듈의 build.gradle을 설정한다.
plugins {
...
alias(libs.plugins.google.protobuf)
}
android {
namespace = "com.jaino.datastore"
}
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
register("java") {
option("lite")
}
register("kotlin") {
option("lite")
}
}
}
}
dependencies {
implementation(libs.bundles.datastore)
...
}
}
2. ProtoBuf 초기 설정하기
Proto DataStore를 사용할 모듈의 [module]/src/main에 proto directory를 만들고 저장하고자 하는 데이터의 형태의 .proto 파일을 만든다.
datastore/src/main/proto/user_preferences.proto
syntax = "proto3";
option java_package = "com.jaino.datastore";
option java_multiple_files = true;
message UserPreferences {
string nickname = 1;
int64 userId = 2;
}
작성한 내용을 살펴보면,
- syntax로 protocol buffers 3를 사용하는 것을 명시하였다..
- option java_package는 현재 생성된 클래스에 대한 패키지 위치를 의미한다.
- option java_multiple_files는 단일 파일만 생성할지 또는 메시지 유형에 따라 별도에 파일이 추가되는지 지정할 수 있다.
- Message는 저장될 데이터의 스키마를 작성한다.
- 1, 2로 default를 초기화 한것 같지만, 실제로는 id를 제공한 것이다. 이를 통해 식별 및 sort option을 사용할 수 있다.
protocol buffers는 아래의 구조로 코드를 생성한다.
public final class UserPreferences extends GeneratedMessageLite<...>
implements UserPreferencesOrBuilder {
private UserPreferences() {}
…
}
3. DataStore Serializer 생성하기
Serializer 클래스는 데이터 타입을 읽고 쓰는(직렬화, 역직렬화) 방법을 Datastore에게 명시한다. 아직 파일이 생성되지 않은 경우 사용할 Serializer에 기본값을 명시해야 한다.
class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferences>{
override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserPreferences =
try{
UserPreferences.parseFrom(input)
} catch(exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
t.writeTo(output)
}
}
코드의 내용을 확인하면,
- defaultValue는 초기 아무런 데이터가 없을 시 반환할 데이터를 의미한다
- writeTo는 객체를 저장소에 저장할 타입으로 직렬화 하는 방법을 명시한다.
- 파일을 직렬화 할 때 Corruption Exception이 발생할 수 있다.
- readFrom는 반대로 저장소에 저장할 타입에서 다시 객체로 역직렬화 하는 방법을 명시한다.
4. Proto DataStore 만들기
DataStore을 사용하기 위해, 데이터를 저장할 곳의 파일 이름과 Serializer를 통해 선언하고 사용할 수 있다.
private val Context.userPreferencesStore: DataStore<UserPreferences> by dataStore(
fileName = DATA_STORE_FILE_NAME,
serializer = UserPreferencesSerializer,
…
)
파일을 만들고 작성하기 때문에, DataStore의 위임은 Application Context 타입의 코틀린 확장 프로퍼티가 되어야 한다. 추가적인 속성으로 CorruptionException을 처리하는 corruptionHandler를 선언할 수 있다. 해당 CorruptionException은 위에서 설명한 것과 같이, 직렬화에 실패하였을 떄 발생하는 예외이다.
corruptionHandler = ReplaceFileCorruptionHandler(
produceNewData = { UserPreferences.getDefaultInstance() }
)
마지막으로
이렇게 Proto DataStore에 대한 개념과 초기 세팅에 대해서 알아보았다. Protobuf File을 생성하지 않고도, Proto DataStore를 만들 수 있다(아래에 링크를 통해 확인). DataStore에서 Flow를 통해서 데이터에 접근이 가능한데, 기본적으로 coroutineScope, suspend function에서 실행이 가능함을 의미한다. Flow를 통해 예외처리등 강점이 있지만, 이미 대부분의 코드가 만들어진 상황에서는 마이그레이션시 불편한 점이 생길 수 있을 것 같았다. 필자 역시도 작은 프로젝트에서 마이그레이션 하였는데, 생각보다 수정되는 곳과 코드 양이 많았다. 이후 포스팅에서는 읽고 쓰는 방법, 의존성 주입을 통해 DataStore를 사용하는 방법에 대해서 소개할 예정이다.
참고 문헌
https://medium.com/androiddevelopers/all-about-proto-datastore-1b1af6cd2879
All about Proto DataStore
In this post, we will learn about Proto DataStore, one of two DataStore implementations. We will discuss how to create it, read and write…
medium.com
https://developer.android.com/topic/libraries/architecture/datastore?hl=ko
앱 아키텍처: 데이터 영역 - Datastore - Android 개발자 | Android Developers
데이터 영역 라이브러리에 관한 이 앱 아키텍처 가이드를 통해 Preferences DataStore 및 Proto DataStore, 설정 등을 알아보세요.
developer.android.com
[Android Datastore] 1. Datastore을 사용해야 하는 이유 : SharedPreferences는 왜 대체되어야 하는가?
SharedPreferences의 한계점 Datastore가 나오기 전까지 안드로이드에서는 가벼운 데이터를 key-value 쌍으로 저장하기 위해 SharedPreferences를 사용했다. SharedPreference는 다양한 한계점이 있었다. SharedPreferenc
kotlinworld.com
Datastore without Protobuf File
https://www.youtube.com/watch?v=yMGAbm84iIY&t=1038s