[iOS] Alamofire의 RequestInterceptor로 토큰 갱신 로직 구현하기
14 min readNov 10, 2023
Interceptor
adapt
: 네트워크 호출을 할 시 서버로 보내기전에 api를 가로채서 전처리를 한 뒤 서버로 보내는 역할을 한다.retry
: response가 fail일 경우retry
구문을 타게되어 해당 api를 다시 쏘거나, 에러와 함께 리턴하는 기능이 있다.
adapt
와 retry
는 별도의 호출없이 생성만 해두면 자동으로 호출이된다.
생성을 위해서는 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 closure
인completion
에 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
retry
는adapt
를 통해 보낸 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를 다시 호출해야하는 로직이였음)
- 엑세스 토큰을 사용하다가 401(토큰 만료)가 뜬다.
- 기존에 만료된 엑세스 토큰이 설정된 헤더에 리프레시 토큰을 넣는다.
- 다시 api 호출(retry)를 하면 해당 API response 헤더로 새로운 액세스 토큰이 발급 된다.
- 해당 새로운 액세스 토큰를 다시 헤더에 넣어 스위칭 한다.
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 closure
인completion
로 .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 시 리스폰스 헤더로 들어오는 엑세스 토큰을 헤더로 설정되도록 로직을 변경했다.