[iOS] Alamofire + RxSwift + Interceptor를 활용한 자동 토큰 갱신

Madeline👩🏻‍💻·2024년 4월 27일
2

iOS study

목록 보기
47/61
post-thumbnail

👩🏻‍🚒 트러블슈팅

문제상황은 이러하다!
서버에서 게시물들을 받는 과정에서, 회원만 접근 가능할 수 있게 액세스토큰이 필요한데, 액세스 토큰은 제한된 유효 시간이 있다.
유효 시간이 지나면 액세스 토큰은 만료되므로, 재발급 받는 로직이 필요하다.
재발급받지 않고, 호출을 하는 경우,
그리고 그 데이터들을 메인 뷰에서 활용하고 있었던 경우에는 크래시가 발생한다.

🚨 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)에 해당하는 게시물을 조회하는 로직은 이미 구현되어 있었으니, 나머지를 수정해보자.

NetworkInterceptor

이 클래스는 Alamofire의 RequestInterceptor를 채택하여, 요청을 조정하고 요청을 재시도 하는 작업을 수행한다.

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

이 문서에서 adapt, retry 메서드를 가져오면 된다.

adpat

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))
}
  • 서버로 요청이 전송되기 직전에 호출된다.
    -> 요청의 URLRequest를 업데이트할 수 있다.
  • 비동기로 동작하며, 요청을 업데이트한 후 completion 핸들러를 호출하여 수정된 요청을 반환하거나, 에러를 반환할 수 있다.
  • 액세스토큰을 유저디폴트에서 가져와, Authorization 헤더에 추가한다.

retry

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))
    }
}
  • 요청이 실패했을 때 호출됨
  • 재시도할지 결정하는 로직을 포함함
  • HTTP 상태코드가 404인 경우, 토큰을 재발급 받고 요청을 재시도 하는 로직

-> 토큰 만료 감지: NetworkInterceptor의 retry 메소드가 419 에러를 감지한다.

FetchPost -> FetchPostWithRetry

토큰 재발급: 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())
    
  • Session 인스턴스가 요청이 완료될때까지 메모리에 있어야 하는데, Session 객체가 함수의 로컬변수로 있고, 그 함수가 종료되면 객체도 사라지므로 발생하던 에러를 해결하기 위해서
  • static 변수로 Session이 네트워크 요청이 끝날때까지 유지되어 에러를 방지한다.

그리고 혹시 모를 크래시를 위해
refresh 토큰이 만료되어서 데이터가 들어오지 않는 경우, 데이터를 사용하고 있는 화면을 그대로 띄우지 않고, 예외처리로 아예 로그인 화면으로 연결하는 편이 좋다.!

profile
🍎 Apple Developer Academy@POSTECH 2기, 🍀 SeSAC iOS 4기

0개의 댓글