Jetpack 라이브러리 중 하나인 Datastore. Android에서는 SharedPreferneces 대신 Datastore 사용을 권장하며, 이미 사용하고 있다면 Datastore로 이전을 추천하고있습니다.
프로토콜 버퍼를 사용해 Key-Value 또는 타입이 지정된 객체를 저장할 수 있는 데이터 저장소 솔루션입니다.
프로토콜 버퍼란?
구조화된 데이터를 직렬화 하기 위한 언어 중립적, 플랫폼 중립적 확장 가능한 메커니즘
또한 Kotlin 코루틴 및 Flow를 사용해 비동기적이고 일관된 트랜잭션 방식으로 데이터를 저장합니다.
💡 Android 에선 대규모 데이터 셋, 부분 업데이트, 참조 무결성을 지원해야 할 경우 Datastore 대신 Room을 사용하는 것을 권장함. Datastore는 단순 데이터셋에 적합하며 부분 업데이트나 참조 무결성은 지원하지 않음
Datastore는 Preferences Datastore, Proto Datastore라는 두 가지의 구현을 제공합니다.
Preferences Datastore
Proto Datastore
같은 프로세스에서 특정 파일의 DataStore 인스턴스를 두 개 이상 생성하면 DataStore 기능이 중단될 수 있습니다.
동일한 프로세스에서 특정 파일의 DataStore 인스턴스가 여러 개 활성화 되어 있다면 데이터의 Read,Update 시 DataStore는 IllegalStateException을 발생시킵니다.
진행하던 프로젝트에서 로그인 후 토큰 관리를 무엇으로 할지 고민했습니다. Shared preferences, DataStore 둘 다 사용해봤던 적이 없었고, 공식 문서에서는 DataStore로 이전하는 것을 권장하고 있었습니다. 그래서 이번 프로젝트에 DataStore를 적용하는 것을 결정했습니다.
토큰만을 관리하기 위한 DataStore를 위해 TokenManager라는 class를 추가해주었습니다.
Datastore 인스턴스를 만들기 위해 preferencesDataStore 위임을 사용하며 수신기로 Context를 사용합니다. preferencesDataStore 위임은 애플리케이션에 이 이름을 가진 Datastore 인스턴스가 하나만 있음을 보장합니다
val Context.datastore: DataStore<Preferences> by preferencesDataStore(name = "token_data_store")
다음 가장 먼저 해야할 일은 accessToken과 refreshToken의 키를 정의해야 합니다.
companion object {
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
}
이제 DataStore에 저장된 accessToken 및 refreshToken의 변경 흐름을 나타내는 Flow 변수입니다.
val accessTokenFlow: Flow<String?> = dataStore.data
.map { preferences ->
preferences[KEY_ACCESS_TOKEN]
}
val refreshTokenFlow: Flow<String?> = dataStore.data
.map { preferences ->
preferences[KEY_REFRESH_TOKEN]
}
로그아웃, 탈퇴 시 DataStore에 저장된 Token을 제거하는 코드입니다. Flow는 Coroutine을 기반으로 하기에 suspend가 붙습니다.
suspend fun deleteToken() {
dataStore.edit { preferences ->
preferences.remove(KEY_ACCESS_TOKEN)
preferences.remove(KEY_REFRESH_TOKEN)
}
}
마지막으로 DataStore에 토큰을 각각 저장하는 코드입니다. edit 함수와 미리 정의해둔 key를 통해 DataStore에 저장합니다.
suspend fun saveAccessToken(accessToken: String) {
if (accessToken.isNotEmpty() && accessToken.length > 10) {
dataStore.edit { preferences ->
preferences[KEY_ACCESS_TOKEN] = accessToken
}
}
}
suspend fun saveRefreshToken(refreshToken: String) {
if (refreshToken.isNotEmpty() && refreshToken.length > 10) {
dataStore.edit { preferences ->
preferences[KEY_REFRESH_TOKEN] = refreshToken
}
}
}
전체 코드입니다.
@Singleton
class TokenManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataStore: DataStore<Preferences> = context.datastore
val accessTokenFlow: Flow<String?> = dataStore.data
.map { preferences ->
preferences[KEY_ACCESS_TOKEN]
}
val refreshTokenFlow: Flow<String?> = dataStore.data
.map { preferences ->
preferences[KEY_REFRESH_TOKEN]
}
suspend fun deleteToken() {
dataStore.edit { preferences ->
preferences.remove(KEY_ACCESS_TOKEN)
preferences.remove(KEY_REFRESH_TOKEN)
}
}
suspend fun saveAccessToken(accessToken: String) {
if (accessToken.isNotEmpty() && accessToken.length > 10) {
dataStore.edit { preferences ->
preferences[KEY_ACCESS_TOKEN] = accessToken
}
}
}
suspend fun saveRefreshToken(refreshToken: String) {
if (refreshToken.isNotEmpty() && refreshToken.length > 10) {
dataStore.edit { preferences ->
preferences[KEY_REFRESH_TOKEN] = refreshToken
}
}
}
companion object {
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
}
}
제 프로젝트에선 Hilt를 적용하였기에 DataStore 또한 hilt와 관계짓고 싶었습니다.
@Singleton
class TokenManager @Inject constructor(
@ApplicationContext private val context: Context
) {
@Inject 어노테이션으로 의존성을 주입하였습니다. 다음으로 주입된 Context가 Activity Context가 아니라 Application Context임을 보장하는 생성자 매개변수를 만들었습니다.
이번엔 DataStoreModule입니다. 이 모듈을 통해 TokenManager에 의존성 주입이 이루어집니다.
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Singleton
@Provides
fun provideTokenManager(@ApplicationContext context: Context): TokenManager {
return TokenManager(context)
}
}
마지막으로 Application Class의 코드입니다. 여기서 DataStoreModule에서 TokenManager를 제공하고, Application에서 Hilt를 사용해 TokenManager를 주입받게 됩니다. 주입된 TokenManager는 애플리케이션 전체에서 사용할 수 있습니다.
@HiltAndroidApp
class ChallengeApplication : Application() {
@Inject
lateinit var tokenManager: TokenManager
companion object {
private lateinit var application: ChallengeApplication
fun getInstance(): ChallengeApplication {
return application
}
}
override fun onCreate() {
super.onCreate()
application = this
}
}
참고 문서