👨🏻‍💻iOS 공부/iOS & Swift

[iOS] URLSession Completion Handler와 Delegate

728x90
반응형

URLSession Completion Handler와 Delegate 를 활용하여 데이터 받아오는 방법

 

URL Session을 통해 data task를 생성하고 메모리에 직접적으로 데이터를 받아보자.

 

서버와 자그마한 상호작용을 위해서는 URLSessionDataTask 클래스를 사용하여 메모리에 response data를 받을 수 있다. 만약 데이터를 파일 시스템에 다운로드 받아야 한다면, URLSessionDownloadTask를 사용하면 된다. data task는 웹 서비스를 호출할 때 이상적인 방법이다.

 

task를 생성하기 위해 URL session 인스턴스를 사용하게 된다. 만약 간단한 경우라면 URLSession 클래스의 shared 인스턴스를 사용할 수 있다. 만약 delegate의 콜백을 통해 상호작용 해야한다면, shared 인스턴스를 쓰기 보다는 session을 생성할 필요가 있다. 세션을 생성할 때는 URLSessionConfiguration 인스턴스를 사용하고, 또한 URLSessionDelegate 또는 하위 프로토콜 중 하나를 구현하는 클래스도 전달한다. (이 부분은 나중에 코드와 함께 보면 이해가 갈 것이다!) 세션은 여러 tasks를 생성하기 위해 재사용 될 수 있으며 이에 각각에 해당하는 유일한 configuration이 필요하고, 세션을 생성하고 프로퍼티로 저장해서 사용해야 한다.

필요 이상의 세션을 생성하지 않도록 주의해야한다. 예를 들어, 유사하게 구성된 세션을 필요로 하는 경우 하나의 세션을 만들고 이를 계속 공유하여 사용하면 된다.

 

세션을 보유하고 있다면 dataTask() 메서드를 통해 data task를 생성할 수 있다. task는 suspended 상태로 생성되기 때문에 resume()을 호출하여 시작시켜줘야 한다.

Completion Handler를 활용하여 결과 받기

데이터를 받기 위한 가장 간단한 방법은 data task를 생성하고 completion handler에서 사용하는 것이다. 이에 task는 서버의 response, data, 가능한 error를 completion handler 블록에 전달해준다. 아래의 그림을 보면 세션과 task간의 관계, 어떻게 completion handler에 결과가 전달되는지 알 수 있다.

 

 

completion handler를 사용하여 data task를 생성할 수 있다(dataTask() 호출) 이제 completion handler는 3가지 일을 해야한다.

 

  1. error 파라미터가 nil인지 확인한다. 만약 nil이 아니라면 전송 에러가 발생한 것으로 에러를 처리하고 종료시켜준다.
  2. response 파라미터를 확인하여 성공을 나타내는 상태 코드를 확인하고 MIME 타입이 원하는 값인지 확인해야한다. 만약 아니라면 서버 에러를 처리하고 종료한다.
  3. data 인스턴스를 필요에 따라 사용하면 된다.

 

물론 문서의 예제가 제일 좋지만 직접 실험해보고 적용해본 코드로 살펴보자.

 

간단하게 살펴보자면 Zoo Animal API를 활용하여 랜덤한 동물을 화면에 보여주도록 한 것이다.

먼저 모델을 만들어준다.

struct Animal: Codable {
    let name, latinName, animalType, activeTime: String
    let lengthMin, lengthMax, weightMin, weightMax: String
    let lifespan, habitat, diet, geoRange: String
    let imageLink: String
    let id: Int

    enum CodingKeys: String, CodingKey {
        case name
        case latinName = "latin_name"
        case animalType = "animal_type"
        case activeTime = "active_time"
        case lengthMin = "length_min"
        case lengthMax = "length_max"
        case weightMin = "weight_min"
        case weightMax = "weight_max"
        case lifespan, habitat, diet
        case geoRange = "geo_range"
        case imageLink = "image_link"
        case id
    }
}

모델을 만드는 부분까지 간단하다.

 

뷰는 만들었다고 가정하고, 핵심이 되는 네트워킹 부분을 살펴보자. 우선 첫 번째로 볼 방법은 completion handler를 활용하는 것이니 이에 포커스를 맞춰보자.

 

  1. url, request 생성
  2. shared 인스턴스에 dataTask() 호출
  3. completion handler에 넘어오는 data, response, error 처리
  4. 순서대로 error -> response -> data 검증
  5. 데이터 디코딩 성공시 completion handler 실행!
  6. 생성한 task resume 시키기
class APIService {
    func getAnimalData(completion: @escaping (Animal) -> ()) {
        guard let url = URL(string: "https://zoo-animal-api.herokuapp.com/animals/rand") else {
            return
        }
        let request = URLRequest(url: url)


        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            // error 검증
            if let error = error {
                print(error.localizedDescription)
                return
            }

            // response status code 검증
            guard let httpResponse = response as? HTTPURLResponse,
                  (200..<300).contains(httpResponse.statusCode) else {
                      print("error")
                      return
                  }

            // data 검증 및 사용
            if let data = data {
                do {
                    let receivedData = try JSONDecoder().decode(Animal.self, from: data)
                    completion(receivedData)
                } catch {
                    print(error.localizedDescription)
                }
            }

        }

        task.resume()
    }
}

url로 넘어오는 이미지도 UIImage로 변환해주는 코드를 추가해보자.

 

class ImageLoader {
    static func loadImage(from url: String, completion: @escaping (UIImage?) -> ()) {
        // 입력된 url 체크
        if url.isEmpty {
            completion(nil)
            return
        }

        // URL 타입으로 변경
        guard let imageURL = URL(string: url) else {
            return
        }


        DispatchQueue.global(qos: .background).async {
            // URL -> Data
            if let data = try? Data(contentsOf: imageURL) {
                // Data -> UIImage
                let image = UIImage(data: data)!

                // UI 업데이트를 위한 main 쓰레드 사용
                DispatchQueue.main.async {
                    completion(image)
                }
            } else {
                DispatchQueue.main.async {
                    completion(nil)
                }
            }
        }
    }
}

간단히 보자면 여기도 마찬가지로 @escaping를 활용하여 문자열을 UIImage로 변환시키고 있음을 알 수 있다.

내부 로직을 잠깐 살펴보자.

 

  1. url 문자열 검사
  2. string -> URL
  3. URL -> Data
  4. (성공시) Data -> UIImage & UI업데이트
  5. (실패시) nil

 

자 이제 모든 준비가 되었다!

뷰 컨트롤러에서 세팅을 마치고 버튼의 액션으로 서버에 요청해서 UI 세팅을 하는 기능을 추가해주자.

@objc func setupData() {
    let group = DispatchGroup()

    apiHandler.getAnimalData { animal in
        DispatchQueue.main.async(group: group) {
            ImageLoader.loadImage(from: animal.imageLink) { animalImage in
                self.imageView.image = animalImage
            }
        }

        group.notify(queue: DispatchQueue.main) {
            self.nameLabel.text = "동물 이름 : \(animal.name)"
            self.lifeLabel.text = "평균 수명 : \(animal.lifespan)년"
        }
    }
}

화면에는 간단하게 사진과 동물의 이름, 평균 수명 그리고 다음 랜덤한 동물을 나타내기 위한 버튼만을 추가해주었다.

이제 실행해보면 다음과 같은 결과를 얻을 수 있다. (아직 이미지 / 레이블의 동기화 순서를 잡아주지는 않았다..!)

 

조금 딜레이가 있지만 서버에 데이터를 요청해서 잘 받아오고 있는 모습을 볼 수 있다.

 

중요한 점은 completion handler가 task가 생성된 곳이 아니라 GCD(메인큐)를 활용하였다는 것이다. 그러므로 data나 error에 대해서 UI를 업데이트 해줄 수 있다(위 예제에서는 error에는 GCD 적용X)

 

Delegate를 활용하여 세부 정보와 결과를 전송받기

data task를 생성할 때에, task의 활동에 더 높은 수준의 접근을 위해 데이터 작업을 작성할 때 completion handler를 제공하는 것 대신에 세션에 delegate를 설정할 수 있다. 아래 그림에서 이를 나타내주고 있다.

 

 

이 방법을 사용하면 전송이 완료되거나 오류가 발생할 때까지 URLSessionDataDelegateurlSession(_:dataTask:didReceive:) 메서드에 데이터 일부가 제공된다. Delegate는 전송이 진행됨에 따라 다른 종류의 이벤트도 수신가능하다.

 

우선 delegate 방식으로 접근하기 위해서는 간단한 shared 인스턴스가 아닌, URLSession 인스턴스를 생성해야한다. 새로운 세션을 생성하는 것은 클래스가 세션의 delgate 역할을 가능하게 하며 이는 위 그림을 통해 나타나있다.

 

클래스 구현시에 하나 혹은 그 이상의 delegate 프로토콜(URLSessionDelegate,URLSessionTaskDelegate,URLSessionDataDelegate,URLSessionDownloadDelegate)을 정의해준다. 그리고 init(configuration:delegate:delegateQueue:)를 활용하여 URL session의 인스턴스를 생성한다. configuration 인스턴스는 이 이니셜라이저를 통해 커스터마이징 할 수 있다.

 

예를 들어서, waitsForConnectivitytrue로 두는 좋은 생각을 해볼 수 있다. 그렇게 되면 세션은 요구되는 연결이 불가능할 때 바로 실패하기 보다는, 적합한 연결을 기다리게 된다.

 

아무튼 이제 가장 처음해야하는 URLSession 생성을 먼저해주자.

// VC
private lazy var session: URLSession = {
    let config = URLSessionConfiguration.default
    config.waitsForConnectivity = true
    return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()

이제 앞으로 볼 코드에서 data task를 시작시키고, 받은 data와 error를 처리하기 위해 delegate 콜백을 활용하는 방법을 볼 것이다.

우선 메서드의 역할부터 보고 넘어가자

 

  • urlSession(_:dataTask:didReceive:completionHandler:)
    • response가 성공적인 HTTP 상태 코드를 가지고 있는지 검증하고 MIME 타입이 text/html인지 text/plain인지 확인한다. 만약 두 경우 모두 아니라면 task는 취소되며, 정상인 경우는 진행된다.
  • urlSession(_:dataTask:didReceive:)
    • task를 통해 받은 각 Data 인스턴스를 receivedData라고 불리는 버퍼에 저장한다.
  • urlSession(_:task:didCompleteWithError:)
    • 먼저 전송 오류가 발생했는지 확인한다.
    • 오류가 없다면 receivedData 버퍼를 원하는 모델 타입에 맞게 디코딩해준다.
// VC
func startLoad() {
        let url = URL(string: "https://zoo-animal-api.herokuapp.com/animals/rand")!
        receivedData = Data()
        let task = session.dataTask(with: url)
        task.resume()
    }

func setupDataByDelegate(from data: Animal) {        
    ImageLoader.loadImage(from: data.imageLink) { image in
        self.imageView.image = image
    }

    DispatchQueue.main.async {
        self.nameLabel.text = "동물 이름 : \(data.name)"
        self.lifeLabel.text = "평균 수명 : \(data.lifespan)년"
    }
}

extension ViewController: URLSessionDataDelegate {
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        guard let response = response as? HTTPURLResponse,
              (200..<300).contains(response.statusCode) else {
                  completionHandler(.cancel)
                  return
              }

        // allow the load operation to continue
        completionHandler(.allow)
    }

    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        self.receivedData?.append(data)
    }

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error  {
            print(error.localizedDescription)
        } else if let receivedData = self.receivedData {
            do {
                let data = try JSONDecoder().decode(Animal.self, from: receivedData)
                setupDataByDelegate(from: data)
            } catch {
                print(error.localizedDescription)
            }
        }
    }
}

이 방법도 마찬가지로 동일한 결과를 보여준다.

 

이외에도 다양한 delegate 프로토콜은 인증 문제, 리다이렉션, 기타 특수한 경우를 처리하기 위해 위에 나타나 있는 코드 이상의 방법을 제공한다. URLSession 문서를 더 살펴보면 다양한 콜백에 대해서 알아볼 수 있다.

 

더보기
//
//  Animal.swift
//  networkingTest
//
//  Created by 이차민 on 2022/01/03.
//

import Foundation

struct Animal: Codable {
    let name, latinName, animalType, activeTime: String
    let lengthMin, lengthMax, weightMin, weightMax: String
    let lifespan, habitat, diet, geoRange: String
    let imageLink: String
    let id: Int

    enum CodingKeys: String, CodingKey {
        case name
        case latinName = "latin_name"
        case animalType = "animal_type"
        case activeTime = "active_time"
        case lengthMin = "length_min"
        case lengthMax = "length_max"
        case weightMin = "weight_min"
        case weightMax = "weight_max"
        case lifespan, habitat, diet
        case geoRange = "geo_range"
        case imageLink = "image_link"
        case id
    }
}
더보기
//
//  ImageLoader.swift
//  networkingTest
//
//  Created by 이차민 on 2022/01/03.
//

import Foundation
import UIKit

class ImageLoader {
    static func loadImage(from url: String, completion: @escaping (UIImage?) -> ()) {
        // 입력된 url 체크
        if url.isEmpty {
            completion(nil)
            return
        }
        
        // URL 타입으로 변경
        guard let imageURL = URL(string: url) else {
            return
        }
        
        
        DispatchQueue.global(qos: .background).async {
            // URL -> Data
            if let data = try? Data(contentsOf: imageURL) {
                // Data -> UIImage
                let image = UIImage(data: data)!
                
                // UI 업데이트를 위한 main 쓰레드 사용
                DispatchQueue.main.async {
                    completion(image)
                }
            } else {
                DispatchQueue.main.async {
                    completion(nil)
                }
            }
        }
    }
}
더보기
//
//  APIService.swift
//  networkingTest
//
//  Created by 이차민 on 2022/01/03.
//

import Foundation

class APIService {
    func getAnimalData(completion: @escaping (Animal) -> ()) {
        guard let url = URL(string: "https://zoo-animal-api.herokuapp.com/animals/rand") else {
            return
        }
        let request = URLRequest(url: url)


        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            // error 검증
            if let error = error {
                print(error.localizedDescription)
                return
            }
            
            // response status code 검증
            guard let httpResponse = response as? HTTPURLResponse,
                  (200..<300).contains(httpResponse.statusCode) else {
                      print("error")
                      return
                  }

            // data 검증 및 사용
            if let data = data {
                do {
                    let receivedData = try JSONDecoder().decode(Animal.self, from: data)
                    completion(receivedData)
                } catch {
                    print(error.localizedDescription)
                }
            }

        }
        
        task.resume()
    }
}
더보기

 

//
//  ViewController.swift
//  networkingTest
//
//  Created by 이차민 on 2022/01/03.
//

import UIKit

class ViewController: UIViewController {

    var receivedData: Data?
    var animalData: [Animal] = []
    
    let apiHandler = APIService()
    
    private lazy var session: URLSession = {
        let config = URLSessionConfiguration.default
        config.waitsForConnectivity = true
        return URLSession(configuration: config, delegate: self, delegateQueue: nil)
    }()
    
    let imageView: UIImageView = {
        let iv = UIImageView()
        iv.contentMode = .scaleAspectFit
        return iv
    }()
    
    let nameLabel: UILabel = {
        let lb = UILabel()
        lb.textColor = .black
        lb.font = .preferredFont(forTextStyle: .title2)
        
        return lb
    }()
    
    let lifeLabel: UILabel = {
        let lb = UILabel()
        lb.textColor = .black
        lb.font = .preferredFont(forTextStyle: .title3)
        
        return lb
    }()
    
    let refreshButton: UIButton = {
        let bt = UIButton()
        
        bt.setTitle("다음 동물 보기", for: .normal)
        bt.setTitleColor(.white, for: .normal)
        bt.titleLabel?.font = UIFont(name: "Helvetica-Bold", size: 17)
        bt.titleLabel?.textAlignment = .center
        bt.sizeToFit()
        
        let size: CGFloat = 5
        bt.titleEdgeInsets = UIEdgeInsets(top: size, left: size, bottom: size, right: size)
        bt.layer.masksToBounds = false
        bt.layer.cornerRadius = 5
        bt.backgroundColor = .systemIndigo
        bt.addTarget(self, action: #selector(setupData), for: .touchUpInside)
        
        return bt
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        startLoad()
//        setupData()
    }
    
    // default method
    @objc func setupData() {
        let group = DispatchGroup()
        
        apiHandler.getAnimalData { animal in
            DispatchQueue.main.async(group: group) {
                ImageLoader.loadImage(from: animal.imageLink) { animalImage in
                    self.imageView.image = animalImage
                }
            }
            
            group.notify(queue: DispatchQueue.main) {
                self.nameLabel.text = "동물 이름 : \(animal.name)"
                self.lifeLabel.text = "평균 수명 : \(animal.lifespan)년"
            }
        }
    }
    
    func setupUI() {
        [imageView, nameLabel, lifeLabel, refreshButton].forEach {
            self.view.addSubview($0)
            $0.translatesAutoresizingMaskIntoConstraints = false
        }
        
        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
            imageView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            imageView.heightAnchor.constraint(equalToConstant: self.view.frame.height / 3),
            
            nameLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 20),
            nameLabel.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
            
            lifeLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 20),
            lifeLabel.centerXAnchor.constraint(equalTo: nameLabel.centerXAnchor),
            
            
            refreshButton.topAnchor.constraint(equalTo: lifeLabel.bottomAnchor, constant: 40),
            refreshButton.centerXAnchor.constraint(equalTo: lifeLabel.centerXAnchor),
            refreshButton.widthAnchor.constraint(equalToConstant: 130)
        ])
    }

    // delegate
    
    func startLoad() {
        let url = URL(string: "https://zoo-animal-api.herokuapp.com/animals/rand")!
        receivedData = Data()
        let task = session.dataTask(with: url)
        task.resume()
    }
    
    func setupDataByDelegate(from data: Animal) {        
        ImageLoader.loadImage(from: data.imageLink) { image in
            self.imageView.image = image
        }
        
        DispatchQueue.main.async {
            self.nameLabel.text = "동물 이름 : \(data.name)"
            self.lifeLabel.text = "평균 수명 : \(data.lifespan)년"
        }
    }
}

extension ViewController: URLSessionDataDelegate {
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        guard let response = response as? HTTPURLResponse,
              (200..<300).contains(response.statusCode) else {
                  completionHandler(.cancel)
                  return
              }
        
        // allow the load operation to continue
        completionHandler(.allow)
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        self.receivedData?.append(data)
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        if let error = error  {
            print(error.localizedDescription)
        } else if let receivedData = self.receivedData {
            do {
                let data = try JSONDecoder().decode(Animal.self, from: receivedData)
                setupDataByDelegate(from: data)
            } catch {
                print(error.localizedDescription)
            }
        }
    }
}

끄읕

 

Ref: https://developer.apple.com/documentation/foundation/url_loading_system/fetching_website_data_into_memory

 

 

Apple Developer Documentation

 

developer.apple.com

 

728x90
반응형