[Android] DataStore란? (Instead SharedPreference)

부나·2024년 5월 13일
1

안드로이드

목록 보기
9/12
post-thumbnail

DataStore란?

Jetpack DataStore는 프로토콜 버퍼를 사용하여 키-값 쌍 또는 type이 지정된 객체를 저장할 수 있는 데이터 저장소 솔루션입니다.
DataStore는 Kotlin 코루틴 및 Flow를 사용하여 비동기적이고 일관된 트랜잭션 방식으로 데이터를 저장하고 관리합니다.

공식문서에 따르면 현재 SharedPreference를 사용하고 있다면 DataStore로 마이그레이션 하기를 권장합니다.
다만, 기존과 동일하게 복잡하고 대규모 데이터 세트, 참조 무결성, 개체 무결성 등을 지원해야 하는 경우에는 Room을 사용하라고 명시되어 있습니다.

DataStore는 크게 2가지로 구분됩니다.
1. Preference DataStore : 기존의 SharedPreference와 유사한 방식을 사용
2. Proto DataStore : 포로토콜 버퍼를 사용하여 type-safe하게 데이터를 관리

Why DatStore?

DataStore 공식 문서에는 SharedPreference에서 마이그레이션 하라고만 명시할 뿐 구체적인 이유를 찾기 어렵습니다.

제가 생각하기에 DataStore의 장점은 다음과 같습니다.

  1. Flow를 지원하며, 데이터에 변경이 발생했을 때 Flow를 collect하고 있다면 자동으로 데이터를 전달받을 수 있습니다. (SharedPreference도 OnSharedPreferenceChangeListener를 통해 데이터 변경을 감지할 수 있지만 Flow를 사용하면 보일러 플레이트를 줄일 수 있고, 더 단순하고 일관된 구조를 유지할 수 있습니다.)

  2. 자동으로 IoDispatcher를 사용하여 백그라운드에서 작업을 진행합니다. 물론 SharedPreference 또한 apply() 메서드를 제공하지만, 개발자의 실수로 Main Thread에서 동작시킬 수 있으며, 내부적으로 파일을 디스크에 저장하는 fsync()가 Main Thread를 block한다는 잠재적 리스크를 가지고 있습니다.

  3. 변경 사항 반영 도중에 에러가 발생해도 알 수 있는 방법이 없습니다. DataStore는 Flow의 catch() 연산자를 통해 에러를 적절히 처리할 수 있습니다.

  4. 모든 작업이 하나의 트랜잭션 내에서 원자적으로 발생하기 때문에 안전합니다.

  5. Proto Datastore 사용 시, type safe 하다는 장점이 있어 런타임 에러 가능성을 줄일 수 있습니다.


테이블로 정리된 각 클래스의 차이

Preference DataStore

Preference DataStore는 DataStore 클래스와 Preference 클래스를 사용하여 간단한 키-값 쌍을 디스크에 저장하고 관리합니다. 이 구현은 type-safe를 제공하지 않으며 사전 정의된 스키마가 필요하지 않습니다.

사용하는 방식은 기존의 Preference와 유사하지만 DataStore 자체의 장점을 포함한 방식이라고 볼 수 있습니다.

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

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

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

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

기본적으로 Flow와 사용하기 적합하지만, optional로 RxJava와 KMM에서 사용할 수 있는 강력한 라이브러리입니다.

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

Preference Datastore 만들기

preferencesDataStore() 로 만든 property delegate를 사용하여 Datastored<Preference> 인스턴스를 만듭니다.
DataStore는 싱글톤 방식으로 하나의 객체로 관리해야 합니다.
따라서 top-level 인스턴스로 한 번만 생성하고 애플리케이션의 나머지 부분에서 해당 인스턴스에 접근하도록 구현해야 합니다.

// Kotlin 파일의 top-level에 위치시킵니다:
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

필수 name 매개변수는 Preferences Datastore의 이름입니다.

Preference Datastore에서 읽기

Preference Datastore는 사전 정의된 스키마를 사용하지 않습니다.
따라서 Datastored<Preference> 인스턴스에 쌍으로 저장합니다.
예를 들어 int 값의 키를 정의하려면 intPreferencesKey()를 사용합니다. 그런 다음 DataStore.data 프로퍼티에 접근하여 Flow를 사용한 적절한 저장 값을 노출합니다.

val EXAMPLE_COUNTER = intPreferencesKey("example_counter")
val exampleCounterFlow: Flow<Int> = context.dataStore.data
  .map { preferences ->
    // type-safe 하지 않습니다.
    preferences[EXAMPLE_COUNTER] ?: 0
}

Preferences DataStore에 쓰기

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

suspend fun incrementCounter() {
  // edit 메서드를 사용하여 datastore에 접근하고 key를 통해 값을 가져와 업데이트 합니다.
  context.dataStore.edit { settings ->
    val currentCounterValue = settings[EXAMPLE_COUNTER] ?: 0
    settings[EXAMPLE_COUNTER] = currentCounterValue + 1  // 마지막 라인에 변환 할 값 작성
  }
}

edit() : atomic하게 읽기-수정-쓰기 작업을 하므로 DataStore의 값을 트랜잭션 방식으로 편집합니다.
코루틴은 데이터가 디스크에 영구적으로 유지되면 완료됩니다(그 후 DataStore.data에 업데이트가 반영됨). 디스크에 대한 변환 또는 쓰기가 실패하면 트랜잭션이 중단되고 예외가 발생합니다.

Proto DataStore

Proto Datastore는 커스텀 데이터 타입의 인스턴스로 데이터를 type-safe하게 저장/관리합니다. 이 구현은 포로토콜 버퍼를 사용하여 스키마를 정의하고 객체를 디스크에 유지합니다.

프로토콜 버퍼(Protocol buffer) : 구조화된 데이터를 직렬화 하는 방식이다.
Json과 같이 네트워크 통신, IO 작업에서 바이트스트림 형태로 변환한다.
다만, Json보다 더 경량화 된 방식으로 대용량 데이터 송수신에 유리한 방식이다.

[Protocol buffer 포스팅 보러 가기](블로그 링크)

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

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

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

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

스키마 정의

Proto Datastore는 type-safe한 특징을 가지고 있듯이 기본적으로 스키마를 정의하여 이 틀 내에서 데이터의 형식을 유지합니다.
직접 클래스를 만들고 @Serializable 애노테이션을 붙일 수도 있고, proto 파일에 형식을 정의하여 컴파일 타임에 IDE에서 자동으로 클래스를 만들도록 구현할 수 있습니다.

해당 포스팅에서는 공식문서와 같이 proto 파일을 만드는 방식으로 진행합니다.
app/src/main/proto/ 경로에 proto 파일을 생성합니다.

syntax = "proto3";  // proto3 문법을 사용한다.

option java_package = "com.example.application";  // 생성 될 message 관련 클래스가 위치 할 패키지 경로
option java_multiple_files = true;  // message가 여러 개일 때 파일을 분리해서 여러개로 만들 것인가

message Settings {
  // 필드 타입 : int
  // 필드명 : example_counter
  // 필드를 식별하기 위한 고유 번호 : 1
  int32 example_counter = 1;
}

message 는 클래스와 유사한 개념이며, Settings 는 message의 이름을 의미합니다.
자세한 내용은 위 코드의 주석에 설명해두었습니다.

참고: 저장된 객체의 클래스는 컴파일 시간에 proto 파일에 정의된 message에서 생성됩니다. 프로젝트를 다시 빌드해야 합니다.

Proto Datastore 만들기

type이 지정된 객체를 저장할 Proto DataStore를 만드는 작업은 두 단계로 이루어집니다.

  1. Serializerd&#60;T> 를 구현하는 클래스를 정의합니다. 여기서 T는 proto 파일에 정의된 타입입니다. serializer 클래스는 데이터 타입을 읽고 쓰는 방법을 Datastore에 알립니다. 아직 파일이 생성되지 않은 경우 사용할 serializer의 기본값을 포함해야 합니다.

  2. dataStore 로 만든 속성 위임을 사용하여 DataStored<T>의 인스턴스를 만듭니다. 여기서 T는 proto 파일에 정의된 유형입니다. kotlin 파일의 최상위 수준에서 인스턴스를 한 번 호출한 후 애플리케이션의 나머지 부분에서는 이 속성을 통해 인스턴스에 액세스합니다. filename 매개변수는 데이터를 저장하는 데 사용할 파일을 Datastore에 알리고 serializer 매개변수는 1단계에서 정의한 serializer 클래스 이름을 Datastore에 알립니다.

object SettingsSerializer : Serializer<Settings> {
  override val defaultValue: Settings = Settings.getDefaultInstance()

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

  override suspend fun writeTo(
    t: Settings,
    output: OutputStream) = t.writeTo(output)
}

val Context.settingsDataStore: DataStore<Settings> by dataStore(
  fileName = "settings.pb",
  serializer = SettingsSerializer
)

Proto Datastore에서 읽기

DataStore.data 를 사용하여 저장된 객체에서 적절한 속성의 Flow 를 노출합니다.

val exampleCounterFlow: Flow<Int> = context.settingsDataStore.data
  .map { settings ->
    // exampleCounter 프로퍼티는 proto 스키마에서 생성되었습니다.
    settings.exampleCounter
  }

Proto DataStore에 쓰기

Preference Datastore와 동일하게 트랜잭션 방식으로 업데이트 하는 updateData() 메서드를 제공합니다.
updateData() 는 데이터의 현재 상태를 인스턴스로 제공하고 atomic하게 읽기-쓰기-수정 작업을 통해 트랜잭션 방식으로 데이터를 업데이트합니다.

suspend fun incrementCounter() {
  context.settingsDataStore.updateData { currentSettings ->
    currentSettings.toBuilder()
      .setExampleCounter(currentSettings.exampleCounter + 1)
      .build()
    }
}

동기 코드에서 Datastore 사용

Datastore의 큰 장점은 알아서 IoDispatcher를 통해 백그라운드에서 비동기 작업을 수행해주는 것입니다.
하지만, 어쩔 수 없이 동기적으로 작업해야 하는 상황이 있을 수 있습니다.
비동기 API를 제공하지 않는 다른 종속 항목이 있거나, 기존 코드가 동기 디스크 I/O를 사용하는 경우가 이에 해당합니다.

이 때 Kotlin 코루틴의 runBlocking() 코루틴 빌더를 사용하여 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 작업을 할 수 있습니다.


레퍼런스

profile
망각을 두려워하는 안드로이드 개발자입니다 🧤

0개의 댓글