토큰이란 사용자의 인증 정보와 보완 정보를 포함하고 있는 암호화된 문자열이다.
이 토큰을 가지고 사용자는 서버와 API 통신을 하며 데이터를 주고 받는다.
토큰의 종류는 엑세스 토큰, 리프레시 토큰 2가지 토큰이 있다.
엑세스 토큰은 보내는 데이터의 헤더에 포함하여 서버와 API 통신을 할 때 사용된다.
엑세스 토큰은 짧은 유효기간을 갖고 있다.
만약, 엑세스 토큰이 유출된다면 외부에서 중요한 데이터에 접근하여 맘대로 다루는 상황이 생길 수 있다.
앞서 말한 악용이 생길 수 있어 엑세스 토큰은 짧은 시간 단위로 토큰이 갱신된다.
그러나 정상적인 사용자가 처음 엑세스 토큰을 발급받아서 사용하고 있는데 얼마 지나지 않아 엑세스 토큰이 갱신되어 이전에 쓰던 엑세스 토큰이 유효하지 않게 되었다. 이러한 문제를 어떻게 해결할까?
정답은 리프레시 토큰을 갖고 해결한다.
리프레시 토큰의 유효기간은 길기 때문에 엑세스 토큰이 만료되었다면 리프레시 토큰을 갖고 새로운 액세스 토큰을 발급하는 역할을 가진다.

스플래시 화면에서 이루어지는 과정을 살펴보자.
AccessToken을 가지고 request Header에 넣어 api 요청을 보낸다.AccessToken이 유효하다면 5번으로 가게 되고 화면의 흐름은 스플래시 화면에서 메인 화면으로 이동한다.AccessToken이 유효하지 않은 경우에 실행되고 request Header에 RefreshToken을 넣어주고 AccessToken을 재발급 하는 api를 요청한다.RefreshToken이 유효하면 새로 발급받은 AccessToken을 datastore에 업데이트한다.RefreshToken이 유효하면 스플래시 화면에서 메인 화면을 이동하고 유효하지 않다면 로그인 화면으로 이동한다.
Interceptor는 HTTP 통신을 모니터링할 수 있게 해주고, API 재요청 등을 가능하게 해주는 클래스이다.
Interceptor는 HTTP 통신을 하기전에 request나 response에 별도의 작업을 할 수있다.
Interceptor에서 Header에 Token이 추가되는 경우 HTTP 통신전에 작업이 진행된다.
Interceptor 인터페이스를 구현해야 되며 intercept()를 오버라이딩 해야된다.
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(request)
return response
}

엑세스 토큰 유효성 검증 API를 호출하는 상황이다.
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager
) : Interceptor {
private val AUTHORIZATION = "Authorization"
private val NETWORK_ERROR = 401
override fun intercept(chain: Interceptor.Chain): Response {
val token: String = runBlocking {
tokenManager.getAccessToken().first()
} ?: return errorResponse(chain.request())
// Header에 AccessToken을 추가하는 상황
val request = chain.request().newBuilder().header(AUTHORIZATION, "Bearer $token").build()
val response = chain.proceed(request)
return response
}
private fun errorResponse(request: Request): Response = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_2)
.code(NETWORK_ERROR)
.message("엑세스 토큰 없음")
.body(ResponseBody.create(null, ""))
.build()
}
AccessToken을 가져온다.AccessToken이 없는 경우 401 Unauthorized Error를 발생시킨다.AccessToken이 있다면 request Header에 추가한 후 api를 호출 한다.만약 AccessToken의 기간이 만료되어 서버로부터 401 Error를 받는다면 Authenticator 를 사용해 RefreshToken을 header에 넣고 다시 api를 요청하겠지만,
서버에서 HTTP CODE는 200으로 보내고 response body의 code 값을 "JWT407" 과 같이 예외 처리해서 보내게 된다면 Authenticator는 쓸모가 없다. Authenticator는 HTTP CODE가 401인 경우에만 사용되기 때문이다.
이 경우에는 AuthInterceptor에서 처리한다.

AccessToken이 유효하지 않아 RefreshToken으로 엑세스 토큰 재발급 API를 호출하는 상황이다.
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager
) : Interceptor {
private val AUTHORIZATION = "Authorization"
private val NETWORK_ERROR = 401
override fun intercept(chain: Interceptor.Chain): Response {
// ...
val response = chain.proceed(request)
// 새롭게 추가된 코드
val responseBody = response.body ?: return response
// 엑세스 토큰이 유효하지 않아도 백엔드에서 HTTP CODE를 200으로 보내고 json code를 JWT407으로 보내는 상황
if (response.code == HTTP_OK) {
val source = responseBody.source()
source.request(Long.MAX_VALUE) // Buffer the entire body.
val buffer = source.buffer.clone()
val responseString = buffer.readUtf8() // 복사된 Buffer에서 문자열 추출
val code = JSONObject(responseString).optString("code")
if (code == "JWT407") {
val newRequest = HttpSuccessAuthenticator(tokenManager).authenticate(response)
return newRequest?.let { chain.proceed(it) } ?: response
}
}
return response
}
}
HTTP code가 HTTP_OK(200)이면 response의 body의 code 값을 확인한다.
바로 response의 body에서 code 확인하면 되지만 버퍼를 이용한 복잡한 코드가 있는데 그 이유는 나중에 살펴보겠다.
code 값이 "JWT407"이면 HttpSuccessAuthenticator의 authenticate() 메서드를 통해 RefreshToken을 Header로 가진 새로운 request를 만들고 다시 api를 호출한다. chain.proceed(it) 는 api를 호출하는 코드이다.
class HttpSuccessAuthenticator(private val tokenManager: TokenManager) {
private val REFRESH_TOKEN = "Refresh-Token"
fun authenticate(response: Response): Request? {
val refreshToken = runBlocking {
tokenManager.getRefreshToken().first()
}
if (refreshToken == null || refreshToken == "LOGIN")
return null
return newRequestWithToken(refreshToken, response.request)
}
private fun newRequestWithToken(token: String, request: Request): Request {
val newUrl = request.url.newBuilder()
.encodedPath("/api/v1/users/refresh") // 기존 Base URL 유지, 경로만 변경
.build()
return request.newBuilder()
.url(newUrl)
.header(REFRESH_TOKEN, token)
.post(RequestBody.create(null, ByteArray(0)))
.build()
}
}
HttpSuccessAuthenticator 클래스에서 새로운 request를 만드는 과정을 살펴보자.
1. datastore에서 RefreshToken을 가져온다.
2.RefreshToken이 없다면 새로운 request를 만들지 않고 종료한다.(accessToken을 발급할 refreshToken이 없어서 api를 호출할 필요가 없기 때문)
3. RefreshToken이 있다면 호출할 api의 URL을 지정하고 HTTP method를 설정한다.
4. RefreshToken을 Header에 넣고 새롭게 만든 request를 반환한다.

response의 body는 한번 사용하면 닫힌다. response의 body에 다시 접근이 불가능하는 의미이다.
필자는 Ìnterceptor 에서도 response.body에 접근하고 CallAdapter에서도 response.body에 접근하기 때문에 IllegalStateException closed 오류가 발생한 것이다.
이를 해결하기 위해 버퍼를 활용하였다.
// AuthÌnterceptor의 내부의 코드
val source = responseBody.source()
source.request(Long.MAX_VALUE) // Buffer the entire body.
val buffer = source.buffer.clone()
val responseString = buffer.readUtf8() // 복사된 Buffer에서 문자열 추출
AccessToken은 서버와 API 통신할 때 사용하는 토큰이다.RefreshToken은 AccessToken을 재발급 하기 위한 토큰이다.Interceptor는 말 그대로 api 요청이나 응답을 중간에서 가로채 필요한 기능을 수행하는 역할이다.response에서 token은 어디 위치에 있는 것이 좋을까!?
포스팅에는 없지만 RefreshToken으로 새로 발급한 AccessToken은 TokenRepositoryImple에서 저장된다.
현재 response의 body에서 data로 accessToken을 받고 있다. DTO 모델로 변환이 가능한 TokenRepositoryImpl에서 새롭게 발급받은 AccessToken을 datastore에 업데이트 할 수 밖에 없다.
그러나 response의 header에 accessToken을 담아준다면 AuthInterceptor에서 새롭게 발급받은 AccessToken을 datastore에 업데이트 할 수 있다.
굳이 Repository까지 넘어가서 토큰 저장 작업을 수행하지 않고 Interceptor에서 작업 수행이 가능한 장점이 있다.
이렇게 생각하면 response의 header에 token을 담는 것이 좋아보이지만... 확신이 서지 않는 고민이다.
AccessToken이 유효하지 않을 경우의 HTTP code
현재 AccessToken이 유효하지 않을 경우 HTTP code는 200, response의 body의 code가 "JWT407"로 응답을 받는다.
AuthInterceptor에서 HttpSuccessAuthenticator 를 호출하여 로직을 처리하고 있다. HttpSuccessAuthenticator은 retrofit2 만들 때 interceptor로 등록되지 않아 임의로 호출해줘야 된다는 단점이 있다.
만약 AccessToken이 유효하지 않을 경우 HTTP code를 401 Error 보내게 되고 Authenticator를 retrofit2 만들 때 interceptor로 등록한다면 AccessToken이 유효하지 않을 때마다 자동으로 호출된다는 이점이 있다.
그러나 "JWT407" 처럼 서버에서 예외 처리를 하는 것은 올바른 방법인 것 같다.. 어떤 방법이 좋은 지는 미지수..
참고 레퍼런스
https://velog.io/@chuu1019/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C%EC%97%90%EC%84%9C-Access-Token-%EB%A7%8C%EB%A3%8C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0-Feat.-dataStore
https://velog.io/@chuu1019/Access-Token%EA%B3%BC-Refresh-Token%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C