쌈뽕하게 토큰 관리 하기

h2on ·2023년 9월 11일
4

Android

목록 보기
4/4
post-thumbnail

토큰 관리, 이 글 하나로 마스터 해보자 💪

해당 글은 안드로이드에서 토큰 관리(로그인, 자동 로그인, 토큰 갱신, 토큰 만료 처리) 하는 방법을 다룹니다. 자세한 코드보다는 관리 방법에 초점을 두고 있는 글이니 참고 바랍니다. 로그인 하나로 쩔쩔매던 시절부터 갈고 닦아온 내용이라 조금은 도움이 될지도..? 😏


토큰? 어디서 들어보긴 했는데

사용자가 자신의 아이덴티티를 확인하고 고유한 액세스 토큰을 받을 수 있는 프로토콜
-Google

간단히 말해 '서버에서 클라이언트 인증을 확인하는 방식 중 하나' 입니다. 인증은 매우 중요한 절차이며, 사용자의 보안에 있어서 필수적이라 할 수 있습니다. 대표적으로는 쿠키/세션/토큰 3가지의 방식으로 인증을 확인할 수 있습니다. 토큰을 배우기 앞서 각각의 특징을 조금만 알아보고 넘어가보겠습니다.

특정 웹사이트를 방문할 경우 그 사이트가 사용하는 서버를 통해 사용자의 브라우저에 저장되는 작은 정보 파일입니다. 가끔 로그인을 하지 않아도 장바구니에 담은 물건이 남아있거나, 언어 설정을 저장해도 그대로인 경우가 있습니다. 이는 쿠키 덕분에 가능한 일입니다.

이 친구의 단점은 보안이 쓰레기라는 점입니다. 클라이언트가 요청을 보낼 때마다 헤더에 쿠키를 담아 보내는데, 값을 그대로 보내기 때문에 보안에 취약합니다.

Session

쿠키를 기반으로 하는 세션은 이런 이슈 때문에 전화번호, 비밀번호 등의 민감한 정보를 서버에서 저장하고 관리합니다. (누가 요청을 보냈는지 알고 인증을 수행해야하기 때문에 Session ID를 기준으로 정보를 저장합니다.)

앱 개발자는 이 둘이 생소할 수도 있을 것 같습니다. 앱에는 쿠키와 세션이 없기 때문이죠. 그럼 앱에서는 인증을 어떻게 할까요?

Token

이때 토큰이 등장합니다. 서버에서 사용자의 인증 정보를 관리하여 성능의 문제를 일으킬 수 있는 세션과 달리, 토큰 기반 인증 시스템은 사용자가 요청 헤더에 토큰을 함께 보내 인증받은 사용자인지 확인하기 때문에 성능의 부하는 신경쓰지 않아도 됩니다.

토큰은 인증 요청이 많아질수록 네트워크 부하가 심해지며, 탈취당하면 대처가 어렵다는 단점이 있습니다. (만료 시간 설정해서 보완하긴 합니다)

JWT : JSON Web Token

우리가 주로 말하는 '토큰' 은 바로 JWT : JSON Web Token입니다. 인증에 필요한 정보들을 암호화 시킨 JSON 토큰을 의미합니다.

Header : JWT에서 사용할 타입과 해시 알고리즘의 종류를 담고 있습니다.
Payload : 사용자의 정보와 데이터를 담고 있습니다.
Signature : Header, Payload를 Base64 URL-safe Encode를 한 이후 Header에 명시된 해시 함수를 적용, 개인키로 서명한 전자 서명이 담겨있습니다.

지루한 내용은 이정도면 충분한 것 같고, 이제 안드로이드에서 토큰관리를 어떻게 하는지 알아보러 가보겠습니다. 🥱🥱🥱

토큰 관리의 시작, 로그인

토큰을 관리하려면 우선 토큰이 있어야 관리를 하던 말던 할 겁니다. 로그인 API를 연동해서 AccessTokenRefreshToken 을 받아오겠습니다.

fun login() {
	viewModelScope.launch {
    	runCatching {
        	LoginUseCase(id, password)
        }.onSuccess {
        	// API 호출 성공 시 (ex. 토큰 저장, 메인 페이지 이동 etc...)
        }.onFailure {
        	// API 호출 실패 시
        }
    }
}

이렇게 로그인 API를 호출하는 비즈니스 로직을 작성했습니다. 로그인 API 호출이 성공할 경우 Response 핸들링을 할 수 있습니다.

매번 로그인 하기는 조금 귀찮은데 🫥

대부분의 서비스를 보면 '자동 로그인', '로그인 상태 유지' 체크박스가 존재합니다. 덕분에 사용자가 서비스를 다시 돌아와도 로그인 상태가 유지되며 매번 인증을 해야하는 수고를 덜 수 있습니다. 그럼 안드로이드에서는 자동 로그인을 어떻게 구현할까요?

로컬 저장(SharedPreferences, Encryptedsharedpreferences, DataStore) 을 활용하여 사용자 고유의 ID 혹은 토큰을 저장하여 자동 로그인을 시도합니다.

DataStore에 토큰을 저장하기

// SharedPreferences, Encryptedsharedpreferences, DataStore 어느 것이든 상관 없습니다.
// 로컬에 인증 정보를 저장하는 것이 핵심입니다.

val Context.userDataStore: DataStore<Preferences> by preferencesDataStore(
	name = BuildConfig.APPLICATION_ID
)

suspend fun saveAccessToken(accessToken: String) {
	context.userDataStore.edit { preferences ->
    	preferences["AccessToken"] = accessToken
    }
}

suspend fun fetchAccessToken(): String {
	val flow = context.userDataStore.data
    	.catch { exception ->
        	when (exception) {
            	is IOException -> emit(emptyPreferences())
                else -> throw exception
            }
         }
         .map { preferences ->
         	preferences["AccessToken"]
         }
    return flow.firstOrNull().orEmpty()
}

// RefreshToken도 같은 방식으로 저장

앱 시작 시 자동 로그인 시도하기

// DataStore에서 저장된 데이터를 불러옵니다.
val accessToken = runBlocking { fetchAccessToken() }

if (accessToken.isNotEmpty()) {
	// 자동 로그인이 성공했으므로 메인페이지로 이동 등의 동작을 처리합니다.
} else {
	// 사용자의 로그인한 정보가 없기 때문에 로그인 페이지에 머뭅니다.
}

토큰 갱신이 빠지면 섭하지

AccessToken 이 만료되면 RefreshToken 을 통해 새로운 AccessToken 을 발급받게 됩니다. 근데 한 가지 의문이 듭니다.

토큰이 만료되면 갱신을 하는 건 알겠는데, 만료된 토큰으로 요청을 보냈을 때 토큰을 갱신하고 '이전의 요청을 어떻게 다시 보내지?' 🫨🫨🫨

의문에 대한 해답 👉 Authenticator

401이 발생했을 때 토큰을 갱신하고 직전의 요청을 다시 보내는 Authenticator(okhttp3)를 개발하고, Interceptor로 등록하여 핸들링하는 것이 핵심입니다.

class TokenAuthenticator @Inject constructor(
	@ApplicationContext private val context: Context,
    ...
) : Authenticator {
	
    override fun authenticate(route: Route?, response: Response): Request? {
    	val isPathRefresh =
        	response.request.url.toString() == BuildConfig.BASE_URL + "토큰 갱신 url"
        
        if (response.code == 401 && !isPathRefresh) {
        	val tokenRefreshSuccess == fetchUpdateToken()
            
            return if (tokenRefreshSuccess) {
            	val newToken = runBlocking { fetchAccessToken() }
                // UnAuthorized 예외가 발생한 요청을 복제하여 다시 요청합니다.
                response.request.newBuilder().apply {
                	removeHeader("Authorization")
                    addHeader("Authorization", "Bearer $newToken")
                }.build()
            } else {
            	// RefreshToken도 만료되어 로그인이 다시 필요한 상황입니다.
                // ex. 로그인 화면으로 이동
            	...
            	null
            }
        }
        return null
    }
}

private fun fetchUpdateToken(): Boolean {
	val request = runBlocking {
    	// 토큰 갱신 API 호출, 로컬 저장
    }
}

위와 같은 로직을 통해 토큰 갱신 뿐만 아니라, 다른 기기에서 로그인을 했을 경우 자동으로 로그아웃을 시키는 기능까지 갖추게 됐습니다. 이제 사용자가 서비스를 사용하다가 토큰이 만료되어도 아무일 없었다는 듯이 토큰을 갱신하고, 계정당 로그인 가능한 기기는 하나로 제한할 수 있게 됐습니다. 🥳🥳

마무리하며

처음 안드로이드 공부를 할 당시, 유저 인증이라는 부분이 항상 해야만 하는 부분이지만 막상 개발은 어딘가 답답하고 원하는대로 흘러가지 않는 기능 중 하나였습니다. (마치 닿지 않는 부위를 긁으려고 하는 기분) 이제는 시원시원하게 개발하지만 문득 누군가는 비슷한 경험을 하고 있을지도 모른다는 생각에 조금은 두서 없이 글을 쓴 것 같습니다.

'이게 완벽한 정답이다!' 는 아니지만 그래도 참고서 정도는 되지 않을까...? 😅

profile
돈 버는 백수가 꿈

4개의 댓글

comment-user-thumbnail
2023년 9월 13일

쌈뽕하네요

1개의 답글
comment-user-thumbnail
2023년 11월 10일

좀 치시네요

1개의 답글