[Android] Datastore

MariGold·2025년 9월 16일

[Android]

목록 보기
4/12
post-thumbnail

안드로이드 개발을 하다 보면 앱 내에서 작은 데이터를 저장하고 활용해야 하는 경우가 많습니다.

  • 다크모드 설정 값
  • 자동 로그인 여부
  • 사용자 토큰
  • 앱 설정 정보

위와 같은 값들은 앱 곳곳에서 자주 사용되지만, 이를 저장하기 위해 굳이 Room 같은 관계형 데이터베이스를 사용하는 것은 오버엔지니어링입니다. 그동안은 SharedPreferences를 많이 사용했지만, Google은 이제 DataStore를 공식적으로 권장하고 있습니다.

이번 글에서는 DataStore의 두 가지 구현체인 Preference DataStoreProto DataStore를 비교하며 실제 사용법까지 자세히 알아보겠습니다.


🚀 DataStore란?

DataStore는 Jetpack 라이브러리에서 제공하는 데이터 저장 솔루션으로, SharedPreferences의 단점을 개선한 비동기·타입 안전·트랜잭션 보장 기능을 갖추고 있습니다.

주요 특징

1. 코루틴(Flow) 기반

  • 데이터 읽기/쓰기 모두 suspend 함수와 Flow를 사용
  • 메인 스레드 블로킹 없이 비동기 처리

2. 트랜잭션 보장

  • 동시에 여러 스레드에서 접근하더라도 데이터 무결성 유지
  • 데이터 손실이나 부분 쓰기 방지

3. 타입 안전성 (Proto DataStore 전용)

  • 단순 Key-Value가 아니라 구조화된 데이터 모델 정의 가능
  • 컴파일 타임에 타입 체크

즉, DataStore는 SharedPreferences보다 안전하고 현대적인 방식의 데이터 저장소라고 할 수 있습니다.


🔑 Preference DataStore

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에서 활용하기

매번 함수를 호출하는 것이 번거롭다면, 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 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")
}

Protobuf 설정

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 파일 생성

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: 저장할 데이터 구조 정의

Serializer 구현

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)
    }
}

DataStore 생성

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()
    }
}

🔧 실제 프로젝트에서 활용하기

Hilt와 함께 사용하기

실제 프로젝트에서는 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
        }
    }
}

ViewModel에서 사용하기

@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)
        }
    }
}

Compose에서 구독하기

@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 vs Proto DataStore 비교

항목Preference DataStoreProto DataStore
데이터 구조Key-ValueProto(직렬화 객체)
타입 안정성런타임에서만 보장컴파일 타임 보장
초기 설정간단다소 복잡(.proto 파일 필요)
스키마 진화어려움쉬움(proto 버전 관리)
성능빠름약간 느림(직렬화 오버헤드)
사용 사례단순 설정값복잡한 구조화된 데이터
마이그레이션SharedPreferences에서 쉬움구조 변경 시 안전함

언제 무엇을 사용할까?

Preference DataStore를 선택하는 경우:

  • SharedPreferences에서 마이그레이션할 때
  • 간단한 설정값 저장 (다크모드, 알림 설정 등)
  • 빠른 프로토타이핑이 필요할 때

Proto DataStore를 선택하는 경우:

  • 복잡한 사용자 데이터 구조가 있을 때
  • 타입 안전성이 중요한 프로젝트
  • 데이터 스키마가 자주 변경될 가능성이 있을 때
  • 여러 개발자가 협업하는 프로젝트

🎯 마무리

DataStore는 SharedPreferences의 한계를 극복한 현대적인 데이터 저장 솔루션입니다. 단순한 설정값은 Preference DataStore로, 복잡한 구조화된 데이터는 Proto DataStore로 관리하면 됩니다.

특히 코루틴과 Flow 기반의 비동기 처리가 기본 제공되어, 현대적인 Android 앱 개발에 매우 적합합니다. 아직 SharedPreferences를 사용하고 계신다면, DataStore로의 마이그레이션을 고려해보시기 바랍니다.

참고 자료


💡 TIP: DataStore는 싱글톤으로 구현되어 있어 메모리 효율적입니다. Context 확장 프로퍼티로 정의하면 앱 전체에서 동일한 인스턴스를 사용하게 됩니다.

profile
많은 것을 알아가고 싶은 Android 주니어 개발자

0개의 댓글