-
Jetpack Datastore 이해하기Android 2024. 5. 22. 22:41
안녕하세요. Mash-Up 14기 Android 팀으로 활동하고 있는 전계원입니다.
Android 에는 앱 내 저장소에 정보를 key-value 기반으로 저장하기 위해 SharedPreferences 가 존재합니다.
하지만 요즘은 SharedPreferences 대신 Datastore 을 많이 사용하고 있습니다.
그런데
여러분들은 SharedPreferences 대신 Datastore 를 사용하는 이유는 무엇인가요?
Async API 제공? Type Safety? Runtime Exception 방지?
그럼 Datastore 는 어떻게 이러한 장점들을 누릴 수 있는 걸까요?
Datastore 관련하여 이와 같은 궁금증이 생겼고, 이를 위해 Datastore 에 대해 공부했던 내용을 이번 포스팅을 통해 공유드리고자 합니다.
* 본 포스팅에서는 Datastore 을 사용하는 방법에 대해 다루지 않습니다. (Datastore 사용 방법은 공식문서를 참조 부탁드립니다!)
1. Android 저장소의 종류
Android 에는 다음과 같은 저장소들이 있습니다.
- 이미지, 동영상, 문서 등을 보관하는 공유저장소(Shared Storage)
- 앱 내 데이터베이스를 다루고, 설정내용을 보관하는 앱 내부저장소(Internal Storage)
- 앱 내 문서, Asset 정보들을 SD Card 에 보관하는 앱 외부저장소 (App External Storage)
데이터베이스 관리를 효과적으로 관리할 수 있도록 Jetpack Room 이 출시된 것처럼.
설정정보 관리를 효과적으로 관리할 수 있도록 Jetpack Datastore 가 출시되었습니다.
https://developer.android.com/topic/libraries/architecture/datastore?hl=ko 그리고 이미 Android 공식문서에서는 SharedPreferences 를 사용하고 있다면 Datastore 로 이전하기를 권장하고 있습니다.
2. Datastore 개요
https://developer.android.com/topic/libraries/architecture/datastore?hl=ko Data 공식문서에서 Datastore 는 프로토콜 버퍼를 사용하여 key-value 객체를 저장하는 데이터솔루션이고, Kotlin Coroutine 과 Flow 를 사용하여 비동기적이고 일관된 트랜잭션 방식으로 데이터를 저장한다고 기재되어 있습니다.
프로토콜 버퍼..?
프로토콜 버퍼는 무엇일까요?
3. 프로토콜 버퍼 개요
https://protobuf.dev/ Protocol Buffer 는 Google 에서 만든 개발언어에 구애받지 않고(language-neutral), 플랫폼에 구애받지 않고(platform-neutral), 작고(높은 데이터 압축률), 빠르게 전송할 수 있는 데이터 직렬화 솔루션입니다.
위와 같은 내용처럼 protocol buffer 는 데이터 직렬화 솔루션이며, 내부적인 encoding, decoding 로직을 통해 key-value 기반의 정보를 xml 이나 json 에 비해 높은 압축률로 저장할 수 있어서 동일한 데이터를 다른 솔루션들에 비해 더 작고, 빠르게 전송할 수 있습니다.
(정말로 xml 이나 json 에 비해 압축률이 높은지는 차차 확인해 보겠습니다)
* 데이터 직렬화 : 객체 데이터를 저장/통신하기 쉬운 포맷으로 인코딩하는 것
4. Datastore 의 장점
https://android-developers.googleblog.com/2020/09/prefer-storing-data-with-jetpack.html Google 블로그에는 Datastore 와 SharedPreferences 에 비해 갖는 여러 장점들이 표를 통해 나열되어 있습니다.
수많은 장점들이 있지만, 대부분 Coroutine, Flow, Protocol Buffer 을 이용하면서 발생되는 장점들입니다.
이번 포스팅에서는 Datastore 장점에 대해 위 내용들 중
- UI Thread 에서 안전하게 호출될 수 있다(Safe to call on UI Thread)
- 런타임 예외 방지 (Safe from runtime exceptions)
- 에러 신호를 받을 수 있다 (Can signal Errors)
- 타입 안정성 (Type Safety)
- (표에는 없지만) 높은 압축률로 데이터 저장
위 다섯 가지 관점을 기반으로 알아보겠습니다.
1) UI Thread 에서 안전하게 호출될 수 있다(Safe to call on UI Thread)
(1) Datastore 은 UI Thread 에서 안전하게 호출될 수 있다
// Preferences 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) }
// Proto Datastore public fun <T> dataStore( fileName: String, serializer: Serializer<T>, corruptionHandler: ReplaceFileCorruptionHandler<T>? = null, produceMigrations: (Context) -> List<DataMigration<T>> = { listOf() }, scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) ): ReadOnlyProperty<Context, DataStore<T>> { return DataStoreSingletonDelegate( fileName, serializer, corruptionHandler, produceMigrations, scope ) }
PreferencesDatastore 와 Proto Datastore 을 만드는 두 함수의 Datastore 객체 생성 함수를 살펴보면,
Dispatchers.IO + SupervisorJob() 을 default 값으로 받는 scope 인자를 받아서 DataStoreSingletonDelegate() 내부로 전달하는 것을 볼 수 있습니다.
그리고 Datastore 내부에서는 해당 scope 값을 기반으로 데이터를 저장하고 가져오는 API 를 제공합니다.
Datastore 는 이처럼 데이터를 저장하고 가져오는 과정을 Dispatchers.IO 에서 작업하기에 UI Thread 를 block 하지 않습니다.
(2) 왜 SharedPreferences 는 UI Thread 에서 안전하지 않나요?
https://android-developers.googleblog.com/2020/09/prefer-storing-data-with-jetpack.html SharedPreferences 는 UI Thread 에서 호출해도 안전해보이는 동기적인 API 를 가지고 있지만, 사실은 Disk I/O 작업을 수행합니다. apply() 는 fsync() 에서 UI Thread 를 차단합니다. apply() 에 의해 예약된 fsync() 호출은 Service 와 Activity 의 시작/종료 마다 트리거 됩니다. UI Thread 는 apply() 에 의해 호출된 fsync() 로 인해 차단될 수 있으며, ANR 의 원인이 될 수 있습니다.
관련된 내용은 바로 표의 하단에서 확인할 수 있었습니다.
SharedPreferences 의 commit() 은 동기적인 API 이며, Disk I/O 작업을 수행할 수 있습니다.
https://developer.android.com/reference/android/content/SharedPreferences.Editor#apply() 하지만 분명 apply() 는 공식문서에서 commit 과 다르게 비동기적으로 disk 에 commit 한다고 하였는데.. "fsync() 에서 UI Thread 를 차단될 수 있다" 는 말은 무슨 말인 걸까요?
차근차근히 이해해 보겠습니다.
(3) fsync() 와 apply()
https://manual.cs50.io/2/fsync fsync() 는 file descriptor (fd) 가 참조하고 있는 파일에서 수정된 모든 데이터를 disk device(혹은 디바이스의 또다른 영구 저장소) 로 전송하는 함수이다.
fsync() 는 파일에서 수정한 모든 데이터를 disk device 로 전송하는 system call 명령어입니다.
* system call : 응용프로그램(어플리케이션 등)이 커널을 통해서 할 수 있는 작업들을 실행하기 위해서 사용하는 일종의 커널 조작 API
// SharedPreferencesImpl.java @Override public void apply() { ... final MemoryCommitResult mcr = commitToMemory(); final Runnable awaitCommit = new Runnable() { @Override public void run() { try { mcr.writtenToDiskLatch.await(); } catch (InterruptedException ignored) { } if (DEBUG && mcr.wasWritten) { Log.d(TAG, mFile.getName() + ":" + mcr.memoryStateGeneration + " applied after " + (System.currentTimeMillis() - startTime) + " ms"); } } }; ... Runnable postWriteRunnable = new Runnable() { @Override public void run() { awaitCommit.run(); QueuedWork.removeFinisher(awaitCommit); } }; SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable); ... }
apply() 는 내부적으로 postWriteRunnable 에서 Runnable 이 실행되었을 때 메모리에 값을 업데이트하는 코드가 있습니다.
// SharedPreferencesImpl.java private void enqueueDiskWrite(final MemoryCommitResult mcr, final Runnable postWriteRunnable) { final boolean isFromSyncCommit = (postWriteRunnable == null); final Runnable writeToDiskRunnable = new Runnable() { @Override public void run() { synchronized (mWritingToDiskLock) { writeToFile(mcr, isFromSyncCommit); } synchronized (mLock) { mDiskWritesInFlight--; } if (postWriteRunnable != null) { postWriteRunnable.run(); } } }; // Typical #commit() path with fewer allocations, doing a write on // the current thread. if (isFromSyncCommit) { ... } QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit); } ... @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553) public static void queue(Runnable work, boolean shouldDelay) { Handler handler = getHandler(); synchronized (sLock) { sWork.add(work); if (shouldDelay && sCanDelay) { handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY); } else { handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN); } } }
Runnable 은 QueuedWork 를 통해 Handler 에 스케줄 됩니다.
하지만 Handler 를 통해 스케줄 되어도, writeToFile() 코드를 자세히 살펴보면
@GuardedBy("mWritingToDiskLock") private void writeToFile(MemoryCommitResult mcr, boolean isFromSyncCommit) { ... boolean fileExists = mFile.exists(); ... // Rename the current file so it may be used as a backup during the next read if (fileExists) { ... } // Attempt to write the file, delete the backup and return true as atomically as // possible. If any exception occurs, delete the new file; next time we will restore // from the backup. try { FileOutputStream str = createFileOutputStream(mFile); if (DEBUG) { outputStreamCreateTime = System.currentTimeMillis(); } if (str == null) { mcr.setDiskWriteResult(false, false); return; } XmlUtils.writeMapXml(mcr.mapToWriteToDisk, str); writeTime = System.currentTimeMillis(); FileUtils.sync(str); fsyncTime = System.currentTimeMillis(); ...
다음과 같이 내부에서 FileOutputStream 을 생성하고, FileUtils.sync() 를 실행하는데
// FileUtils.java /** * Perform an fsync on the given FileOutputStream. The stream at this * point must be flushed but not yet closed. * * @hide */ @UnsupportedAppUsage public static boolean sync(FileOutputStream stream) { try { if (stream != null) { stream.getFD().sync(); } return true; } catch (IOException e) { } return false; }
여기서 fsync() 를 유발하는 작업이 동작합니다.
정리하면 apply() 는 Handler 에 작업을 스케쥴하여 비동기적으로 동작하도록 구성하였지만, Handler 에 스케줄 되었던 코드가 UI Thread 위에서 실행되고, fsync() 함수가 실행되면 이 과정에서 UI Thread blocking 이 잠재적으로 발생할 수 있다는 의미였습니다.
2) 런타임 예외 방지 (Safe from runtime exceptions)
(1) SharedPreferences 의 문제점
// SharedPreferencesImpl.java (android-33) final class SharedPreferencesImpl implements SharedPreferences { public final class EditorImpl implements Editor { @GuardedBy("mEditorLock") private final Map<String, Object> mModified = new HashMap<>(); ... @Override public Editor putInt(String key, int value) { synchronized (mEditorLock) { mModified.put(key, value); return this; } } } @Override public int getInt(String key, int defValue) { synchronized (mLock) { awaitLoadedLocked(); Integer v = (Integer)mMap.get(key); return v != null ? v : defValue; } }
SharedPreference 의 내부구조는 Map<String, Object> 에 String 을 기반으로 Object 값을 저장하고 가져오며,
getInt() 이면 Integer 로 강제로 형변환하는 식의 구조입니다.
class MainActivity : AppCompatActivity() { val sharedPreference by lazy { getSharedPreferences("user_preferences", MODE_PRIVATE) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // {"test_key": "hello"} sharedPreference.edit { putString("test_key", "hello") } // "hello" return sharedPreference.getString("test_key", "") // "ClassCastException : cannot be cast to Integer" sharedPreference.getInt("test_key", 0) }
그래서 개발자의 실수로 인하여 위와 같이 String 객체로 저장된 값을 getInt() 로 가져오면, String 을 강제로 Integer 로 형변환하여 Runtime Exception 을 유발할 수 있습니다.
(2) Preferences DataStore 의 Runtime Exception 해결
// Preferences Datastore // MainActivity.kt class MainActivity : AppCompatActivity() { val datastore by preferencesDataStore("user_preferences") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) CoroutineScope(Dispatchers.Default).launch { datastore.data.collectLatest { user -> val age = user[PreferencesKeys.AGE] ... } } } object PreferencesKeys { val AGE = intPreferencesKey("age") val BIRTH_YEAR = intPreferencesKey("birth_year") val IS_JUNIOR = booleanPreferencesKey("is_junior") val NAME = stringPreferencesKey("name") val COMP_NAME = stringPreferencesKey("comp_name") val DEV_TEAM = stringPreferencesKey("sort_order") } enum class SortOrder { API, MOBILE, QA } }
하지만 Preferences Datastore 는 intPreferencesKey() 와 같은 코드로 키 값을 선언하며, 값을 저장하고 가져올 때 제네릭을 통해 컴파일타임에서 오류를 발생시켜 개발자의 실수로 인한 런타임에러를 방지할 수 있습니다.
(3) Proto DataStore 의 Runtime Exception 해결
// Proto Datastore // MainActivity.kt class MainActivity : AppCompatActivity() { val datastore by dataStore("user_preferences.pb", PreferencesSerializer()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) CoroutineScope(Dispatchers.Default).launch { datastore.data.collectLatest { user -> val age = user.age ... } } } } // user_pref.proto syntax = "proto3"; option java_package = "com.example.myapplication"; option java_multiple_files = true; message UserPreferences { int32 age = 1; int32 birth_year = 2; bool isJunior = 3; string name = 4; string compName = 5; enum DevTeam { API = 0; MOBILE = 1; QA = 2; } DevTeam team = 6; }
Proto Datastore 은 사전에 작성한 proto 파일을 기반으로 데이터를 저장하고 가져오는 함수들을 자동생성합니다. 개발자들은 자동생성된 함수들을 기반으로 개발하기에 개발자의 실수로 인한 런타임에러를 방지할 수 있습니다.
3) 에러 신호를 받을 수 있다 (Can signal Errors)
// SingleProcessDataStore.kt internal class SingleProcessDataStore<T>( private val produceFile: () -> File, private val serializer: Serializer<T>, initTasksList: List<suspend (api: InitializerApi<T>) -> Unit> = emptyList(), private val corruptionHandler: CorruptionHandler<T> = NoOpCorruptionHandler<T>(), private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) ) : DataStore<T> { override val data: Flow<T> = flow { ... emitAll( downstreamFlow.dropWhile { ... }.map { when (it) { is ReadException<T> -> throw it.readException is Final<T> -> throw it.finalException is Data<T> -> it.value is UnInitialized -> error( "This is a bug in DataStore. Please file a bug at: " + "https://issuetracker.google.com/issues/new?" + "component=907884&template=1466542" ) } } ) }
Datastore 에서는 SharedPreferences 와 다르게 data 를 flow 를 통해 받는 과정에서 발생한 Error Signal 도 제공합니다. Error Signal 종류는 아래와 같습니다.
- ReadException : disk 에서 읽기에 실패하였을 때 발생하는 Exception
- Final : Datastore 의 인자로 제공된 scope(CoroutineScope) 가 cancel 되었을 때 발생하는 Exception
- UnInitialized : 그 외의 예기치 못한 Exception
... override fun onCreate(savedInstanceState: Bundle?) { ... CoroutineScope(Dispatchers.Default).launch { datastore.data.catch { // Error Signal 시 처리 로직 }.collectLatest { // 정상 동작 시 처리 로직 } } ...
그래서 다음과 같이 Flow<T>.catch 를 통해 error signal 을 받아서 처리할 수 있습니다.
4) 타입 안정성 (Type Safety)
어떤 조건이 성립되어야 Type Safety 하다고 할 수 있을까?
Type Safety 에 대해 논하기 전 위 궁금증을 먼저 해결해보겠습니다.
(1) Type Safety 의 조건
https://en.wikipedia.org/wiki/Type_safety#Definitions 1994 년 Andrew Wright 와 Matthias Felleisen 은 operational semantics 에 의해 정의된 언어에 대한 type safety 의 표준정의와 증명방법을 제시하였으며, 이는 대부분의 프로그래머가 이해하고 있는 type safety 의 개념과 가장 가깝다. 이 접근 방식에 따르면, 언어의 의미론은 두 가지 속성을 가져야 type-sound(타입-안정성) 라고 할 수 있다. 진행(Progress) well-typed 프로그램은 '멈추지 않는다': 모든 표현식은 이미 값이거나, 명확하게 정의된 방법을 통해 값으로 축소될 수 있다. 즉, 프로그램은 변형될 수 없는 undefined 상태가 되지 않는다. 보존(Preservation) 각 계산단계 이후에도 각 표현식의 타입은 동일하게 유지된다(즉, 그 타입은 보존된다)
풀어서 정리해보면 결국 type safety 하다는 것은 모든 표현식은 에러없이 값으로 반환될 수 있어야하고, 각 계산단계 이후에도 표현식의 타입이 보존될 수 있을 때 Type Safety 하다는 것을 알 수 있습니다.
(2) SharedPreferences 는 Type Safety 한가?
// SharedPreferencesImpl.java (android-33) final class SharedPreferencesImpl implements SharedPreferences { public final class EditorImpl implements Editor { @GuardedBy("mEditorLock") private final Map<String, Object> mModified = new HashMap<>(); ... @Override public Editor putInt(String key, int value) { synchronized (mEditorLock) { mModified.put(key, value); return this; } } } @Override public int getInt(String key, int defValue) { synchronized (mLock) { awaitLoadedLocked(); Integer v = (Integer)mMap.get(key); return v != null ? v : defValue; } }
앞서 나온 내용과 같이 SharedPreferences 는 putInt() 와 같은 함수 호출 이후 상황에서 타입이 Object 로 변환되어 hashMap 에 저장되기에 타입이 보존되지 않습니다. 그래서 SharedPreferences 는 Type Safety 하지 않습니다.
(3) Preferences Datastore 는 Type Safety 한가?
Preference Datastore 는 개발자가 정의해둔 PreferencesKey 의 key 값과 저장된 파일의 key 값이 일치하는 값을 가져와서 해당 값을 알맞는 자료형으로 형변환을 하는 구조입니다.
구조를 살펴보면, 파일에 저장되어있던 값을 Preference Datastore 로 가져올 때 Key<*> 타입의 key 값과 Any 타입의 value 를 저장하는 preferencesMap 에 저장됩니다. 이 상황에서 Type 정보가 보존되지 않고, 그래서 Preferences Datastore 는 Type Safety 하지 않습니다.
* 여담) Preferences Datastore 의 ClassCastException 이 발생하는 시나리오
Preference Datastore 는 key 값 중복을 확인하지 않습니다. 때문에 개발자의 실수로 인하여 중복된 key 값이 사용된 경우 value 를 가져왔지만 사전 정의한 자료형으로 형변환 할 수 없는 시나리오가 발생할 수 있으며, 이 경우 ClassCastException 이 발생할 수 있습니다.
(4) Proto Datastore 는 Type Safety 한가?
Proto Datastore 는 사전에 proto 파일에 정의된 내용을 기반으로 컴파일타임에 setter(), getter(), 내부 parsing 코드가 자동생성되고, 개발단계에서 이를 활용하여 정보를 저장하고 가져오기에 전체 과정에서 타입이 명확하게 정의되고, 보존된 상태로 전달받습니다. 이러한 점에서 Proto Datastore 는 Type Safety 합니다.
5) 높은 압축률로 데이터 저장
Boolean is_junior = false String dev_team = "MOBILE" String comp_name = "Gmarket Coporation" String name = "gjeon" Integer age = 26 Integer birth_year = 1997"
위와 같은 정보들을 저장해야한다고 가정해 보겠습니다.
(1) SharedPreferences 의 값 저장
// user_preferences.xml <?xml version='1.0' encoding='utf-8' standalone='yes' ?> <map> <boolean name="is_junior" value="false" /> <string name="dev_team">MOBILE</string> <string name="comp_name">Gmarket Coporation</string> <string name="name">gjeon</string> <int name="age" value="26" /> <int name="birth_year" value="1997" /> </map>
SharedPreferences 는 xml 의 형태로 정보를 저장하며, 위 저장정보에 대해 334 Byte 를 사용합니다.
(2) Preferences Datastore 의 값 저장
Preferences Datastore 는 Protocol Buffer 의 인코딩 로직이 적용되어있지만, PreferencesKey 에서 정의해준 key 값을 기반으로 값을 식별하기 위해 파일에 PreferencesKey 의 key 정보를 함께 저장하고 있습니다.
// protobuf decoder web : https://protobuf-decoder.netlify.app/ { protobuf 1 : { string 1 : "age", protobuf 2 : { varint 3 : 26 } }, protobuf 1 : { string 1 : "birth_year", protobuf 2 : { varint 3 : 1997 } }, protobuf 1 : { string 1 : "name", protobuf 2 : { string 5 : "gjeon" } }, protobuf 1 : { string 1 : "comp_name", protobuf 2 : { string 5 : "Gmarket Coporation" } }, protobuf 1 : { protobuf 1 : { fixed64 13 : 8245925365875892083 // is_junior to decimal }, protobuf 2 : { varint 1 : 0 // false } }, protobuf 1 : { string 1 : "dev_team", protobuf 2 : { string 5 : "MOBILE" } } }
결과적으로 Preferences Datastore 는 protocol buffer 파일의 형태로 정보를 저장하며, 위 저장정보에 대해 121 Byte 을 사용합니다.
(3) Proto Datastore 의 값 저장
Proto Datastore 는 proto 파일에서 정의해준 내용을 기반으로 모두 자동생성되기에 Preferences Datastore 과 다르게 파일에 key 정보를 저장할 필요가 없습니다.
// protobuf decoder web : https://protobuf-decoder.netlify.app/ { varint 1 : 26, varint 2 : 1997, // 3 : false 는 default 값이어서 인코딩 과정에서 축약되었음 string 4 : gjeon, string 5 : Gmarket Coporation, varint 6 : 1 // user_pref 에 정의한 Mobile 을 의미 }
결과적으로 Proto Datastore 는 protocol buffer 파일 형태로 정보를 저장하며, 위 저장정보에 대해 34 Byte 을 사용합니다.
5. SharedPreferences, Preferences Datastore, Proto Datastore 뭐가 좋아?
그럼 무조건 Proto Datastore 가 제일 좋은거야?
이 글을 읽다보면, Datastore 의 장점은 모두 가지면서 Preferences Datastore 에 비해 정보 압축률이 높고, 완전한 Type Safety 를 제공하는 Proto Datastore 가 무조건 좋은 것이 아닐까하는 생각이 들 수 있습니다.
하지만 사실 여느 다른 기술들처럼 Datastore 도 무조건 좋은 것은 없습니다.
이리저리 공부하면서 정리된 개인적인 견해는 다음과 같습니다.
(개인적인 견해이기에 비판적으로 봐주시길 바랍니다)
신 기술을 도입하기 어려운 환경이면, SharedPreferences 를 그대로 사용하여도 괜찮을 것 같습니다
Datastore 가 이론적으로 지니는 이점은 분명히 많이 존재하지만, 유저에서는 유의미한 차이가 발생하진 않습니다. (런타임 에러만 발생하지 않게 조심한다면 말이죠..) 그렇기에 신 기술을 도입하기 어려운 환경이라면 무리하여 Datastore 의 도입은 후순위로 두어도 되지 않을까 싶습니다.
빠르게 Datastore 을 적용해보고 싶다면, Preferences Datastore 를 추천드립니다
Preferences Datastore 는 완전하지 않아도 어느정도의 타입 안정성을 보장하고, Protocol Buffer 인코딩 로직을 통해 값을 저장하고 불러오기 때문에 xml 에 비해 압축효과를 누릴 수 있습니다. 그리고 Datastore 가 제공하는 여러가지 이점도 동시에 누릴 수 있습니다.
마지막으로 이 모든 것을 누리기 위해 개발자는 의존성 한 줄만 추가하면 되기에 세팅하는 것도 어렵지 않습니다.
그래서 빠르게 Datastore 을 적용해보고 싶다면 Preferences Datastore 를 추천드립니다.
Datastore 의 모든 이점을 누려보고 싶다면, Proto Datastore 가 좋습니다
앞서 나온 내용과 같이 Proto Datastore 는 SharedPreference 나 Preferences Datastore 에 비해 많은 이점을 제공하고 있습니다. Proto 파일 작성방법을 공부하기 위한 시간이 충분하고, 환경세팅을 하기 위한 시간이 충분하다면 Proto Datastore 는 최고의 선택이 될 수 있습니다.
Datastore 는 할 수 없고, SharedPreferences 는 할 수 있는 것
하지만 Datastore 로 구현하기 어려운 점이 있습니다. 바로 변수를 활용하여 key 이름을 만들 수 없다는 점 입니다. Datastore 는 타입 안정성을 지키기 위해 key 값과 타입값을 명시하고 있습니다. 하지만 그렇기에 putString("key ${keyNo}", value) 와 같은 SharedPreferences 에서는 지원하는 변수를 활용한 키 값 생성은 Datastore 에 적합하지 않습니다.
만약 변수를 활용하여 key 값을 만들어주어야한다면 SharedPreferences 를 선택하는 것을 추천드립니다.
6. TL;DR;
- Datastore 는 Protocol Buffer, Kotlin Coroutine, Flow 를 사용하여 key-value 기반으로 객체를 저장하고 가져오는 데이터 솔루션이다.
- Protocol Buffer 는 내부적인 encoding, decoding 로직을 통해 key-value 기반의 정보를 높은 압축률로 저장할 수 있는 Google 의 데이터 직렬화 솔루션이다.
- [DataStore 는 UI Thread 에서 안전하게 호출될 수 있다]
- SharedPreferences 의 commit() 은 동기적인 API 이기에 Disk I/O 를 유발하고 UI Thread 를 blocking 할 수 있으며, apply() 는 비동기적으로 작업을 수행하지만, Handler 에 작업을 트리거 하는 로직이기에 스케쥴 되었던 작업이 UI Thread 에서 실행될 경우 UI Thread 를 blocking 할 수 있는 잠재성을 가지고 있다.
- Datastore 는 default 값이 Dispatcher.IO 인 scope 값이 객체를 만들 때의 전달되어, 이를 기반으로 데이터를 업데이트하고 불러오기 때문에 이를 활용하면 UI Thread 에서 안전하게 호출될 수 있다.
- [DataStore 는 Runtime Exception 을 방지한다]
- SharedPreferences 는 Map<String, Object> 에 값을 저장하고, 가져올 때 강제 형변환해서 가져오기 때문에 잘못된 자료형으로 가져올 경우 Runtime Exception 이 유발될 수 있다.
- Preferences Datastore 는 제네릭이 포함된 아키텍쳐로 Runtime Exception 을 방지하며, Proto Datastore 는 사전에 정의한 proto 파일을 기반으로 자동생성된 코드를 사용하게 하여 Runtime Exception 을 방지하였다.
- [Datastore 는 에러신호를 받을 수 있다]
- datastore 는 data.catch { } 를 통해 "ReadException", "Final", "UnInitiallized" 타입의 에러신호를 받아서 처리할 수 있다
- [Proto Datastore 는 Type Safety 하다]
- SharedPreferences 는 파일에서 파싱한 값이 Map<String, Object> 에 저장되어 Type 이 보존되지 않는다. 그래서 Type Safety 하지 않다.
- Preferences Datastore 는 제네릭을 통해 컴파일타임에서 Type Safety 를 어느정도는 Type 을 보장하지만, 파일에서 파싱한 값이 MutableMap<Key<*>, Any> 에 저장되어 Type 이 보존되지 않는다. 그래서 Type Safety 하지 않다.
- Proto Datastore 는 파싱하는 로직과 데이터를 저장하고 가져오는 함수들이 모두 proto 파일을 기반으로 컴파일타임에 자동생성되며, 자동생성된 코드들은 타입을 Any 등으로 변경하지 않고 그대로 보존한다. 그래서 Type Safety 하다.
- [높은 압축률로 데이터를 저장]
- SharedPreferences 는 정보를 xml 구조로 저장한다.
- Preferences Datastore 는 Protocol Buffer 파일로 encoding 후 저장하기에 압축하여 정보를 저장할 수 있다. 하지만 preferencesKey 에서 정의한 key 값으로 식별해야해서 파일에 PreferencesKey 에서 정의한 key 정보가 함께 저장되어 Proto Datstore 에 비해 압축률이 떨어진다.
- Proto Datastore 는 proto 파일에 key 매핑정보가 함께 포함되어 있어서 Protocol Buffer 파일에 값만 저장할 수 있다. 그래서 이 중 가장 높은 압축률로 저장할 수 있다.
- 어떤 기술이 제일 좋을까?
- 사실 신 기술 적용이 어려우면 SharedPreferences 그대로 사용해도 문제 없을 것 같다.
- Datastore 의 모든 이점을 온전하게 누려보고 싶다면 Proto Datastore 을 적용해보는 것이 좋다.
- 빠르게 Datastore 의 이점을 누리고 싶다면 Preferences Datastore 를 적용하는 것이 적절하다.
- Datastore 는 key 값에 변수를 넣어줄 수 없기 때문에 런타임에 key 값이 정해지는 기능이 있다면 SharedPreferences 를 사용하는 것이 더욱 적절할 수 있다.
- 위와 같이 여러가지 상황들을 고려해보면서 적용하는 것이 좋다.
'Android' 카테고리의 다른 글
Compose PreviewParameterProvider 알아보기 (0) 2024.06.27 Fastlane으로 Android앱 자동 배포해보기 (feat. Firebase App Distribution) (1) 2024.04.20 MVVM과 MVI 디자인 패턴 차이점 알아보기 (0) 2023.03.23 repeatOnLifecycle, launchWhen...에 대해서 (0) 2023.03.07 Jetpack Compose Side Effect (1) 2023.03.07