몇 년 전 구글(Google)은 오랫동안 사랑받아 온 SharedPreferences를 대체할 DataStore를 발표했습니다.
애플리케이션에서 SharedPreferences를 사용 중이거나 사용해 본 경험이 있다면 전환을 고려하고 있을 것입니다. 하지만 모든 기술 도입이 그렇듯 핵심 질문은 "개발 비용이 얼마나 드는가?"일 것입니다.
DataStore에는 여러 장점이 있지만, 객체를 저장하면서 타입 안정성(type safety)까지 제공하는 것은 오직 Proto DataStore뿐입니다.
그런데 Proto DataStore 공식 문서를 살펴보면 다소 오래되었고, 실제 사용 과정에서 꼭 필요한 몇 가지 단계가 빠져 있는 경우가 많습니다. 그래서 이 글에서는 Proto DataStore를 애플리케이션에 통합하는 방법을 단계별로 살펴보고, 생각만큼 어렵지 않다는 점을 보여드리고자 합니다.
DataStore란 무엇인가?
Jetpack DataStore는 두 가지 종류로 제공됩니다:
- Preferences DataStore
- Proto DataStore
Preferences DataStore는 SharedPreferences와 매우 유사하고 이미 널리 소개된 바 있어 이번 글에서는 다루지 않겠습니다. 대신 Proto DataStore의 'Proto'가 무엇을 의미하는지 알아보겠습니다.
'Proto'는 구글이 Protocol Buffers(프로토콜 버퍼)를 지칭하기 위해 붙인 이름입니다. Protocol Buffers는 구조화된 데이터를 직렬화(serialize)하는 구글의 메커니즘으로, 특정 프로그래밍 언어에 종속되지 않습니다. 일반적으로 작업하려는 데이터 타입을 정의하면, 데이터를 읽고 쓰는 데 필요한 코드가 자동으로 생성됩니다.
✋ 이 글에서는 Proto 3 버전을 사용합니다.
그렇다면 그 정의는 어떤 모습일까요?
message MyItem {
string itemName = 1;
int32 itemId = 2;
}
먼저 message 키워드로 객체를 정의하고, 그 안에 해당 객체와 연관된 필드들을 나열합니다. 각 필드 끝에 붙은 숫자는 필드를 식별하는 용도로 사용되며, 한 번 설정되고 객체가 사용 중이라면 변경할 수 없습니다.
그렇다면 하나의 .proto 파일에 여러 객체를 담으려면 어떻게 해야 할까요? 객체들이 서로 연관되어 있다면, message 객체를 추가하는 것만으로 간단히 해결할 수 있습니다:
message MyItem {
string itemName = 1;
int32 itemId = 2;
}
message MyListOfItems {
repeated MyItem items = 1;
}
위 코드에서는 앞서 정의한 MyItem 객체에 의존하는 또 다른 message 객체를 추가했습니다. 객체 목록을 정의하려면 repeated 키워드를 사용해야 한다는 점에 주목하세요.
Proto DataStore 설정 방법
시작하려면 앱 수준(app-level) build.gradle에 다음 의존성을 추가해야 합니다:
implementation "androidx.datastore:datastore-preferences:1.0.0"
implementation "com.google.protobuf:protobuf-javalite:3.18.0"
그다음 프로젝트 내부에 proto 디렉터리를 생성합니다. 이 디렉터리는 프로젝트 구조상 Java 폴더와 같은 위치(형제 폴더)에 있어야 합니다.
proto 디렉터리 안에는 .proto 파일을 생성하게 되는데, 이 파일이 Proto DataStore에 저장할 데이터 타입 생성을 담당합니다.
확장자가 .proto인 파일을 만듭니다. 우리의 .proto 파일은 Todo 리스트를 나타내는 객체를 담게 됩니다. 파일 이름은 todo.proto로 지정하고 내용은 다음과 같습니다:
syntax = "proto3";
option java_package = "com.yourPackageName.todo";
option java_multiple_files = true;
message TodoItem {
string itemId = 1;
string itemDescription = 2;
}
message TodoItems {
repeated TodoItem items = 1;
}
두 개의 message 객체를 정의했습니다:
- TodoItem – 할 일(todo) 항목 하나를 정의합니다
- TodoItems – TodoItem 객체들의 목록을 정의합니다
그다음 프로젝트를 빌드하면 TodoItem과 TodoItems 클래스가 자동으로 생성됩니다.
데이터 객체 정의가 끝났다면, 이제 이 객체들을 직렬화할 클래스를 만들어야 합니다. 이 클래스는 DataStore에게 객체를 읽고 쓰는 방법을 알려주는 역할을 합니다.
// 1
object TodoItemSerializer: Serializer<TodoItems> {
// 2
override val defaultValue: TodoItems = TodoItems.getDefaultInstance()
// 3
override suspend fun readFrom(input: InputStream): TodoItems {
try {
return TodoItems.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
// 4
override suspend fun writeTo(
t: TodoItems,
output: OutputStream
) = t.writeTo(output)
}
이 클래스의 구성 요소를 살펴보겠습니다:
- 클래스 선언 시 Serializer 인터페이스를 우리 객체(TodoItems)를 타입(T)으로 하여 구현합니다
- 파일이 아직 생성되지 않은 경우를 대비해 직렬화기(serializer)의 기본값을 정의합니다
- readFrom/writeTo 메서드를 오버라이드하고, 해당 메서드에서 우리 객체를 데이터 타입으로 지정합니다
.proto 파일로 데이터 타입을 정의했고 직렬화기도 준비했으니, 다음 단계는 DataStore 인스턴스를 생성하는 것입니다. dataStore가 제공하는 프로퍼티 위임(property delegate)을 사용해, 데이터가 저장될 파일명과 위에서 정의한 직렬화기를 지정하면 됩니다.
private const val DATA_STORE_FILE_NAME = "todo.pb"
private val Context.todoItemDatastore: DataStore<TodoItems> by dataStore(
fileName = DATA_STORE_FILE_NAME,
serializer = TodoItemSerializer,
)
이 코드는 원하는 클래스의 최상단, 즉 클래스 정의 바로 위에 위치해야 합니다. 구체적으로 다음과 같습니다:
private const val DATA_STORE_FILE_NAME = "todo.pb"
private val Context.todoItemDatastore: DataStore<TodoItems> by dataStore(
fileName = DATA_STORE_FILE_NAME,
serializer = TodoItemSerializer,
)
class YourClassName {
}
애플리케이션의 다른 곳에서 이 객체에 접근하려면 컨텍스트(context)를 사용해야 합니다. 예를 들어 ViewModel 클래스에서 애플리케이션 컨텍스트를 활용하는 방식입니다:
class MyViewModel(application: Application): AndroidViewModel(application) {
val todoDataStore = application.todoItemDataStore
//...
}
Kotlin Flow 활용 방법
여기까지 DataStore 설정을 마쳤으니, 이제 실제로 DataStore와 상호작용하는 방법을 알아보겠습니다. 목표는 데이터를 읽고 쓰는 것이지만, 그 방식은 SharedPreferences에서 익숙했던 것과는 조금 다릅니다.
앞서 정의한 DataStore에는 data라는 필드가 있으며, 이 필드가 DataStore에 정의된 속성들에 대한 Flow를 노출합니다.
🚰 Flow에 익숙하지 않다면 코틀린 공식 문서의 Flow 섹션부터 시작하는 것을 추천합니다.
val todoItemFlow: Flow<TodoItems> = todoItemDataStore.data
.catch { exception ->
if (exception is IOException) {
emit(TodoItems.getDefaultInstance())
} else {
throw exception
}
}
위 코드는 Proto DataStore에서 데이터를 수집하는 Flow를 정의하는 방법을 보여줍니다. 예외 발생에 대비해 catch 블록이 추가되었습니다. 이 로직은 DataStore를 정의한 클래스에 두고, ViewModel에서는 다음과 같이 활용할 수 있습니다:
val todoItemsFlow: LiveData<TodoItems> = todoItemsRepository.todoItemFlow.asLiveData()
Flow를 LiveData로 변환한 데에는 두 가지 이유가 있습니다:
- Flow는 이를 사용하는 Activity/Fragment와 무관하게 계속 활성 상태를 유지할 수 있습니다
- 많은 개발자에게 LiveData가 익숙하기 때문에, 예제를 최대한 쉽게 접근할 수 있도록 만들고 싶었습니다
이를 위해 build.gradle 파일에 다음 의존성을 추가해야 합니다:
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.6.2"
Activity/Fragment 클래스에서는 다음과 같이 LiveData를 관찰(observed)할 수 있습니다:
myViewModel.todoItemFlow.observe(LocalLifecycleOwner.current) { todoItems ->
// DataStore에서 데이터를 가져오는 로직
}
언제, 왜 DataStore를 사용해야 할까?
여기까지 검토한 내용을 바탕으로 이제 본론으로 들어가 보겠습니다. 기존 또는 다음 프로젝트에서 DataStore(Preferences 또는 Proto)를 사용해야 할까요?
제 답변은 "그렇다"입니다. 구글이 SharedPreferences에서 벗어나는 추세라는 점 외에도, DataStore는 데이터 영속성 관리가 아니라 애플리케이션 자체에 집중할 수 있게 해주는 다양한 장점을 제공합니다.
DataStore는 UI 스레드에서 안전하게 접근할 수 있고(작업이 자동으로 I/O 스레드로 이동), 아직 Flow를 사용하지 않았다면 자연스럽게 Flow를 활용하게 되면서 그 모든 이점을 누릴 수 있습니다. 또한 SharedPreferences에서 Preferences DataStore로 손쉽게 마이그레이션하는 방법도 제공됩니다.
Proto DataStore 대신 Room 사용을 고려 중이라면, 그것은 사용 사례(use case)에 따라 달라집니다. 저장할 데이터의 양이 적고 부분 업데이트가 필요 없다면 Proto DataStore가 적합합니다. 반대로 데이터 세트가 크거나 구조가 복잡하다면 Room을 선택하는 것이 좋습니다.
이 모든 코드가 실제 애플리케이션에서 어떻게 동작하는지 궁금하다면, 위 코드를 직접 따라 하며 Todo 리스트 앱을 만들어 보는 것을 추천합니다.
읽어주셔서 감사합니다!
참고 자료
- Protocol Buffers 공식 문서 (proto 3)
- Working With Proto DataStore Codelab
- Jetpack DataStore 공식 문서