[Android] 프로젝트로 배우는 DataStore, Preferences DataStore, Proto DataStore

WonseokOh·2024년 9월 14일

BuddyCon

목록 보기
6/7
post-thumbnail

사용자 정보, 설정 정보와 같은 간단한 정보들은 앱 내부 저장소에 저장을 하곤 합니다. 대부분 한번쯤은 저장을 해봤을텐데 저는 이전까지는 SharedPreferences 를 이용했었습니다. 그 외에도 암호화 적용된 Encryptedsharedpreferences 채택할 수도 있지만 BuddyCon에서는 DataStore를 이용하였고 DataStore에 대해 빠르게 소개해볼까 합니다.


DataStore

앱에서 데이터를 저장하고 관리하기 위한 솔루션으로 SharedPreferences 대안으로 설계되었습니다. DataStore는 Kotlin 코루틴 및 Flow 를 사용하여 비동기적으로 일관된 트랜잭션 방식으로 데이터를 저장합니다.


SharedPreferences

  • 동기적으로 데이터를 읽고 쓰기 때문에 메인스레드에서 작업이 진행되면 UI 지연을 초래할 수 있습니다.
  • 또한, SharedPreferences를 여러 스레드에서 동시에 변경 시에 업데이트에 문제 생길 수 있습니다. (ex. Race Condition 문제)

DataStore

  • 비동기적 API를 제공하여 I/O 작업을 메인스레드에서 분리하여 성능 저하를 방지합니다.
  • DataStore는 트랜잭션 방식으로 처리하기 때문에 Race Condition 상태를 예방할 수 있습니다.
  • DataStore는 프로토콜 버퍼 방식도 지원하여 효율적인 구조화, 데이터 관리가 가능합니다.

DataStore에서는 2가지 형태로 Preferences DataStore와 Proto DataStore가 있습니다.

  • Preferences DataStore : 키를 사용하여 데이터를 저장하고 데이터에 액세스
  • Proto DataStore : 프로토콜 버퍼를 사용하여 맞춤 데이터 유형의 인스턴스로 데이터 저장, 스키마 정의 필요

BuddyCon에서는 많은 데이터를 저장하지도 않고 클래스를 별도로 정의할 필요도 없었기에 Proto DataStore 보다는 쉽게 구현이 가능한 Preferences DataStore를 사용하였습니다. 하지만, 요즘 프로토콜 버퍼를 활용하여 데이터를 저장하고 서버와 데이터를 주고 받는다고 알고 있어서 나중에 한번쯤 사용해보면 좋을 것 같습니다. 추후에 프로토콜 버퍼를 사용한 후에 Proto DataStore 사용가이드까지 업데이트 해보겠습니다!


프로토콜 버퍼

간단하게 프로토콜 버퍼에 대해서도 알아가면 Google 에서 개발한 언어 중립적, 플랫폼 중립적인 직렬화 포맷입니다. 데이터를 구조화하여 효율적으로 저장하고 네트워크를 통해 전송할 수 있는 방식입니다. 만일, 프로토콜 버퍼를 통해서 서버와 데이터를 주고 받으려면 클라이언트 - 서버 모두 프로토콜 버퍼를 통해 파싱을 하는 작업이 필요하니 협업을 하실 때 미리 정하면 좋을 것 같습니다.


Preferences DataStore 생성

@InstallIn(SingletonComponent::class)
object LocalDataModule {
    const val BUDDYCON_DATASTORE = "BUDDYCON_DATASTORE"

    @Provides
    @Singleton
    fun provideDataStore(
        @ApplicationContext context: Context
    ): DataStore<Preferences> =
        PreferenceDataStoreFactory.create {
            context.preferencesDataStoreFile(BUDDYCON_DATASTORE)
        }

}
  • 구글 공식문서에서는 preferencesDataStore 의 속성 위임을 사용해서 만드는 방법에 대해서 소개하지만, BuddyCon에서는 Hilt를 사용하여 의존성 제공하려고 하였기에 다음과 같이 PreferenceDataStoreFactory를 통해서 생성하였습니다.
  • 공식문서에서 제안한 kotlin 파일의 최상위 수준에서 정의하여서 어디에서든 접근하도록 하여도 무방합니다.

Preferences DataStore 읽기

class TokenRepositoryImpl @Inject constructor(
    private val dataStore: DataStore<Preferences>
) : TokenRepository {
    override fun getToken(): Flow<String> =
        dataStore.data.map { preference ->
            preference[BUDDYCON_TOKEN] ?: ""
        }

    override fun getTokenExpiration(): Flow<Long> =
        dataStore.data.map { preference ->
            preference[BUDDYCON_TOKEN_EXPIRATION] ?: 0L
        }

    override fun getRefreshToken(): Flow<String> =
        dataStore.data.map { preference ->
            preference[BUDDYCON_REFRESH_TOKEN] ?: ""
        }
        
    companion object {
        val BUDDYCON_TOKEN = stringPreferencesKey("BUDDYCON_TOKEN")
        val BUDDYCON_TOKEN_EXPIRATION = longPreferencesKey("BUDDYCON_TOKEN_EXPIRATION")
        val BUDDYCON_REFRESH_TOKEN = stringPreferencesKey("BUDDYCON_REFRESH_TOKEN")
    }
}
  • Prefrerences DataStore는 스키마를 정의하지 않기 때문에 저장하려는 값의 키를 정의하려면 같은 타입의 함수를 사용해야합니다.
  • Token, RefreshToken 문자열이기 때문에 stringPreferencesKey를 사용하고 Token 유효시간은 Long 타입이기에 longPreferencesKey를 사용하였습니다.
  • DataStore는 Flow 형태로 데이터 스트림을 제공하기에 DataStore.data 를 통해 제공받고 원하는 값으로 매핑하여 변환하면 됩니다.
  • 비동기 API 인 Flow로 데이터를 관찰하고 있으면 언제든 데이터를 쓰기만 하면 자동으로 업데이트 되어 편리하게 UI 갱신 및 로직을 처리할 수도 있습니다.

Preferences DataStore 쓰기

class TokenRepositoryImpl @Inject constructor(
    private val dataStore: DataStore<Preferences>
) : TokenRepository {
    override suspend fun saveToken(
        accessToken: String,
        accessTokenExpiresIn: Long,
        refreshToken: String
    ) {
        dataStore.edit { preference ->
            Timber.d("saveToken accessToken: $accessToken accessTokenExpiresIn: $accessTokenExpiresIn refreshToken: $refreshToken")
            preference[BUDDYCON_TOKEN] = accessToken
            preference[BUDDYCON_TOKEN_EXPIRATION] = accessTokenExpiresIn
            preference[BUDDYCON_REFRESH_TOKEN] = refreshToken
        }
    }

    companion object {
        val BUDDYCON_TOKEN = stringPreferencesKey("BUDDYCON_TOKEN")
        val BUDDYCON_TOKEN_EXPIRATION = longPreferencesKey("BUDDYCON_TOKEN_EXPIRATION")
        val BUDDYCON_REFRESH_TOKEN = stringPreferencesKey("BUDDYCON_REFRESH_TOKEN")
    }
}
  • Preferences DataStore는 트랜잭션 방시긍로 업데이트하는 edit 함수를 제공합니다.
  • edit 함수는 transform 함수를 매개변수로 하여 필요에 따라 값을 업데이트할 수 있습니다. 변환 블록 자체가 하나의 트랜잭션으로 처리됩니다.

Interceptor 에서 DataStore 읽기

class BuddyConInterceptor @Inject constructor(
    private val tokenRepository: TokenRepository,
    private val userRepository: UserRepository
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val tokenInfo = runBlocking {
            combine(
                tokenRepository.getToken(),
                tokenRepository.getRefreshToken(),
                tokenRepository.getTokenExpiration()
            ) { accessToken, refreshToken, accessTokenExpiresIn ->
                UserInfo(accessToken, refreshToken, accessTokenExpiresIn)
            }.first()
        }

        var (accessToken, refreshToken, accessTokenExpiresIn) = tokenInfo
        val currentTime = System.currentTimeMillis()

        if (accessTokenExpiresIn < currentTime) {
            try {
                val refreshTokenInfo = runBlocking {
                    userRepository.requestRefreshToken(accessToken, refreshToken).first()
                }

                accessToken = refreshTokenInfo.accessToken
                refreshToken = refreshTokenInfo.refreshToken
                accessTokenExpiresIn = refreshTokenInfo.accessTokenExpiresIn
                runBlocking { tokenRepository.saveToken(accessToken, accessTokenExpiresIn, refreshToken) }
            } catch (_: Exception) {

            }
        }

        val newRequest = chain.request().newBuilder()
            .addHeader("Authorization", "Bearer $accessToken")
            .build()
        return chain.proceed(newRequest)
    }
}
  • BuddyCon의 Interceptor 에서는 DataStore에 저장된 Token 유효시간이 만료되면 RefreshToken을 통해서 갱신하고 새롭게 저장을 합니다.
  • 이 때, runBlocking 과 Flow 형태로 저장된 데이터를 제공받기에 first() 로 데이터를 방출 시켜서 읽고 있는데 혹시 이 방법이 잘못되었거나 다른 좋은 방법이 있다면 댓글로 알려주시면 감사드리겠습니다 🙏🏼

References

profile
"Effort never betrays"

0개의 댓글