Android/프로젝트

Api 요청 시 Access Token 을 갱신해야 할 때 Refresh Token 으로 토큰 갱신 후 같은 api 재요청

sh1mj1 2023. 9. 25. 15:36

이전 글 [안드로이드 레트로핏 api 호출하는 인터페이스에서 @Header 중복을 제거하기]

 

안드로이드 레트로핏 api 호출하는 인터페이스에서 @Header 중복을 제거하기

지난 4개월 전쯤 친구를 통해서 여러 다른 분들과 플레이스토어에 바바-BABA 라는 앱을 출시했습니다. 작성했던 코드들을 천천히 보면서 리팩토링 및 버그 픽스를 하며 공부를 더 해보려고 합니

sh1mj1-log.tistory.com

 

에서 리팩토링/버그 픽스를 하며 작성했던 글의 연장선입니다.

 

 

추가로 이 글도 보면 도움이 되겠네요

[그래서 jwt, Access, Refresh Token 이 뭔데?]

 

그래서 jwt, Access, Refresh Token 이 뭔데?

이전 글 안드로이드 레트로핏 api 호출하는 인터페이스에서 @Header 중복을 제거하기 에서 Access token 을 @Header 에 넣는 부분에 대해서 다뤘습니다. 그렇다면 Access Token 이 대체 무엇일까요? Access Token

sh1mj1-log.tistory.com

 

이번에는 토큰 갱신 관련 문제에 대해 버그 픽스를 하고 리팩토링하도록 하겠습니다.

먼저 현재 문제 상황부터 알아봅시다.

 

문제 상황

현재 앱에서는 서버로 Api 를 호출했는데 Access Token 이 만료되어서 Refresh Token 으로 토큰 갱신을 하고 나서 생기는 문제입니다.

 

 

access 토큰이 만료되었을 때 Refresh 토큰으로 토큰 갱신하고 나서 같은 api 를 재요청하지 않는 모습.

 

만약 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 의 리턴 타입은 RetrofitResponse<RequestType> 으로 제네릭을 사용한 리턴 타입입니다. RequestType 은 api 의 응답의 Body 에 오는 데이터의 타입입니다. 실제로 함수를 사용하는 곳에서 다양하게 타입을 지정할 수 있도록 되어 있습니다.

 

mapping 은 해당 api 를 호출했을 때 RequestType 을 리턴받고 있습니다.

 

그리고 getSafe 함수의 구현 부분, 즉 함수의 바디 부분을 보면,

코틀린 패키지의 Result 가 정상적인 결과를 받아왔을 때(onSuccess)와 비정상적인 결과를 받았을 때(onFailure) 로 분기합니다.

 

그리고 ResponseisSuccessful 일 때와 그렇지 않을 때로 분기합니다.

여기서 ResultonFailure 는 예외를 받았을 때처럼 함수가 실패했을 때입니다.

그리고 ResponseisSuccessful 인 경우는 서버로부터 Result 를 받았는데 그 ResponseHttp 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 이 들어갈 필요가 없습니다. 서버 측에서 헤더에 추가적인 아무것도 요구하지 않습니다.

 

그러므로 AuthClientAuthorizationInterceptor 의 의존성은 불필요합니다.

    @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 의 중복 코드 메서드 분리

위에서 provideBabaRetrofitRefreshTokenRetrofit 은 완전히 함수의 바디가 같습니다.

 

그러므로 동일한 하나의 함수로 분리할 수 있습니다.

 

@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()

 

결론

최종적으로 만들어지는 의존성 구조입니다.

크게 변경된 것은 없습니다.

 

  • 토큰을 갱신할 때 갱신 후에 같은 요청을 다시 보내도록 함.
  • 불필요한 의존관계를 찾아내어 제거함.
  • 불필요한 분기를 찾아내어 제거함.
  • 중복되는 코드를 메서드로 분리해서 사용함.

 

버그 픽스를 포함해서 위 네 가지를 리팩토링했다고 보면 되겠네요.