문제 상황1
현재 Interceptor가 굉장히 살이 많이 쪄있다. 만료된 액세스 토큰을 리프레시 하는 기능을 모두 Interceptor안에 몰아두었기 때문인데 이를 역할과 책임을 적절히 분배할 것이다. 현재 프로젝트에는 AuthRepository가 이미 있기 때문에 액세스 토큰의 리프레시 로직을 AuthRepository가 가지고 있는 것이 적절하다고 생각한다.
그러기 위해서는 Interceptor가 AuthRepository를 들고 있어야 한다. 이 것이 적절할까? 에 대해서 많이 고민하긴 했는데, 역할과 책임을 적절히 분배한다 라는 관점에서는 이게 맞는 것 같기도 했고, 이렇게 구현한 레퍼런스가 아예 없는 것도 아니기에 생각을 밀고 나가보기로 결정했다. (팀원의 생각이 다르다면 받아들여야지 하는 생각도 있다.)
이에 대해서는 단순히 코드를 분리한 것이고, 이 내용이 중요한 것이 아니기 때문에 설명보다는 링크를 통해 확인하는 것이 좋겠다.
문제 상황2
현재 구조는 서버로 요청을 보내고 응답이 돌아왔을 때 인터셉터에서 다음의 작업을 진행한다.
- http 응답코드 401을 받으면 서버에게 액세스 토큰의 리프레시 요청을 한다.
- 액세스 토큰의 리프레시가 성공하면 새로운 액세스 토큰을 사용하여 원래의 요청을 보낸다.
- 돌아온 응답을 반환한다.
- (http 응답코드가 401이 아니라면 응답을 바로 반환한다.)
아무런 문제가 없어보인다. 나도 그랬다.
하지만 여러 개의 요청이 비동기로 진행된다면 그 때는 문제가 생긴다. 다음의 상황을 보자.
한 뷰를 구성하기 위해 서버로 3개의 요청을 보내야하는 상황이다.
만약 액세스토큰이 만료된 경우 3개의 요청을 한 번에 보내는 뷰로 들어가면 세 개의 요청이 비동기로 전송되어 서버에 세 번의 리프레시 요청을 하게되고, 결국 어느 요청도 성공하지 못하는 결과가 나온다.
액세스 토큰의 기한이 15분인 이유
현재 사용하는 토큰 방식은 JWT, 즉 토큰 방식은 서버에서 유저의 상태를 저장하지 않는 무상태성의 로그인 방식이다. 그렇다보니, 액세스 토큰의 유효기간에 따라 서버에 무한정으로 접근할 수 있는 수단이 열려있는 상태이기 때문에 액세스 토큰의 유효기간을 매우 짧게하고 리프레시 토큰을 함께 발행해 해당 리프레시 토큰에 대한 정보를 서버에서 저장하고 해당 리프레시 토큰을 가진 유저에 대해서는 액세스 토큰을 재발급하기 위함이다.
1️⃣ 시도 방법 1 (실패)
리프레시 요청 함수에 @Synchronized 를 붙인다.
class AuthInterceptor(
private val authRepository: AuthRepository,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = authRepository.getAccessToken() ?: return chain.proceed(chain.request())
val headerAddedRequest = chain.request().newBuilder().addHeader(AUTH_KEY, accessToken).build()
val response: Response = chain.proceed(headerAddedRequest)
if (response.code == 401) {
response.closeQuietly()
refresh()
return chain.proceed(
chain.request().newBuilder().addHeader(AUTH_KEY, authRepository.getAccessToken()!!).build(),
)
}
return response
}
@Synchronized
private fun refresh() {
runBlocking {
authRepository.refreshAccessToken()
}
}
companion object {
private const val AUTH_KEY = "Authorization"
}
}
@Synchronized
키워드를 사용하면 해당 메서드나 블록이 단일 스레드에서만 접근되도록 보장된다. 여러 스레드가 이 메서드나 블록에 동시에 접근하려고 하면 하나의 스레드만 접근이 가능하고 나머지 스레드는 대기하도록 만든다.
@Synchronized
는 스레드 레벨에서 동작한다. 하지만 코루틴은 스레드에 종속적이지 않기 때문에 직접적으로 해결할 수 있는 방법이 아니다.
2️⃣ 시도 방법2 (성공)
ViewModel에서 여러 요청을 하나의 viewModelScope에서 진행한다.
Before
fun fetchRank() {
viewModelScope.launch {
runCatching {
Log.d("krrong", "CoroutineName : $coroutineContext")
Log.d("krrong", Thread.currentThread().name)
rankRepository.getMyRank()
}.onSuccess { rank ->
_rank.value = rank
}.onFailure {
setThrowable(it)
}
}
}
fun fetchStatistics() {
viewModelScope.launch {
runCatching {
Log.d("krrong", "CoroutineName : $coroutineContext")
Log.d("krrong", Thread.currentThread().name)
statisticsRepository.getMyStatistics()
}.onSuccess { statistics ->
_statistics.value = statistics
}.onFailure {
setThrowable(it)
}
}
}
fun fetchPlaces() {
viewModelScope.launch {
runCatching {
Log.d("krrong", "CoroutineName : $coroutineContext")
Log.d("krrong", Thread.currentThread().name)
placeRepository.fetchMyPlaces(SortType.TIME.name, OrderType.DESCENDING.name)
}.onSuccess { places ->
_places.value = places
}.onFailure {
setThrowable(it)
}
}
}
코드를 보면 알겠지만 Rank, Statistics, Places를 가져오는 네트워크 요청을 모두 각각의 CoroutineScope
에서 진행한다.
각각의 메서드가 어떤 스레드에서 동작되는지 확인하고 싶어서 viewModelScope
내에서 CoroutineContext
와 currentThread의 이름을 로그로 찍어봤다.
코루틴의 주소는 다르지만, 디스패처가 Dispatchers.Main.immediate
인 것을 볼 수 있고 이 때문에 Thread의 이름이 Main인 것을 확인할 수 있다.
After
fun fetchData() {
viewModelScope.launch {
runCatching {
val statistics = statisticsRepository.getMyStatistics()
val rank = async { rankRepository.getMyRank() }
val places = async { placeRepository.fetchMyPlaces(SortType.TIME.name, OrderType.DESCENDING.name) }
_statistics.value = statistics
_rank.value = rank.await()
_places.value = places.await()
}.onFailure {
setThrowable(it)
}
}
}
📝 후기1
해결하기는 했다. 하지만 분명 더 좋은 방법이 있을 것 같다. 그래서 코루틴을 좀 더 알아보고 더 좋은 해결방법이 있는지 고민해보려고 한다. 코틀린 코루틴 책을 천천히 정독했는데 겨우 이 정도의 해결책이라니. 몇 번은 더 읽어봐야겠다. ㅠ.ㅠ
레아에게 여쭤봤을 때 답변이 다음과 같이 왔기 때문에 분명히 다른 방법도 있을거라 생각한다.
그리고 심금을 울리는 마지막 멘트…! 윽,,,
🛜 Interceptor
그러나 이와는 약간 다른 한 가지 문제가 있다.
리프레시 토큰마저 만료가 되면 리프레시 요청도 실패할 것이고 UX적으로는 다시 로그인 화면으로 이동하도록 하는 것이 알맞다고 생각하는데 뭔가 이상하다.
class AuthInterceptor(
private val authRepository: AuthRepository,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = authRepository.getAccessToken() ?: return chain.proceed(chain.request())
val headerAddedRequest = chain.request().newBuilder().addHeader(AUTH_KEY, accessToken).build()
val response: Response = chain.proceed(headerAddedRequest)
if (response.code == 401) {
response.closeQuietly()
runBlocking {
authRepository.refreshAccessToken()
}
return chain.proceed(
chain.request().newBuilder().addHeader(AUTH_KEY, authRepository.getAccessToken()!!).build(),
)
}
return response
}
...
}
위에 보이는 것처럼 현재 Interceptor는 리프레시 요청이 실패한 경우 예외가 발생할 것이다.
Response
의 확장함수로 서버에서 내려주는 예외코드에 따라 적절한 DataThrowable(커스텀한 예외)로 변환해주는 코드를 작성했기 때문에 DataThrowable
이 발생할 것으로 예상된다.
리프레시 토큰마저 만료된 경우 서버에서는 http 상태코드 401과 “토큰 정보가 옳지 않습니다”라는 메시지를 응답으로 내려주고, 우리 구조에서는 응답이 AuthRepository
→ AuthInterceptor
→ SplashViewModel
이렇게 전해진다.
// DefaultAuthRepository.kt
override suspend fun refreshAccessToken() {
val response = authService.requestRefresh(RefreshTokenDto(authDataSource.getRefreshToken()!!))
val naagaAuthDto = response.getValueOrThrow()
storeToken(naagaAuthDto.accessToken, naagaAuthDto.refreshToken)
}
// SplashViewModel.kt
fun testTokenValid() {
viewModelScope.launch {
runCatching {
statisticsRepository.getMyStatistics()
}.onSuccess {
_isTokenValid.value = true
}.onFailure {
_isTokenValid.value = false
setThrowable(it)
}
}
}
private fun setThrowable(throwable: Throwable) {
when (throwable) {
is IOException -> { _throwable.value = DataThrowable.NetworkThrowable() }
is DataThrowable.AuthorizationThrowable -> { _throwable.value = throwable }
}
}
그래서 리포지터리에서 발생한 예외는 ViewModel에서 핸들링하고 있다.
runCatching
을 사용하여 예외가 발생한 경우 setThrowable
메서드를 실행하는데, 여기서는 IOException
과 DataThrowable
의 AuthorizationThrowable
에 대한 처리를 한다. (토큰 갱신 요청에 실패한 경우 AuthorizationThrowable
예외가 발생한다.)
그래서 종국적으로는 앱이 비정상종료 할 일이 없겠다고 생각했다.
그런데 이게 웬걸? AuthorizationThrowable
예외가 발생하며 앱이 비정상종료된다…?
아니 분명히 ViewModel에서 처리를 해줬잖아..!!
그래서 디버깅을 해봤다.
디버깅을 이용하여 setThrowable 메서드에 들어온 throwable을 확인해보면 다음과 같다.
IOException
이며 AuthorizationThrowable
때문에 취소되었다고 설명이 나와있다.
ViewModel에서 IOException
과 AuthorizationThrowable
에 대한 처리를 해두었는데 프로그램이 비정상 종료된다... 대체 왜일까? 나한테 왜그러는 것이야?
이유를 찾기 위해 디버깅도 해보고 코드도 요리조리 바꿔보고 리프레시 요청을 suspendCancellableCoroutine
으로 감싸보기도 하고 난리부르스를 피워봤다.
그리고 너무 매몰되어서 문제를 찾지 못했구나 생각했다. 집요하게 해결할 때까지 보는 것도 좋지만, 잠시 제쳐두고 다른 일을 하는 것도 방법인 것 같다. 이런 경험이 한 두번이 아니기에 나는 조금 쉬어가는 시간이 필요한 사람임을 다시 한 번 생각하게 되었다.
무튼 이게 중요한 것이 아니니 각설하고, 왜인이 추측해보았다.
가장 처음에는 예외가 AuthRepository
→ AuthInterceptor
→ SplashViewModel
전파될 것이라고 생각했다.
class AuthInterceptor(
private val authRepository: AuthRepository,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = authRepository.getAccessToken() ?: return chain.proceed(chain.request())
val headerAddedRequest = chain.request().newBuilder().addHeader(AUTH_KEY, accessToken).build()
val response: Response = chain.proceed(headerAddedRequest)
if (response.code == 401) {
response.closeQuietly()
runBlocking {
authRepository.refreshAccessToken()
}
return chain.proceed(
chain.request().newBuilder().addHeader(AUTH_KEY, authRepository.getAccessToken()!!).build(),
)
}
return response
}
...
}
현재 Interceptor 코드다.
AuthRepository
에서 getValueOrThrow
에 의해 예외가 던져질텐데 현재 Interceptor에서는 해당 예외에 대한 처리를 하지 않고있다.
// SplashViewModel.kt
fun testTokenValid() {
viewModelScope.launch {
runCatching {
statisticsRepository.getMyStatistics()
}.onSuccess {
_isTokenValid.value = true
}.onFailure {
_isTokenValid.value = false
setThrowable(it)
}
}
}
private fun setThrowable(throwable: Throwable) {
when (throwable) {
is IOException -> { _throwable.value = DataThrowable.NetworkThrowable() }
is DataThrowable.AuthorizationThrowable -> { _throwable.value = throwable }
}
}
왜냐하면 위에서 보이는 것처럼 뷰모델에서 runC시atching
의onFailure
를 통해 실패한 경우를 처리하고 있기 때문에 예외를 잘 처리하고 있는 줄 알았다. 그리고 디버깅 해보면 실제로 예외가 여기서 잡히긴 잡힌다.
시도방법 1
class AuthInterceptor(
private val authRepository: AuthRepository,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = authRepository.getAccessToken() ?: return chain.proceed(chain.request())
val tokenAddedRequest = chain.request().putToken(accessToken)
val response: Response = chain.proceed(tokenAddedRequest)
if (response.isTokenInvalid()) {
response.closeQuietly()
runCatching {
runBlocking { authRepository.refreshAccessToken() }
}.onFailure { throw DataThrowable.AuthrizationThrowable(123, "문제!") } // 변경점
return chain.proceed(chain.request().putToken(authRepository.getAccessToken()!!))
}
return response
}
private fun Response.isTokenInvalid(): Boolean {
return this.code == 401
}
private fun Request.putToken(accessToken: String): Request {
return this.newBuilder()
.addHeader(AUTH_KEY, accessToken)
.build()
}
...
}
액세스 토큰의 리프레시 요청이 실패한 경우 Interceptor에서 AuthrizationThrowable
을 던지도록 해봤다.
SplashViewModel
에 IOException
대신 AuthrizationThrowable
이 들어온다. 그런데 역시나 비정상 종료가 된다. 왜인지는 여전히 잘 모르겠다.
시도방법 2
class AuthInterceptor(
private val authRepository: AuthRepository,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = authRepository.getAccessToken() ?: return chain.proceed(chain.request())
val tokenAddedRequest = chain.request().putToken(accessToken)
val response: Response = chain.proceed(tokenAddedRequest)
if (response.isTokenInvalid()) {
response.closeQuietly()
runCatching {
runBlocking { authRepository.refreshAccessToken() }
}
return chain.proceed(chain.request().putToken(authRepository.getAccessToken()!!))
}
return response
}
private fun Response.isTokenInvalid(): Boolean {
return this.code == 401
}
private fun Request.putToken(accessToken: String): Request {
return this.newBuilder()
.addHeader(AUTH_KEY, accessToken)
.build()
}
...
}
그래서 혹시나 해서 runCatching
으로 Interceptor에서 발생한 예외를 흘려보내도록 해봤다.
그랬더니 처음에 원했던 동작이 발생(로그인 뷰로 이동하는 동작)했고 SplashViewModel
에서 잡히는 예외를 확인해보니 AuthrizationThrowable
이었다.
비정상종료가 되지 않았기 때문에 “이 정도면 됐지!” 하고 넘어갈 수 있겠지만 그러기엔 굉장히 찝찝하고 어떻게 해결했는가? 에 대한 질문에 답을 할 수 없었기 때문에 이유와 정확한 해결책을 찾고 싶어서 좀 더 붙들어봤다.
❓ Interceptor야 왜그래?
예외가 위로 전파되고 심지어 잡히기까지 하는데 대체 왜 비정상 종료가 될까 고민을 정말 많이 했다.
그러다 또 엄청 매몰되었고, interceptor
메서드를 어디선가 호출하고 있지 않아서 이 메서드가 호출 스택의 최상위인 것인가? 그래서 예외를 처리하지 못하는 것인가? 이런 생각까지 도달했다.(그냥 정신을 못차렸다. 역시나 시간이 필요했을 지도..? ㅋㅋ)
그리고 해결한 것처럼 보이는 위의 상황을 조금 더 자세히 살펴보니 내 예상과는 조금 달랐고 실제는 다음과 같았다.
intercept에서 발생한 예외는 runCatching
이후 onFailure
가 없기 때문에 아무런 처리도 하지 않았고 그대로 흘러간다. 그리고 현재 뷰모델에서 잡히는 예외는 statisticsRepository.getMyStatistics()
에서 발생한 예외다.
예외를 흘려보내는 것은 적절한 처리 방법이 아니라고 생각했다. 현재 코드는 토큰을 리프레시 하는 과정에서 어떤 예외든 흘려보내기 때문에 문제가 발생해도 알아차릴 수 없다. 그럼 어떻게 해야할까 계속 고민해봤다. 스스로는 답이 나오지 않았다. 그래서 handle, exception, interceptor
이런 키워드를 묶어서 계속 찾아봤다.
그러다 발견한 medium의 질문과 답변.
어..? 내가 겪은 문제와 동일한 것 같은데..? 하면서 사막에서 오아시스를 찾았을 때 이런 기분이겠구나 하는 생각을 하며 바로 프로젝트에 적용해봤다.
class AuthInterceptor(
private val authRepository: AuthRepository,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = authRepository.getAccessToken() ?: return chain.proceed(chain.request())
val tokenAddedRequest = chain.request().putToken(accessToken)
val response: Response = chain.proceed(tokenAddedRequest)
if (response.isTokenInvalid()) {
response.closeQuietly()
runCatching {
runBlocking { authRepository.refreshAccessToken() }
}.onFailure {
if (it is IOException) {
throw it
} else {
throw IOException(it)
}
}
return chain.proceed(chain.request().putToken(authRepository.getAccessToken()!!))
}
return response
}
private fun Response.isTokenInvalid(): Boolean {
return this.code == 401
}
private fun Request.putToken(accessToken: String): Request {
return this.newBuilder()
.addHeader(AUTH_KEY, accessToken)
.build()
}
...
}
Interceptor를 위와 같이 변경해주고, SplashViewModel
에서 디버깅을 해보았을 때 들어온 Throwable
은 다음과 같았다.
이전과 비교해보았을 때 due to.. 어쩌구가 없어졌다. 그리고 SplashViewModel에서 예외가 예상한대로 핸들링되고, 프로그램도 비정상종료 하지 않았다…! 🤩🤩
Square에서 issue를 좀 찾아보니까 나와 같은 현상을 겪은? 아니면 같은 궁금증을 가진 사람들이 좀 있었고, 이유를 알 수 있었다.
Interceptor에서는 IOException
인 경우에만 외부로 throw를 하는 것이다.
디버깅을 통해 다시 확인해보니 실패한 경우 들어오는 Throwable은 AuthorizationThrowable
이었다. IOException
이 아니기 때문에 예외를 외부로 throw하지 않고, 프로그램을 종료시켰던 것이다. 그런데 곰곰히 생각해보니 SplashViewModel
에서 확인했을 때의 예외는 IOException
이었는데 그건 뭐였지?
이전에 due to.. 가 포함된 IOException
이 발생한 이유는 RealCall.kt의 530번 라인을 보면 알 수 있다.
530번 라인에서 IOException
으로 감싸며 이를 responseCallback
의 onFailure
에 담아서 내보낸다. 그리고 다시 throw를 해버리며 프로그램이 비정상 종료된다.
📝 후기2
문제가 발생한 이유를 고민해보고, 찾아보면서 프로젝트의 구조를 바꿔가는게 재밌는 것 같다. 그 과정은 뭐랄까.. 약간의 고통이 수반되긴 하지만.. 후..
사실 엄청난 코드를 작성한 것도, 대단한 문제를 해결한 것도 아닌 것처럼 보일 수 있다. 또 누구는 뭐 이렇게까지 알아보나. 해결만 되면 되는거 아닌가? 라고 생각할지도.
그냥 나는 문제를 직면하고 이를 해결하는 과정을 재밌어하는 것 같다. 그리고 이 과정에서 부가적인 지식들을 습득하기도 하고, 코드를 어떻게 하면 더 단단하게 짤 수 있는가와 같은 것을 고민하는 것은 정말 소중한 것 같다.
프로젝트에 다양한 기능을 마구마구 추가하는 것보다 조금이라도 레거시 코드를 리팩터링하고 문제를 개선하는 경험을 통해 더 나은 코드를 짤 수 있는 힘을 기르도록 도와준다고 믿고있다. 앞으로도 정진!🔥
참고