ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [iOS] Alamofire의 고급 사용법!! SessionManager, SessionDelegate, RequestAdapter, RequestRetrier
    앱등이에게 살충제를 뿌린다./iOS 2018. 1. 16. 21:21

    Alamofire - AdvancedUsage

    제가 개발중인 프로젝트에서는 중요하다고 판단되는 API의 경우, API가 실패하면 3회 재시도하는 로직이 있습니다.

    이러한 로직을 공통으로 처리하고자 하는 도중, Alamofire에서 제공하는 Retrier는 뭐지?라는 의문을 시작으로 이 문서를 작성한다.

    (Alamofire - Advanced Usage를 기반으로 작성하였음)


    Session Manager

    Retrier를 알아보기 전에 SessionManager라는 클래스를 짚고 넘어갈 필요가 있다. 우리는 보통 Alamofire의 request를 생성할 때, 아래와 같은 코드를 작성한다.

    Alamofire.request("http://www.naver.com")
    

    사실 Alamofire.request()는 Alamofire.SessionManager가 Default로 생성한 request이다. 따라서, 위에서 생성한 request는 이렇게 생성하는 것과도 같다.

    let sessionManager = Alamofire.SessionManager.default
    sessionManager.request("http://www.naver.com")
    

    그렇다면 굳이 SessionManager를 사용하는 경우는 언제인가?

    아래처럼, 디폴트가 아닌 커스텀한 request를 생성하고 싶을 때, SessionManager를 사용하면 된다.

    • URLSession의 configuration을 default가 아닌 다른 값으로 설정하거나,
    • Background Session을 만든다던가,
    • 디폴트 헤더를 넣어주길 원한다던가

    물론 Alamofire.request()로 생성한 request에 커스텀한 옵션을 추가해도 된다.


    아래는 SessionManager를 사용하여 request를 생성하는 예제 코드다.

    Default Configuration으로 Session Manager 생성하기

    // Default Session: 기본적인 Session으로 디스크 기반 캐싱을 지원합니다.
    let configuration = URLSessionConfiguration.default
    let sessionManager = Alamofire.SessionManager(configuration: configuration)
    

    Background Configuration으로 Session Manager 생성하기

    // Background Session: 앱이 종료된 이후에도 통신이 이뤄지는 것을 지원하는 세션입니다.
    let configuration = URLSessionConfiguration.background(withIdentifier: "com.example.app.background")
    let sessionManager = Alamofire.SessionManager(configuration: configuration)
    

    Ephemeral Configuration으로 Session Manager 생성하기

    // Ephemeral Session: 어떠한 데이터도 저장하지 않는 형태의 세션입니다. (1회성 리퀘스트)
    let configuration = URLSessionConfiguration.ephemeral
    let sessionManager = Alamofire.SessionManager(configuration: configuration)
    

    디폴트 헤더 설정하기

    var defaultHeaders = Alamofire.SessionManager.defaultHTTPHeaders
    defaultHeaders["DNT"] = "1 (Do Not Track Enabled)"
    
    let configuration = URLSessionConfiguration.default
    configuration.httpAdditionalHeaders = defaultHeaders
    
    let sessionManager = Alamofire.SessionManager(configuration: configuration)
    
    디폴트가 아닌 옵션을 적용한다는 것이 포인트이니, Background가 뭔지 Ephemeral이 뭔지는 몰라도 무방합니다.


    SessionDelegate

    SessionManager는 SessionDelegate타입의 프로퍼티를 갖고 있다. SessionDelegate는 URLSession에서 발생하는 콜백함수들을 처리하기 위한 Delegate역할을 하는 객체이고, SessionManager가 디폴트로 생성하여 오너쉽을 갖는다.

    /// SessionManager의 생성자
    public init(
            configuration: URLSessionConfiguration = URLSessionConfiguration.default,
            delegate: SessionDelegate = SessionDelegate(),
            serverTrustPolicyManager: ServerTrustPolicyManager? = nil)
        {
            self.delegate = delegate
            self.session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
    
            commonInit(serverTrustPolicyManager: serverTrustPolicyManager)
        }


    SessionManager와 SessionDelegate요약

    • Alamofire는 Foundation프레임워크의 URLSession을 기반으로 네트워킹기능을 제공하고 있다.
    • SessionManager로 request를 생성하면 커스텀한 request를 생성하기 편하다.
    • Alamofire.request(:_)로 생성한 request는 SessionManager에서 default로 생성한 request와 같다.
    • SessionDelegate는 URLSession에서 발생하는 콜백함수들을 처리하기 위한 Delegate역할을 하는 객체이고, SessionManager가 디폴트로 생성하여 오너쉽을 갖는다.



    Adapting and Retrying Requests

    Alamofire에서는 RequestAdapter와 RequestRetrier를 설명하면서 OAuth를 구현하는 상황을 가정하고 있습니다.

    예제코드를 볼 때, 참고 바랍니다.


    RequestAdapter

    Retrier는 이름에서 감이 온다. 하지만 Adapter는 무엇일까? 결론부터 말하면 Adapter는 딱히 필요 없다고 보여진다.

    SessionManager는 RequestAdapter타입의 프로퍼티를 갖고 있다.

    public protocol RequestAdapter {
        /// Inspects and adapts the specified `URLRequest` in some manner if necessary and returns the result.
        /// (파라미터로 전달받은 urlReqeust를 적절히 수정하여 리턴한다.)
        func adapt(_ urlRequest: URLRequest) throws -> URLRequest
    }
    
    open class SessionManager {
        /// The request adapter called each time a new request is created.
        open var adapter: RequestAdapter?
    ...
    }
    

    그리고 request를 생성할 때마다, 이 adapter를 사용한다. adapter는 request를 생성하기 전에, request에 원하는 작업을 수행시키는 역할을 한다. 즉, 개발자가 원하는 request 커스터마이징이 진행된다.

    원하는 헤더가 삽입된 request가 생성된다.

    class AccessTokenAdapter: RequestAdapter {
        private let accessToken: String
    
        init(accessToken: String) {
            self.accessToken = accessToken
        }
    
        /// "https://httpbin.org"로 요청하는 리퀘스트는 헤더필드에 "Authorization"이 추가된다.
        func adapt(_ urlRequest: URLRequest) throws -> URLRequest {
            var urlRequest = urlRequest
    
            if let urlString = urlRequest.url?.absoluteString, urlString.hasPrefix("https://httpbin.org") {
                urlRequest.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
            }
    
            return urlRequest
        }
    }
    
    let sessionManager = SessionManager()
    sessionManager.adapter = AccessTokenAdapter(accessToken: "1234")
    
    sessionManager.request("https://httpbin.org/get")



    RequestRetrier

    RequestRetrier 프로토콜은 Request에서 Error가 발생했을 때, 재시도할 수 있는 기능을 제공한다.

    public protocol RequestRetrier {
        /// Determines whether the `Request` should be retried by calling the `completion` closure.
        ///
        /// This operation is fully asychronous. 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.
        ///
        /// completion 클로져에서 Request를 재시도할 지 말지 지정할 수 있다.
        ///
        /// 이 메소드는 비동기로 작동한다. 따라서, Request를 재시도할 지 말지 판단할 때, 많은 시간을 써도 문제없다.
        /// 다만, Request가 완료되었음을 알려주기 위해 completion 클로져는 반드시 실행되어야 한다.
        func should(_ manager: SessionManager, retry request: Request, with error: Error, completion: @escaping RequestRetryCompletion)
    }
    
    open class SessionManager {
        /// The request retrier called each time a request encounters an error to determine whether to retry the request.
        open var retrier: RequestRetrier? {
            get { return delegate.retrier }
            set { delegate.retrier = newValue }
        }
    ...
    }
    

    Ex) RequestAdapter와 RequestRetrier를 이용하여 OAuth를 구현하기.

    class OAuth2Handler: RequestAdapter, RequestRetrier {
        private typealias RefreshCompletion = (_ succeeded: Bool, _ accessToken: String?, _ refreshToken: String?) -> Void
    
        private let sessionManager: SessionManager = {
            let configuration = URLSessionConfiguration.default
            configuration.httpAdditionalHeaders = SessionManager.defaultHTTPHeaders
    
            return SessionManager(configuration: configuration)
        }()
    
        private let lock = NSLock()
    
        private var clientID: String
        private var baseURLString: String
        private var accessToken: String
        private var refreshToken: String
    
        private var isRefreshing = false
        private var requestsToRetry: [RequestRetryCompletion] = []
    
        // MARK: - Initialization
    
        public init(clientID: String, baseURLString: String, accessToken: String, refreshToken: String) {
            self.clientID = clientID
            self.baseURLString = baseURLString
            self.accessToken = accessToken
            self.refreshToken = refreshToken
        }
    
        // MARK: - RequestAdapter
    
        func adapt(_ urlRequest: URLRequest) throws -> URLRequest {
            if let urlString = urlRequest.url?.absoluteString, urlString.hasPrefix(baseURLString) {
                var urlRequest = urlRequest
                urlRequest.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
                return urlRequest
            }
    
            return urlRequest
        }
    
        // MARK: - RequestRetrier
    
        func should(_ manager: SessionManager, retry request: Request, with error: Error, completion: @escaping RequestRetryCompletion) {
            lock.lock() ; defer { lock.unlock() }
    
            if let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401 {
                requestsToRetry.append(completion)
    
                if !isRefreshing {
                    refreshTokens { [weak self] succeeded, accessToken, refreshToken in
                        guard let strongSelf = self else { return }
    
                        strongSelf.lock.lock() ; defer { strongSelf.lock.unlock() }
    
                        if let accessToken = accessToken, let refreshToken = refreshToken {
                            strongSelf.accessToken = accessToken
                            strongSelf.refreshToken = refreshToken
                        }
    
                        strongSelf.requestsToRetry.forEach { $0(succeeded, 0.0) }
                        strongSelf.requestsToRetry.removeAll()
                    }
                }
            } else {
                completion(false, 0.0)
            }
        }
    
        // MARK: - Private - Refresh Tokens
    
        private func refreshTokens(completion: @escaping RefreshCompletion) {
            guard !isRefreshing else { return }
    
            isRefreshing = true
    
            let urlString = "\(baseURLString)/oauth2/token"
    
            let parameters: [String: Any] = [
                "access_token": accessToken,
                "refresh_token": refreshToken,
                "client_id": clientID,
                "grant_type": "refresh_token"
            ]
    
            sessionManager.request(urlString, method: .post, parameters: parameters, encoding: JSONEncoding.default)
                .responseJSON { [weak self] response in
                    guard let strongSelf = self else { return }
    
                    if 
                        let json = response.result.value as? [String: Any], 
                        let accessToken = json["access_token"] as? String, 
                        let refreshToken = json["refresh_token"] as? String 
                    {
                        completion(true, accessToken, refreshToken)
                    } else {
                        completion(false, nil, nil)
                    }
    
                    strongSelf.isRefreshing = false
                }
        }
    }
    
    let baseURLString = "https://some.domain-behind-oauth2.com"
    
    let oauthHandler = OAuth2Handler(
        clientID: "12345678",
        baseURLString: baseURLString,
        accessToken: "abcd1234",
        refreshToken: "ef56789a"
    )
    
    let sessionManager = SessionManager()
    sessionManager.adapter = oauthHandler
    sessionManager.retrier = oauthHandler
    
    let urlString = "\(baseURLString)/some/endpoint"
    
    sessionManager.request(urlString).validate().responseJSON { response in
        debugPrint(response)
    }
    

    OAuth2Handler는 SessionManager에 adapter와 retrier를 모두 제공하고 있다.

    그 결과 API에 필요한 access token이 잘못된 경우, 자동으로 access token을 재발급하는 기능이 구현되었다.


    개인적 의견

    Alamofire에서 Retry를 하는 코드를 살펴보면 URLSessionTaskDelegate의 콜백메소드인urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)에서 호출된다. 즉, RequestRetrier는 HTTP 통신의 결과에서 에러를 검출되어야 작동하는 것이다.

    API를 사용하는 환경에서 발생하는 예외케이스는 몇 가지가 있겠다.

    1. HTTP통신 자체에서 에러 발생하는 경우. Ex) 500, 404 등
    2. HTTP통신은 성공적이지만, 기대하는 결과값이 아닌 경우. Ex) 좋아요 어뷰징 방지, 삭제된 글 접근 등

    1의 경우는 RequestAdapter RequestRetrier를 통해 해결이 가능한 반면, 2의 경우는 HTTP 통신 자체는 성공적이었으므로, RequestRetrier가 반응하지 않을 것이다.

    따라서, RequestAdapter RequestRetrier를 적절히 사용한다면, API를 호출하는 주요로직에서 코드를 분리할 수 있는 장점이 있다고 판단된다.

    하지만, 2와 같은 예외케이스를 didCompleteWithError메소드에서 걸러지지 않기 때문에, 쓰지 않는편이 좋겠다.


    댓글 1

Designed by Tistory.