서론: 가볍고 편리한 SharedPreferences, 그러나 보이지 않는 함정
안드로이드 앱 개발에서 데이터를 영구적으로 저장하는 방법은 여러 가지가 있습니다. 복잡한 구조의 데이터를 다루기 위해 Room이나 SQLite와 같은 데이터베이스를 사용하기도 하고, 서버와의 통신을 통해 원격에 데이터를 보관하기도 합니다. 하지만 단순히 사용자의 설정 값, 앱의 마지막 상태, 자동 로그인 여부와 같은 가볍고 간단한 데이터를 저장할 때는 SharedPreferences가 가장 먼저 떠오르는 선택지일 것입니다. Key-Value 쌍으로 데이터를 직관적으로 저장하고 불러올 수 있어 사용법이 매우 간단하며, 별도의 라이브러리 추가 없이 Android SDK에 기본적으로 내장되어 있어 접근성이 매우 높기 때문입니다.
많은 개발자들이 SharedPreferences의 편리함에 매료되어 별다른 의심 없이 사용하곤 합니다. 예를 들어, 사용자가 선호하는 테마(다크 모드/라이트 모드)를 저장하거나, 튜토리얼을 다시 보지 않도록 설정하는 플래그를 저장하는 등의 시나리오에서 완벽하게 동작하는 것처럼 보입니다. 그러나 때로는 개발자를 당혹스럽게 만드는 미스터리한 현상과 마주하게 됩니다. 분명히 값을 저장하는 로직을 호출했고, 앱을 잠시 나갔다가 다시 들어왔을 때는 값이 유지되는 것을 확인했는데, 스마트폰의 '최근 앱' 목록에서 앱을 완전히 종료(Task Kill)한 후 다시 실행하면 방금 저장했던 데이터가 감쪽같이 사라져 있는 현상입니다. 이 문제는 특히 `apply()` 메소드를 사용할 때 빈번하게 관찰되며, 개발자는 자신의 코드를 의심하거나 SharedPreferences 자체의 불안정성을 탓하게 됩니다.
이 글에서는 바로 이 'SharedPreferences 데이터 유실' 현상의 근본적인 원인을 심층적으로 파헤쳐 보고자 합니다. 단순히 'A 대신 B를 사용하세요'라는 식의 표면적인 해결책을 넘어, SharedPreferences의 내부 동작 메커니즘을 이해하고, 데이터가 사라지는 구체적인 시나리오를 분석할 것입니다. 또한, 이 문제를 해결하기 위한 안정적인 데이터 저장 전략과 함께, 현대 안드로이드 개발에서 SharedPreferences를 대체하기 위해 등장한 Jetpack DataStore의 필요성과 강력함까지 상세히 다룰 것입니다. 이 글을 통해 개발자 여러분은 더 이상 데이터 유실의 공포에 떨지 않고, 데이터의 생명주기를 완벽하게 제어하는 견고한 애플리케이션을 구축할 수 있게 될 것입니다.
1. SharedPreferences의 동작 원리: 메모리와 디스크, 두 개의 저장소
데이터 유실 문제를 이해하기 위해서는 먼저 SharedPreferences가 내부적으로 어떻게 데이터를 관리하는지 알아야 합니다. 많은 개발자들이 SharedPreferences를 단순한 파일 입출력 API로 생각하지만, 실제로는 메모리 캐시와 물리적 파일이라는 두 개의 저장소를 함께 사용하는 구조로 되어 있습니다.
1.1. 물리적 저장소: XML 파일
SharedPreferences에 저장되는 모든 데이터의 최종 목적지는 앱의 내부 저장소에 생성되는 XML 파일입니다. 이 파일은 일반적으로 다음 경로에 위치합니다.
/data/data/<패키지_이름>/shared_prefs/<PREFERENCE_이름>.xml
예를 들어, getSharedPreferences("settings", MODE_PRIVATE)
를 호출하면 /data/data/com.example.myapp/shared_prefs/settings.xml
이라는 파일이 생성되거나 로드됩니다. 이 XML 파일은 우리가 저장한 Key-Value 데이터를 아래와 같은 형식으로 담고 있습니다.
앱이 시작될 때 SharedPreferences 객체를 처음 요청하면, 안드로이드 프레임워크는 이 XML 파일을 읽어 그 내용을 파싱한 후, 메모리에 올려놓습니다. 바로 이 '메모리에 올려놓은 데이터'가 두 번째 저장소인 메모리 캐시입니다.
1.2. 메모리 캐시: 즉각적인 응답의 비밀
만약 SharedPreferences에서 값을 읽어올 때마다 매번 디스크의 XML 파일에 접근(I/O)한다면 어떻게 될까요? 디스크 I/O 작업은 CPU 연산에 비해 매우 느리기 때문에, 잦은 호출은 앱의 성능 저하, 특히 UI 스레드에서의 버벅임(Jank)을 유발할 수 있습니다. 이를 방지하기 위해 SharedPreferences는 최초 로드 시 XML 파일의 모든 내용을 메모리 상의 `Map` 객체에 캐싱해 둡니다.
이후 getString()
, getInt()
와 같은 메소드로 데이터를 조회할 때는 더 이상 디스크를 읽지 않고, 매우 빠른 속도로 메모리 캐시에서 값을 직접 가져와 반환합니다. 이것이 SharedPreferences가 가볍고 빠르게 동작하는 이유입니다.
1.3. 데이터 저장 과정: Editor와 두 가지 커밋 방식
이제 가장 중요한 데이터 저장 과정을 살펴보겠습니다. SharedPreferences에 데이터를 쓰기 위해서는 edit()
메소드를 호출하여 SharedPreferences.Editor
객체를 얻어야 합니다. 이 Editor 객체를 통해 putString()
, putBoolean()
등의 메소드로 원하는 데이터를 추가, 수정, 삭제한 후, 마지막으로 변경 사항을 시스템에 적용해야 합니다. 이 '적용'을 위한 메소드가 바로 논란의 중심에 있는 `commit()`과 `apply()`입니다.
-
commit()
: 동기적(Synchronous) 방식commit()
메소드는 호출되는 즉시 두 가지 작업을 동기적으로 수행합니다.
1. 변경된 데이터를 메모리 캐시에 즉시 반영합니다.
2. 변경된 데이터를 디스크의 XML 파일에 즉시 기록합니다.
이 모든 과정이 완료될 때까지 호출한 스레드(주로 UI 스레드)는 블로킹(Blocking)됩니다. 즉, 파일 쓰기가 끝날 때까지 다음 코드로 넘어가지 못합니다. 작업이 성공하면true
를, 실패하면false
를 반환하므로 작업 성공 여부를 즉시 알 수 있다는 장점이 있습니다. 하지만 UI 스레드에서 호출할 경우, 디스크 I/O 작업이 길어지면 ANR(Application Not Responding)을 유발할 수 있는 심각한 단점을 가지고 있습니다. -
apply()
: 비동기적(Asynchronous) 방식apply()
메소드는commit()
의 성능 문제를 해결하기 위해 등장했습니다. 이 메소드는 다음과 같이 동작합니다.
1. 변경된 데이터를 메모리 캐시에 즉시 반영합니다. (여기까지는 `commit()`과 동일)
2. 실제 디스크에 파일로 쓰는 작업은 별도의 백그라운드 스레드에서 비동기적으로 수행하도록 예약합니다.apply()
는 파일 쓰기를 기다리지 않고 즉시 리턴하기 때문에 UI 스레드를 블로킹하지 않아 훨씬 안전하고 빠릅니다. 구글이 권장하는 방식이기도 합니다. 하지만 작업 결과를 반환하지 않으며, 파일 쓰기가 언제 완료될지 보장하지 않습니다. 바로 이 지점에서 데이터 유실의 함정이 시작됩니다.
2. 데이터 유실 미스터리: `apply()`의 배신과 프로세스 종료 시나리오
이제 SharedPreferences의 동작 원리를 바탕으로 데이터가 왜 사라지는지 구체적인 시나리오를 재구성해 보겠습니다.
시나리오: 사용자가 앱을 사용하다 강제 종료하는 경우
- 데이터 저장 요청: 사용자가 앱의 특정 기능을 사용했고, 앱은 중요한 상태 값("last_item_id" = 123)을 SharedPreferences에 저장하기로 합니다. 개발자는 UI 스레드의 성능을 고려하여 `apply()`를 사용했습니다.
val prefs = context.getSharedPreferences("app_data", Context.MODE_PRIVATE) prefs.edit().putInt("last_item_id", 123).apply()
- 메모리 캐시 업데이트:
apply()
가 호출되는 순간, SharedPreferences의 메모리 캐시에는 "last_item_id"가 123으로 즉시 업데이트됩니다. 동시에, 안드로이드 프레임워크는 이 변경 사항을 디스크에 기록하는 작업을 백그라운드 큐에 등록합니다. - 앱 내에서의 데이터 확인: 만약 이 직후에 같은 앱 프로세스 내에서 "last_item_id" 값을 다시 조회하면 어떻게 될까요?
val lastId = prefs.getInt("last_item_id", -1) // lastId는 123이 됩니다.
데이터 조회는 메모리 캐시를 우선적으로 참조하므로, 파일 쓰기가 아직 완료되지 않았더라도 정상적으로 '123'이라는 값을 반환합니다. 이 때문에 개발자는 데이터가 성공적으로 저장되었다고 믿게 됩니다.
- 프로세스 강제 종료 (Process Kill): 사용자가 홈 버튼을 누른 후, 최근 앱 목록을 열어 해당 앱을 스와이프하여 완전히 종료시킵니다. 또는, 시스템이 메모리 확보를 위해 앱의 프로세스를 강제로 종료(Kill)할 수도 있습니다.
- 비극의 발생: 이 프로세스 종료 시점이, 2번 단계에서 큐에 등록되었던 백그라운드 파일 쓰기 작업이 실행되기 전이라면 어떻게 될까요? 안타깝게도, 해당 쓰기 작업은 프로세스와 함께 소멸되어 영원히 실행되지 않습니다. 디스크에 있는
app_data.xml
파일은 여전히 "last_item_id"라는 키가 없는 예전 상태 그대로입니다. - 앱 재시작과 데이터 유실 확인: 사용자가 앱 아이콘을 눌러 앱을 다시 시작합니다. 새로운 프로세스가 생성되고, 앱은 SharedPreferences 객체를 다시 초기화합니다. 이 과정에서 시스템은 디스크의
app_data.xml
파일을 읽어 메모리 캐시를 구성합니다. 하지만 이 파일에는 '123'이라는 값이 기록된 적이 없으므로, 캐시에도 해당 데이터는 존재하지 않습니다.val prefs = context.getSharedPreferences("app_data", Context.MODE_PRIVATE) val lastId = prefs.getInt("last_item_id", -1) // lastId는 이제 -1(기본값)이 됩니다.
결과적으로, 분명히 저장했던 데이터가 사라지는 현상이 발생한 것입니다. 이것이 `apply()`로 인한 데이터 유실의 가장 일반적인 원인입니다.
원문에서 언급된 'PreferenceManager.getDefaultSharedPreferences(this)를 사용하니 해결되었다'는 경험은 아마도 다른 원인이 겹쳤을 가능성이 높습니다. 예를 들어, 앱의 여러 곳에서 SharedPreferences를 호출할 때 파일 이름을 다르게 사용(실수)했다가, `getDefaultSharedPreferences`를 사용하면서 파일 이름이 <패키지_이름>_preferences
로 통일되어 문제가 해결된 것처럼 보였을 수 있습니다. 하지만 근본적인 `apply()`의 비동기적 특성으로 인한 데이터 유실 위험은 `getDefaultSharedPreferences`를 사용한다고 해서 사라지지 않습니다. `getDefaultSharedPreferences`는 단지 기본 파일 이름을 사용하는 편의 메소드일 뿐, 동작 방식의 차이는 없습니다.
3. 안정적인 데이터 저장을 위한 전략적 접근
그렇다면 우리는 이 데이터 유실 문제에 어떻게 대처해야 할까요? 무조건 `commit()`만 사용하는 것이 답일까요? 상황에 따라 적절한 전략을 선택하는 지혜가 필요합니다.
3.1. `commit()`의 전략적 사용: 절대 잃으면 안 되는 데이터
만약 저장하려는 데이터가 앱의 핵심 로직과 직결되어 있거나, 유실될 경우 사용자에게 치명적인 불편함을 초래한다면(예: 구매 정보, 중요한 일회성 동의 여부, 로그인 토큰), 주저 없이 `commit()`을 사용해야 합니다.
// 예시: 사용자가 유료 기능을 구매했음을 기록
// 이 정보는 절대 유실되어서는 안 됨
fun savePurchaseFlag() {
val prefs = context.getSharedPreferences("user_profile", Context.MODE_PRIVATE)
val success = prefs.edit().putBoolean("has_premium", true).commit()
if (!success) {
// 파일 쓰기 실패에 대한 예외 처리 로직 (예: 로그 남기기)
Log.e("SharedPreferences", "Failed to save premium flag!")
}
}
다만, commit()
은 UI 스레드를 블로킹할 수 있으므로, 반드시 필요한 최소한의 경우에만 사용해야 합니다. 만약 루프 안에서나, 사용자의 입력에 실시간으로 반응해야 하는 콜백 함수 안에서 commit()
을 호출하는 것은 피해야 합니다. 정말로 UI 스레드에서 `commit()`을 호출해야 한다면, 그 작업이 매우 짧을 것이라는 확신이 있어야 합니다.
3.2. 생명주기(Lifecycle)를 활용한 `apply()`의 안전한 사용
모든 데이터를 `commit()`으로 처리하는 것은 비효율적입니다. UI 설정이나 마지막 스크롤 위치처럼, 유실되어도 큰 문제가 없는 데이터는 여전히 `apply()`가 좋은 선택입니다. 다만, `apply()`의 비동기 쓰기가 완료될 가능성을 높여주기 위해 앱의 생명주기를 활용할 수 있습니다.
예를 들어, Activity
나 Fragment
의 onPause()
또는 onStop()
콜백에서 `apply()`를 호출하는 것이 좋습니다. 이 시점들은 사용자가 화면을 벗어나거나 앱이 백그라운드로 전환되는 때로, 이후에 프로세스가 종료될 가능성이 있는 상태로 넘어가기 직전입니다. 시스템은 onPause()
가 호출된 이후에 백그라운드 쓰기 작업을 처리할 충분한 시간을 가질 확률이 높습니다.
override fun onPause() {
super.onPause()
// 사용자가 마지막으로 보던 아이템의 위치를 저장
val prefs = getSharedPreferences("ui_state", Context.MODE_PRIVATE)
prefs.edit().putInt("last_scroll_position", currentScrollY).apply()
}
하지만 이 방법 역시 100% 보장되는 해결책은 아닙니다. `onPause()` 호출 직후 시스템이 즉시 프로세스를 종료해야 하는 매우 긴급한 상황이라면 여전히 데이터는 유실될 수 있습니다.
3.3. 올바른 Context 사용: 메모리 누수 방지
데이터 유실과는 다른 문제지만, SharedPreferences를 사용할 때 흔히 저지르는 실수는 Context를 잘못 사용하는 것입니다. 만약 SharedPreferences 객체를 싱글톤이나 Application 클래스의 전역 변수 등으로 오래 유지해야 할 경우, 절대로 Activity Context를 넘겨서는 안 됩니다. Activity가 파괴되어도 SharedPreferences 객체가 해당 Activity의 참조를 계속 붙잡고 있어 메모리 누수(Memory Leak)가 발생할 수 있습니다.
항상 Application Context를 사용하여 SharedPreferences 인스턴스를 얻는 것이 안전합니다.
// 좋은 예: Application Context 사용
val prefs = context.applicationContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
// 나쁜 예: Activity Context를 장기간 유지될 수 있는 객체에 전달
// val prefs = activityContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
4. 현대 안드로이드 개발의 대안: Jetpack DataStore
SharedPreferences가 가진 `apply()`의 데이터 유실 위험, `commit()`의 UI 스레드 블로킹 문제, 타입 안정성 부재(런타임 오류 위험), 제한적인 오류 처리 등 여러 단점을 극복하기 위해 구글은 Jetpack DataStore라는 새로운 데이터 저장 솔루션을 발표했습니다. DataStore는 SharedPreferences를 완전히 대체하는 것을 목표로 하며, 현대적인 안드로이드 개발 패러다임에 맞춰 설계되었습니다.
DataStore는 두 가지 구현체를 제공합니다.
- Preferences DataStore: SharedPreferences와 마찬가지로 Key-Value 쌍으로 데이터를 저장합니다. SharedPreferences에서 마이그레이션하기 쉽습니다.
- Proto DataStore: Protocol Buffers를 사용하여 커스텀 데이터 타입의 객체를 저장합니다. 강력한 타입 안정성을 제공하며 스키마를 정의해야 합니다.
여기서는 SharedPreferences의 직접적인 대안인 Preferences DataStore가 어떻게 기존의 문제들을 해결하는지 집중적으로 살펴보겠습니다.
4.1. Preferences DataStore의 장점
- 완전한 비동기 및 트랜잭션 보장: DataStore는 코루틴(Coroutines)과 Flow를 기반으로 동작합니다. 모든 데이터 읽기/쓰기 작업은 비동기적으로 이루어지며, 데이터 업데이트는 트랜잭션(transaction) 내에서 안전하게 처리됩니다. `apply()`처럼 '실행하고 잊어버리는' 방식이 아니라, 데이터 업데이트가 완전히 디스크에 반영되었음을 보장받을 수 있습니다.
- UI 스레드 안정성: 모든 API는 `suspend` 함수이거나 Flow를 반환하므로, 메인 스레드에서 직접 호출하면 컴파일 에러가 발생합니다. 개발자가 자연스럽게 백그라운드 스레드에서 데이터 작업을 처리하도록 유도하여 ANR 위험을 원천적으로 차단합니다.
-
데이터 일관성 및 오류 처리: 데이터 읽기는 `Flow
`를 통해 이루어집니다. 데이터가 변경될 때마다 새로운 값을 자동으로 스트림으로 전달받을 수 있어 UI와 데이터를 항상 최신 상태로 동기화하기 용이합니다. 또한, 파일 읽기/쓰기 도중에 발생하는 I/O Exception 등을 코루틴의 예외 처리 메커니즘(`try-catch`)을 통해 섬세하게 다룰 수 있습니다.
4.2. Preferences DataStore 사용 예제
DataStore를 사용하기 위해서는 먼저 `build.gradle` 파일에 의존성을 추가해야 합니다.
// build.gradle.kts (Module)
dependencies {
implementation("androidx.datastore:datastore-preferences:1.0.0")
// 코루틴 의존성도 필요합니다.
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
다음으로, DataStore 인스턴스를 생성합니다. 보통은 Context의 확장 프로퍼티로 정의하여 싱글톤으로 사용합니다.
import androidx.datastore.preferences.preferencesDataStore
// 파일 이름을 "settings"로 하여 DataStore 인스턴스 생성
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
// 데이터를 저장할 때 사용할 키(Key)를 정의합니다.
// stringPreferencesKey, intPreferencesKey 등 타입에 맞는 키를 사용합니다.
objectPreferenceKeys {
val USERNAME = stringPreferencesKey("username")
val IS_DARK_MODE = booleanPreferencesKey("is_dark_mode")
}
데이터 저장하기 (쓰기)
데이터 저장은 `dataStore.edit()`를 통해 이루어지며, 이 함수는 `suspend` 함수이므로 반드시 코루틴 스코프 내에서 호출되어야 합니다.
suspend fun setDarkMode(context: Context, isDarkMode: Boolean) {
context.dataStore.edit { settings ->
settings[PreferenceKeys.IS_DARK_MODE] = isDarkMode
}
// 이 블록이 끝나면 데이터가 디스크에 완전히 저장된 것이 보장됩니다.
}
`edit` 블록은 트랜잭션 단위로 동작하여, 내부의 모든 작업이 원자적으로(atomically) 처리됩니다. 더 이상 `apply()`의 유실 걱정을 할 필요가 없습니다.
데이터 읽어오기 (읽기)
데이터 읽기는 `dataStore.data` 속성을 통해 이루어집니다. 이 속성은 `Flow<Preferences>`를 반환하므로, 데이터가 변경될 때마다 자동으로 새로운 값을 방출(emit)합니다.
// ViewModel에서 다크 모드 설정을 관찰하는 예시
class SettingsViewModel(private val application: Application) : AndroidViewModel(application) {
val isDarkModeFlow: Flow<Boolean> = application.dataStore.data
.catch { exception ->
// DataStore 읽기 중 오류 발생 시 처리 (예: 파일 손상)
if (exception is IOException) {
Log.e("SettingsViewModel", "Error reading preferences.", exception)
emit(emptyPreferences()) // 기본값 방출
} else {
throw exception
}
}
.map { preferences ->
// 키가 존재하지 않으면 기본값으로 false를 사용
preferences[PreferenceKeys.IS_DARK_MODE] ?: false
}
}
// UI (Activity/Fragment)에서 Flow를 수집하여 UI 업데이트
lifecycleScope.launch {
viewModel.isDarkModeFlow.collect { isDarkMode ->
// isDarkMode 값에 따라 UI 테마 변경 로직 수행
updateTheme(isDarkMode)
}
}
이처럼 Jetpack DataStore는 SharedPreferences의 단점을 완벽하게 보완하며, 더 안전하고 현대적인 방식으로 간단한 데이터를 관리할 수 있도록 도와줍니다. 새로 시작하는 프로젝트라면 SharedPreferences 대신 DataStore를 사용하는 것을 최우선으로 고려해야 합니다.
결론: 더 나은 도구를 향한 끊임없는 발전
'SharedPreferences에 저장한 데이터가 사라진다'는 미스터리는 `apply()`와 `commit()`의 동작 방식, 그리고 메모리 캐시와 디스크 파일이라는 이중 구조에 대한 이해 부족에서 비롯된 경우가 대부분입니다. `apply()`는 UI 스레드를 블로킹하지 않는 편리함 이면에 '비동기 쓰기'라는 특성으로 인한 데이터 유실의 위험을 내포하고 있으며, 이는 앱 프로세스가 예기치 않게 종료될 때 현실이 됩니다.
우리는 이 문제를 해결하기 위해 데이터의 중요도에 따라 `commit()`을 전략적으로 사용하거나, 앱의 생명주기를 활용하여 `apply()`의 안정성을 높이는 방법을 살펴보았습니다. 하지만 이러한 방법들은 근본적인 해결책이라기보다는 문제점을 회피하는 임시방편에 가깝습니다.
궁극적인 해결책은 바로 Jetpack DataStore로의 전환입니다. DataStore는 코루틴과 Flow를 통해 트랜잭션의 완전성과 데이터 일관성을 보장하며, 비동기 처리를 강제하여 UI 안정성을 확보합니다. 더 나아가 타입 안정성과 체계적인 오류 처리까지 지원함으로써 개발자가 더 견고하고 예측 가능한 코드를 작성하도록 이끕니다.
소프트웨어 개발의 세계는 끊임없이 발전합니다. 과거의 표준이었던 기술이 가진 한계는 더 나은 철학을 담은 새로운 도구의 등장을 촉발합니다. SharedPreferences에서 DataStore로의 전환은 단순히 API를 바꾸는 것을 넘어, 안드로이드 개발이 비동기 및 반응형 프로그래밍 패러다임을 얼마나 중요하게 생각하는지를 보여주는 명백한 증거입니다. 이제 우리는 SharedPreferences의 숨겨진 함정에서 벗어나, Jetpack DataStore가 제공하는 안정성과 명확성 위에서 더 높은 품질의 애플리케이션을 만들어 나가야 할 때입니다.
0 개의 댓글:
Post a Comment