프로젝트 관련 글
어플을 사용하다 보면 어플의 로고가 뜨고 메인화면으로 넘어가는 구조를 보신 적이 있을 것입니다.
Android에서는 SplashActivity
로 구현합니다.
대략적인 구조는 다음과 같습니다.
최초 화면 진입점인 Splash Activity에서 자동 로그인을 요청하고, 실패했다면 Login 화면으로, 성공했다면 Main 화면으로 넘어갑니다.
자 이제 그럼 궁금증은 자동 로그인을 요청하는 곳은 어디이며, 어떠한 로직으로 결과를 반환하는가?입니다.
크게 remote 요청과 local 요청이 있습니다.
- remote 요청 : 서버 요청으로 인한 결과 반환
- local 요청 : Room, DataStore, sharedPreference 등 Android 내부 요청으로 인한 결과 반환
요청하는 수단은 우선 서버는 제외했습니다.
이유는 유저의 액세스토큰과 리프래시 토큰의 유무를 검사하는 작업이 굳이 서버를 통해야 하나라는 생각이 있었습니다.
서버가 있었다면 액세스토큰이 만료된 경우 리프래시 토큰을 통해 액세스 토큰을 갱신하고, 이를 업데이트해야 했지만,
안드로이드와 서버 동시에 진행하고 있어서 Oauth까지 처리하기엔 무리라고 판단했습니다..ㅠ
다음으로 Local에서 간단하게 토큰 정보를 저장하기에 Room을 사용하는 것은 적절하지 않다고 판단했습니다.
왜냐하면 단순한 토큰의 정보를 저장하기 때문
입니다.
따라서 요청하는 수단은 DataStore
와 sharedPreference
중 고민했습니다.
SharedPreference
sharedPreference는 초창기 기술로 가벼운 데이터를 key-value쌍
으로 저장하는 방식이었습니다.
다른 기술에 비해 처음 나온 기술이기에 다른 기술에 비해 많은 한계가 있었습니다.
첫 번째로 UI Thread에 안전하지 않다입니다.
실제 파일에서 쓰고 읽기 때문에 IO Thread
에서 작업을 해야 하는데, 이를 Main Thread
에서 진행할 경우 ANR
오류가 생깁니다.
두 번째로, RunTime Exception에 취약하다입니다.
내부적으로 Excepion에 대한 에러 핸들링을 제공하지 않기
때문에, 에러를 처리하기에 어려움이 있습니다.
따라서 Android 공식문서에서도 DataStore를 권장한다고 나와있습니다.
DataStore
코루틴
및 Flow
를 지원하기에 이에 대한 이점이 있습니다.
- 비동기 작업이 가능하다.
- 데이터를 저장하거나, 읽는 무거운 작업은 Dispatchers.IO에서 작업되기 때문에 UI Thread에서 사용해도 안 접합니다.
- 그 외에도 Flow의 확장함수인 Catch와 코루틴을 사용하기 때문에 Coroutine exceptions handler를 통해 에러에 대응할 수 있습니다.
간단하게 비교만 하고, 설정과 사용법에 대해서는 다른 좋은 글이 많기 때문에 생략하겠습니다.
저는 자동 로그인을 요청하고 결과를 반환하기 위해 Local 저장소중 DataStore를 선택했는데요,
DataStore는 같은 프로세스에서 인스턴스를 2개 이상 만들지 않는 것을 권장하고 있습니다.
물론 Hilt를 사용한다면 ApplicationContext를 통해서 주입할 수 있지만, 아직 Hilt를 적용하지 않았기 때문에
가장 상위인 Application에서 DataStore를 선언했습니다.
이렇게 하면 싱글톤 형태로 DataStore를 유지할 수 있습니다.
val Context.dataStore
: DataStore<Preferences> by preferencesDataStore(name = BuildConfig.DATASTORE_NAME)
제가 생각한 시나리오는 최초 로그인 시 데이터스토어에 액세스토큰과 리프래시 토큰을 저장한 후,
다음 로그인 시 데이터 스토어를 훑으면서 토큰이 있는지 검사하고, 자동 로그인을 처리하는 것입니다.
그를 위해 최초 로그인 시 호출되는 콜백에 결과를 추적할 필요가 있었습니다.
private fun observerLoginResponse() {
viewModelScope.launch {
NaverLoginManager.loginResult.collectLatest { loginResponse ->
when (loginResponse) {
is ApiResponse.Success -> {
dataStoreRepository.saveAccessToken(loginResponse.data)
_event.emit(LoginEvent.Success)
}
is ApiResponse.Error -> {
_event.emit(
LoginEvent.Error(
errorCode = loginResponse.errorCode,
errorMessage = loginResponse.errorMessage,
),
)
}
is ApiResponse.Failure -> {}
}
}
}
}
저번 코드에서 성공 시 dataStoreRepository에 saveAccessToken을 reponse의 data를 담아서 요청했습니다.
response의 data는 액세스토큰과 refresh토큰이 있습니다.
DataStoreRepository
우선 액세스 토큰을 저장하는 saveAccessToken입니다.
suspend fun saveAccessToken(loginSuccess: LoginResponse) {
dataStore.edit { prefs ->
prefs[ACCESS_TOKEN_KEY] = loginSuccess.accessToken
prefs[REFRESH_TOKEN_KEY] = loginSuccess.refreshToken
}
}
sharedPreference와 다르게 비동기 작업을 지원해 주기 때문에 suspend 키워드는 반드시 필요합니다.
이렇게 key-value 쌍에 맞게 액세스토큰과 리프래시 토큰을 저장합니다.
해당 키의 맞는 액세스토큰과 리프래시 토큰이 있을 경우 저는 자동 로그인을 진행했습니다.
그를 위해 Splash Activity에서 토큰을 검사해야 하는데요.
그를 위해 getAccessToken이라는 함수를 살펴보면,
fun getAccessToken(): Flow<ApiResponse<LoginResponse>> = dataStore.data.map { prefs ->
if (prefs[ACCESS_TOKEN_KEY] == null) {
ApiResponse.Failure
} else {
ApiResponse.Success(
LoginResponse(
accessToken = prefs[ACCESS_TOKEN_KEY] ?: "",
refreshToken = prefs[REFRESH_TOKEN_KEY] ?: "",
),
)
}
}.catch { exception ->
ApiResponse.Error(
errorMessage = exception.message ?: "",
)
}
반환타입을 Flow 형태로 LoginResponse를 감싸는 ApiResponse로 설정했습니다.
dataStore의 data를 훑으면서, 저장된 액세스 토큰이 있는지 검사합니다.
만약 액세스 토큰이 없다면 실패를 반환합니다.
있다면 액세스토큰과 리프래시 토큰을 담아서 성공을 반환합니다.
dataStore의 장점인 flow를 사용하기 때문에 내부 과정에서 에러가 발생할 경우 catch를 통해서 다양한 에러에 대응할 수 있고, 에러 메시지를 Error에 담아서 반환할 수 있습니다.
지금 와서 생각해 보면 null 일 경우 error를 던져서 catch 블록에서 잡는 코드가 더 깔끔할 것 같습니다.
Splash ViewModel
SplashViewModel은 accessToken에 대한 결과를 Collect 합니다.
private fun getUserPreferences() {
viewModelScope.launch {
dataStoreRepository.getAccessToken().collectLatest { loginResponse ->
when (loginResponse) {
is ApiResponse.Success -> _event.emit(LoginEvent.Success)
is ApiResponse.Error -> _event.emit(
LoginEvent.Error(
errorCode = loginResponse.errorCode,
errorMessage = loginResponse.errorMessage,
),
)
is ApiResponse.Failure -> {
_event.emit(LoginEvent.Error())
}
}
}
}
}
액세스 토큰이 있다면 성공에 대한 이벤트를, 실패했다면 실패를 반환하고, 이에 대한 이벤트 처리를 View에서 해줍니다.
Splash Activity
private fun collectUserPreferences() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.event.collectLatest { loginEvent ->
when (loginEvent) {
is LoginEvent.Success -> {
startActivity(Intent(this@SplashActivity, MainActivity::class.java))
}
is LoginEvent.Error -> {
startActivity(Intent(this@SplashActivity, LoginActivity::class.java))
}
}
}
}
}
}
만약 성공할 경우 MainActivity로 이동하고, 실패했다면 Login을 진행하기 위해 LoginActivity로 이동합니다.
이렇게 보니 LoginResponse를 Splash에서 활용하고 있는데, 옳지 못한 구조인 것 같네요,, ㅠ
다음 리팩토링 때 분리를 해서 진행해야 할 것 같습니다.
LoginResponse의 값이 변경될 예정이기 때문에 분리는 필수적인 것 같네요.
Hilt를 적용하기 전 DataStore를 이용해 자동로그인을 구현해 봤는데요, 매번 viewModel의 dataStoreRepository와 dataStore를 주입해줘야 하는 번거로움이 있어서 불편한 것 같습니다..
다음 글은 Hilt를 적용한 후 유저의 정보를 DB에 저장하는 글을 작성해 볼 예정입니다.
참고
https://kotlinlang.org/docs/exception-handling.html#coroutineexceptionhandler
https://readystory.tistory.com/215
'Skils > Android' 카테고리의 다른 글
[Android] DataSource 적용 및 분리 (4) | 2024.05.02 |
---|---|
[Android] 네이버 로그인 프로필 가져오기 (0) | 2024.05.01 |
[Android] 네이버 간편 로그인(Kotlin) (0) | 2024.04.29 |
[Android] - BaseActivity, BaseFragment .. BaseX에 대한 고찰 (1) | 2024.04.22 |
[Android] - 네트워크 연결 관리하기(ConnectivityManager) (0) | 2024.04.09 |