Api 요청 시 Access Token 을 갱신해야 할 때 Refresh Token 으로 토큰 갱신 후 같은 api 재요청
이전 글 [안드로이드 레트로핏 api 호출하는 인터페이스에서 @Header 중복을 제거하기]
에서 리팩토링/버그 픽스를 하며 작성했던 글의 연장선입니다.
추가로 이 글도 보면 도움이 되겠네요
[그래서 jwt, Access, Refresh Token 이 뭔데?]
이번에는 토큰 갱신 관련 문제에 대해 버그 픽스를 하고 리팩토링하도록 하겠습니다.
먼저 현재 문제 상황부터 알아봅시다.
문제 상황
현재 앱에서는 서버로 Api 를 호출했는데 Access Token 이 만료되어서 Refresh Token 으로 토큰 갱신을 하고 나서 생기는 문제입니다.
만약 Access Token 의 유효 기간이 지났을 때 서버가 유효 기간이 지난 토큰에 대해서 판단을 해주고 다시 토큰을 갱신해주면 이 갱신된 토큰으로 같은 api 를 재호출해야 합니다.
하지만 같은 api 를 재호출하는 부분이 구현이 안 되어 있는 것 같네요
코드 분석
코드 구조
현재 코드 구조는 이렇습니다.
이전 글에서는 필요한 부분만 표현했지만 이번에는 로깅 관련 인터셉터를 제외하고 모든 구조를 간단하게 그렸습니다.
이전에 봤던 코드 부분을 제외하고 이번에는 SafeApiHelper
, TokenAuthenticator
, AuthApi
, AuthRetrofit
, AuthClient
부분의 코드만 간단히 살펴봅시다.
SafeApiHelper
실제로는 인터페이스인 SafeApiHelper
와 그 구현체인 SafeHelperImpl
로 나뉘어져 있습니다.
내부 구현도 작성되어 있는 구현체의 코드를 봅시다.
class SafeApiHelperImpl @Inject constructor(
) : SafeApiHelper {
private val gson = Gson()
override suspend fun <ResultType, RequestType> getSafe(
remoteFetch: suspend () -> Response<RequestType>,
mapping: (RequestType) -> ResultType
): Result<ResultType> {
lateinit var result: Result<ResultType>
runCatching { remoteFetch() }
.onSuccess {
if (it.isSuccessful) {
val body = it.body()
result = if (body != null) {
Result.Success(mapping(body))
} else {
Result.Unexpected(NullBodyException("Body가 null임"))
}
} else {
var errorMessage = try {
gson.fromJson(
it.errorBody()?.string(),
ErrorResponse::class.java
)?.message
} catch (e: Exception) {
null
}
if (errorMessage.isNullOrBlank()) {
errorMessage = "Unknown Error"
}
result = Result.Failure(it.code(), errorMessage, UnKnownException())
}
}
.onFailure {
result = when (it) {
is IOException -> Result.NetworkError(it)
else -> Result.Unexpected(it)
}
}
return result
}
}
간단하게 코드를 분석해봅시다.
먼저 getSafe
라는 함수는 remoteFetch
, mapping
이라는 함수를 패러미터로 받는 고차 함수입니다.
remoteFetch
의 리턴 타입은 Retrofit
의 Response<RequestType>
으로 제네릭을 사용한 리턴 타입입니다. RequestType
은 api 의 응답의 Body 에 오는 데이터의 타입입니다. 실제로 함수를 사용하는 곳에서 다양하게 타입을 지정할 수 있도록 되어 있습니다.
mapping
은 해당 api 를 호출했을 때 RequestType
을 리턴받고 있습니다.
그리고 getSafe
함수의 구현 부분, 즉 함수의 바디 부분을 보면,
코틀린 패키지의 Result
가 정상적인 결과를 받아왔을 때(onSuccess
)와 비정상적인 결과를 받았을 때(onFailure
) 로 분기합니다.
그리고 Response
가 isSuccessful
일 때와 그렇지 않을 때로 분기합니다.
여기서 Result
의 onFailure
는 예외를 받았을 때처럼 함수가 실패했을 때입니다.
그리고 Response
가 isSuccessful
인 경우는 서버로부터 Result
를 받았는데 그 Response
의 Http status 코드가 200
~ 299
일 때를 말합니다.
그리고 위에서 생기는 각 분기에 따라서 미리 직접 정해둔 Result
에 맞는 타입으로 result 를 설정해 두고 그것을 리턴하고 있습니다.
여기에서 미리 정의한 Result
는 코틀린 패키지의 Result
(onSuccess
)를 호출할 때의 Result
가 아닙니다. 이 부분이 코드 상에서 햇갈리기 쉬우니 다시 네이밍을 해야 겠네요.
일단 이제부터는 BabaResult
라고 지칭하겠습니다.
TokenAuthenticator
@Singleton
@Provides
fun provideTokenAuthenticator(
@ApplicationContext context: Context,
authApi: AuthApi
) = Authenticator { _, response ->
val tag = "TokenAuthenticator"
val isPathRefresh =
response.request.url.toUrl().toString() == BuildConfig.BASE_URL + "auth/refresh"
if (response.code == 401 && !isPathRefresh) {
try {
val refreshToken = EncryptedPrefs.getString(PrefsKey.REFRESH_TOKEN_KEY)
val tokenRefreshRequest = TokenRefreshRequest(refreshToken)
// 토큰 갱신
val resp = authApi.tokenRefresh(tokenRefreshRequest).execute()
EncryptedPrefs.clearPrefs()
if (!resp.isSuccessful) {
IntroActivity.startActivity(context)
throw TokenRefreshFailedException("토큰 갱신 실패")
}
val token = resp.body() ?: throw TokenEmptyException("받아온 토큰 값이 null임")
EncryptedPrefs.putString(PrefsKey.ACCESS_TOKEN_KEY, token.accessToken)
EncryptedPrefs.putString(PrefsKey.REFRESH_TOKEN_KEY, token.refreshToken)
response.request.newBuilder().apply {
removeHeader("Authorization")
addHeader("Authorization", "Bearer ${token.accessToken}")
}.build()
} catch (e: Exception) {
Log.e(tag, e.message.toString(), e)
}
}
null
}
서버에 요청한 api 에 대해서 엑세스 토큰이 유효하지 않았다면 리프레시 토큰으로 토큰을 재발급하는 부분입니다.
isPathRefresh
라는 것은 리프레시 토큰으로 토큰 재발급을 무한으로 하게 되는 무한 루프를 방지하기 위해서 만들어둔 것 같네요.
AuthApi
@Singleton
@Provides
fun provideAuthApi(
@AuthRetrofit retrofit: Retrofit
): AuthApi = retrofit.create(AuthApi::class.java)
interface AuthApi {
@POST("auth/refresh")
fun tokenRefresh(@Body tokenRefreshRequest: TokenRefreshRequest): Call<TokenResponse>
...
}
TokenAuthenticator
에서 의존하는 AuthApi
입니다.
Hilt 를 이용해서 provideAuthApi
함수에서 어떤 타입의 retrofit 을 만들지 결정해주고 있습니다.
AuthRetrofit
@AuthRetrofit
@Singleton
@Provides
fun provideRefreshTokenRetrofit(
@NetworkModule.AuthClient
okHttpClient: OkHttpClient,
gsonConverterFactory: GsonConverterFactory
): Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(gsonConverterFactory)
.build()
여기서는 실제 레트로핏을 만들어 주고 있습니다.
OkHttpClient
클라이언트를 AuthClient
를 사용하도록 만들어주고 있습니다.
AuthClient
@AuthClient
@Singleton
@Provides
fun provideAuthClient(
authorizationInterceptor: Interceptor
): OkHttpClient {
val builder = OkHttpClient.Builder()
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
builder.apply {
addInterceptor(loggingInterceptor)
addInterceptor(authorizationInterceptor)
}
return builder.build()
}
OkHttpClient
를 만들어줍니다.
loggingInterceptor
를 사용하여 로깅 기능을 붙여주고 있고 authorizationInterceptor 가 엑세스 토큰 인증에 대해서 결정해 주고 있습니다.
AuthorizationInterceptor
@Singleton
@Provides
fun provideAuthorizationInterceptor() = Interceptor { chain ->
val request = chain.request().newBuilder()
val accessToken = EncryptedPrefs.getString(PrefsKey.ACCESS_TOKEN_KEY)
request.header("Authorization", "Bearer $accessToken")
Log.e("AuthorizationInterceptor", "request.build: ${request.build()}")
// 가로챈 요청을 다시 보내기
chain.proceed(request.build())
}
엑세스 토큰을 헤더에 넣어주는 인터셉터입니다.
먼저 Api 를 재호출하도록 만들자
먼저 Api 를 호출하는 책임을 가지고 있는 객체가 무엇인지에 대해서 생각해봅시다.
코드 구조를 보면 api 를 호출하고 있는 부분은 SafeApiHelper
입니다.
그러므로 아래처럼 수정하면 되겠네요
class SafeApiHelperImpl @Inject constructor(
) : SafeApiHelper {
private val gson = Gson()
override suspend fun <ResultType, RequestType> getSafe(
remoteFetch: suspend () -> Response<RequestType>,
mapping: (RequestType) -> ResultType
): ApiResult<ResultType> {
lateinit var apiResult: ApiResult<ResultType>
runCatching { remoteFetch() }
.onSuccess {
if (it.isSuccessful) {
...
} else {
...
apiResult = ApiResult.Failure(it.code(), errorMessage, UnKnownException())
}
}
.onFailure {
apiResult = when (it) {
...
}
}
// 추가한 부분 ------------
if (apiResult is ApiResult.Failure) {
if ((apiResult as ApiResult.Failure).code == 401) {
return getSafe(remoteFetch, mapping)
}
}
// 추가한 부분 ------------
return apiResult
}
}
혹은 apiResult 에 대해 유효하지 않은 토큰이라는 것을 명시하는 InvalidAccessTokenException 을 정의해주고 일부 메서드를 분리해주면 더 가독성 좋은 코드가 될 수도 있겠네요.
class SafeApiHelperImpl @Inject constructor(
) : SafeApiHelper {
private val gson = Gson()
override suspend fun <ResultType, RequestType> getSafe(
remoteFetch: suspend () -> Response<RequestType>,
mapping: (RequestType) -> ResultType
): ApiResult<ResultType> {
lateinit var apiResult: ApiResult<ResultType>
runCatching { remoteFetch() }
.onSuccess {
if (it.isSuccessful) {
val body = it.body()
apiResult = successApiResult<RequestType, ResultType>(body, mapping)
} else {
val errorMessage = setErrorMessage(it)
apiResult = errorCodeApiResult(it, errorMessage)
}
}
.onFailure {
apiResult = when (it) {
is IOException -> ApiResult.NetworkError(it)
else -> ApiResult.Unexpected(it)
}
}
if ((apiResult as? ApiResult.Failure)?.throwable is InvalidAccessTokenException) {
return getSafe(remoteFetch, mapping)
}
return apiResult
}
private fun <RequestType, ResultType> successApiResult(
body: RequestType?,
mapping: (RequestType) -> ResultType
) = if (body != null) {
ApiResult.Success(mapping(body))
} else {
ApiResult.Unexpected(NullBodyException("Body가 null임"))
}
private fun <RequestType> errorCodeApiResult(
it: Response<RequestType>,
errorMessage: String?
) = if (it.code() == 401) {
ApiResult.Failure(it.code(), errorMessage, InvalidAccessTokenException())
} else {
ApiResult.Failure(it.code(), errorMessage, UnKnownException())
}
private fun <RequestType> setErrorMessage(it: Response<RequestType>): String {
...
return errorMessage
}
}
이렇게 너무 긴 메서드(몬스터 메서드)였던 getSafe
를 작은 메서드로 분해해서
- 가독성을 높이고
- 추후 수정할 부분이 생겼을 때 코드 파악을 조금 더 쉽게 만들었습니다. (오브젝트 - 코드로 이해하는 객체지향 설계의 CHAPTER 03_역할, 책임, 협력 에서 메서드 응집도 에 대해 잠깐 다룬 내용입니다.)
이렇게 변경하면 정상적으로 작동하게 되었습니다.
로그를 분석해보면
불필요한 의존성 제거
문제는 해결되었지만 불필요한 의존성 및 분기가 존재하므로 이를 제거해야 할 것 같습니다.
AuthClient 와 AuthorizationInterceptor 의 의존성 제거
먼저 이 프로젝트에서는 AuthApi 에서 호출하는 모든 api 들은 헤더에 Access Token 이 들어갈 필요가 없습니다. 서버 측에서 헤더에 추가적인 아무것도 요구하지 않습니다.
그러므로 AuthClient
와 AuthorizationInterceptor
의 의존성은 불필요합니다.
@AuthClient
@Singleton
@Provides
fun provideAuthClient(
// authorizationInterceptor: Interceptor 제거 ---------
): OkHttpClient {
val builder = OkHttpClient.Builder()
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
builder.apply {
addInterceptor(loggingInterceptor)
// addInterceptor(authorizationInterceptor) 제거 ----------
}
return builder.build()
}
TokenAuthenticator 의 불필요한 분기 제거
객체 의존성 그림을 잘 보면 Refresh Token 으로 토큰을 갱신하는 역할을 하는 TokenAuthenticator
에 의존하는 객체는 오직 BabaClient
입니다.
즉, 의존성 구조 상 토큰을 갱신해야 해서 호출하는 AuthApi
에 의해서 다시 TokenAuthenticator
가 호출될 수가 없는 것이죠.
그런데 우리는 위에서 isPathRefresh
라는 변수를 사용해서 무한 루프를 방지하고자 했습니다. 이 부분도 필요없는 분기 코드가 됩니다.
@Singleton
@Provides
fun provideTokenAuthenticator(
@ApplicationContext context: Context,
authApi: AuthApi
) = Authenticator { _, response ->
val tag = "TokenAuthenticator"
// val isPathRefresh = 제거 -----------
// response.request.url.toUrl().toString() == BuildConfig.BASE_URL + "auth/refresh" 제거 -----
if (response.code == 401 && /*!isPathRefresh 제거 --- */ ) {
try {
val refreshToken = EncryptedPrefs.getString(PrefsKey.REFRESH_TOKEN_KEY)
val tokenRefreshRequest = TokenRefreshRequest(refreshToken)
// 토큰 갱신
val resp = authApi.tokenRefresh(tokenRefreshRequest).execute()
EncryptedPrefs.clearPrefs()
if (!resp.isSuccessful) {
IntroActivity.startActivity(context)
throw TokenRefreshFailedException("토큰 갱신 실패")
}
val token = resp.body() ?: throw TokenEmptyException("받아온 토큰 값이 null임")
EncryptedPrefs.putString(PrefsKey.ACCESS_TOKEN_KEY, token.accessToken)
EncryptedPrefs.putString(PrefsKey.REFRESH_TOKEN_KEY, token.refreshToken)
response.request.newBuilder().apply {
removeHeader("Authorization")
addHeader("Authorization", "Bearer ${token.accessToken}")
}.build()
} catch (e: Exception) {
Log.e(tag, e.message.toString(), e)
}
}
null
}
BabaRetrofit 과 RefreshTokenRetrofit 의 중복 코드 메서드 분리
위에서 provideBabaRetrofit
과 RefreshTokenRetrofit
은 완전히 함수의 바디가 같습니다.
그러므로 동일한 하나의 함수로 분리할 수 있습니다.
@BabaRetrofit
@Singleton
@Provides
fun provideBabaRetrofit(
@NetworkModule.BabaClient
okHttpClient: OkHttpClient,
gsonConverterFactory: GsonConverterFactory
): Retrofit = buildBaseRetrofit(okHttpClient, gsonConverterFactory)
@AuthRetrofit
@Singleton
@Provides
fun provideRefreshTokenRetrofit(
@NetworkModule.AuthClient
okHttpClient: OkHttpClient,
gsonConverterFactory: GsonConverterFactory
): Retrofit = buildBaseRetrofit(okHttpClient, gsonConverterFactory)
private fun buildBaseRetrofit(
okHttpClient: OkHttpClient,
gsonConverterFactory: GsonConverterFactory
) = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(okHttpClient)
.addConverterFactory(gsonConverterFactory)
.build()
결론
최종적으로 만들어지는 의존성 구조입니다.
크게 변경된 것은 없습니다.
- 토큰을 갱신할 때 갱신 후에 같은 요청을 다시 보내도록 함.
- 불필요한 의존관계를 찾아내어 제거함.
- 불필요한 분기를 찾아내어 제거함.
- 중복되는 코드를 메서드로 분리해서 사용함.
버그 픽스를 포함해서 위 네 가지를 리팩토링했다고 보면 되겠네요.