[iOS] Alamofire의 RequestInterceptor로 토큰 갱신 로직 구현하기

kyuchulkim
14 min readNov 10, 2023

--

https://github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#adapting-and-retrying-requests-with-requestinterceptor

Interceptor

  • adapt : 네트워크 호출을 할 시 서버로 보내기전에 api를 가로채서 전처리를 한 뒤 서버로 보내는 역할을 한다.
  • retry : response가 fail일 경우 retry 구문을 타게되어 해당 api를 다시 쏘거나, 에러와 함께 리턴하는 기능이 있다.

adaptretry는 별도의 호출없이 생성만 해두면 자동으로 호출이된다.

생성을 위해서는 RequestAdapter, RequestRetrier 프로토콜을 채택해야한다.

class AuthInterceptor: RequestAdapter, RequestRetrier {
...
  • 하지만 RequestInterceptor 라는 프로토콜을 채택하면 두가지 모두 사용이 가능하다.
class AuthInterceptor: RequestInterceptor {
...
  • Codable 프로토콜이 Encodable, Decodable을 모두 포함하고 있는것과 같다.

adapt

  • adapt method는 request 전 특정 작업을 먼저 수행이 된다.
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
var urlRequest = urlRequest
urlRequest.setValue(UserDefaultHandler.shared.acceesToken, forHTTPHeaderField: "Authorization")

print("adator 적용 \(urlRequest.headers)")
completion(.success(urlRequest))
}
  • header에 bearer token를 추가하는 과정이 특정 작업이라고 볼 수 있다. 매 api가 호출되면 token을 세팅해주고 다시 서버로 보내는 기능을 한다.
  • escaping closurecompletion에 url을 담아주면 전처리한 url이 서버로 보내지게 된다.
guard urlRequest.url?.absoluteString.hasPrefix("<http://어쩌구>") == true,
let accessToken = UserManager.shared.accessToken, // 기기에 저장된 토큰들
let refreshToken = UserManager.shared.refreshToken
else {
completion(.success(urlRequest))
return
}
  • urlRequest를 바로 setValue하지 않고 urlRequest.url을 체크해서 안전하게 completion 에 보낼 수 도 있다.
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
var urlRequest = urlRequest
urlRequest.headers.add(.authorization(bearerToken: accessToken))

completion(.success(urlRequest))
}
  • 헤더 앞에 bearer를 붙여야 하는 경우 .authorization(bearerToken: accessToken) 사용하면 bearer가 붙는다.

Retry

  • retryadapt를 통해 보낸 api가 실패일 경우 도달하게 되는 구문이다.
  • 재시도 횟수, 재시도간의 딜레이를 커스텀하게 설정할 수도 있다.
  • 프로젝트에서 모든 api 실패가 아닌 401(access_token 만료로 인한 인증에러)인 경우에만 retry구문아래에서 refresh_token으로 로그인을 진행하는 작업을 진행하기로 하였다.
  • 401 에러가 아닌 다른 모든 에러는 completion(.doNotRetryWithError(error)) 을 통해 재시도 없이 에러내용과 함께 리턴한다.

let retryLimit = 3
let retryDelay: TimeInterval = 1

func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
print("retry 진입")
guard let response = request.task?.response as? HTTPURLResponse else {
completion(.doNotRetryWithError(error))
return
}

guard let statusCode = response.statusCode, statusCode == 401 else {
completion(.doNotRetry)
return }

getToken { isSuccess in
if isSuccess {
if request.retryCount < self.retryLimit {
print("try to retry...")
completion(.retryWithDelay(self.retryDelay))
}
} else {
// refresh_token 만료시 로그인 모달을 띄워서 다시 로그인하도록 유도
print("refresh token expired")
NotificationCenter.default.post(name: .refreshTokenExpired, object: nil, userInfo: ["showLoginModal": true])
completion(.doNotRetry)
}
}
}

private func getToken(completion: @escaping(Bool) -> Void) {
NetworkService.shared.oauth.getToken(socialType: UserDefaultHandler.shared.socialType) { result in
// token 재발급 api
}
}
  • statusCode가 401인 경우 토큰을 갱신하는 API를 호출
  • 401이 아닌 경우에는 completion(.doNotRetryWithError(error)) 를 통해 retry를 하지 않도록 한다.
  • completion(.retryWithDelay(self.retryDelay)) 를 통해 1초 간격으로 총 3번 재시도를 하게 된다.

Moya에서 Retry가 불리지 않는 문제

var validationType: ValidationType {
return .successCodes
}
  • validationType의 초깃값이 none으로 설정되어 있는 문제가 있다.

Moya에 Interceptor 적용

private let authProvider = MoyaProvider<OauthRouter>(session : Moya.Session(interceptor: Interceptor()), plugins: [MoyaLoggerPlugin()])
  • MoyaProvider에 session : Moya.Session(interceptor: Interceptor())를 추가해줘야 한다.

Interceptor 적용이 어려웠던 경우

  • 토큰 재발급 API가 따로 존재하지 않았다. (토큰이 만료됐을 때 헤더에 리프레시 토큰을 넣고 해당 API를 다시 호출해야하는 로직이였음)
  1. 엑세스 토큰을 사용하다가 401(토큰 만료)가 뜬다.
  2. 기존에 만료된 엑세스 토큰이 설정된 헤더에 리프레시 토큰을 넣는다.
  3. 다시 api 호출(retry)를 하면 해당 API response 헤더로 새로운 액세스 토큰이 발급 된다.
  4. 해당 새로운 액세스 토큰를 다시 헤더에 넣어 스위칭 한다.
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {

// ... 401 체크 코드들 ..

changerefershToken { isBool in
if isBool {
completion(.retry)
} else {
completion(.doNotRetry)
}
}
}
func changerefershToken(completion: @escaping(Bool) -> Void) {
UserDefaultHandler.shared.acceesToken = UserDefaultHandler.shared.refershToken
if UserDefaultHandler.shared.acceesToken == UserDefaultHandler.shared.refershToken {
completion(true)
} else {
completion(false)
}
}
  • 해당 플로우를 성공시키기 위해선 먼저 401이 뜨면 기존에 만료된 엑세스 토큰이 설정된 헤더에 리프레시 토큰을 넣어줘야 했다.
  • 그리고 다시 api 호출(retry)를 해야만 해당 API의 헤더로 새로운 액세스 토큰이 발급이 됐다.
  • 하지만 리프레시 헤더로 api 호출을 하게되면 escaping closurecompletion 로 .retry가 실행되고 retry가 종료되게 된다.
  • 즉, 리프레시 헤더로 호출한 API의 리스폰스 헤더 값(새로운 액세스 토큰)을 Interceptor에서 적용시킬 수 없는 것 같았다.
changerefershToken { isBool in
if isBool {
completion(.retry)
// 밑에 호출한 API의 리스폰스 헤더 값을 얻는 코드는 실행되지 않음
if let authorization = response.allHeaderFields["Authorization"] as? String,
let token = authorization.split(separator: " ").last {
UserDefaultHandler.shared.acceesToken = String(token)
print(" 현재 헤더 토큰\\(UserDefaultHandler.shared.acceesToken)")
} else {
completion(.doNotRetry)
}
}
  • completion(.retry)로 이미 escaping closure 가 종료되었기 때문에 호출한 API의 리스폰스 헤더 값(새로운 액세스 토큰)을 다시 클라이언트 헤더로 설정할 수 없었다.
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
guard let tokenExpirationDate = UserDefaultHandler.shared.tokenExpirationTime.stringTofullDate else { return completion(.success(urlRequest)) }

if tokenExpirationDate < Date() {
var urlRequest = urlRequest
urlRequest.setValue(UserDefaultHandler.shared.refershToken, forHTTPHeaderField: "Authorization")

print("리프레쉬 adator 적용 \(urlRequest.headers)")
completion(.success(urlRequest))
} else {
var urlRequest = urlRequest
urlRequest.setValue(UserDefaultHandler.shared.acceesToken, forHTTPHeaderField: "Authorization")

print("기존 엑세스 adator 적용 \(urlRequest.headers)")
completion(.success(urlRequest))
}
}
  • 두 번째로 시도한 방법은 만료 시간을 adapt에서 체크해서 상황에 맞는 헤더를 보내는 방법이였다.
  • 로그인API response로 엑세스 토큰 만료 시간도 보내주고 있었기 때문에 UserDefaultHandler에 저장한 토큰 만료 시간을 현재 시간과 비교했다.
  • 현재시간이 토큰 만료 시간보다 더 빠르면 urlRequest.setValue로 refershToken를 설정하고, 반대면 acceesToken을 설정했다.
  • 하지만 문제는 retry가 실행되지 않는다는 것 이였다. 이렇게 되면 토큰이 만료되었을 때 리프레시 토큰으로 정상적으로 api가 호출되어서 401이 뜨지 않고 200이 떴다.
  • 그렇기 때문에 retry가 호출되지 않았다. retry는 200이 아닌 즉, 토큰이 만료된 상황에서 호출된다.

refreshToken API가 따로 있었다면..

func retry(_ request: Request, for _: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
guard let response = request.task?.response as? HTTPURLResponse else {
completion(.doNotRetryWithError(error))
return
}

switch response.statusCode {
case 150: // timeout
if request.retryCount < limit {
completion(.retry)
} else {
Singleton.shared.presentToastAlert.onNext(.network)
completion(.doNotRetry)
}
case 401: // unauthorized
AuthAPI.shared.refreshAuthentication()
.subscribe(onSuccess: { result in
switch result {
case let .success(data):
data.refreshAccessToken()
completion(.retry)
case let .failure(error):
Singleton.shared.unauthorized.onNext(())
}
})
.disposed(by: bag)

default:
completion(.doNotRetry)
}
}
  • 만약 refreshAuthentication와 같은 토큰 재발급 api가 따로 존재했다면 쉽게 구현했을 수 있을 거같다.
  • 토큰만료(401)이 떴을 때 refreshAuthentication를 호출해 새로운 엑세스토큰을 받아와서 교체해주면 쉽게 해결할 수 있었을 거 같다.
  • 토큰 재발급 api가 존재하는 경우 토큰이 만료 됐을 때, 리프레쉬 토큰을 헤더 or 파라미터로 보내면 해당 토큰 재발급 API의 response Body로 새로운 엑세스 토큰을 받아왔다. ← 이 방식이 Interceptor에 적절해 보인다.
  • Interceptor의 retry에서는 401 시 호출 될 refreshToken API가 존재하면 손쉽게 사용하기 좋아보인다.

추후 Interceptor 문제 해결

  • Interceptor에서는 401 시 리프레쉬 토큰으로 헤더만 변경해주고, api에서 200 시 리스폰스 헤더로 들어오는 엑세스 토큰을 헤더로 설정되도록 로직을 변경했다.

--

--