Android 개발/트러블슈팅

SharedPreferences를 DataStore로 옮긴 110ms 사냥 기록

stackD 2026. 7. 24. 18:00
반응형

 

저사양 테스트 폰을 켰는데 스플래시에서 110ms가 그냥 날아갔습니다. StrictMode 로그를 까보니 getBoolean() 한 줄이 메인 스레드를 잡고 있더라고요.

 

처음엔 "에이, 그래봐야 몇 십 ms겠지" 싶었는데, 막상 저사양 기기에서 측정해보니 체감이 됐어요. 결국 그 한 줄을 추적해 DataStore로 옮겼고, 일부 키는 끝까지 SharedPreferences에 남겼습니다. 그 과정을 정리해봤어요.

 

StrictMode로 잡은 SharedPreferences 메인 스레드 호출

Application onCreate에 StrictMode.ThreadPolicy.Builder().detectDiskReads().penaltyLog()를 켜두면 메인 스레드에서 디스크 I/O가 일어날 때마다 logcat에 위반이 찍힙니다. 첫 번째로 잡힌 게 다크 모드 플래그를 읽는 getBoolean("dark_mode", false) 한 줄이었어요.

 

원인은 단순했습니다. SharedPreferences는 첫 접근 시 XML 파일 전체를 파싱해서 메모리 HashMap에 통째로 올리거든요. 저장 키가 47개였는데 평균 30~50ms, 저사양 폰에선 110ms까지 메인 스레드가 잡혔어요. 110ms가 어느 정도냐면, 60fps 기준 약 6.6프레임을 통째로 날리는 시간입니다. 스플래시가 한 박자 멈칫하던 게 그래서였어요.

 

호출 지점마다 IO 디스패처로 감싸는 것도 방법이었지만, 코드 베이스 전체에 흩어진 호출이 워낙 많아서 저장소 자체를 비동기 모델로 갈아치우는 쪽이 깔끔하다고 봤습니다.

 

 

SharedPreferences apply() 함정과 getString 동기 호출

흔히 "쓰기는 apply()로 비동기 처리하니까 안전하다" 정도로 정리하고 넘어가는데, 두 가지 함정이 있어요.

 

먼저 getString, getBoolean 같은 읽기 API는 전부 동기입니다. 캐시가 따뜻해진 뒤에는 HashMap 조회라 빠르지만, 콜드 스타트의 첫 호출은 디스크 I/O를 그대로 떠안아요.

 

그리고 apply()도 완전히 안전하지 않습니다. Activity의 onPause, onStop 시점에 보류된 쓰기 작업을 메인 스레드에서 동기적으로 기다리는 자리가 있어요. Perfetto 트레이스를 보면 QueuedWork.waitToFinish 구간으로 잡힙니다. 화면 전환 중 미세하게 끊기는 jank의 흔한 원인 중 하나입니다.

 

요약하면 SharedPreferences는 처음부터 '메인 스레드 안전' 라이브러리로 설계된 게 아니라는 점이에요. 시작 경로처럼 지연 시간에 민감한 자리에서는 결국 문제로 드러나는 경우가 많습니다.

 

Preferences DataStore 마이그레이션 코드와 Flow 구독

DataStore 인스턴스는 톱레벨 위임으로 한 번만 만들어두면 됩니다.

 

val Context.settingsDataStore by preferencesDataStore(
    name = "settings",
    produceMigrations = { context ->
        listOf(SharedPreferencesMigration(context, "user_settings"))
    }
)

 

읽기는 Repository가 Flow를 제공하고, ViewModel은 stateIn으로 UI 상태에 묶습니다.

 

private val DARK_MODE = booleanPreferencesKey("dark_mode")

class SettingsRepository(private val dataStore: DataStore<Preferences>) {
    val darkMode: Flow<Boolean> = dataStore.data.map { it[DARK_MODE] ?: false }
}

// ViewModel
val darkMode: StateFlow<Boolean> = repository.darkMode
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)

 

쓰기는 Repository에 suspend 함수로 위임하고, ViewModel은 viewModelScope.launch로 호출합니다.

 

// Repository
suspend fun setDarkMode(enabled: Boolean) {
    dataStore.edit { it[DARK_MODE] = enabled }
}

// ViewModel
fun setDarkMode(enabled: Boolean) {
    viewModelScope.launch { repository.setDarkMode(enabled) }
}

 

여기서 절대 하지 말아야 할 게 runBlocking { dataStore.data.first() } 같은 패턴입니다. 메인 스레드에서 코루틴 디스패치 비용까지 얹혀서 SharedPreferences보다 오히려 느려지는 경우도 있더라고요. 비동기 이점을 다 버리는 셈이라, 정말 단순 동기화 자리가 아니면 피하는 게 맞다고 봅니다.

 

SharedPreferencesMigration 함정과 워밍업 전략

SharedPreferencesMigration은 첫 데이터 접근 시점에 기존 XML을 읽어 DataStore로 한 번만 옮겨줍니다. 마이그레이션이 끝나면 옮겨진 키들은 원본 SharedPreferences에서 제거돼서 양쪽이 어긋날 일이 없어요. 다만 마이그레이션 대상으로 지정된 키만 삭제되니, 외부 SDK가 같은 XML을 공유해서 쓰는 자리라면 keysToMigrate로 옮길 키를 명시적으로 골라야 안전합니다.

 

다만 제가 직접 부딪힌 함정이 두 가지 있었습니다.

 

  1. 첫 접근 시 마이그레이션 자체가 무겁습니다. 제 앱 기준 평균 220ms 정도 추가 I/O가 잡혔어요. 그래서 Application.onCreate에서 별도 코루틴 스코프로 settingsDataStore.data.first()를 미리 한 번 호출해 워밍업했습니다. 사용자가 첫 화면에 닿기 전에 마이그레이션을 끝내두는 전략이에요.
  2.  
  3. SharedPreferences 인터페이스를 직접 요구하는 외부 라이브러리가 있었습니다. 인증 SDK 쪽에서 SharedPreferences 객체를 인자로 받는 구조라 손댈 수 없었어요. 이런 키들은 그대로 SharedPreferences에 두고, 앱 자체 설정만 DataStore로 옮기는 하이브리드 구조로 갔습니다. 무리해서 한쪽으로 통일하려다 외부 라이브러리 호환성을 깨면 손해가 더 커요.
  4.  

 

DataStore 마이그레이션, 안 옮겨도 되는 두 가지 경우

전부 옮길 필요는 없어요. 제가 보기엔 두 조건 중 하나라도 걸리면 그대로 두는 쪽이 낫습니다.

 

먼저 측정된 성능 문제가 없는 경우입니다. StrictMode 로그가 깨끗하고 콜드 스타트 시간이 안정적이라면, SharedPreferences가 보일러플레이트도 적고 호출도 단순해요. Macrobenchmark나 Perfetto로 실제 병목을 확인하지 않은 상태에서 막연히 "최신 API니까" 옮기는 건 오버 엔지니어링이라고 봅니다.

 

그리고 멀티 프로세스 접근이 필요한 자리는 그대로 두셔야 합니다. Preferences DataStore는 멀티 프로세스가 정식 지원되지 않아요. (DataStore 1.1.0부터 MultiProcessDataStoreFactory로 Proto DataStore 멀티 프로세스 지원이 추가됐지만, 위젯·별도 프로세스 서비스에서 단순히 Preferences를 공유하려는 자리는 여전히 SharedPreferences가 안전합니다.)

 

결국 이번 작업의 진짜 동기는 "최신 API라서"가 아니라 StrictMode 로그에 찍힌 그 110ms 한 줄이었습니다. 시작 경로의 디스크 I/O를 비동기로 흘려보내니 콜드 스타트 측정치가 80ms 안쪽으로 안정됐고, 화면 전환 jank도 같이 줄었어요. 마이그레이션을 가를 기준은 늘 같다고 봅니다. 프로파일러에 진짜 멈칫하는 자리가 잡혔는가, 결국 그 110ms 한 줄이 전부였습니다.

 

반응형
LIST