안드로이드 개발을 하다 보면 앱 내에서 작은 데이터를 저장하고 활용해야 하는 경우가 많습니다.
위와 같은 값들은 앱 곳곳에서 자주 사용되지만, 이를 저장하기 위해 굳이 Room 같은 관계형 데이터베이스를 사용하는 것은 오버엔지니어링입니다. 그동안은 SharedPreferences를 많이 사용했지만, Google은 이제 DataStore를 공식적으로 권장하고 있습니다.
이번 글에서는 DataStore의 두 가지 구현체인 Preference DataStore와 Proto DataStore를 비교하며 실제 사용법까지 자세히 알아보겠습니다.
DataStore는 Jetpack 라이브러리에서 제공하는 데이터 저장 솔루션으로, SharedPreferences의 단점을 개선한 비동기·타입 안전·트랜잭션 보장 기능을 갖추고 있습니다.
1. 코루틴(Flow) 기반
2. 트랜잭션 보장
3. 타입 안전성 (Proto DataStore 전용)
즉, DataStore는 SharedPreferences보다 안전하고 현대적인 방식의 데이터 저장소라고 할 수 있습니다.
Preference DataStore는 Key-Value 형태로 데이터를 저장합니다. SharedPreferences의 비동기 버전이라고 생각하면 됩니다.
dependencies {
implementation("androidx.datastore:datastore-preferences:1.0.0")
}
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
val DARK_MODE_KEY = booleanPreferencesKey("dark_mode")
val AUTO_LOGIN_KEY = booleanPreferencesKey("auto_login")
val USER_TOKEN_KEY = stringPreferencesKey("user_token")
// 데이터 저장
suspend fun saveDarkMode(context: Context, enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[DARK_MODE_KEY] = enabled
}
}
// 데이터 읽기
fun readDarkMode(context: Context): Flow<Boolean> =
context.dataStore.data
.catch { exception ->
// IO 예외 처리 - 파일 손상/읽기 실패 시 빈 Preferences로 대체
if (exception is IOException) {
emit(emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
preferences[DARK_MODE_KEY] ?: false // 기본값 false
}
매번 함수를 호출하는 것이 번거롭다면, Repository에서 StateFlow로 변환해서 사용할 수 있습니다.
class SettingsRepositoryImpl(
private val context: Context,
private val appScope: CoroutineScope
) : SettingsRepository {
override val darkMode: StateFlow<Boolean> = context.dataStore.data
.map { preferences -> preferences[DARK_MODE_KEY] == true }
.stateIn(
scope = appScope,
started = SharingStarted.Eagerly,
initialValue = false
)
override suspend fun updateDarkMode(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[DARK_MODE_KEY] = enabled
}
}
}
이렇게 하면 ViewModel에서 darkMode.collectAsState() 등으로 바로 구독할 수 있어 훨씬 깔끔해집니다.
Proto DataStore는 데이터를 직렬화된 객체(proto 파일 기반)로 저장합니다. Key-Value 대신 정해진 데이터 구조를 정의해두고, 그 구조에 맞게 안전하게 저장하는 방식입니다.
plugins {
id("com.android.application")
kotlin("android")
id("com.google.protobuf") version "0.9.4"
}
dependencies {
implementation("androidx.datastore:datastore:1.0.0")
implementation("com.google.protobuf:protobuf-javalite:3.24.0")
}
app 수준의 build.gradle.kts 파일에 다음 설정을 추가합니다:
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.24.0"
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
}
}
}
}
💡 주의: protoc와 protobuf-javalite 버전은 서로 호환되는 조합을 사용하세요.
Proto 파일은 다음 중 원하는 위치에 생성할 수 있습니다:
app/src/main/proto/ (권장)src/main/proto/ 예시로 app/src/main/proto/user_settings.proto 파일을 생성해보겠습니다:
syntax = "proto3";
option java_package = "com.example.data.proto";
option java_multiple_files = true;
message UserSettingsProto {
int64 current_account_id = 1;
bool dark_mode_enabled = 2;
bool auto_login_enabled = 3;
string user_token = 4;
}
각 요소에 대한 설명:
syntax = "proto3": proto3 문법 사용option java_package: Java/Kotlin으로 생성될 클래스의 패키지 지정option java_multiple_files = true: 각 메시지를 별도 파일로 생성message UserSettingsProto: 저장할 데이터 구조 정의Proto DataStore에서는 Serializer가 필요합니다. 이는 메모리 상의 객체를 파일로 저장하거나, 파일에서 객체로 변환하는 역할을 담당합니다.
🤔 왜 Serializer가 필요한가요?
- Proto 객체를 바이너리 형태로 파일에 저장하고 읽어올 때 필요
- 데이터 손상이 발생했을 때 기본값 제공
- DataStore가 언제 어떻게 데이터를 읽고 써야 하는지 알려주는 역할
object UserSettingsSerializer : Serializer<UserSettingsProto> {
// 기본값 정의 - 데이터가 없거나 손상되었을 때 사용
override val defaultValue: UserSettingsProto = UserSettingsProto.getDefaultInstance()
// 파일에서 Proto 객체로 변환 (역직렬화)
override suspend fun readFrom(input: InputStream): UserSettingsProto =
try {
UserSettingsProto.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
// 데이터 손상 시 CorruptionException을 던져서 DataStore가 처리하도록 함
throw CorruptionException("Cannot read proto.", exception)
}
// Proto 객체를 파일로 저장 (직렬화)
override suspend fun writeTo(t: UserSettingsProto, output: OutputStream) {
t.writeTo(output)
}
}
val Context.userSettingsDataStore: DataStore<UserSettingsProto> by dataStore(
fileName = "user_settings.pb",
serializer = UserSettingsSerializer
)
// 데이터 읽기
val userSettingsFlow: Flow<UserSettingsProto> = context.userSettingsDataStore.data
.catch { exception ->
if (exception is IOException) {
emit(UserSettingsProto.getDefaultInstance())
} else {
throw exception
}
}
// 특정 값만 가져오기
val darkModeFlow: Flow<Boolean> = userSettingsFlow
.map { settings -> settings.darkModeEnabled }
// 데이터 저장
suspend fun updateUserSettings(
context: Context,
accountId: Long,
darkMode: Boolean,
autoLogin: Boolean,
token: String
) {
context.userSettingsDataStore.updateData { current ->
current.toBuilder()
.setCurrentAccountId(accountId)
.setDarkModeEnabled(darkMode)
.setAutoLoginEnabled(autoLogin)
.setUserToken(token)
.build()
}
}
// 부분 업데이트
suspend fun updateDarkMode(context: Context, enabled: Boolean) {
context.userSettingsDataStore.updateData { current ->
current.toBuilder()
.setDarkModeEnabled(enabled)
.build()
}
}
실제 프로젝트에서는 DI 프레임워크와 함께 사용하는 경우가 많습니다. Hilt를 예시로 보겠습니다.
// DataStore Module
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
fun providePreferencesDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
return context.dataStore
}
@Provides
@Singleton
fun provideUserSettingsDataStore(@ApplicationContext context: Context): DataStore<UserSettingsProto> {
return context.userSettingsDataStore
}
}
// Repository
@Singleton
class SettingsRepository @Inject constructor(
private val preferencesDataStore: DataStore<Preferences>,
private val userSettingsDataStore: DataStore<UserSettingsProto>
) {
val darkMode: Flow<Boolean> = preferencesDataStore.data
.map { preferences -> preferences[DARK_MODE_KEY] ?: false }
.catch { emit(false) }
val userSettings: Flow<UserSettingsProto> = userSettingsDataStore.data
.catch { emit(UserSettingsProto.getDefaultInstance()) }
suspend fun updateDarkMode(enabled: Boolean) {
preferencesDataStore.edit { preferences ->
preferences[DARK_MODE_KEY] = enabled
}
}
}
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository
) : ViewModel() {
val darkMode = settingsRepository.darkMode
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = false
)
fun toggleDarkMode() {
viewModelScope.launch {
settingsRepository.updateDarkMode(!darkMode.value)
}
}
}
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel()
) {
val darkMode by viewModel.darkMode.collectAsStateWithLifecycle()
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("다크 모드")
Switch(
checked = darkMode,
onCheckedChange = { viewModel.toggleDarkMode() }
)
}
}
}
| 항목 | Preference DataStore | Proto DataStore |
|---|---|---|
| 데이터 구조 | Key-Value | Proto(직렬화 객체) |
| 타입 안정성 | 런타임에서만 보장 | 컴파일 타임 보장 |
| 초기 설정 | 간단 | 다소 복잡(.proto 파일 필요) |
| 스키마 진화 | 어려움 | 쉬움(proto 버전 관리) |
| 성능 | 빠름 | 약간 느림(직렬화 오버헤드) |
| 사용 사례 | 단순 설정값 | 복잡한 구조화된 데이터 |
| 마이그레이션 | SharedPreferences에서 쉬움 | 구조 변경 시 안전함 |
Preference DataStore를 선택하는 경우:
Proto DataStore를 선택하는 경우:
DataStore는 SharedPreferences의 한계를 극복한 현대적인 데이터 저장 솔루션입니다. 단순한 설정값은 Preference DataStore로, 복잡한 구조화된 데이터는 Proto DataStore로 관리하면 됩니다.
특히 코루틴과 Flow 기반의 비동기 처리가 기본 제공되어, 현대적인 Android 앱 개발에 매우 적합합니다. 아직 SharedPreferences를 사용하고 계신다면, DataStore로의 마이그레이션을 고려해보시기 바랍니다.
💡 TIP: DataStore는 싱글톤으로 구현되어 있어 메모리 효율적입니다. Context 확장 프로퍼티로 정의하면 앱 전체에서 동일한 인스턴스를 사용하게 됩니다.