본문 바로가기

Android/Trouble Shoot

AccessToken 재발급과 예외 처리 (feat.Ktor)

Unsplash, Alicia Chong.

동기

예외는 전형적이지 않은 경우나 사건을 의미합니다. 프로그래밍을 하다보면 정말 수없이 겪게 되는 것이 예외 상황인데요. 오늘은 KtorJWT 사용에 있어서 발생할 수 있는 예외 상황에 대한 처리를 하였습니다. 그 과정 중 저는 어떤 문제를 만났고, 어떤 고민을 했는지, 또 그 고민을 어떻게 해결하였는지에 대해 포스팅하려 합니다.

주니어 개발자(지망생?)의 시선이므로, 잘못된 내용이 포함될 수 있어 해당 포스트에서 나올 기술적인 부분에 대해 참고하시는 것은 고려해보시면 좋을 것 같습니다.

 


요구 사항은 무엇이었나?

서버의 API 를 이용하기 위해 JWT 를 활용하는 상황입니다. 모든 API 통신에 대해 AccessToken 이 요구되고, AccessToken 이 만료되면 RefreshToken 을 통해 이를 재발급 받아야 하는 아주 전형적인 상황이었습니다.

 

어려울 것이 없을 거라고 생각했습니다. 로직 자체도 별 거 없을 거라 생각했고, 이와 같은 코드를 작성해 본 경험이 없는 것도 아니었으니까요. 다만, 문제는 코드를 작성하면서 시작되었습니다.

 

 

처음 해당 상황에 대해 생각했을 때, 그림처럼 생각했습니다. API 통신을 수행하고, AccessToken 이 만료되었는지 체크한 뒤,만약 AccessToken 이 만료되었다면 이를 재발급 받고, 이전의 요청을 재시도하는 정도로요. 이렇게 끝났다면 너무나 아름다웠을 것 같습니다. 

 

 

 

그림으로만 그려도 어지럽습니다. 경험이 부족한 탓인지, 예측 가능한 모든 상황에 대한 예외 처리가 필요할 것이라 생각하였습니다.(물론 지금도 그러합니다.) 원래는 이렇게까지 하지 않았던 것 같은데, 왜인지 모르겠지만 이번 프로젝트를 진행하면서는 '모든 예외 상황에 대한 처리를 다 수행해보자' 라는 생각이 끊임없이 들었던 것 같아요.

 


어떠한 과정을 겪었는가?

JWT 를 사용함에 있어 유의해야 하는 것은, 유저가 서비스를 이용하는 모든 순간에 AccessToken 이 만료될 가능성이 있다는 점입니다. 무슨 기능을 이용하던, 무슨 화면에 머무르고 있던 간에, AccessToken 은 만료될 수 있습니다. AccessToken 이 만료된다면, 디바이스에서 보유하고 있던 RefreshToken 으로 이를 재발급 받아 사용해야 하고요. 

 

이렇게 적으면 사실 간단한데, 처리해야 하는 상황이 상당히 많습니다. 예측 가능한 모든 상황을 고려하며 들었던 생각들을 소주제와 함께 하나 씩 기록하고자 합니다.

401이 한 개가 아니다

401 Unauthorized 는 특정 리소스를 이용하기 위한 자격 증명이 충족되지 않았을 경우 서버에서 내려주는 코드입니다. 즉, '너 이 리소스에 접근하면 안 되는 사람이야' 라고 말하는 것입니다.

 

그래서 처음에는, '401 받으면 그냥 리프레시 해주면 안 되나?' 라고 생각을 했습니다. 이후 API 문서를 보다보니 401을 내려주는 상황이 생각보다 많다는 것을 인지하게 됩니다. 그러므로, 다음과 같이 코드를 작성하면 AccessToken 의 재발급이 필요하지 않을 순간에도 AccessToken 의 재발급 작업을 실행하게 됩니다.

 

if (responseBody.statusCode == UNAUTHORIZED) {
    refreshAccessToken()
}

 

이렇게 해서 모든 문제가 해결된다면 너무나 아름답겠습니다. 그러나 아쉽게도, 401이 떨어지는 모든 순간에 refreshAccessToken() 메서드를 실행하게 되기 때문에, 이와 같이 작성하면 안 됩니다. 단적인 예로, AccessToken 을 갱신하는 과정에서도 RefreshToken 이 유효하지 않다면 401을 내려주기 때문에, 이러한 경우엔 의미없는 네트워크 요청만 쌓여가는 겁니다.

 

다행인 것은 저희가 별도의 StatusCode 를 운용하고 있다는 사실입니다. 저희의 경우, 3001 이었습니다. 즉, 다음과 같이 코드를 작성할 수 있습니다.

 

if (responseBody.statusCode == ACCESS_TOKEN_EXPIRED) { // 3001
    refreshAccessToken()
}

 

재시도를 보내야 한다

요청에 대한 재시도를 보내야 합니다. 유저가 CTA 버튼을 누를 때는, '이 버튼을 누르면 처리될 것이다' 는 생각을 갖고 버튼을 누르게 됩니다. 이후에 아무런 반응이 없다던지, 로딩 화면이 무한히 재생된다던지 하는 경우, 유저는 이탈을 고려합니다. 그러므로, AccessToken 의 갱신 이후에 신속히 지연된 요청을 다시 보내야 합니다.

 

이를 해결하기 위해 저는 재시도 로직을 추가하였습니다. 다만 이 과정에서 조금 시간이 걸렸는데요. 재시도를 위해 재귀 호출을 하려 하였으나, 기존의 request() 메서드는 inline-reified 키워드를 붙였기 때문에 재귀 호출이 불가능했습니다. 함수 내부에 추가적으로 함수를 구현할 수도 없고요. 그러므로, 이를 람다화 하기로 결정합니다.

 

val request: suspend () -> ApiResponse<T>? = {
    client.request {
        this.method = method
        if (isAccessTokenNeeded) {
            headers {
                append(
                    name = AUTHORIZATION,
                    value = "$BEARER $accessToken"
                )
            }
        }

        url {
            host = BASE_URL
            url()
        }

        content?.let {
            body = FormDataContent(Parameters.build { content() })
        }

        retry {
            maxRetries = RETRY_COUNT
            delayMillis { RETRY_DELAY }
            retryIf { _, response ->
                response.status.value == UNAUTHORIZED && isTokenRefreshing
            }
        }
    }.body()
}

 

처음부터 람다를 고려하였던 것은 아니었습니다. 다만, 재시도가 필요한 여러 상황을 고려하다보니 최적의 선택이 람다였습니다. request 라는 명칭으로 만든 람다는 이후 재시도가 필요한 여러 로직에서 사용됩니다.

 

지연된 요청을 다시 보내기 위해, 위 람다를 활용하여 다음과 같이 사용할 수 있습니다.

 

if (isRetryNeeded) {
    request()?.let { newResponse ->
        emit(newResponse)
    }
}

 

추후에 작성하겠지만, 지연된 요청을 다시 보내야하는 상황도 여러 가지가 있기에, 이 역시 람다로 만들게 되었습니다.

 

val requestDeferredCall: suspend () -> Unit = {
    request()?.let { newResponse ->
        emit(newResponse)
    } ?: throw Exception(UNCONNECTED_EXCEPTION)
}

여러 개의 요청이 한 번에 이루어질 수 있다

사실 이 정도면 충분할 것이라 생각했습니다만, 한 가지 문제가 더 있었습니다. 바로 여러 요청을 한 번에 보내는 경우입니다. 만약 요청이 단 하나만 발생했을 때 AccessToken 이 만료된 경우라면 큰 문제가 없습니다.

 

 

그냥 순서대로 요청을 지연시키고, AccessToken 을 재발급 받은 뒤 지연된 요청을 재시도하고 결과를 반환하면 되니까요. suspend 를 잘 이용하면 아주 쉽게 구현할 수 있을 겁니다. 

그러나 만약, 여러 요청이 아주 짧은 텀을 가지고 동시에 들어오게 된다면 어떨까요.

 

 

물론 UI 에는 아무런 문제가 없을 겁니다. 이미 UnidirectionalDataFlow 를 구현해두었기 때문에, 데이터의 실질적인 변경이 없는 이상은 UI 가 변경되지 않을 거니까요.

 

다만 문제는, AccessToken 의 재발급도 여러 번 이루어진다는 점입니다. 정말 운이 안 좋다면, 마지막 요청으로 인해 재발급된 AceessToken 이 최종 할당되지 않고 그 이전에 재발급된 AccessToken 이 최종 할당될 수도 있겠지요. 그러한 경우, 이후 요청이 필요할 때 또 AccessToken 을 재발급받아야 하는 최악의 상황이 벌어집니다.

 

이러한 상황을 회피하기 위해, 저는 별도의 isTokenRefreshing 변수를 하나 두었습니다. 현재 AccessToken 을 재할당하는 과정 중에 있는지를 마킹하기 위함입니다. 이를 다음과 같이 활용하고 있습니다.

 

if (responseBody.statusCode == ACCESS_TOKEN_EXPIRED && !isTokenRefreshing) {
    isTokenRefreshing = true
    val isAccessTokenRefreshed = refreshAccessToken()
    if (isAccessTokenRefreshed) {
        isTokenRefreshing = false
        requestDeferredCall()
    } else {
        // todo : RefreshToken 없을 시 예외 처리
    }
} else if (isTokenRefreshing) {
    requestDeferredCall()
} else {
    emit(responseBody)
}

 

Response 의 StatusCode 프로퍼티가 ACCESS_TOKEN_EXPIRED 이고, 현재 AccessToken 을 재발급 받고 있는 상황이 아닌 경우, 해당 변수를 true 로 할당하고, AccessToken 을 재발급받는 메서드를 실행합니다.

 

이후 조건문이 또 나오는데, 이는 AccessToken 의 재발급에 실패한 경우를 체크하기 위함입니다. 어쨌든, AccessToken 의 재발급에 성공하였다면, 다시 isTokenRefreshing 을 false 로 할당하고, 지연된 요청을 재시도합니다.

 

client.use { client ->
    ...
    retry {
        maxRetries = RETRY_COUNT
        delayMillis { RETRY_DELAY }
        retryIf { _, response ->
            response.state.value == UNAUTHORIZED && isTokenRefreshing
        }
    }
}

 

이 때, 비슷한 시간에 발생한 요청들은 isTokrenRefreshing 이 true 인 경우, 1초의 딜레이를 두고 5회 재시도합니다. 재시도 도중 isTokenRefreshing 이 false 가 되면, 해당 요청을 일반화한 모델로 반환하게 됩니다.

즉, 다음과 같이 동작합니다.

 

 

결국 Trade-Off

그럼에도 불구하고, 대응해야 할 부분이 너무나 많았습니다. AccessToken 재발급에 성공했지만 모종의 이유로 AccessTokennull 인 경우, RefershToken 이 없는 경우, RefreshToken 이 만료된 경우 등등... 모든 경우에 대해 처리하려고 하면 할 수록, 더 많은 예외 상황들이 예측되곤 했습니다. 코드의 인덴트가 깊어지는 건 덤이고요.

 

제 경험이 부족해서인지, 코드 가독성을 위해 애써 무시하게 되는 예외 상황도 존재했습니다. 결국엔 모든 것이 Trade-Off 라는 생각이 듭니다. 모든 경우에 대한 대응을 하게 된다면 코드의 가독성이 떨어지거나 개발 공수가 늘어나게 되고, 대응을 조금 줄인다고 하면 앱의 안정성이 떨어집니다.

 

그러므로, 상황의 대응에 대한 적절한 팀의 기준을 수립하고, 이를 기반으로 예외를 구조적으로 핸들링하는 것이 앱의 안정성과 공수 사이의 저울질을 쉽게하는 방법이라는 생각이 들었습니다.

 


후기

쉽지 않았습니다. 이렇게까지 예외에 대응하려고 시도했던 적은 없었던 것 같아요. '이정도면 그래도 괜찮겠다' 라던가, '설마 이렇게 하는 사람이 있겠어?' 라거나, '설명 적어놨는데 당연히 지켜주겠지' 라는 생각을 하면서 조금은 안일하게 생각했던 시간들도 분명히 있었고요.

 

이전의 저보다 조금 더 예외 상황에 신경을 쓰면서, 저는 나름 만족했던 것 같습니다. 물론 더 나은 방향이 있을 수도 있겠지만요.