wift 정리 - URLSession + Combine

19604 단어 정리swiftiOSiOS

출처: Processing URL Session Data Task Results with Combine - Apple Developer

비동기 연산자들을 사용해 URL로부터 받아온 데이터를 처리하는 방법을 알아봅니다.

네트워크 작업은 본질적으로 비동기 작업이고, 이러한 비동기 작업들을 처리하는 방법은 여러 가지가 있다.
Combine 또한 비동기를 처리하는 프레임워크이므로, 이를 사용하여 네트워크 작업을 간단하게 처리할 수 있다.

dataTaskPublisher(for:)

URLSessionURL 혹은 URLRequest로부터 받아온 데이터를 publish하기 위한 URLSession.DataTaskPublisher라는 Combine publisher을 제공한다.

dataTaskPublisher(for:) 메서드를 통해 publisher을 생성할 수 있고, 이는 다음 두 가지를 방출할 수 있다.

  • task 성공 시
    data와 URLResponse로 이루어진 튜플
  • task 실패 시
    error

기존 dataTask(with:completionHandler:)와는 다르게, publisher이 옵셔널을 해제해 줍니다.

또한 completion handler을 사용한 코드는 관련 작업들을 전부 다 completion handler 클로저를 사용하여 처리해야 합니다. 하지만 publisher을 사용한다면 클로저의 수많은 작업들을 Combine 연산자들로 대체할 수 있습니다.

Raw Data -> Custom Type

네트워크 작업을 마치면 Data 타입의 값이 전달됩니다.
Combine은 Data에서 Custom Type으로 변환하는 작업을 연산자들로 지원해줍니다.

위에서 언급한 대로, task 성공DataURLResponse가 전달되는데, map(_:) 또는 tryMap(_:)으로 타입을 변경해줄 수 있습니다.

DataCustom Type으로 변경하기 위해
(Custom TypeDecodable을 채택한다고 가정합니다.)
Combine의 decode(type:decoder:)을 사용합니다.

다음은 URL에서 받은 json 데이터를 User 타입으로 변환하는 과정입니다.

struct User: Codable {
    let name: String
    let userID: String
}
let url = URL(string: "https://example.com/endpoint")!
cancellable = urlSession
    .dataTaskPublisher(for: url)
    .tryMap() { element -> Data in
        guard let httpResponse = element.response as? HTTPURLResponse,
            httpResponse.statusCode == 200 else {
                throw URLError(.badServerResponse)
            }
        return element.data
        }
    .decode(type: User.self, decoder: JSONDecoder())
    .sink(receiveCompletion: { print ("Received completion: \($0).") },
          receiveValue: { user in print ("Received user: \(user).")})
  • dataTaskPublisher(for: url) 로 네트워크 작업을 시작합니다.

  • tryMap() { element -> Data in } 으로 원래 dataTaskPublisher이 방출하는 튜플인 (Data, Response)을 검사하고, 원하는 형태로 가공합니다.

  • decode(type: User.self, decoder: JSONDecoder()) 으로 tryMap()에서 내려온 data를 User타입으로 변환합니다.

Retry + Error Handling

네트워크 작업은 수많은 에러가 예상되는 작업입니다. 따라서 우리의 앱은 에러를 훌륭히 처리해줘야 할 필요가 있습니다.
경우에 따라서 실패한 작업을 재시도해야하는 상황이 필요할 수 있습니다.

completion handler을 사용한다면, 재시도를 위해 handler을 다시 작성해야 합니다.

retry(_:)

Combine은 retry(_:) 한 줄로 가능합니다.
에러를 받으면 upstream publisher에 대한 구독을 지정된 횟수만큼 다시 생성합니다.

하지만 네트워크 작업이 비용이 많이 드는 작업이므로 너무 많이 재시도를 요청하는것은 좋지 않습니다. 또한 모든 요청이 유효한지 확인해야 합니다.

catch(_:)

에러를 다른 publisher로 변환합니다.
fallback URL에서 데이터를 로드하는 것과 같이, 다른 URLSession.DataTaskPublisher과 사용할 수 있습니다.

replaceError(with:)

개발자가 제공한 내용으로 에러를 변경합니다.
에러를 다른 내용으로 변경하여, 성공했을 때와 동일한 처리를 실행할 수도 있습니다.

let pub = urlSession
    .dataTaskPublisher(for: url)
    .retry(1)
    .catch() { _ in
        self.fallbackUrlSession.dataTaskPublisher(for: fallbackURL)
    }
cancellable = pub
    .sink(receiveCompletion: { print("Received completion: \($0).") },
          receiveValue: { print("Received data: \($0.data).") })
  • retry(1) 로, 에러가 발생하면 upstream publisher에 대한 구독을 한 번 다시 생성합니다.
  • catch() 로, 에러가 발생하면 fallBackUrlSession을 호출할 수 있습니다.
    해당 구문이 실행되는 것은 retry(1)을 한 번 거친 후 실행된다는 것을 의미하므로, 두 번째 error이라고 할 수 있습니다.

Dispatch Queue + Scheduling Operators

URLSession을 completion handler로 처리할 때에는 handler 내부에서 직접 Dispatch Queue 등을 사용하여 특정 큐로 작업을 옮겨야 했습니다.

receive(on:options:) 메서드로 해당 메서드 이후의 subscriber와 operator의 작업 큐를 지정해줄 수 있습니다.

DispatchQueue와 RunLoop 모두 Combine의 Scheduler 프로토콜을 구현했기 때문에 바로 사용할 수 있습니다.

cancellable = urlSession
    .dataTaskPublisher(for: url)
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { print ("Received completion: \($0).") },
          receiveValue: { print ("Received data: \($0.data).")})
  • receive(on: DispatchQueue.main)으로, sink의 로그를 Dispatch Queue의 main에서 출력합니다.

Share Data Task Publisher

네트워크 작업은 비용이 많이 드는 작업입니다. 따라서 하나의 요청으로 앱의 여러 부분에서 하나의 DataTaskPublisher을 구독할 수 있습니다.

Combine의 share() 연산자를 사용하면 여러 연산자 및 subscriber을 연결할 수 있으며, upstream publisher은 하나의 downstream만을 확인할 수 있습니다. 이는 URLSession.DataTaskPublisher이 네트워크 작업을 한 번만 수행한다는 의미입니다.

let sharedPublisher = urlSession
    .dataTaskPublisher(for: url)
    .share()

cancellable1 = sharedPublisher
    .tryMap() {
        guard $0.data.count > 0 else { throw URLError(.zeroByteResource) }
        return $0.data
    }
    .decode(type: User.self, decoder: JSONDecoder())
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { print ("Received completion 1: \($0).") },
          receiveValue: { print ("Received id: \($0.userID).")})

cancellable2 = sharedPublisher
    .map() {
        $0.response
    }
    .sink(receiveCompletion: { print ("Received completion 2: \($0).") },
           receiveValue: { response in
            if let httpResponse = response as? HTTPURLResponse {
                print ("Received HTTP status: \(httpResponse.statusCode).")
            } else {
                print ("Response was not an HTTPURLResponse.")
            }
    }
)
  • share()으로, 여러 subscriber이 있지만 네트워크 작업을 한 번만 수행할 수 있도록 합니다.

  • 두 개의 subscriber을 sharedPublisher에 연결합니다.

  • sharedPublisher이 정말로 한 번만 요청하는지 확인하려면 .print(_:to:).share()연산자 위에 넣어 디버깅할 수 있습니다.

URLSession은 downstream subscriber이 연결되지 않아도, 첫 번째 sink가 연결되면 데이터를 로드합니다.
다른 subscriber을 연결하는 데 시간이 필요하다면 makeConnectable()을 사용하여 Publisher.Share publisher을 ConnectablePublisher로 변환합니다.
그 후 모든 subscriber이 준비되면, connect() 메서드로 데이터 로드를 시작할 수 있습니다.

좋은 웹페이지 즐겨찾기