Android

안드로이드 [Kotlin] - DataStore를 이용한 자동로그인

🤖 Play with Android 🤖 2022. 8. 16. 17:34
728x90


SharedPreferences

  • Key, Value 형태로 이용한다.
  • String, Int, Float, Boolean과 같은 원시형 데이터들을 저장하고 검색할 수 있다.
  • 내부적으로는 XML 파일로 저장된다.

SharedPreferences를 사용하는 방법은 이전 포스팅을 참고하면 된다.

https://jminie.tistory.com/169

 

안드로이드 [Kotlin] - SharedPreferences 를 이용해 Retrofit2 Header에 JWT 담기

안드로이드에서 데이터를 저장하는 방법으로 여러 가지가 있다. 파일 I/O (내부 또는 외부 저장소) 접근 권한을 획득하고 파일을 열었다 닫았다 하는 수고가 필요함 관계형 데이터베이스 SQLite 등

jminie.tistory.com

 

 

🚨  SharedPreferences의 한계점

  • UI 스레드(메인 스레드)에서 호출할 수 있도록 API가 설계되었지만, UI 스레드를 블로킹해 ANR을 발생시킬 수 있다.
  • 런타임에 예외가 생기면 에러가 발생해 앱이 강제 종료될 수도 있다.
  • Strong Consistency(강한 일관성)이 보장되지 않아 다중 스레드 환경에서 안전하지 않다.
    • Strong Consistency(강한 일관성)이란 다중 스레드 환경에서 안전하게 데이터를 입력하거나 조회하게 하는 것이다.
      • 이를 통해 다중 스레드 환경에서 안전하게 데이터를 입력, 조회할 수 있도록 한다.
      • 이는 동시성 프로그래밍에서 중요한 요소이다.
  • Type Safey가 보장되지 않아 어떤 데이터가 저장되고 추출되는지 일일히 데이터로 Type Convertind(형 변환) 해주어야 했다.

 

공식문서에서도 만약 SharedPrefereces를 사용하고 있다면 DataStore로 이전할 것을 권고하고 있다.

https://developer.android.com/topic/libraries/architecture/datastore

 

 

📌  DataStore

  • DataStore는 Coroutine을 사용해 동시성 프로그래밍에 최적화된 API를 제공한다.
    • 경량 스레드 모델을 구현하는 Coroutine을 사용해 내부를 구현함으로써 더욱 효율적으로 데이터를 저장할 수 있도록 한다.
    • 기존 UI 스레드에서 호출되어 ANR을 발생시킬 수 있었던 SharedPreferences와 다르다.
    • 내부에서 Coroutine의 IO Dispathcer를 사용해 IO를 담당하는 스레드 풀에서 데이터를 조작하도록 강제했다.
    • 또한 Flow를 사용해 데이터를 추출할 수 있도록 만들어 데이터의 입출력을 모두 Coroutine에서 사용할 수 있도록 하였다.
  • Strong Consistency(강한 일관성)을 보장하는 Transaction API를 제공한다.
  • Type Safety 보장
    • DataStore는 Type Safety를 지원한다.
      • Type Safety란 데이터가 타입을 기준으로 입력되고 출력될 수 있는지에 대한 것이다.
      • 즉 “예측불가능한 결과를 내지 않는다”라는 것을 뜻한다.
      • Type Safety를 지원한다는 것은 객체를 직접 직렬화 하지 않고도 저장할 수 있다는 것을 뜻하며 안전하게 데이터를 DataStore로부터 꺼내올 수 있다는 것을 의미한다.
      • 하지만 Preference DataStore, Proto DataStore 중 ProtoDataStore 만 Type Safety를 보장한다.

 


 

📌  코드 예제 (실습)

DataStore를 이용하여 자동로그인 기능을 구현해보자. 프로젝트를 진행하면서 구현했던 코드를 공유하고자 한다.

여기서는 Preferences DataStore를 사용할 것이다.

 

의존성 추가

build.gradle(Module)

dependencies {

    // DataStore
    implementation 'androidx.datastore:datastore-preferences:1.0.0'
}

2022 8월 기준 DataStore 버전이다. 버전은 지속해서 올라갈 수 있다.

 

레포지토리 구현

LoginRepositoryImpl.kt

class LoginRepositoryImpl @Inject constructor(
    private val loginApi: LoginApi,
    @ApplicationContext private val context: Context
) : LoginRepository {

    override suspend fun getKakaoToken(kakaoOauthRequest: KakaoOauthRequest): JWT {
        return loginApi.getKakaoToken(kakaoOauthRequest).toJWT()
    }

    override suspend fun getNaverToken(naverOauthRequest: NaverOauthRequest): JWT {
        return loginApi.getNaverToken(naverOauthRequest).toJWT()
    }

    private object PreferenceKeys {
        val ACCESS_TOKEN = stringPreferencesKey("access_token")
        val REFRESH_TOKEN = stringPreferencesKey("refresh_token")
        val LOGIN_CHECK = booleanPreferencesKey("login_check")
    }

    private val Context.tokenDataStore by preferencesDataStore(TOKEN_DATASTORE)
    private val Context.loginCheckDataStore by preferencesDataStore(LOGIN_CHECK_DATASTORE)

}

Preferences DataStore 구현은 DataStroe 클래스와 Preferences 클래스를 사용하여 간단한 Key, Value 쌍을 저장한다.

 

우선 로그인을 담당하는 레포지토리에서 DataStore<Preferences> 구현을 preferencesDataStore에 맡기는 Context 확장 프로퍼티를 만들어준다. 

여기서는

  • 서버에서 보내주는 AccessToken과 RefreshToken을 저장하는 tokenDataStroe
  • 유저가 로그인을 한 상태인지 Boolean타입으로 저장하는 loginCheckDataStore

이렇게 두 개의 DataStore를 만들어주었다.

 

그리고 object 키워드로 DataStore에 사용할 키 값을 선언해준다.

 

❗ 이 때 키를 선언하는 함수를 보면 'stringPreferenceKey', 'booleanPreferenceKey' 이런 식으로 표현이 되어있다.

분명히 우리는 Preference DataStore를 이용하고 있고 Preference DataStore는 Type Safety 즉 타입 안전성을 보장하지 않는다고 배웠는데 이 표현만 보면 타입 안정성을 보장하는 것처럼 보인다.

 

All about Preferences DataStore의 Defining keys 문단을 보면

"While this does put some constraints on data types, keep in mind that it doesn’t provide definite type safety."

라고 되어 있다. 공부했던 대로 Preferences Datastore는 타입세이프가 아니라는 말이다.

"By specifying a preference key of a certain type, we hope for the best and rely on our assumptions that a value of a certain type would be returned."

단지 지정한 타입이 반환될 것이라고 '기대'한다는 이상한 표현을 사용하고 있다.

 

라이브러리 사용자의 관점에서는 PreferencesKey를 통하면 겉으로는 타입세이프처럼 동작하는 것 같지만 Get Your Hand Dirty With Jetpack Datastore 의 2. Jetpack Datastore 문단을 보면 실제로는 Preferences DataStore의 데이터는 json처럼 저장이 된다.  저장된 시점에서 타입 정보가 사라졌다가 읽어 들일 때 다시 복원을 하는 것이다. 따라서 Type Safety를 보장할 수 없다. 단지 타입이 잘 복원될 것이라고 '기대'를 하는 것뿐이다.

 

 

이제 Token을 저장하는 함수를 만들어 주자

DataStore는 기본적으로 Coroutine안에서 작동하므로 suspend 키워드를 붙여준다.

override suspend fun saveToken(token: List<String>) {
    if (token.isNotEmpty()) {
        context.tokenDataStore.edit { prefs ->
            prefs[ACCESS_TOKEN] = token.first()
            prefs[REFRESH_TOKEN] = token.last()
        }
        // AccessToken, RefreshToken 이 제대로 들어온 여부를 확인하는 boolean 값
        context.loginCheckDataStore.edit { prefs ->
            prefs[LOGIN_CHECK] = true
        }
    }
}

Preferences DataStore는 위와 같이 DataStore 객체를 edit 하여 수정이 가능하다. SharedPrefernces와 다르게 commit 하지 않아도 값이 업데이트된다. 

AccessToken과 RefreshToken을 List<String>타입으로 받아와서 각각 미리 선언해둔 Key에 넣어준다.

 

또한 자동 로그인 구현을 위해 Token이 제대로 들어왔는지를 확인해야 하기 때문에 Boolean 타입의 LOGIN_CHECK DataStore에 token이 제대로 들어왔다면 true를 넣어준다.

 

Token을 저장하는 함수를 만들었으니 Token을 꺼내오는 함수도 필요할 것이다.

override suspend fun getToken(): Flow<List<String>> {
    return context.tokenDataStore.data
        .catch { exception ->
            if (exception is IOException) {
                exception.printStackTrace()
                emit(emptyPreferences())
            } else {
                throw exception
            }
        }
        .map { prefs ->
            prefs.asMap().values.toList().map {
                it.toString()
            }
        }
}

기본적으로 DataStore Interface의 data 프로퍼티는 Flow를 반환한다.

public interface DataStore<T> {
    public val data: Flow<T> 
    public suspend fun updateData(transform: suspend (t: T) -> T): T
}

이때 Key-Value 쌍들의 집합을 Map 형태의 데이터 구조로 반환하는데 이 데이터 구조에서 특정한 값을 추출하려면 위 코드와 같이 map 함수를 이용해 데이터를 변환할 수 있다.

 

위 코드는 Map 형태로 받은 데이터 구조를 우선 toList()를 통해 List로 변환한 뒤 각각을 toStirng()을 통해 결과적으로 Flow<List<String>> 타입으로 반환해주고 있다.

 

 

마지막으로 로그인 여부의 Boolean 값을 불러오는 함수를 만들어보자

override suspend fun getIsLogin(): Flow<Boolean> {
    return context.loginCheckDataStore.data
        .map { prefs ->
            prefs[LOGIN_CHECK] ?: false
        }
}

위 코드와 data와 map을 쓰는 로직은 거의 비슷하다.

loginCheckDataStore에서 값을 가져오고 만약 값이 없다면 기본적으로 false를 리턴해주도록 했다.

 

 

 

위의 코드를 모두 합치면 아래와 같다

class LoginRepositoryImpl @Inject constructor(
    private val loginApi: LoginApi,
    @ApplicationContext private val context: Context
) : LoginRepository {

    override suspend fun getKakaoToken(kakaoOauthRequest: KakaoOauthRequest): JWT {
        return loginApi.getKakaoToken(kakaoOauthRequest).toJWT()
    }

    override suspend fun getNaverToken(naverOauthRequest: NaverOauthRequest): JWT {
        return loginApi.getNaverToken(naverOauthRequest).toJWT()
    }

    private object PreferenceKeys {
        val ACCESS_TOKEN = stringPreferencesKey("access_token")
        val REFRESH_TOKEN = stringPreferencesKey("refresh_token")
        val LOGIN_CHECK = booleanPreferencesKey("login_check")
    }

    private val Context.tokenDataStore by preferencesDataStore(TOKEN_DATASTORE)
    private val Context.loginCheckDataStore by preferencesDataStore(LOGIN_CHECK_DATASTORE)

    override suspend fun saveToken(token: List<String>) {
        context.tokenDataStore.edit { prefs ->
            prefs[ACCESS_TOKEN] = token.first()
            prefs[REFRESH_TOKEN] = token.last()
        }
        // AccessToken, RefreshToken 이 제대로 들어온 여부를 확인하는 boolean 값
        context.loginCheckDataStore.edit { prefs ->
            prefs[LOGIN_CHECK] = true
        }
    }

    override suspend fun getToken(): Flow<List<String>> {
        return context.tokenDataStore.data
            .catch { exception ->
                if (exception is IOException) {
                    exception.printStackTrace()
                    emit(emptyPreferences())
                } else {
                    throw exception
                }
            }
            .map { prefs ->
                prefs.asMap().values.toList().map {
                    it.toString()
                }
            }
    }

    override suspend fun getIsLogin(): Flow<Boolean> {
        return context.loginCheckDataStore.data
            .map { prefs ->
                prefs[LOGIN_CHECK] ?: false
            }
    }
}

이제 자동 로그인을 위한 모든 함수는 준비가 끝났다. ViewModel에서 saveToken을 통해 서버에서 응답으로 받아온 토큰을 넣어주고 getIsLogin을 통해 유저가 로그인이 완료된 유저인지 아닌지 판단하면 된다.

당연히도 DataStore를 사용했기 때문에 앱을 종료하고 다시 시작하더라도 로그인은 그대로 유지된다.