문제상황은 이러하다!
서버에서 게시물들을 받는 과정에서, 회원만 접근 가능할 수 있게 액세스토큰이 필요한데, 액세스 토큰은 제한된 유효 시간이 있다.
유효 시간이 지나면 액세스 토큰은 만료되므로, 재발급 받는 로직이 필요하다.
재발급받지 않고, 호출을 하는 경우,
그리고 그 데이터들을 메인 뷰에서 활용하고 있었던 경우에는 크래시가 발생한다.
🚨 Error Domain=NSURLErrorDomain Code=-999 "(null)"
🚨 Thread 1: Fatal error: Binding error: responseSerializationFailed(reason: Alamofire.AFError.ResponseSerializationFailureReason.decodingFailed(error: Swift.DecodingError.keyNotFound(CodingKeys(stringValue: "data", intValue: nil), Swift.DecodingError.Context(codingPath: [], debugDescription: "No value associated with key CodingKeys(stringValue: \"data\", intValue: nil) (\"data\").", underlyingError: nil))))
-> 에러 코드를 살펴보자.
언뜻보면 decoding error니까, 데이터가 누락되었나? 디코딩?싶지만,
백이면 백 내가 데이터를 받을 준비가 안된거다.
데이터 모델의 형식이 틀렸든지, 나의 경우처럼 액세스 토큰이 만료되어 잘못된 호출을 해서 데이터를 받을 수 없던지!
나의 기존 코드는 다음과 같다.
//FetchPostsNetworkManager.swift
static func fetchPost(query: FetchPostQuery) -> Single<FetchModel> {
return Single<FetchModel>.create { single in
do {
let urlRequest = try MainRouter.fetchPost(query: query).asURLRequest()
AF.request(urlRequest)
.responseDecodable(of: FetchModel.self) { response in
switch response.result {
case .success(let success):
single(.success(success))
case .failure(let error):
single(.failure(error))
}
}
} catch {
single(.failure(error))
}
return Disposables.create()
}
}
게시글을 Fetch 해오는 과정을 구현해놨는데, 이 로직에는 토큰이 만료되었을 경우에 대한 예외처리가 필요하다.
그러니까,
1) 게시물 조회하는 API 요청
2) 토큰이 만료되었다면, 토큰 재발급
3) 새로운 토큰 저장
4) 다시 게시물 조회하는 API 요청
이런 로직을 수행해야 한다.
1)에 해당하는 게시물을 조회하는 로직은 이미 구현되어 있었으니, 나머지를 수정해보자.
이 클래스는 Alamofire의 RequestInterceptor를 채택하여, 요청을 조정하고 요청을 재시도 하는 작업을 수행한다.
이 문서에서 adapt, retry 메서드를 가져오면 된다.
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
var request = urlRequest
if let token = UserDefaults.standard.string(forKey: "AccessToken") {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
completion(.success(request))
}
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
guard let statusCode = request.response?.statusCode, statusCode == 404 else {
completion(.doNotRetry)
return
}
refreshToken { isSuccess in
isSuccess ? completion(.retry) : completion(.doNotRetryWithError(error))
}
}
-> 토큰 만료 감지: NetworkInterceptor의 retry 메소드가 419 에러를 감지한다.
토큰 재발급: refreshToken 메소드가 새 토큰을 요청하고 성공적으로 받아오면 UserDefaults에 저장한다.
-> 요청 재시도: 새 토큰을 사용하여 원래의 네트워크 요청을 다시 시도한다.
static func fetchPostWithRetry(query: FetchPostQuery) -> Single<FetchModel> {
return Single<FetchModel>.create { single in
let session = Session(interceptor: NetworkInterceptor())
do {
let urlRequest = try MainRouter.fetchPost(query: query).asURLRequest()
session.request(urlRequest)
.responseDecodable(of: FetchModel.self) { response in
switch response.result {
case .success(let success):
single(.success(success))
case .failure(let error):
single(.failure(error))
}
}
} catch {
single(.failure(error))
}
return Disposables.create()
}
}
요렇게 하면 잘 동작하겠지??
🚨 Session was invalidated without error, so it was likely deinitialized unexpectedly. Be sure to retain a reference to your Session for the duration of your requests
Session 객체가 네트워크 요청 동안 유지되지 않아 발생하는 문제다.
static let session = Session(interceptor: NetworkInterceptor())
그리고 혹시 모를 크래시를 위해
refresh 토큰이 만료되어서 데이터가 들어오지 않는 경우, 데이터를 사용하고 있는 화면을 그대로 띄우지 않고, 예외처리로 아예 로그인 화면으로 연결하는 편이 좋다.!