ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 안드로이드 만료 토큰 갱신 / 요청 api에 토큰 삽입 자동화 시스템 개발기 - 2 Token Repository 설계, 관심사의 분리 편 [Android Retrofit Auto Insert Token & Auto Refresh Token]
    안드로이드 2021. 11. 13. 16:47
    반응형

    이번 편에서는 토큰 관리를 어떻게 했는지 다뤄보려고 한다.

     

    사내 인증 시스템은 토큰 발급이 이중으로 되어있었다.

    1. 사내 인증 SDK를 통한 MobileToken 발급 (id, password 인풋)

    2. 인증 API에 MobileToken을 Header로 넣고 요청을 보내 응답받은 OnlineToken

    3. OnlineToken으로 모든 사내 API 요청 가능

     

    이렇게 OnlineToken이 필요한 모든 요청에서 토큰 요청과 만료됐을 때의 재발급 로직이 중복해서 여기저기 흩어져있다면 유지 보수하기 매우 어려운 코드가 될 것이라고 생각했다.

     

     

    관심사의 분리

    Repository Layer에서 API 요청에 필요한 검증된 OnlineToken을 반환하기만 한다면, 상위 Layer에서는 매우 편리하게 Token을 받아 활용할 수 있을 것이다. 

    그렇다. 외부에는 정상적인 Token을 주기만 하면 된다. 이 Token을 받아 쓰는 곳은 이 토큰의 원천이 무엇이었든 관심을 가질 필요가 없다. Repository 내부에서만 이 토큰을 발급받고, 저장해 두고, 재발급받는 일련의 과정에만 관심을 갖고 수행하면 된다.

     

    Repository 자체도 마찬가지로 관심사를 분리할 수 있다. 이 객체가 서버로부터 Token을 가져오는 것과 Local 저장소로부터 Token을 가져오는 것을 모두 알 필요가 없다. 각각 Local, Remote Source로 분리해서, Repository는 각 Source 별로 Token을 가져올 수 있는지 없는지 판단하면 된다.

     

    Local Source로부터 Token을 가져올 수 없다는 것을 알게 된다면, Remote Source로부터 Token을 요청하면 된다. Local Source가 어느 저장소(SQLite, Room, Text File 등등..)로부터 Token을 가져오는지는 Repository가 관심을 가질 필요가 없다. Remote Source도 마찬가지다.

     

    이렇게 서로 관심사를 분리해서 레이어를 나눌 수 있다. 토큰 자동화 시스템의 전체적인 구조는 아래와 같다.

     

    Online Token Repository 설계

    OnlineTokenRepository는 아래 기능을 수행한다.

    1. 메모리 캐시에 Online Token이 유효한지 확인한다. (유효하다면 바로 해당 토큰을 반환한다)
    2. 캐시가 null이면 LocalSource에서 Online Token을 가져온다. (유효하다면 토큰을 반환한다)
    3. 가져온 OnlineToken이 만료되었다면, RemoteSource에서 Online Token을 발급받는다.

    Token은 서버에 요청을 보낼 때 생각보다 자주 사용하는 녀석이기 때문에 Repository에 cache를 두었다.

    아래 사진처럼 Flow Chart를 그려가면서 필요한 기능들을 설계해보았다.

    그리고 아래는 구현 코드이다

    class RealOnlineTokenRepository(
        private val onlineTokenLocalSource: OnlineTokenSource,
        private val onlineTokenRemoteSource: OnlineTokenSource,
    ) : OnlineTokenRepository {
    
        private var cachedOnlineToken: OnlineToken? = null
    
        override suspend fun getOnlineToken(): OnlineToken {
            val cachedOnlineToken = this.cachedOnlineToken
            if (cachedOnlineToken != null) {
                if (cachedOnlineToken.isNotExpired()) return cachedOnlineToken
                return getRemoteOnlineToken().also { saveAndCacheOnlineToken(it) }
            }
            val localOnlineToken = onlineTokenLocalSource.getOnlineToken()
            if (localOnlineToken != null && localOnlineToken.isNotExpired()) {
                return localOnlineToken.also { this.cachedOnlineToken = it }
            }
            return refreshOnlineToken()
        }
    
    	// ...생략
    }
    사내에서는 의존성 주입으로 Koin을 사용하고 있다.

    Repository 자체에는 SharedPreference든, Retorifit Service에 해당하는 로직이 전혀 없다. 그저 Token의 원천에게 토큰을 가져올 뿐이다. 그리고 그 원천이 Token을 뱉어내지 못한다면, 다른 원천으로부터 Token을 가져올 뿐이다.

     

    이런 관심사의 분리로 객체가 어떤 일을 하는지 코드를 명확하고 간단하게 작성할 수 있다.

     


     

    Local Data Source

    Token이 필요할 때마다 인증 서버에 발급 요청을 보낸다면, 리소스 낭비이다. 만료되지 않은 토큰을 저장해 두었다가 재사용하게 된다면 불필요한 트래픽을 줄일 수 있을 것이다.

     

    Data Source로 사용하고 있는 interface는 단순히 OnlineToken을 저장하고, 가져오는 것만 수행한다.

    interface OnlineTokenSource {
    
        suspend fun getOnlineToken(): OnlineToken?
    
        suspend fun saveOnlineToken(onlineToken: OnlineToken)
    }

     

    토큰의 로컬 저장은 SharedPreference로 간단하게 구현했다.

    class OnlineTokenLocalSource(
        private val sharedPreferences: SharedPreferences,
    ) : OnlineTokenSource {
    
        override suspend fun getOnlineToken(): OnlineToken? {
            return OnlineToken(
                token = sharedPreferences.getString(...) ?: return null,
                refreshToken = sharedPreferences.getString(...) ?: return null,
                expireDate = sharedPreferences.getDate(...) ?: return null,
            )
        }
    
        override suspend fun saveOnlineToken(onlineToken: OnlineToken) {
            sharedPreferences.edit {
            ...
                putDate(..., onlineToken.expireDate)
            }
        }
    }

    putDate()와 getDate() 함수는 SharedPreference를 활용해서 필자가 구현해둔 확장 함수이다.

     

    DataSource Layer에서는 데이터를 가지고 오는 일 하나에만 관심을 갖고, Local에서 어떻게 데이터를 저장하고 가져오는지 위주로 구현했다.

     


    Remote Data source

    Repository에서 Cache와 Local Token이 모두 만료가 되었다는 것을 확인했다면, 토큰을 갱신하거나, 새로 발급받아야 한다.

     

    RemoteSource 자체도 굉장히 간단하게 구현되어있다.

    class OnlineTokenRemoteSource(
        private val membershipService: membershipService,
    ) : OnlineTokenSource {
    
        override suspend fun getOnlineToken(): OnlineToken {
            return membershipService.getOnlineToken()
                .getSuccessfulResponse()
                .toOnlineToken()
        }
    
        override suspend fun saveOnlineToken(onlineToken: OnlineToken) {
            throw UnsupportedOperationException("remote saveOnlineToken cannot called")
        }
    }

    Remote Source에서는 Token을 저장하는 기능은 사용하지 않으므로 호출하면 Exception을 던지게 만들었다. 

     

    getSuccessfulResponse()는 Response<T>의 확장 함수이다. 응답이 서버로부터 "정말로" 성공인 경우를 걸러서 ResponseBody를 넘겨주는 형식이다. 

    inline fun <reified T : MembershipResponse> Response<T>.getSuccessfulResponse(): T {
        val apiResponse = this.body() as? MembershipResponse ?: throw HttpException(this)
    
        if (this.isFail() || apiResponse.code != API_SUCCESS_CODE) {
            throw ------Exception(apiResponse.code, apiResponse.message)
        }
        return this.body()
            ?: throw IllegalStateException("api successful, but body is empty")
    }
    문제가 될 수도 있으니, Exception 명은 지웠습니다! (------Exception)

    사내 api 응답 코드는 무조건 200으로 온다.

    필자는 처음에는 이해를 하지 못했지만, 이곳에서 오는 장점도 분명히 있다.

    (이것 때문에 사실 통신 로직을 몇 번이고 갈아엎었다... 😇😇)

     

    RestAPI의 응답 코드와 message로는 상세한 에러 핸들링에 한계가 있다.

    예를 들면, 특정 정보를 id 값으로 조회할 때, 해당 id에 대한 정보가 없을 수도 있지만, 요청 보낸 id의 형식이 잘못되어서 응답을 주지 않는 경우도 있을 것이다. 이런 것을 message로 분기 처리를 할 수도 없는 노릇이고.. 그래서 따로 Error 코드를 정의해서 보내주면 더욱 상세한 에러 처리가 가능하다.

     

    아무튼, 사내 api는 완전히 잘못된 방법으로 요청을 보내거나, 서버 내부 에러가 아닌 이상 반드시 status code가 200이다. 그래서 성공일 때의 코드일 때만 body를 추출하고, 그렇지 않으면 Exception을 던지는 형태의 확장 함수를 구현했다.

     

     

    그래도 너무 간단한 것 아닌가요?

    OnlineToken을 발급받기 위해서는 사내 SDK에서 발급받은 MobileToken이 필요하다. 이 MobileToken을 넣어서 사내 인증팀의 API로 OnlineToken을 발급받는다.

    이런 과정들에는 관심을 가질 필요가 없기 때문에 간단한 것이다.

    Service의 Interceptor에 더욱 상세한 로직들이 들어있다. (이 Service는 우리 팀이 아닌 인증 팀의 API Service이다!)

    class MembershipAuthInterceptor(
        private val mobileAuthTokenRepository: MobileAuthTokenRepository,
    ) : Interceptor {
    
        override fun intercept(chain: Interceptor.Chain): Response {
            val mobileAuthToken = mobileAuthTokenRepository.getToken()
                ?: return chain.proceed(chain.request())
            val tokenAddedRequest = chain.request().putTokenHeader(mobileAuthToken)
            val response = chain.proceed(tokenAddedRequest)
            if (response.json[RETURN_CODE] == CODE_INVALID_TOKEN) {
                val refreshedToken = getRefreshedToken() ?: return response
                val refreshedRequest = chain.request().putTokenHeader(refreshedToken)
                return chain.proceed(refreshedRequest)
            }
            return response
        }
    
        private fun getRefreshedToken(): MobileAuthToken? {
            return runBlocking { mobileAuthTokenRepository.refreshToken() }
        }
    
        private fun Request.putTokenHeader(mobileToken: MobileAuthToken): Request {
            return this.newBuilder()
                .addHeader(AUTHORIZATION, mobileToken.token)
                .build()
        }
        // 생략...
    }
    사실 다음 포스트에 언급할 Token을 자동으로 집어넣는 로직이 여기에도 있다 😅😅

    언급해왔듯이, 사내 인증 시스템은 이중으로 되어있다. 

    MobileAuthTokenRepository는 사내 SDK로부터 발급받는 토큰 관리 Repository이다. Online Token Repository와 구조가 매우 유사하다.

     

    인터셉터의 로직은 어렵지 않다.

    1. SDK로부터 MobileToken을 발급받는다. (발급받지 못한다면, 그대로 통신을 진행해서 토큰 만료 응답을 받는다.)
    2. MobileToken으로 Online Token을 발급받는다
    3. 위 응답에서 토큰 만료 응답이 오면, MobileToken을 refresh 한다.
    4. refresh 한 Token을 다시 담아서 Online Token을 발급받는다.

    이곳에서 OnlineToken을 발급받을 때 MobileToken을 가지고 자동화를 수행했다.

    위 2, 3, 4에 대한 로직은 RemoteSource Layer에서는 관심을 가지고 있지 않다. 그저 Token을 줄 뿐, Refresh를 했는지, 새로 발급받았는지에 대한 것을 알 필요가 없다.

     


     

    개선할 수 있는 점

    위 설계에 대해 정말 많은 생각을 했지만, 글을 쓰면서도 몇 가지 아쉬웠던 부분들이 존재했다.

    그래서 생각났던 개선안들을 써본다.

     

    1. OnlineTokenRepository 에서 cache 를 멤버 변수로 가지고 있다.

    이 부분은 차라리 DataSource Interface를 상속받아 Cache Source를 따로 만들었다면 어땠을까 하는 생각이 들었다. Cache관리 자체도 Repository로부터 관심을 분리할 수도 있었을 것이라 생각이 들었다.

     

    2. 토큰 만료 처리

    지금은 Repository에서 Token이 만료되었는지 확인한다. 이 관심 자체도 DataSource 쪽에 두었으면 더 좋았지 않았을까?라는 생각이 든다.

    그러니까, DataSource가 Token을 반환할 때, 거기서 토큰이 만료되었는지 검사를 하고 만료되었다면 null을 반환하든 그런 식으로도 할 수 있겠다는 생각을 했다.

     

    하지만, 각 DataSource마다 모두 토큰 만료를 검사하는 로직이 중복적으로 들어간다는 단점이 있다. 이 부분은 어디에 어디까지 책임을 지녀야 하는지 팀의 의견대로 움직일 것 같다는 생각이 든다.

     

    3. 토큰 반환

    토큰을 가지고 올 수 없는 경우에 null을 반환하게 만들었다.

    단순히 null을 반환한다면, 뭔가 문제가 생겼겠구나 라고 생각할 수는 있겠지만, 자세한 사정은 알 수 없다는 단점이 있다. 그래서 Result<Token> 형태나, sealed class를 반환했다면 어땠을까 하는 생각이 든다.

    그렇다면 좀 더 다양한 상황에 대한 핸들링을 취할 수 있을 것이다.

     

    하지만 이 경우에도 단순하게만 처리하면 되는 로직의 경우에 로직이 매우 많이 늘어나고, sealed class나 다른 파일들이 많아져, 자칫 관리가 어려워질 수 있다는 단점이 있다.

    이것도 팀원들이 어떻게 생각하느냐에 따라 가져 가는 것이 좋다고 생각했다.

    개인적으로는 null 반환을 피하려고 노력할 것 같다.

     


    마무리

    토큰 발급/재발급 자동화와 각 Layer별로 그것들의 관심을 어떻게 분리했는지 개인적인 생각을 풀어 적었다.

    생각만 하던 토큰 재발급 자동화를 직접 만들어 볼 수 있는 기회가 있어서 매우 좋았다.

    역시나 생각만 하던 것과 직접 구현하기 위해서 본격적으로 설계하는 것은 차원이 달랐다.

     

    항상 모든 케이스를 고려하려는 성격 때문에, 이런 관리 자동화 하나 구현하기 위해서 정말 많은 케이스들을 다루어야 했다. 지금도 분명 여러 케이스들에서 구멍이 뻥뻥 뚫려있을 수도 있다.

    아는 만큼 보인다고... 정말 얼마나 더 공부를 해야 할지 까마득하다는 생각도 들었다.

     

    다음 포스팅도 열심히 적고 있으니 올해 안으로는 발행할 수 있기를!!

     


     

    이 글이 도움이 되셨나요?

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

     

    Buy Me A Coffee

     

    반응형

    댓글

Designed by Tistory.