
지난 봄에, 설정값 하나 고치려고 SharedPreferences 파일을 열었는데요. 키가 30개 넘게 쌓여 있고, 어느 게 진짜 쓰는 값인지 아무도 확신을 못 하는 상태였습니다. 그날 DataStore 마이그레이션을 시작했어요.
한 줄 요약: SharedPreferencesMigration으로 기존 값 살리고,
runBlocking지뢰와 원본 삭제 롤백 리스크까지 실측으로 정리한 기록입니다.
옮기고 나서 제일 크게 배운 건, "옮기면 자동으로 빨라진다"는 게 오해였다는 점이에요. 왜 옮기는지, 실제 코드는 어떻게 짜는지, 어디서 발을 헛디뎠는지 순서대로 풀어볼게요.
apply()가 즉시 저장이 아니어서 ANR로 이어지던 지점
일단 왜 불안했는지부터요. SharedPreferences의 getString() 같은 읽기는 동기 호출입니다. 파일 로드가 끝날 때까지 기다리기 때문에, UI 스레드에서 부르면 그 자리에서 ANR 후보가 되거든요. 키가 늘수록 이 지연을 체감하게 됩니다.
apply()도 함정이었어요. 메모리에는 즉시 반영되지만 디스크 fsync()는 백그라운드로 미룹니다. 문제는 이 미뤄진 fsync가 UI 스레드를 붙잡을 수 있다는 점이에요. 구글도 공식 문서(DataStore 가이드)에서 이걸 ANR의 원인으로 짚은 바 있습니다.
거기다 데이터가 손상돼도 조용히 무너지고 복구 신호가 없어요. 문자열로 저장한 걸 정수로 꺼내다 ClassCastException이 런타임에 터지는 일도 겪었습니다. SharedPreferences를 DataStore로 갈아탈까 고민하게 된 게 딱 이 지점이었어요.

Preferences DataStore 예제로 기본 구조 잡기
DataStore는 코루틴과 Flow 기반이라 완전 비동기입니다. 내부적으로 Dispatchers.IO에서 돌기 때문에, UI 스레드에서 값을 읽어도 안전하지요.
구현이 두 가지인데요. 단순 키-값이고 스키마가 필요 없으면 Preferences DataStore, protobuf로 타입 안전을 챙겨야 하면 Proto DataStore 입니다. 제 경우엔 저장하던 게 테마·플래그 같은 단순 키-값이라 전자를 골랐어요. Proto는 스키마 정의에 빌드 설정까지 붙기 때문에, 키 몇 개짜리 안드로이드 설정 저장에는 과설계가 되기 쉽습니다.
최소 예제는 이렇게 잡았어요.
// 최상위에 델리게이트로 선언 (앱 프로세스에 하나만)
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val THEME_KEY = stringPreferencesKey("theme")
SharedPreferencesMigration으로 기존 값 잃지 않게 이관
핵심은 기존 사용자 값을 안 날리는 겁니다. SharedPreferencesMigration을 델리게이트에 넘기면 자동으로 옮겨줘요.
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "settings",
produceMigrations = { context ->
// 기존 SharedPreferences 파일명을 그대로 지정
listOf(SharedPreferencesMigration(context, "legacy_settings"))
}
)
여기서 반드시 기억할 게 있어요. 이 마이그레이션은 data의 첫 방출이나 edit() 첫 갱신보다 먼저 성공해야 합니다. 그러니까 어떤 데이터 접근이든 그 앞에서 이관이 끝나 있어야 한다는 얘기예요.
그리고 자동으로 옮겨주는 타입은 6종뿐입니다. boolean, float, int, long, string, string set. JSON을 문자열에 욱여넣어 뒀던 값은 그냥 문자열로만 넘어와서 다시 파싱해야 하더라고요. 커스텀 직렬화 값이 많으면 Proto와 별도 마이그레이션 코드가 필요합니다.

동기 읽기 runBlocking first가 새 ANR을 부르던 자리
읽기는 Flow로 관찰하고, 쓰기는 트랜잭션으로 처리합니다.
// 읽기: Flow로 리액티브하게 관찰
val themeFlow: Flow<String> = context.dataStore.data
.map { prefs -> prefs[THEME_KEY] ?: "light" }
// 쓰기: edit 블록 안이 하나의 트랜잭션
suspend fun setTheme(value: String) {
context.dataStore.edit { prefs -> prefs[THEME_KEY] = value }
}
문제는 여기서 터졌어요. 스플래시에서 테마를 정하거나, 시작 직후 로그인 여부를 분기하는 로직은 값이 "지금 당장" 필요합니다. 그런데 DataStore는 동기 읽기를 기본으로 안 줘요. ANR을 막으려고 일부러 뺀 설계입니다.
급해서 runBlocking { dataStore.data.first() }로 막았다가 도로 블로킹이 걸렸습니다. 미리 preload된 상태라면 안전하지만, 아니면 첫 읽기가 Flow 방출을 기다리면서 한 프레임이 밀리거나 테마가 번쩍이는 일이 생겨요. 억지로 블로킹을 걸면 비동기로 옮긴 이점이 그대로 사라지지요. 결국 시작 분기 로직 자체를 비동기로 다시 짜는 게 정답이었어요.

두 파일 공존 과도기와 EncryptedSharedPreferences deprecated 대응
첫 접근 전에 마이그레이션이 도는 특성 때문에, 두 저장소가 공존하는 과도기가 생깁니다. 이때 읽기 소스를 하나로 강제하지 않으면 이관 타이밍 레이스가 나요. 저는 하이브리드 기간에 무조건 DataStore만 단일 소스로 읽도록 막아두고 검증했습니다.
검증은 단순하게 갔어요. 6종 타입을 각각 이관 전후로 값이 같은지 대조하는 회귀 테스트를 붙였습니다. 이걸 먼저 붙인 이유가 있는데요. 마이그레이션이 성공하면 원본 항목을 비워버립니다. 사실상 1회 실행이고, 검증 없이 지웠다가는 롤백할 원본이 없어져요. 단계 배포에서 이게 제일 무서운 지점이었습니다.
시의성도 하나 있어요. security-crypto 1.1.0-alpha07(2025-04-09)에서 EncryptedSharedPreferences가 deprecated 됐고, 공식 원스톱 대체 API는 아직 없습니다. 성능 병목과 keyset 손상 이슈 때문이에요. 구글 권장은 DataStore로 저장하고 Google Tink로 암호화, 키는 Android Keystore로 보호하는 조합인데요. 이게 원스톱 API가 아니라 직접 조립해야 하는 게 한계입니다.
모든 앱이 옮겨야 하는 건 아니에요. 부팅 시 원시 플래그 하나만 읽는 앱이라면 전환 비용이 이득보다 큽니다. SharedPreferences는 deprecated도 아니고 여전히 잘 돌아가니까요.
다만 옮기기로 마음먹었다면, 마이그레이션 코드는 세 줄이지만 성패를 가르는 건 그 세 줄이 아니었습니다. 원본이 비워지기 전에 검증 로깅을 붙였느냐, 시작 분기 로직을 비동기로 다시 짤 각오를 했느냐. 이 둘을 준비 안 하고 델리게이트만 끼우면, 빨라지기는커녕 runBlocking 하나로 도로 원점이에요.

'Android 개발 > 트러블슈팅' 카테고리의 다른 글
| 코루틴 테스트 runTest로 delay 5초를 밀리초로 줄인 트러블슈팅 기록 (0) | 2026.10.19 |
|---|---|
| 안드로이드 StrictMode로 '가끔 뚝 끊김'을 매번 재현되는 위반으로 바꾼 일지 (0) | 2026.10.18 |
| 6년차도 커서만 깜빡였다: 낯선 레거시 코드 읽는 순서 (0) | 2026.10.11 |
| 에뮬레이터 3분 부팅을 90밀리초로: Robolectric로 JVM에서 UI 테스트 돌린 일지 (0) | 2026.10.09 |
| Android 17 QPR3 Beta 1, 고칠 코드는 0줄인데 왜 열어봐야 하나 (0) | 2026.10.06 |