최근 프로젝트에서 직접 적용했던 인사이트와 실제 코드 분석을 통해 얻은 깨달음을 공유하고자 한다.
안드로이드 개발에서 앱 내부에 간단한 키-값 쌍 데이터를 저장하는 것은 필수적인 기능이다. 사용자 설정, 로그인 토큰, 앱 상태 정보 등을 로컬에 저장해야 하는 상황은 매우 빈번하게 발생한다. 전통적으로 이런 용도로는 SharedPreferences가 널리 사용되어 왔지만, 최근 구글에서는 Jetpack DataStore를 새로운 표준으로 제시하며 기존 SharedPreferences에서의 마이그레이션을 강력히 권장하고 있다.
하지만 많은 개발자들이 "왜 굳이?"라는 의문을 가지는 것도 사실이다. 특히 성능 측면에서 SharedPreferences가 더 빠르다는 벤치마크 결과들이 나오면서, DataStore의 필요성에 대한 의구심이 더욱 커지고 있다.
이 글에서는 단순한 성능 비교를 넘어서, 왜 DataStore가 필요한지, 그리고 어떤 근본적인 문제들을 해결하는지에 대해 실제 프로젝트 경험을 바탕으로 깊이 있게 탐구해보겠다.

SharedPreferences의 치명적 한계들
Thread Safety 문제: 운영 환경에서 만난 실제 이슈
SharedPreferences의 가장 치명적인 문제는 스레드 안전성 부족이다. 이는 단순히 이론적인 문제가 아니라 실제 운영 환경에서 빈번히 발생하는 실질적 이슈이다.
// 실제 프로젝트에서 발생했던 문제 상황class UserSessionManager {
private val sharedPrefs = context.getSharedPreferences("user_session", Context.MODE_PRIVATE)
// 문제가 있는 코드 - 여러 스레드에서 동시 접근 시 데이터 손상fun updateUserInfo(userId: String, token: String) {
Thread {
sharedPrefs.edit()
.putString("user_id", userId)
.putString("auth_token", token)
.apply()
}.start()
}
fun logout() {
Thread {
sharedPrefs.edit()
.remove("user_id")
.remove("auth_token")
.apply()
}.start()
}
}
위 코드에서 updateUserInfo()와 logout()이 동시에 실행되면 예측할 수 없는 상태가 발생할 수 있다. 실제로 프로덕션 환경에서 사용자가 로그아웃했는데도 토큰이 남아있거나, 반대로 로그인 상태인데 사용자 정보가 없는 상황을 경험한 적이 있다.
UI 스레드 블로킹: ANR의 주범
SharedPreferences는 동기적으로 작동하여 UI 스레드를 블로킹할 수 있다. 특히 첫 번째 접근 시 XML 파일을 파싱해야 하므로 상당한 지연이 발생할 수 있다.
// 실제 프로덕션에서 ANR을 유발했던 코드class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 이 코드가 ANR의 원인이었다val preferences = getSharedPreferences("app_settings", Context.MODE_PRIVATE)
val theme = preferences.getString("theme", "light")// UI 스레드 블로킹!val isFirstLaunch = preferences.getBoolean("is_first_launch", true)
val userPreferences = preferences.getString("user_prefs", "{}")
// 첫 번째 실행 시 XML 파싱으로 인한 지연으로 ANR 발생
setTheme(if (theme == "dark") R.style.DarkTheme else R.style.LightTheme)
}
}
StrictMode를 활성화하면 다음과 같은 경고를 볼 수 있다:
StrictMode policy violation; ~duration=1949 ms:
android.os.StrictMode$StrictModeDiskReadViolation: policy=23 violation=2
Runtime Exception의 위험성: 예기치 않은 크래시
SharedPreferences는 파싱 에러 시 런타임 예외를 발생시킨다. XML 데이터가 손상되거나 형식이 잘못된 경우, 앱이 예기치 않게 크래시될 수 있다.
// SharedPreferences에서 실제로 발생했던 런타임 예외try {
val value = sharedPrefs.getInt("corrupted_key", 0)
} catch (e: ClassCastException) {
// XML 파일이 손상된 경우 런타임에 발생// 사용자에게는 갑작스러운 앱 크래시로 나타남
Log.e("SharedPrefs", "Data corruption detected", e)
Crashlytics.recordException(e)
}
XML 기반 저장소의 보안 취약점
SharedPreferences는 XML 형식으로 데이터를 저장하는데, 이는 다양한 XML 기반 공격에 노출될 수 있다. XML External Entity(XXE) 공격, XML 폭탄 공격 등의 보안 취약점이 존재한다.
DataStore: 해결책
완전한 비동기 아키텍처로 ANR 해결
DataStore는 Kotlin Coroutines와 Flow를 기반으로 설계되어 완전히 비동기적으로 작동한다. 모든 I/O 작업이 Dispatchers.IO에서 실행되어 UI 스레드를 절대 블로킹하지 않는다.

실제 프로젝트에서 구현한 확장 함수들을 보면 이런 철학이 잘 드러난다
private fun Preferences.Key<String>.flowIn(store: DataStore<Preferences>) =
store.data.map { it[this] }
private suspend fun Preferences.Key<String>.saveTo(
store: DataStore<Preferences>,
value: String
) = store.edit { it[this] = value }
private suspend fun Preferences.Key<String>.deleteFrom(
store: DataStore<Preferences>
) = store.edit { it.remove(this) }
class AuthDataStore @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
val accessTokenFlow = ACCESS_TOKEN.flowIn(dataStore)
val refreshTokenFlow = REFRESH_TOKEN.flowIn(dataStore)
suspend fun saveAccessToken(token: String) = ACCESS_TOKEN.saveTo(dataStore, token)
suspend fun deleteAccessToken() = ACCESS_TOKEN.deleteFrom(dataStore)
}
이런 구조의 가장 큰 장점은 UI 스레드 안전성이다. 어떤 상황에서도 메인 스레드가 블로킹되지 않는다.
ACID 특성을 통한 데이터 무결성 보장
DataStore는 원자적(Atomic) 읽기-수정-쓰기 연산을 지원하여 데이터 무결성을 보장한다. 이는 데이터베이스 수준의 트랜잭션 안전성을
제공한다.
// ACID 특성을 보장하는 원자적 업데이트suspend fun handleComplexUserUpdate(
newToken: String,
userInfo: UserInfo,
settings: AppSettings
) {
authDataStore.edit { preferences ->
// 모든 변경사항이 하나의 트랜잭션으로 처리됨
preferences[AUTH_TOKEN] = newToken
preferences[USER_ID] = userInfo.id
preferences[USER_NAME] = userInfo.name
preferences[LAST_LOGIN] = System.currentTimeMillis()
preferences[THEME_SETTING] = settings.theme
// 중간에 실패하면 모든 변경사항이 롤백됨
}
}
Protocol Buffers와 타입 안전성
DataStore는 Protocol Buffers를 사용하여 효율적인 직렬화와 강력한 타입 안전성을 제공한다. 컴파일 타임에 타입 검사가 가능하여 런타임 오류를 방지한다.
// 타입 안전한 키 정의companion object {
private val ACCESS_TOKEN = stringPreferencesKey("access_token")
private val REFRESH_TOKEN = stringPreferencesKey("refresh_token")
private val USER_ID = longPreferencesKey("user_id")
private val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
}
// 컴파일 타임에 타입 검증이 이루어짐suspend fun saveUserSession(token: String, userId: Long) {
dataStore.edit { preferences ->
preferences[ACCESS_TOKEN] = token// String 타입 보장
preferences[USER_ID] = userId// Long 타입 보장
preferences[IS_LOGGED_IN] = true// Boolean 타입 보장
}
}
성능 트레이드오프: 숫자 너머의 진실
실제 벤치마크 분석과 맥락
실제 벤치마크 결과를 보면 SharedPreferences가 더 빠른 것은 사실이다.
| Operation | SharedPreferences_ms | DataStore_ms | Performance_Ratio |
| Read String (1st time) | 15.2 | 25.8 | 1.7 |
| Read String (cached) | 0.3 | 2.1 | 7.0 |
| Write String | 2.1 | 8.4 | 4.0 |
| Read Int | 0.2 | 1.9 | 9.5 |
| Write Int | 1.8 | 7.2 | 4.0 |
| Read Complex Object | 5.4 | 12.3 | 2.3 |
하지만 이 성능 차이에는 중요한 맥락들이 있다
DataStore의 추가 비용 분석
- ACID 특성 보장 비용: DataStore는 데이터 무결성을 보장하기 위해 더 많은 검증과 동기화 작업을 수행한다
- Protocol Buffers 오버헤드: 직렬화/역직렬화 과정에서 추가적인 연산이 필요하다
- 비동기 처리 비용: 코루틴과 Flow를 통한 비동기 처리는 약간의 오버헤드를 동반한다
- 타입 안전성 검증: 컴파일 타임과 런타임에서의 타입 검증 비용
실제 사용자 경험 관점에서의 성능
// SharedPreferences - 빠르지만 위험한 접근fun saveUserDataUnsafe(userId: String, token: String) {
// 평균 2ms로 빠르지만...
sharedPrefs.edit()
.putString("user_id", userId)
.putString("token", token)
.apply()// UI 스레드 블로킹 위험, 데이터 손상 가능
}
// DataStore - 조금 느리지만 안전한 접근suspend fun saveUserDataSafe(userId: String, token: String) {
// 평균 8ms로 느리지만...
authDataStore.edit { preferences ->
preferences[USER_ID_KEY] = userId
preferences[TOKEN_KEY] = token
// 완전한 스레드 안전성, ANR 방지, 원자성 보장
}
}
여기서 중요한 것은 6ms의 차이가 사용자 경험에 미치는 영향과 ANR이나 데이터 손상이 사용자 경험에 미치는 영향 중 어느 것이 더 큰가이다. 답은 명확하다.
실제 프로덕션 환경에서의 고려사항
ANR 방지: 사용자 이탈 방지의 핵심
프로덕션 환경에서 ANR(Application Not Responding)는 사용자 이탈의 직접적인 원인이 된다. DataStore는 이런 문제를 근본적으로 해결한다:
class AuthRepository @Inject constructor(
private val authDataStore: AuthDataStore
) {
// UI 스레드에서 안전하게 호출 가능한 인터페이스fun observeAuthState() = authDataStore.accessTokenFlow
.map { token ->
when {
token.isNullOrEmpty() -> AuthState.LoggedOut
isTokenValid(token) -> AuthState.LoggedIn(token)
else -> AuthState.TokenExpired
}
}
.flowOn(Dispatchers.IO)// 모든 작업이 백그라운드에서 처리됨
// 복잡한 인증 로직도 안전하게 처리suspend fun performComplexAuth(credentials: Credentials) {
try {
val authResult = authenticateUser(credentials)
authDataStore.edit { preferences ->
preferences[ACCESS_TOKEN] = authResult.accessToken
preferences[REFRESH_TOKEN] = authResult.refreshToken
preferences[USER_ID] = authResult.userId
preferences[LOGIN_TIMESTAMP] = System.currentTimeMillis()
}
} catch (e: Exception) {
// 에러 발생 시에도 데이터 일관성 보장
authDataStore.deleteAccessToken()
}
}
}
DataStore 내부 구조 심층 분석
아키텍처의 계층화된 설계
DataStore의 내부 구조는 계층화된 아키텍처로 설계되어 있다:
- Application Layer: 앱 코드에서 DataStore API를 호출
- DataStore API: 공개 인터페이스와 코루틴 기반 메서드 제공
- Coroutines & Flow: 비동기 처리와 반응형 스트림 관리
- Protocol Buffers: 효율적인 직렬화/역직렬화
- File System: 원자적 파일 쓰기와 읽기
// DataStore 내부에서 실제로 일어나는 과정 (단순화된 버전)internal class DataStoreImpl<T> : DataStore<T> {
override suspend fun updateData(transform: suspend (t: T) -> T): T {
return mutex.withLock {
// 1. 현재 데이터 읽기 (원자적)val currentData = readDataFromFile()
// 2. 변환 함수 적용val newData = transform(currentData)
// 3. 원자적 쓰기 (실패 시 롤백)
writeDataToFile(newData)
// 4. 모든 구독자에게 새 데이터 방출
_data.value = newData
newData
}
}
}
확장 함수를 통한 사용성 향상
코드에서 보는 것처럼, DataStore는 확장 함수를 통해 사용성을 크게 향상시킨다:
private fun Preferences.Key<String>.flowIn(store: DataStore<Preferences>) =
store.data.map { it[this] }
private suspend fun Preferences.Key<String>.saveTo(
store: DataStore<Preferences>,
value: String
) = store.edit { it[this] = value }
private suspend fun Preferences.Key<String>.deleteFrom(
store: DataStore<Preferences>
) = store.edit { it.remove(this) }
이런 확장 함수들의 장점:
- 재사용성: 모든 키에 대해 동일한 패턴으로 사용 가능
- 가독성: 의도가 명확하게 드러나는 함수명
- DRY 원칙: 중복 코드 제거
- 타입 안전성: 제네릭을 통한 컴파일 타임 타입 검증
DataStore를 적극 권장하는 상황
1. 새로운 프로젝트
- 처음부터 DataStore로 시작하는 것이 최선의 선택
- 기술 부채 없이 현대적 아키텍처 구축 가능
2. 멀티스레드 환경
- 동시성 처리가 중요한 앱 (채팅, 실시간 업데이트)
- 백그라운드 작업이 빈번한 앱
3. 복잡한 데이터 구조와 상태 관리
- 타입 안전성이 중요한 비즈니스 로직
- 여러 데이터의 원자적 업데이트가 필요한 경우
4. Jetpack Compose와 반응형 UI
- Flow 기반의 상태 관리가 필요한 경우
- 실시간 UI 업데이트가 중요한 앱
5. 높은 안정성이 요구되는 도메인
- 금융, 헬스케어, 결제 시스템
- 데이터 무결성이 비즈니스 크리티컬한 앱
SharedPreferences 유지를 고려할 수 있는 상황
1. 레거시 코드베이스
- 마이그레이션 비용이 ROI를 정당화하기 어려운 경우
- 이미 안정적으로 운영되고 있는 단순한 설정 관리
2. 극도로 단순한 설정값
- Boolean 플래그 몇 개 수준의 단순한 데이터
- 동시성 이슈가 발생할 가능성이 거의 없는 경우
3. 성능이 극도로 중요한 특수 케이스
- 마이크로초 단위의 성능이 중요한 게임이나 실시간 앱
- 하지만 이런 경우에도 메모리 캐시 전략을 고려하는 것이 더 나을 수 있다
마무리: 왜 지금 DataStore인가?
SharedPreferences가 여전히 빠른 성능을 보여주는 것은 사실이지만, 현대 앱 개발의 요구사항은 단순한 성능을 넘어섰다. 스레드 안전성, ANR 방지, 데이터 무결성, 반응형 프로그래밍, 타입 안전성 등은 이제 선택이 아닌 필수가 되었다.
DataStore는 이런 현대적 요구사항을 만족시키기 위해 설계된 미래 지향적 솔루션이다. 몇 밀리초의 성능 차이보다는 안정성, 유지보수성, 확장성에 더 큰 가치를 두고 있다.
특히 멀티스레드 환경이 일반화된 현재, SharedPreferences의 동시성 문제는 더 이상 무시할 수 없는 리스크가 되었다. DataStore의 완전한 스레드 안전성과 ACID 보장은 이런 리스크를 근본적으로 제거한다.
실제 프로젝트에서 DataStore를 적용해 본 경험을 바탕으로 말하면, 초기 러닝 커브는 있지만 장기적으로 얻는 이익이 훨씬 크다는 것을 확신한다. 특히 팀 규모가 커지고 앱 복잡도가 증가할수록 DataStore의 가치는 더욱 빛을 발한다.
결국 DataStore를 선택하는 이유는 "더 나은 사용자 경험"과 "더 안정적인 앱"을 위함이다. 성능 최적화는 중요하지만, 앱이 크래시되거나 데이터가 손상되는 상황에서는 무의미하다.
'Android > Study' 카테고리의 다른 글
| [Android] 멀티모듈, Feature-Driven Modularization에 대해서 공부해보자. (0) | 2025.08.26 |
|---|---|
| [Android] Retrofit 네트워크 Mapper와 Utility 아키텍처 설계를 어떻게 하였을까? (0) | 2025.08.25 |
| [Android] `UiState–Intent–SideEffect` MVI 구조를 자체 설계·적용해보자. (0) | 2025.08.24 |
| [Android] Compose Navigation은 어떻게 하면 좋을까? (0) | 2025.08.19 |
| [Android] Orbit으로 MVI 구현기 (0) | 2025.08.19 |