앱 환경에서는 자동로그인이 거의 필수적이다.
최초에 로그인을 해놓고 다음날 또 접속하면, 로그인 없이 행동할 수 있다.
왜 앱에서는 자동로그인을 많이 지원하는 것일까?
컴퓨터같은 웹환경에서는 사용자가 필요할때 컴퓨터를 부팅시키고 크롬을 눌러서 해당사이트를 접속하고 로그인하기 때문에, 필요할 때마다 로그인하는 것에 대한 불편함이 느껴지지 않는다.
그 이전 일련의 과정이 많기 때문에 로그인 과정 하나가 더 있다고 불편함을 느끼지 않기 때문이다.
하지만, 휴대폰은 사용자가 켜고 앱 아이콘만 누르면 바로 실행되기에, 자주 앱을 킬 수 밖에 없고 그 과정이 편하다.
그래서 이 자주 존재하는 과정에 로그인이 매 번 붙어있다면, UX적으로 많이 불편하다.
이러한 자동 로그인은 어떻게 구현할 수 있을까?
클라이언트가 액세스 토큰과 리프레스 토큰을 가지고 있고, 이를 서버에 로그인 요청하는 것으로 동작한다.
자동 로그인이 되려면, 토큰의 만료가 자주 일어나지 않아야한다.
그렇다면 생각해 볼 것이 여러가지가 있다.
보통 많은 웹 페이지에서 액세스토큰은 분 단위(10분~30분), 리프레스토큰은 주 단위(1~2주)로 둔다고 한다.
처음 로그인을 진행하면 10~30분 동안은 로그인을 하지 않아도 되고, 해당 시간이 지났으면 리프레스토큰으로 토큰을 재발급해 연장하는 것이다.
이 시간 자체를 늘리면 자동로그인과 유사하게 동작할지언정, 보안은 취약하다. 그래서 잘 쓰이지 않는 방법이다.
토큰의 만료시간이 15분이라면, 여유롭게 14분 30초마다 재발급 요청을 하는 것이다.
사실 이 방법도, 흐르는 시간을 계속 감지해야하는 소요가 있기 때문에 하지 않는다.
API 요청을 하면, 사용자는 그 서비스를 잠시 후에도 더 사용할 의도가 있다고 판단한다.
그래서 매 API 요청마다 토큰을 갱신받는 것이다.
은행이나 자소서, 수강신청 사이트 같은 것을 사용할 때, 만료 시간 10:00에서 점점 1초씩 줄어드는 것을 볼 수 있다.
그런데 페이지를 이동하거나 API를 이용하면 이 시간이 갱신되는데 이러한 서비스에만 이 방식을 많이 사용하는 것 같다.
하지만 앱 서비스와는 거리가 멀다.
API 요청의 결과가 401 Unauthroization과 같은 토큰 만료에 대한 결과라면, 클라이언트가 토큰 갱신을 요청해서 갱신받는다.
방금 API 요청의 결과가 401이었기 때문에 서버에서는 명령을 수행하지 않았다.
갱신 받은 토큰으로 다시 API 요청을 해 정상적인 결과를 받는다.
이 방식이 클라이언트에서도 무리가 없고, 위의 3개 로직보다는 깔끔한 편이다.
리스트로 사용자의 분류와 유즈케이스를 깔끔하게 정리하면 다음과 같다.
조건: 만료되지 않은 토큰을 가진 사용자
1. 사용자가 API에 요청을 한다.
2. 정상적으로 응답이 온다.
조건: 만료된 액세스토큰, 만료되지 않은 리프레시토큰을 가진 사용자
1. 사용자가 API에 요청을 한다.
2. 토큰이 만료되었다.(401 Unauthorization)
3. 자동으로 리프레시토큰을 통해 토큰을 갱신한다.
4. 자동으로 다시 API에 요청을 한다.
하지만 개발에 대한 소요를 생각해 볼 필요가 있다.
하나의 API를 요청하는 함수가 있으면, 모든 API에 자동으로 401을 감지하고 리프레시토큰으로 갱신요청을 하는 이런 코드를 다 붙혀야할까?
너무 코드의 중복이 많아질 것이다.
그래서 등장한 것이 Interceptor이다.
Interceptor은 무언가를 가로채는 것을 의미하는데, 백엔드에서도 사용되는 의미이지만, 프론트에서는 보통 API Client에서 사용하는 용어이다.
필자는 Flutter와 React에서도 Interceptor라는 단어를 들어봤는데 둘다 토큰 자동재발급 로직 구현에서 사용했다.
그 코드를 Android에도 적용해보자.
class TokenInterceptor(private val context: Context) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// 액세스 토큰을 가져온다.
val accessToken = SharedPreferences(context).getStringPref("accessToken")
?: return chain.proceed(chain.request())
// 토큰을 헤더에 자동으로 집어넣어준다.
val tokenAddedRequest = chain.request().newBuilder()
.addHeader("authorization", "Bearer $accessToken")
.build()
val response = chain.proceed(tokenAddedRequest)
// 응답이 401 Unauthorization 이라면
if (response.code() == 401) {
// 리프레시 토큰을 가져와서, 갱신 요청을 한다.
var refreshedAccessToken: String
var refreshedRefreshToken: String
runBlocking {
val refreshTokenRequest = RefreshTokenRequest(accessToken)
val refreshTokenResponse =
RetrofitInstance.apiService.refreshToken(refreshTokenRequest)
// 갱신 결과를 로컬스토리지(여기선 SharedPreferences)에 저장한다.
refreshedAccessToken = refreshTokenResponse.accessToken
refreshedRefreshToken = refreshTokenResponse.refreshToken
SharedPreferences(context).setStringPref(
"accessToken",
refreshedAccessToken
)
SharedPreferences(context).setStringPref(
"refreshToken",
refreshedRefreshToken
)
}
// 다시 원래의 API에 갱신받은 토큰을 가지고 새로 요청을 한다.
val refreshedRequest = chain.request().newBuilder()
.addHeader("authorization", "Bearer $refreshedAccessToken")
.build()
return chain.proceed(refreshedRequest)
}
// 응답이 401 Unauthorization이 아니면, 그냥 원래 응답을 그대로 반환한다.
return response
}
}
주석을 살펴보면 잘 이해가 될 것이다.
이를 RetrofitInstance에 연동한다.
object RetrofitInstance {
lateinit var context: Context
private const val BASE_URL = "http://~~~~~.~~~~"
fun init(context: Context) {
this.context = context
}
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(
OkHttpClient.Builder()
.addInterceptor(TokenInterceptor(context)) // 이 부분에 Interceptor 추가
.build()
)
.build()
}
val apiService: ApiService by lazy {
retrofit.create(ApiService::class.java)
}
}
SharedPreferences를 로컬스토리지로 사용하는데, context가 필요해서 많이 애먹었다.
context는 @Composable(위젯 요소)에서만 사용이 가능한데, 이를 가져오려면 주입 받아야한다.
@Composable 외부에서 context를 사용하려면, Application() 객체를 상속한 프로젝트의 MainApplication context를 생성해서 주입하는 수 밖에 없다.
그래서 위의 코드를 보면 lateinit var context: Context
로 내부 변수를 가지고 있고, init
함수를 만들어 초기화하도록 구현했다.
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
RetrofitInstance.init(this)
}
}
class MyApplication
에서 init
함수를 호출함으로써, context를 넘겨줄 수 있다.
미처 발견하지 못 한 유즈케이스가 있었다.
조건: 만료된 액세스토큰, 만료된 리프레시토큰을 가진 사용자
1. 사용자가 API에 요청을 한다.
2. 토큰이 만료되었다.(401 Unauthorization)
3. 자동으로 리프레시토큰을 통해 토큰을 갱신한다.
4. 리프레시토큰도 만료되었기 때문에, 로그인 페이지로 이동시킨다.
이 경우 였다.. 리프레시 요청도 401이 뜨는 경우(리프레시토큰 너마저..) 401이 뜨기때문에, 이 경우는 로그인 페이지로 이동 시켜줘야 했다.
class TokenInterceptor(private val context: Context) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// 액세스 토큰을 가져온다.
val accessToken = SharedPreferences(context).getStringPref("accessToken")
// 액세스 토큰이 존재한다면
if (accessToken != null) {
// 토큰을 헤더에 자동으로 집어넣어준다.
val tokenAddedRequest = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $accessToken")
.build()
val response = chain.proceed(tokenAddedRequest)
// 토큰이 만료되었다면
if (response.code() == 401) {
// 리프레시토큰을 꺼낸다.
val refreshToken = SharedPreferences(context).getStringPref("refreshToken")
// 리프레시토큰이 있다면
if (refreshToken != null) {
// Retrofit을 사용하여 새로운 토큰들을 받는다.
val (refreshedAccessToken, refreshedRefreshToken) = refreshToken(refreshToken)
if (refreshedAccessToken != null && refreshedRefreshToken != null) {
// 새로운 액세스토큰과 리프레시토큰을 SharedPreferences에 저장한다.
SharedPreferences(context).setStringPref("accessToken", refreshedAccessToken)
SharedPreferences(context).setStringPref("refreshToken", refreshedRefreshToken)
// 새로운 액세스토큰을 사용하여 요청을 다시 보냅니다.
val newTokenAddedRequest = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $refreshedAccessToken")
.build()
return chain.proceed(newTokenAddedRequest)
} else {
// 새로운 토큰을 받을 수 없으면 로그인 페이지로 이동합니다.
moveToLoginPage()
}
} else {
// 리프레시토큰이 없으면 로그인 페이지로 이동한다.
moveToLoginPage()
}
}
} else {
// 액세스토큰이 없으면 로그인 페이지로 이동한다.
moveToLoginPage()
}
return response
}
private fun refreshToken(refreshToken: String): Pair<String?, String?> {
return try {
// Retrofit을 사용하여 새로운 토큰들을 받아온다.
val response = RetrofitInstance.apiService.refreshToken(refreshToken)
Pair(response.accessToken, response.refreshToken) // 새로 갱신된 액세스 토큰과 리프레시 토큰을 반환한다.
} catch (e: Exception) {
Pair(null, null) // 실패 시 null을 반환한다.
}
}
private fun moveToLoginPage() {
// 로그인 페이지로 이동하는 로직을 여기에 작성합니다.
}
}
사실 로그인 페이지 이동같은 Side Effect는 Interceptor 같은 외부 클래스에서 작성하면 안된다고 한다.
그런데 뭐 아직 클린 아키텍처 이런 것을 배우지 않았기에 그렇게까지 분리하는 방법은 잘 모르겠다.
로직의 이해가 됐다면 성공이다.