본문 바로가기

iOS

[Swift] Alamofire를 Moya처럼 사용해보자! By Router Pattern (5편 - Interceptor를 통해 네트워크 에러 및 토큰 갱신 처리하기)

[Swift] Alamofire를 Moya처럼 사용해보자! By Router Pattern (1편 - Foundation Setting)

[Swift] Alamofire를 Moya처럼 사용해보자! By Router Pattern (2편 - Services, Routers 구현)

[Swift] Alamofire를 Moya처럼 사용해보자! By Router Pattern (3편 - body, queryBody, requestPlain, Multipart 구현)

[Swift] Alamofire를 Moya처럼 사용해보자! By Router Pattern (4편 - EventLogger로 통신 결과 확인하기)


4편에서는 EventLogger를 구현하여 통신 결과를 확인하는 Plugin을 구현했습니다.

이번에는 Interceptor의 개념에 대해 알아보고, 이를 이용해 네트워크 에러를 처리하고 토큰을 갱신하는 방법에 대해 알아보겠습니다.

RequestInterceptor 프로토콜

Alamofire에는 Interceptor pattern을 활용하여 특정 request에 대해 전처리 및 후처리를 할 수 있도록 돕는 Interceptor 프로토콜이 존재합니다.
/// Type that provides both `RequestAdapter` and `RequestRetrier` functionality.
public protocol RequestInterceptor: RequestAdapter, RequestRetrier {}

extension RequestInterceptor {
    public func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
        completion(.success(urlRequest))
    }

    public func retry(_ request: Request,
                      for session: Session,
                      dueTo error: Error,
                      completion: @escaping (RetryResult) -> Void) {
        completion(.doNotRetry)
    }
}

먼저 RequestInterceptor의 정의를 보면, RequestAdapter 및 RequestRetirer라는 프로토콜을 상속하고 있는 것을 확인할 수 있습니다. RequestInterceptor에 대해 알기 위해서는 두 프로토콜에 대해 알 필요가 있겠네요!

RequestAdapter : URLRequest를 검사하고 변형시키자!

/// A type that can inspect and optionally adapt a `URLRequest` in some manner if necessary.
public protocol RequestAdapter {
    /// Inspects and adapts the specified `URLRequest` in some manner and calls the completion handler with the Result.
    ///
    /// - Parameters:
    ///   - urlRequest: The `URLRequest` to adapt.
    ///   - session:    The `Session` that will execute the `URLRequest`.
    ///   - completion: The completion handler that must be called when adaptation is complete.
    func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void)

    /// Inspects and adapts the specified `URLRequest` in some manner and calls the completion handler with the Result.
    ///
    /// - Parameters:
    ///   - urlRequest: The `URLRequest` to adapt.
    ///   - state:      The `RequestAdapterState` associated with the `URLRequest`.
    ///   - completion: The completion handler that must be called when adaptation is complete.
    func adapt(_ urlRequest: URLRequest, using state: RequestAdapterState, completion: @escaping (Result<URLRequest, Error>) -> Void)
}

친절하게 달려있는 주석을 천천히 읽어보면, 우리가 검사하고 변형시킬 urlRequest와 그것이 수행될 session을 파라미터로 받아오네요! 그리고 이 adapt의 결과를 result 타입 completion을 통해 적절하게 내보낼 수 있겠네요!

 

우리가 원하는 특정 urlRequest가 들어오면(insepct 과정), 원하는 대로 변형(adapt 과정)하고 성공적으로 변환되었을 시에 completion에 전달하면 되겠습니다!

RequestRetrier : 필요한 경우에 Request를 Retry시키자!

/// A type that determines whether a request should be retried after being executed by the specified session manager
/// and encountering an error.
public protocol RequestRetrier {
    /// Determines whether the `Request` should be retried by calling the `completion` closure.
    ///
    /// This operation is fully asynchronous. Any amount of time can be taken to determine whether the request needs
    /// to be retried. The one requirement is that the completion closure is called to ensure the request is properly
    /// cleaned up after.
    ///
    /// - Parameters:
    ///   - request:    `Request` that failed due to the provided `Error`.
    ///   - session:    `Session` that produced the `Request`.
    ///   - error:      `Error` encountered while executing the `Request`.
    ///   - completion: Completion closure to be executed when a retry decision has been determined.
    func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void)
}

RequestRetrier 프로토콜에는 retry 메서드가 존재하고, 파라미터는 RequestAdapter의 메서드와 비슷해 보입니다. 살짝 다른 점은 dueTo error 부분인데, error 타입에 따라서 retry를 할지 말지에 대한 결정을 해줄 수 있겠네요!

다시, RequestInterceptor로 무엇을 해볼까?

이제 RequestInterceptor의 핵심인 adapt와 retry의 기능에 대해 알게 되었습니다! 그러면 이를 통해 어떤 기능을 구현해볼 수 있을까요?

 

Adapt: adapt는 URLRequest를 검사하여 조건부로 URLRequest를 변형하거나 필요한 처리를 해줄 수 있는 메서드입니다. URLRequest가 excute되기 전에 실행되기 때문에 전처리를 해줄 수 있고, 따라서 Request가 날아가기 전에 Network 상태를 검사하고 Network 연결이 불완전한 경우 현재 뷰의 최상단에 Alert를 띄워줄 것입니다.

 

Alamofire에서 제공하는 NetworkRechabilityManager를 사용하여 네트워크 상태를 확인하고, alertAlreadySet라는 Bool Property를 통해 Alert가 겹쳐서 등장하는 오류를 막아줍니다.

 

retry: retry는 URLRequest가 실행된 이후에, Request가 실패한 경우 실행됩니다. 그리고 경우에 따라서 Request를 Retry할 수 있게 해줍니다. 이를 이용해 URLRequest가 실패한 경우, 그 Request가 토큰 문제로 인해 실패했을 때, 재발급 API를 호출해줄 것입니다.

 

여기서 핵심은 재발급 API도 실패할 수 있기 때문에, 무한 호출을 피하기 위해서 토큰 재발급 API의 경우 retry를 시도하지 않도록 하는 것입니다.

 

*getMostTopViewController는 재귀적인 방식으로 TabBar, NavigationController, presentingViewController의 유무를 판단하고 가장 위에 보여지는 ViewController를 찾는 메서드입니다.

 

아래는 구현 코드입니다.

class AlamoInterceptor: RequestInterceptor {
    private var isConnectedToInternet: Bool {
        return NetworkReachabilityManager()!.isReachable
    }
    
    private var alertAlreadySet: Bool = false
    
    func adapt(_ urlRequest: URLRequest, for session: Alamofire.Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
        if !isConnectedToInternet && !alertAlreadySet {
            self.showNetworkErrorAlert {
                completion(.success(urlRequest))
            }
        } else {
            completion(.success(urlRequest))
        }
    }
    
    func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
        guard let pathComponents = request.request?.url?.pathComponents,
              !pathComponents.contains("token"),
              let response = request.task?.response as? HTTPURLResponse,
              response.statusCode == 401 else {
            completion(.doNotRetryWithError(error))
            return
        }

        // TODO: - 토큰 재발급 API 호출하기
    }
    
    private func showNetworkErrorAlert(completion: @escaping (()->Void)) {
        alertAlreadySet = true
        DispatchQueue.main.async {
            let rootViewController = UIApplication.getMostTopViewController()
            rootViewController?.makeAlert(title: "네트워크 에러", message: "네트워크 연결 상태를 확인해주세요") { _ in
                completion()
                self.alertAlreadySet = false
            }
        }
    }
}

Custom Session에 Interceptor 할당하기

저번 EventLogger와 마찬가지로, 구현한 Interceptor를 Session에 할당합니다. 아래와 같이 간단히 처리 가능합니다.

그 외의 변동 사항은 여러 Manager를 사용하기 위해 Managers Class를 따로 만들었다는 점입니다. 

class Managers {
    
    static let `default`: Session = {
        var session = AF
        let configuration = URLSessionConfiguration.af.default
        configuration.timeoutIntervalForRequest = NetworkEnvironment.requestTimeOut
        configuration.timeoutIntervalForResource = NetworkEnvironment.resourceTimeOut
        let eventLogger = APIEventLogger()
        let interceptor = AlamoInterceptor()
        session = Session(configuration: configuration, interceptor: interceptor, eventMonitors: [eventLogger])
        return session
    }()

    private init() { }
}

 

 

다음 편에서는 여러 가지 case의 JSON Encoding을 다루기 위한 클래스인 Alamofire의 ParameterEncoding에 대해 다루겠습니다.