Jetpack 라이브러리 중 하나인 Datastore. Android에서는 SharedPreferneces 대신 Datastore 사용을 권장하며, 이미 사용하고 있다면 Datastore로 이전을 추천하고있습니다.

Datastore?

프로토콜 버퍼를 사용해 Key-Value 또는 타입이 지정된 객체를 저장할 수 있는 데이터 저장소 솔루션입니다.

프로토콜 버퍼란?

구조화된 데이터를 직렬화 하기 위한 언어 중립적, 플랫폼 중립적 확장 가능한 메커니즘

또한 Kotlin 코루틴 및 Flow를 사용해 비동기적이고 일관된 트랜잭션 방식으로 데이터를 저장합니다.

💡 Android 에선 대규모 데이터 셋, 부분 업데이트, 참조 무결성을 지원해야 할 경우 Datastore 대신 Room을 사용하는 것을 권장함. Datastore는 단순 데이터셋에 적합하며 부분 업데이트나 참조 무결성은 지원하지 않음

Preferences, Proto Datastore

Datastore는 Preferences Datastore, Proto Datastore라는 두 가지의 구현을 제공합니다.

Preferences Datastore

  • 키를 사용하여 데이터를 저장하고 데이터에 접근
  • 유형 안전성을 제공하지 않음
  • 사전 정의된 스키마가 필요하지 않음

Proto Datastore

  • 맞춤 데이터 유형의 인스턴스로 데이터를 저장
  • 유형 안전성을 제공
  • 프로토콜 버퍼를 사용하여 스키마를 정의해야 함

DataStore 사용 규칙

  1. 두 개 이상의 DataStore 인스턴스 생성 금지

같은 프로세스에서 특정 파일의 DataStore 인스턴스를 두 개 이상 생성하면 DataStore 기능이 중단될 수 있습니다.

동일한 프로세스에서 특정 파일의 DataStore 인스턴스가 여러 개 활성화 되어 있다면 데이터의 Read,Update 시 DataStore는 IllegalStateException을 발생시킵니다.

  1. 변경 불가능한 DataStore 일반 유형
  • DataStore에 사용된 유형을 변경하면 DataStore가 제공하는 모든 보장이 무효화되고 심각하고 포착하기 어려운 버그가 발생할 수 있습니다. 불변성을 보장하고 간단한 API와 효율적인 직렬화를 제공하는 프로토콜 버퍼를 사용하는 것이 좋습니다.
  1. 동일한 파일에서 Single, MultiProcessDataStore 사용 금지
  • 둘 이상의 프로세스에서 DataStore에 접근하려면 항상 MultiProcessDataStore를 사용해야 합니다.

DataStore 적용기

진행하던 프로젝트에서 로그인 후 토큰 관리를 무엇으로 할지 고민했습니다. Shared preferences, DataStore 둘 다 사용해봤던 적이 없었고, 공식 문서에서는 DataStore로 이전하는 것을 권장하고 있었습니다. 그래서 이번 프로젝트에 DataStore를 적용하는 것을 결정했습니다.

TokenManager

토큰만을 관리하기 위한 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

제 프로젝트에선 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
    }
}

참고 문서

0개의 댓글