• [Swift/iOS] Alamofire & Combine 조합으로 네트워크 레이어 만들기

    2021. 1. 22.

    by. dundin

    반응형

    Swift Combine 기반으로 개발을 하다가 Alamofire & Combine Framework 기반의 괜찮은 네트워크 구조가있어서 벤치마킹 해 보았다. 

    아래 링크를 참고

    www.vadimbulavin.com/modern-networking-in-swift-5-with-urlsession-combine-framework-and-codable/

     

    Modern Networking in Swift 5 with URLSession, Combine and Codable

    Making HTTP requests is one of first things to learn when starting iOS and macOS development with Swift 5. In this article we'll build modern networking layer with Swift 5 APIs: URLSession, the Combine framework and Codable. We'll discuss why such librarie

    www.vadimbulavin.com

     

    Alamofire5 부터 Combine을 지원하기 시작했는데, Combine 관련 공식 문서 링크는 다음과 같다. 

    github.com/Alamofire/Alamofire/blob/master/Documentation/AdvancedUsage.md#using-alamofire-with-combine

     

    Alamofire/Alamofire

    Elegant HTTP Networking in Swift. Contribute to Alamofire/Alamofire development by creating an account on GitHub.

    github.com

     

    🏢 전체적인 구조 

    Agent의 run 함수 

    실제 Alamofire Combine을 이용해서 네트워크를 호출하는 부분으로 DataRequest는 Alamofire 프레임워크의 일부이고 리턴되는 Publisher는 Combine 프레임워크의 구조체이다. decoder는 응답을 원하는 데이터 타입 T로 디코드 할수 있는 디코더를 넣어주면 된다. 

    func run<T: Decodable>(_ request: DataRequest, _ decoder: JSONDecoder = JSONDecoder()) 
         -> AnyPublisher<Response<T>, APIError>

    Agent 의 전체 코드

    //
    //  Agent.swift
    //
    
    import Foundation
    import Combine
    import Alamofire
    
    // 1. Error 타입 정의 
    enum APIError: Error {
        case http(ErrorData)
        case unknown
    }
    
    // 2. ErrorData 안에 들어갈 정보 선언 
    struct ErrorData: Codable {
        var statusCode: Int
        var message: String
        var error: String?
    }
    
    struct Agent {
    	// 4. Resonse 선언 
        struct Response<T> {
            let value: T
            let response: URLResponse
        }
    
        func run<T: Decodable>(_ request: DataRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<Response<T>, APIError> {
            return request.validate().publishData(emptyResponseCodes: [200, 204, 205]).tryMap { result -> Response<T> in
                if let error = result.error {
                    if let errorData = result.data {
                        let value = try decoder.decode(ErrorData.self, from: errorData)
                        throw APIError.http(value)
                    }
                    else {
                        throw error
                    }
                }
                if let data = result.data {
                // 응답이 성공이고 result가 있을 때 
                    let value = try decoder.decode(T.self, from: data)
                    return Response(value: value, response: result.response!)
                } else {
                // 응답이 성공이고 result가 없을 때 Empty를 리턴 
                    return Response(value: Empty.emptyValue() as! T, response: result.response!)
                }                
            }
            .mapError({ (error) -> APIError in            
                if let apiError = error as? APIError {
                    return apiError
                } else {
                    return .unknown
                }
            })
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
        }
    }
    

     

    이렇게 만들어놓은 기본적인 APIAgent는 APIClient 가 사용한다. APIClient는 baseURL 별로 따로 만드는 것이 일반적이고 함수 하나 당 API task 하나를 수행하게 한다. 

     

    아래 예시는 

    1) 로컬에 있는 file id를 ApiClient 의 urlForUpload GET 으로 호출해서 AWSUrl을 응답으로 받고 

    2) AWSClient를 이용해서 위에서 받은 id 로 AWS 서버에 파일을 업로드 하는 함수를 각각 구현한 것이다. 

    enum ApiClient {
        static let agent = Agent()
        static let base = "https://test-api.dundinstudio.com/"
    
        static func getUploadURL(caseId: String, uuid: String) -> AnyPublisher<GetUploadURLResponse, APIError> {
            var urlComps = URLComponents(string: base + "/urlForUpload")!
            urlComps.queryItems = [
                URLQueryItem(name: "itemId", value: itemId),
            ]
            
            let request = AF.request(urlComps.url!)
            return agent.run(request)
                .map(\.value)
                .eraseToAnyPublisher()
        }
    } 
    
    
    enum AWSClient {
        static let agent = Agent()    
        static func uploadFile(url: String, fileURL: URL) -> AnyPublisher<Alamofire.Empty, APIError> {
            let request = AF.upload(fileURL, to: url, method: .put)
            return agent.run(request)
                .map(\.value)
                .eraseToAnyPublisher()
        }
    }

     

     

    🏢 API Publisher 조합하기 

    Combine과 Alamofire 이 조합이 너무 깔끔한 이유는 다음 예제 코드에 있다.

     

    file A, file B의 uploadURL을 각각 요청해서 AWS에 업로드 한 후 두 작업이 모두 끝났을 때 이를 조합해서 Api 서버에 싱크되었다는 상태를 저장해 보자.  (요약된 코드) 

    // Viewmodel.swift 
    
    let audioUpload = ApiClient.getUploadURL(uuid: item.id)
        .map { $0.signedUrl }
        .flatMap { signedUrl in
            AWSClient.uploadAudioFile(url: signedUrl, audioURL: item.audioURL)
                .map { empty -> String in
    				return signedUrl
                }
        }
    
    let backgroundUpload = ApiClient.getUploadURL(uuid: item.id)
        .map { $0.signedUrl }
        .flatMap { signedUrl in
            AWSClient.uploadAudioFile(url: signedUrl, audioURL: item.backgroudURL) 
                .map { empty -> String in
                    return signedUrl
                }
        }
            
    Publishers.CombineLatest(audioUpload, backgroundUpload)
        .map { (audioURL, bgURL) -> AudioItem in
     		return AudioItem(audioURL, bgURL) 
        }
        .flatMap { item in
            ApiClient.syncToServer(item)
        }
        .sink(receiveCompletion: { (completion) in
            switch completion {
                case .finished:
                    Toast.show("동기화 완료")                
                case .failure(let error):
                    switch error {
                    case .http(let error):
                        Toast.show(error.message)
                    case .unknown:
                        Toast.show("동기화 중 에러가 발생했습니다. 다시 시도해 주세요.")
                    }
            }
        },
        receiveValue: {
            log.debug("receive Value:\($0)")
        })
        .store(in: &self.subscriptions)
    

     

    여기서 가장 중요하게 봐야 할 것은 map 과 flatMap이 Swift 에서 기본적으로 제공하는 함수와는 다르다는 것이다. Combine에서의 map은 Publisher가 뱉은 값을 단순히 치환해 주는 것이라면 flatMap은 이전 publisher를 새로운 publisher로 바꿔주는 역할을 한다. 

     

    또한 audioUpload 와 backgroundUpload 두개의 publisher를 combineLatest로 묶은 부분도 나중에 다시 보기 좋을 것 같아서 정리했다. 

    반응형

    댓글