ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 안드로이드 만료 토큰 갱신 / 요청 api에 토큰 삽입 자동화 시스템 개발기 - 3 Interceptor에서 Token 삽입하기 편 [Android Retrofit Auto Insert Token & Auto Refresh Token]
    안드로이드 2021. 11. 29. 00:07
    반응형

    이번 편은 Retrofit Interface에서 Token Parameter를 없앤 방법에 대해서 이야기할 것이다.

     

    먼저, 사내 인증 시스템에서는 토큰을 이중으로 발급 받아야하는 상황이라고 적었었다.

    라마인드 하자면, 아래 두 절차를 통해 최종적으로 Online Token을 발급 받아야 사내 API를 요청할 수 있게 된다.

    1. 사내 SDK를 통해 이메일/패스워드를 인풋으로 넣고 Mobile 인증 Token을 발급 받기.
    2. Mobile 인증 Token을 넣고 사내 인증 시스템 api를 호출해서 Online Token 발급받기.

     

    편한 설명을 위해서 아래부터는 1번을 Mobile Token, 2번을 Online Token이라고 부르겠다. Online Token을 발급받기 위해서는 Mobile Token이 필요하다!

     


     

    파라미터에 토큰 제거

    위 사진처럼 interface의 method 내에 Token Parameter를 없애는 방법은 바로 interceptor를 활용하는 것이다.

    Retrofit Interface로부터 만들어진 모든 method를 호출하게 되면, 아래 순서대로 로직이 진행된다.

        1. 서버 통신을 하기 직전에 Application Interceptors에게 해당 요청이 가로채 진다.

        2. 가로챈 요청을 가지고 무언가를 처리한 후에 원래대로 요청을 보낸다.

        3. 실제로 요청을 보낸 직후 Network Interceptors에게 해당 요청이 가로채 진다.

        4. 가로챈 요청을 가지고 무언가를 처리한 후에 원래대로 요청을 보낸다.

     

    거창하게 써놓긴 했지만, 제 갈길 잘 가던 요청을 중간에 납치해서 손에다가 뭔가를 쥐어주고 다시 돌려보내는 셈이다.

     


     

    기본 구현 방법

    기본적으로는 Interceptor Interface를 구현하는 클래스를 만들어주면 된다.

    class AddingMobileTokenInterceptor(
        private val mobileTokenRepository: MobileTokenRepository,
    ): Interceptor {
    
        override fun intercept(chain: Interceptor.Chain): Response {
            val mobileAuthToken = mobileAuthTokenRepository.getToken()
    
            val tokenAddedRequest = chain.request().newBuilder()
                .addHeader("authorization", mobileToken.token)
                .build()
            
            return chain.proceed(tokenAddedRequest)
        }
    }

    아아아아아주 간단하다. Interceptor를 상속받고, intercept 메서드를 구현해주면 된다. intercept의 chain에 가로채 지기 직전의 요청에 대한 정보가 모두 들어있다. 

     

    chain에서 해당 요청에 대한 정보를 끄집어내고, Header에 토큰을 집어넣은 request 객체를 새로 만들어준다.

    그리고 기존 Request정보는 사용하지 않고, 새로 만든 이 Request정보로 요청을 보낸다!

     

    그러면 chain.proceed(...) 의 반환 값으로 response가 나오게 되는데, 이것은 서버로부터 새로운 Request 정보로 요청을 보낸 뒤 받은 응답 값이다. 이 응답 값을 intercept 메서드에서 반환해주기만 하면 끝이다!

    하하 참 쉽죠? 😊

     

    만들어준 Interceptor는 Retrofit 객체를 만들 때 아래처럼 넣어주면 된다.

    val awesomeService = Retrofit.Builder()
        .baseUrl(AWESOME_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(
            OkHttpClient.Builder()
                .addInterceptor(
                    AddingMobileTokenInterceptor(mobileTokenRepository)
                )
                .build()
        )
        .build()
        .create(AwesomeService::class.java)

     

     

    왜 Request객체를 새로 만드는가?

    OkHttp의 Request 객체는 불변 객체이다. 한 번 Request 객체를 만들면 내부 변수나 상태를 외부에서는 변경할 수 없는 상태이다. 그렇기 때문에 우리가 원하는 Header를 추가해줄 수 없다.

     

    OkHttp에서 이 불변 객체를 변경시키고 싶어 하는 사람들을 위해서 newBuilder()라는 메서드를 만들어두었다. newBuilder()를 통해 해당 Request의 정보가 모두 복사가 되고, 마지막에 build를 하면 아예 새로운 Request 객체가 나오게 되는 것이다.

     

    만약 가변 객체라면.... 해당 객체를 참조할 수 있는 어딘가에서 내부 변경을 해버리면, 갑자기 잘 흐름대로 잘 동작하다가 예측할 수 없는 Exception이 발생할 수도 있을 것이다. 그렇기 때문에 불변 객체를 사용한다. 불변 객체를 사용하면 해당 객체를 믿고 사용할 수 있다! (이 내용만 가지고 한참을 떠들 수 있을 거 같아서 그만 줄인다 😅😅 )

     

     

    토큰 만료 응답 시 재요청

    하지만 필자는 여기서 만족하지 않았다.

    이 로직의 가장 큰 문제점은, 헤더에 넣고 보낸 토큰이 만료되었다는 응답이 올 수 있다는 것이다. 필자의 목표는 해당 응답이 왔다면, 자동으로 토큰을 갱신하고 직전 요청을 다시 보내서 마치 토큰이 만료되었던 적이 없게끔 만드는 것이다.

     

    로직을 설명하면 아래와 같다.

    1. MobileToken을 꺼내 Request Header에 넣고 요청을 보낸다.
    2. 위 Response에서 응답 json을 꺼내 서버 응답 코드가 토큰 만료 에러 코드인지 확인한다.
    3. 토큰 만료 에러가 아니면 응답을 그대로 반환하고, 맞다면 아래 로직을 수행한다.
    4. MobileTokenRepository로부터 갱신된 토큰(Refreshed Token)을 가져온다.
    5. chain의 Request 객체를 복사해 재발급한 토큰을 Header에 넣고 요청을 보낸다.
    6. 요청 성공!

     

    물론 사내 인증 SDK 문제가 생겨 Refresh Token을 발급할 수 없는 상황이 생기면 Token 만료 응답이 나올 수는 있다. 하지만 제대로 된 흐름으로 사용한다면 그런 에러는 발생할 일이 없기 때문에 이렇게 로직을 구성했다.

     

    아무튼, 이런 식으로 로직을 처리하면 Remote Source 같은 레이어에서는 토큰이 만료되어서 재요청을 보냈다는 사실 조차 모른 채로 response를 받게 될 것이다. 

     

    class AddingMobileTokenInterceptor(
        private val mobileTokenRepository: MobileTokenRepository,
    ) : Interceptor {
    
        override fun intercept(chain: Interceptor.Chain): Response {
            // 1. MobileToken을 꺼내 Request Header에 넣고 요청을 보낸다.
            val mobileToken = mobileTokenRepository.getToken()
            val tokenAddedRequest = chain.request().putTokenHeader(mobileToken)
            
            val response = chain.proceed(tokenAddedRequest)
            
            // 2. 위 Response에서 응답 json을 꺼내 서버 응답 코드가 토큰 만료 에러 코드인지 확인한다.
            if (response.isTokenInvalid()) { // 응답 토큰이 만료되었는지에 대한 메서드는 비공개합니다!
                
                // 4. MobileTokenRepository 로부터 갱신된 토큰(Refreshed Token)을 가져온다.
                val refreshedToken = mobileTokenRepository.getRefreshedToken()
                
                // 5. chain의 Request 객체를 복사해 재발급한 토큰을 Header에 넣고 요청을 보낸다.
                val refreshedRequest = chain.request().putTokenHeader(refreshedToken)
                return chain.proceed(refreshedRequest)
            }
            
            // 3. 토큰 만료에러가 아니면 응답을 그대로 반환 한다.
            return response
        }
    
        private fun Request.putTokenHeader(mobileToken: MobileToken): Request {
            return this.newBuilder()
                .addHeader(AUTHORIZATION, mobileToken.token)
                .build()
        }
    
        companion object {
            private const val AUTHORIZATION = "authorization"
        }
    }

     

    왜 Authenticator를 쓰지 않았는지?

    이건 할 말이 많다... Authenticator는 서버로부터 407 응답이 올 때만 호출이 되는 특별한 객체이다.

    모든 api server가 Restful 규약을 완벽하게 지키면 좋겠지만, restful 규약을 완벽하게 지키는 곳은 거의 없는 것 같다. 

    그렇기 때문에 이런 규약을 지켜야만 사용할 수 있는 Authenticator는 사용할 수 없었다.

     

    이전 편에서 언급했듯이, 사내 서버는 모든 응답을 200, 즉 성공으로 응답한다. 잘못 요청을 했을 때도, 특히 토큰이 만료되었다는 응답마저도 200으로 응답이 온다. 그렇기 때문에 Authenticator은 사용이 불가능했다. 그래서 Interceptor 내에서 모든 로직을 처리할 수밖에는 없었다.

     

    시도해본 꼼수

    Authenticator는 오로지 토큰 만료 에러를 처리할 수 있게 해주는 기능이다. 아래의 장점이 있어서 꼭 써보고 싶었다.

    1. 역할 분리로 클래스를 나눌 수 있다
    2. 응답 코드를 일일이 확인할 필요가 없다. (unauthorized 응답에만 실행되기 때문에)
    3. retry 전략이 디폴트로 내장되어있다.

    그러면, Interceptor 내에서 response를 반환할 때 응답 코드르 407로 바꿔버리면 Authenticator로 넘어가서 처리가 되지 않을까? 라는 생각에 시도해봤다.

     

    결론은 실패다. 안된다. 그 이유가 궁금해서 코드를 까 봤다.

     

    위 코드는 RealCall.kt 파일의 AsyncCall 클래스 내의 코드이다. 인터페이스 Call의 구현체이고, enqueue를 호출할 때 AsyncCall 객체를 만들고 enqueue 파라미터로 집어넣는다.

     

    아무튼 코드를 잘 보면, interceptor 체인으로부터 response를 가져온 뒤, 바로 callback으로 넘겨주는 형식이다. 즉, 이 interceptor chain이 끝나면, 더 이상의 interceptor나 기타 등등이 호출되지 않는다는 의미이다.

    위 코드는 getResponseWithInterceptorChain() 메서드 코드이다.

    내가 달아둔 interceptor를 먼저 달아두고 (client.intercpetors), 그다음에 실제 통신을 위한 인터셉터들을 달아준다.

    이때 authenticator를 호출해주는 무언가는 Interceptor 마지막에 있는 ConnectInterceptor이다.

     

    그리고 잘 보면, response = chain.proceed(.... 이렇게 적혀있는데, 어디서 많이 보던 proceed이다.

    바로 interceptor에서 요청을 가로챈 뒤 볼일이 끝나면 호출해주는 바로 그 proceed이다. RealInterceptorChain 내부에서 다음 체인으로 엮여있는 Interceptor들을 순서대로 호출 한 뒤 모두 끝나면 response가 리턴되는 것이다.

     

    그렇기 때문에, interceptor에서 한 번 proceed를 호출하면 authenticator의 authenticate()를 호출해주는 ConnectInterceptor까지를 이미 지나왔기 때문에 authenticator가 실행되지 않는 것이다.

    그래서 "interceptor에서 proceed로 받아온 response의 http status code를 407로 바꾸면 authenticator가 호출되지 않을까?!" 하는 꼼수는 먹히지 않았다.. 😇

     

     


     

    또다시 문제에 봉착...

    위에서 자동으로 토큰을 넣는 Interceptor는 OnlineToken을 발급받기 위한 과정이었다.

    아래 그림의 주황색 상자이다.

    하지만 필자는 빨간색 상자인 팀의 API를 호출해야 한다. Online토큰을 넣는 API 또한 Interceptor를 만들어주어야 한다.

    여기서 봉착한 문제는....

    Online Token을 HTTP Header에 붙여야 하는 것이 아닌, Request Body에 넣어주어야 한다는 점이다.

    굉장히 절망적이었지만, 개발자는 문제를 해결하는 사람이지 안된다고 포기해버리는 사람이 아니라고 생각했다 🤣

     

    팀 API에서 요구되는 RequestBody의 종류는 아래와 같았다.

    1. MultipartBody
    2. FormBody
    3. JsonBody

    각 API별로 요구되는 Request Body의 종류가 모두 달랐다. 그렇기 때문에 Online Token을 기존 Request Body에 추가하는 방법이 모두 달랐다. (인터셉터에서 RequestBody를 꺼내 추가해야 하므로)

     

    그래서 필자는 다형성을 이용해서 설계를 해보았다.

     

    AppendableRequestBody

    어쨌든 셋 다 RequestBody인 것은 동일하다. 그리고 원하는 파라미터를 추가할 수 있게 만들어야 한다. 팀 내 API는 로깅을 위해서 Online Token 뿐만 아니라 디바이스의 OS 종류 등 다른 정보들도 RequestBody에 항상 넣어야 했다.

    interface AppendableRequestBody {
    
        fun add(key: String, value: String)
    
        fun add(params: Map<String, String>)
    
        fun get(): RequestBody
    
        companion object {
            fun of(requestBody: RequestBody): AppendableRequestBody {
                return when (requestBody) {
                    is FormBody -> AppendableFormBody(requestBody)
                    is MultipartBody -> AppendableMultipartBody(requestBody)
                    else -> AppendableJsonBody(requestBody)
                }
            }
        }
    }

    필요한 기능을 인터페이스로 만들고, 기존 RequestBody의 인스턴스를 비교해서 거기에 맞는 AppendableRequestBody의 구현체를 생성하는 메서드를 팩터리 메서드 패턴으로 만들어보았다.

     

    코드 내의 Appendable (Form/Mulipart/Json) Body는 AppendableRequestBody의 구현체이다. 내부 구현체는 공개하지 않겠다. Retrofit의 구현체를 잘 참고한다면 누구든 만들 수 있을 것이다.

     

     

    ParamsAppendableRequest

    위에서 RequestBody에 원하는 key value쌍을 추가할 수 있는 객체는 만들었다.

    하지만 GET 이외의 API만 RequestBody를 사용하고 GET은 QueryString으로 Token이나 기타 정보를 보낸다.

     

    그래서 아예 Request 객체에 원하는 Parameter들을 추가할 수 있는 객체를 만들었다.

    이 객체 내부에서는 GET, PUT, POST, DELETE 메서드를 구분해서 Request에 파라미터를 RequestBody에 넣을지, Query String에 넣을지 판단하며, get() 메서드로 Parameter들이 추가된 Request 객체를 받을 수 있다.

    (이 객체 안에서 위 AppendableRequestBody를 사용한다)

     

     

     

    완성된 Interceptor

    이제 팀 내 API를 호출하는 Service 메서드에서도 Online Token 또는 OS 정보를 넣는 파라미터를 제거했다.

    물론 Token이 만료되면 다시 갱신하고 재요청하는 로직도 포함되어있다.

    class ApiAuthInterceptor(
        private val onlineTokenRepository: OnlineTokenRepository
    ) : Interceptor {
    
        override fun intercept(chain: Interceptor.Chain): Response {
            val newRequest = chain.request().appendToken(getOnlineToken())
            val response = chain.proceed(newRequest)
            if (response.isTokenInvalid()) {
                val refreshedRequest = chain.request().appendToken(getRefreshedOnlineToken())
                return chain.proceed(refreshedRequest)
            }
            return response
        }
    
        private fun Request.appendToken(onlineToken: OnlineToken): Request {
            return ParamsAppendableRequest(this)
                .add(OS, OS_ANDROID)
                .add(ACCESS_TOKEN, onlineToken.token)
                .get()
        }
    
        private fun getOnlineToken(): OnlineToken {
            return runBlocking { onlineTokenRepository.getOnlineToken() }
        }
    
        private fun getRefreshedOnlineToken(): OnlineToken {
            return runBlocking { onlineTokenRepository.refreshOnlineToken() }
        }
    
    	//...
    }

    본래 클래스명은 저게 아니지만 혹시 몰라 임의로 변경했다.

     

    로직이 매우 간단하고 깔끔하게 짜여있다고 느낀다.

    첫 줄처럼 "기존 request 객체에 토큰을 추가하는구나"라고 느낄 수 있게끔 구성하는 것이 목표였다.

    그리고 보낸 요청에 대한 응답이 토큰 만료였다면 토큰을 갱신해서 다시 요청을 보낸다.

     

    appendToken() 확장 함수도 굉장히 단순하다. 그저 ParamsAppenableRequest에 원하는 정보를 추가해서 Request 객체를 만들어낼 뿐이다.

    이곳도 관심사의 분리가 적용이 된다. 어떻게 Parameter를 붙이는지는 Interceptor가 알 필요가 전혀 없다. 그저 붙일 뿐이다.

     

    이 Interceptor를 Service 인스턴스를 만들 때 넣어주기만 하면 완료이다.

     


     

    마무리

    Service 메서드에 Token이나 공통 정보에 대한 파라미터를 제거하는 방법에 대해 장황하게 적었다.

    필자가 이전부터 생각만 해왔고 꼭 만들어보고 싶었던 시스템이었는데, 이번 기회에 시행착오를 겪으며 설계부터 구현까지 직접 모두 개발해보았고 굉장히 좋은 경험이었다.

     

    Token을 다른 곳처럼 Header에 넣었으면 이렇게까지 오래 걸리지 않았을지 모르겠지만, 이런 문제를 해결해보려 노력했고 실제로 성공함으로써 굉장히 뿌듯하고 기억에 많이 남았다.

    특히 이 RequestBody에 대해 설계하면서 객체지향과 다형성, 관심사의 분리에 대해서 새삼스럽게 대단하다고 느꼈다. 이런 것들을 잘 활용한다면 코드가 매우 깔끔해지고, 군더더기 없어지는구나.. 정말 마법같이 코드가 변했다.

     

    그리고 이러한 구현체들을 만들기 위해서 Retrofit2 라이브러리의 일부분은 많이 들여다보았는데, 내부를 보면 볼수록 정말 아름다움 그 자체였다. 여전히 너무 어렵고 이해가 잘 되지 않는 로직들도 많았지만, 정말 정말 객체지향적인 로직들을 많이 구경할 수 있었다. 이런 거대한 시스템을 설계하고 구현해내다니... 나는 언제쯤 그런 설계를 해볼 수 있게 될까?

    개발을 하면 할수록 알아야 할 지식의 깊이에 항상 압도당하지만 천천히 정복해 나가려고 한다.

     

     


     

     

    이 글이 도움이 되셨나요?

    말리빈에게 커피를 한 잔 쥐어 주세요! ☕ 커피를 인풋으로 더 좋은 글을 아웃풋으로 쓰려 노력하겠습니다 😊

     

    Buy Me A Coffee

     

    반응형

    댓글

Designed by Tistory.