ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [iOS/Swift] Combine 알아보기 vi
    야매 iOS 2023. 3. 26. 17:09

    Combine으로 네트워킹하기

    네트워킹은 여러 애플리케이션의 중요한 요소이다.

    • Apple은 Combine을 사용해 원활하게 네트워킹을 할 수 있도록 URLSession의 extension을 제공한다.

    Combine을 사용해 간단한 네트워킹 레이어 만들기

    일반적인 방식

    가장 일반적으로 네트워킹을 사용하는 방법은 아래와 같다.

    func fetchURL<T: Decodable>(_ url: URL, completion: @escaping (Result<T, Error>) -> Void) {
        URLSession.shared.dataTask(with: url) { data, response, error intSubject
            guard let data else {
                if let error {
                    completion(.failure(error))
                }
                assertionFailure("Callback got called with no data nad no error")
            }
            do {
                let decoder = JSONDecoder()
                let decodedResponse = try decoder.decode(T.self, from: data)
                completion(.success(decodedResponse))
            } catch {
                completion(.failure(error))
            }
    
        }
        .resume()
    }
    

    위 코드는 사용하는데 문제가 없지만 코드의 복잡도를 줄여 개선할 수 있다.

    1. data task의 콜백은 유저 친화적이지 않다.
      • (Data?, URLResponse?, Error?) → Void는 개발자에게 callback이 정확하게 어떻게 동작해야 하는지 알려주지 않는다.
      • 처음 이것을 접했을 때 3개의 인자 모두 nil이 될 수 있다는 생각을 가지게 한다.
      • 그러므로 개발자는 data가 존재하는지, 에러가 존재하는지 모두 알아봐야 한다.
        • 경험적으로 data와 error가 모두 nil이 될 수 없다는 사실을 알 수 있다.
    2. 콜백 내부에서 JSON 디코딩을 진행한다.
      • 이 코드는 실패할 때 에러를 방출한다.
      • 에러가 발생했을 때 이것이 디코딩으로부터 생성된 에러인지 디코딩이 실패해 발생한 에러인지 확실하게 알 수 없다.
    3. 모든 경로에서 completion을 호출하는지 확인해야 한다.

    Combine 적용

    func fetchURL<T: Decodable> (_ url: URL) -> AnyPublisher<T, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
            .tryMap({ result in
                let decoder = JSONDecoder()
                return try decoder.decode(T.self, from: result.data)
            })
            .eraseToAnyPublisher
    }
    

    Combine을 사용하지 않은 버전보다 짧고 가독성이 높고 어떤 작업을 하는지 확실히 알 수 있다.

    dataTaskPublisher(for:)를 호출하면

    • 성공 시 (data: Data, response: URLResponse)
    • 실패 시 URLError

    네트워크 요청이 성공하고 실패했을 때 어떤 일이 일어나는지 확실히 알 수 있다.

    • 네트워크 요청이 실패한다면 tryMap을 건너뛰고 subscriber에게 에러가 전달된다.
    • 성공한다면 tryMap의 인자로 사용된다.
      • tryMap을 사용했으므로 decoding이 실패하면 subscriber에게 에러가 전달된다.

    URLSession.DataTaskPublisher은 Publisher이기 때문에 subscriber가 있어야 실행된다

    // 아래 코드를 작성한다고 해서 바로 네트워킹이 시작되지 않는다.
    let publisher: AnyPublisher<Model, Error> = fetchURL(url)
    
    var cancellales = Set<AnyCancellable>()
    
    // 구독자가 있어야 fetchURL 내부의 networking이 이뤄진다. 
    publisher
    	.sink(receiveCompletion: { completion in
    			print(completion)
    	}, receiveValue: {
    			print($0)
    	})
    	.store(in: &cancellables)
    

    share

    let publisher: AnyPublisher<Model, Error> = fetchURL(url)
    
    var cancellales = Set<AnyCancellable>()
    
    publisher
    	.sink(receiveCompletion: { completion in
    			print(completion)
    	}, receiveValue: {
    			print($0)
    	})
    	.store(in: &cancellables)
    
    publisher
    	.sink(receiveCompletion: { completion in
    			print(completion)
    	}, receiveValue: {
    			print($0)
    	})
    	.store(in: &cancellables)
    

    위 코드를 실행해 보면 네트워킹이 두 번 이뤄지는 것을 알 수 있다.

    • publisher의 의도된 행동이다.

    하지만 하나의 네트워크 호출로 구독한 모든 객체에 전달하길 원한다면 share 연산자를 사용해야 한다.

    Combine에서 share 연산자를 사용하면 reference semantic을 가진 publisher로 변환한다.

    • 현존하는 publisher를 upstream publisher를 republish하는 클래스 인스턴스로 변환한다.

    → Reference Semantic을 사용해 네트워킹으로부터 얻은 값을 다른 subscriber들과 공유한다.

    republish? upstream publisher로부터 받은 이벤트를 변경없이 포워딩하는 것

    let publisher: AnyPublisher<Model, Error> = fetchURL(url)
    		.share()
    		.eraseToAnyPublisher()
    
    var cancellales = Set<AnyCancellable>()
    
    publisher
    	.sink(receiveCompletion: { completion in
    			print(completion)
    	}, receiveValue: {
    			print($0)
    	})
    	.store(in: &cancellables)
    
    publisher
    	.sink(receiveCompletion: { completion in
    			print(completion)
    	}, receiveValue: {
    			print($0)
    	})
    	.store(in: &cancellables)
    

    코드를 위와 같이 변경하면 네트워킹이 한번만 호출되는 것을 볼 수 있다.

    네트워크 요청으로부터 얻은 JSON 응답 처리하기

    대부분의 경우 data task로부터 얻은 값은 JSON 데이터다.

    디코딩을 쉽게 하기 위해 Combine은 output이 Data인 publisher에 한해 decode 연산자를 제공한다.

    func fetchURL<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
    				/*
    				URLSession.DataTaskPublisher는 (data: Data, response: URLResponse)를 방출한다.
    				그러므로 data만 추출한다.
    				*/
            .map(\\.data)
    				/*
    				decoder로 사용할 객체는 TopLevelDecoder 프로토콜을 준수해야 한다.
    				대표적으로 JSONDecoder와 PropertyListDecoder가 있다.
    				*/
            .decode(type: T.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
    

    HTTP status code 구분

    using FlatMap

    네트워킹은 생각보다 많이 복잡하다.

    네트워킹의 응답으로 에러가 오지 않아도 원하는 데이터 타입으로 파싱 되지 않을 수 있다.

    • 200대의 HTTP status code와 함께 에러를 나타내는 JSON body를 넘겨줄 수 있기 때문
    func fetchURL<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
    				/*
    				flatMap 내부에서 생성되는 publisher의 Error 타입과 맞춰야 하므로 mapError를 사용한다.
    				*/
            .mapError({ $0 as Error })
            .flatMap( { result -> AnyPublisher<T, Error> in
                guard let urlResponse = result.response as? HTTPURLResponse,
                      (200...299).contains(urlResponse.statusCode) else {
                    return Just(result.data)
    										.decode(type: APIError.self, decoder: JSONDecoder())
    										.tryMap({ errorModel in
    												throw errorModel
    										})
    										.eraseToPublisher()
                }
    
                return Just(result.data)
                    .decode(type: T.self, decoder: JSONDecoder())
                    .eraseToPublisher()
    
            })
            .eraseToAnyPublisher()
    }
    

    flatMap을 원활하게 사용하기 위해 mapError를 사용하기 때문에 복잡하지는 경향이 없지 않아 있다.

    flatMap 내부에서 .eraseToPublisher를 사용한다.

    • 성공했을 때와 실패했을 때 조금 다른 publisher를 반환하기 때문
    // 아래의 코드로 사용
    fetchURL(url)
        .sink(receiveCompletion: { completion in
            if case .failure(let error) = completion,
               let apiError = error as? APIError {
                print(error)
            }
        }, receiveValue: {
            print($0)
        })
        .store(in: &cancellables)
    

    without FlatMap

    위의 예시와 같이 flatMap을 사용하니 코드가 많이 복잡해졌다.

    flatMap 대신 tryMap을 사용할 수도 있다.

    func fetchURL<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
            .tryMap( { result in
                let decoder = JSONDecoder()
                guard let urlResponse - result.response as? HTTPURLResponse,
                      (200...299).contains(urlResponse.statusCode) else {
                    let apiError = try decoder.decode(APIError.self, from: result.data)
                    throw apiError
                }
                return try decoder.decode(T.self, from: result.data)
            })
            .eraseToAnyPublisher()
    }
    

    위의 예시는 decode 연산자를 사용하지 않는다.

    decoding을 decode 연산자를 사용할 수도 있고 JSONDecoder 인스턴스를 만들어서 할 수도 있다.

    어떤 문제를 해결하기 위해 다양한 접근법이 있고 다양한 방법이 있다. 그중 가장 현재 상황에 가장 적절한 방법을 택하면 된다.

    Authentication Token Refresh

    토큰을 사용해 인증한다면 언젠간 그 토큰을 refresh해야 할 필요가 있다.

    Combine에선 tryCatch 또는 switchToLatest를 사용해 토큰을 refresh 할 수 있다.

    SwitchToLatest

    SwitchToLatest를 이해하기 위해선 flatMap이 필요하다.

    flatMap은 publisher의 output을 갖고 이를 새로운 publisher를 변환한다.

    새로운 publisher는 subscriber에게 전달되고 하나의 publisher가 모든 값을 방출하는 것처럼 보인다.

    flatMap은 maxPublishers 인자를 사용해 활성화할 publisher의 수를 제한할 수 있다.

    • n이 maxPublishers일 때 그 중 하나의 publisher가 완료되지 않으면 새로운 값이 전달되지 않는다.

    → 어떤 상황에는 위와 같이 작동하는 것이 개발자의 니즈와 다를 수 있다

    이때 switchToLatest 사용

    switchToLatest는 publisher를 생성하는 map 연산자에 사용할 수 있다.

    • map에서 가장 최근에 만들어진 publisher가 방출한 값을 전달한다.

    SwitchToLatest 적용

    struct APIError: Decodable, Error {
        let statusCode: Int
    }
    
    func refreshToken() -> AnyPublisher<Bool, Never> {
        return Just(false).eraseToAnyPublisher()
    }
    
    func fetchURL<T:Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
            .tryMap({ result in
                let decoder = JSONDecoder()
                guard let urlResponse = result.response as? HTTPURLResponse,
                      (200...299).contains(urlResponse.statusCode) else {
                    let apiError = try decoder.decode(APIError.self, from: result.data)
                    throw apiError
                }
    
                return try decoder.decode(T.self, from: result.data)\\
            })
            .tryCatch({ error -> AnyPublisher<T, Error> in
                guard let apiError = error as APIError,
                      apiError.statusCode == 401 else {
                    throw error
                }
    
                return refreshToken()
                    .tryMap({ success -> AnyPublisher<T, Error> in
                        guard success else { throw error }
                        return fetchURL(url)
                    })
                    .switchToLatest()
                    .eraseToAnyPublisher()
    
            })
            .eraseToAnyPublisher()
    }
    

    복잡한 네트워크 요청 체인 만들기

    어느 한 뷰를 표현하기 위해 여러 네트워크 요청이 필요할 수 있다.

    그 상황에서 Combine을 사용해 어떤 방식으로 접근할 수 있는지 알아본다.

    필요한 네트워크 요청은 아래와 같다.

    1. Featured
    2. Favorites
      • 로컬에 저장된 Favorites
      • 저장소에 저장된 Favorites
    3. Curated

    Implementation

    Featured와 Curated는 모두 하나의 네트워크 요청을 사용한다.

    Favorites는 로컬과 저장소에 있는 것을 얻기 위해 개발 Publisher를 통해 얻어오고 Publishers.Zip을 사용해 하나의 publisher로 합친다.

    enum SectionType: String, Decodable {
        case featured, favorites, curated
    }
    
    struct Event: Decodable, Hashable {
    
    }
    /*
    모든 publisher가 output을 HomePageSection으로 매핑한다.
    */
    struct HomePageSection {
        let events: [Event]
        let sectionType: SectionType
    
        static func featured(events: [Event]) -> HomePageSection {
            return HomePageSection(events: events, sectionType: .featured)
        }
    
        static func favorites(events: [Event]) -> HomePageSection {
            return HomePageSection(events: events, sectionType: .favorites)
        }
    
        static func curated(events: [Event]) -> HomePageSection {
            return HomePageSection(events: events, sectionType: .curated)
        }
    }
    

    Featured & Curated

    var featuredPublisher = URLSession.shared.dataTaskPublisher(for: featuredURL)
        .map({ $0.data })
        .decode(type: [Event].self, decoder: JSONDecoder())
    		// 에러가 있다면 빈 배열
        .replaceError(with: [])
        .map({ HomePageSection.featured(events: $0)})
        .eraseToAnyPublisher()
    
    var curatedPublisher = URLSession.shared.dataTaskPublisher(for: curatedURL)
        .map({ $0.data })
        .decode(type: [Event].self, decoder: JSONDecoder())
        .replaceError(with: [])
        .map( { HomePageSection.curated(events: $0) })
        .eraseToAnyPublisher()
    

    Favorites

    class LocalFavorites {
        static func fetchAll() -> AnyPublisher<[Event], Never> {
    
        }
    }
    
    var localFavoritesPublisher = LocalFavorites.fetchAll()
    
    var remoteFavoritesPublisher = URLSession.shared.dataTaskPublisher(for: curatedURL)
        .map({ $0.data })
        .decode(type: [Event].self, decoder: JSONDecoder())
        .replaceError(with: [])
        .eraseToAnyPublisher()
    
    var favoritePublisher = Publishers.Zip(localFavoritesPublisher, remoteFavoritesPublisher)
        .map({ favorites -> HomePageSection in
            let uniqueFavorites = Set(favorites.0 + favorites.1)
            return HomePageSection.favorites(events: Array(uniqueFavorites))
        })
        .eraseToAnyPublisher()
    

    Rendering

    var viewPublisher = Publishers.Merge3(featuredPublisher, favoritePublisher, curatedPublisher)
    // 먼저 준비된 것을 먼저 뷰에 렌더링 한다. 
    viewPublisher
        .sink(receiveValue: { section in
            switch section.sectionType {
            case .featured:
    
            case .curated:
    
            case .favorites:
            }
        })
        .store(in: &cancellables)
    

    '야매 iOS' 카테고리의 다른 글

    [iOS/Swift] Combine 알아보기 viii  (0) 2023.03.26
    [iOS/Swift] Combine 알아보기 vii  (0) 2023.03.26
    [iOS/Swift] Combine 알아보기 v  (0) 2023.03.05
    [iOS/Swift] Combine 알아보기 iv  (0) 2023.02.27
    [iOS/Swift] Combine 알아보기 iii  (0) 2023.02.25
Designed by Tistory.