[iOS] URLSession을 활용한 비동기 프로그래밍 (with. NASA API)
👨🏻‍💻iOS 공부/iOS & Swift

[iOS] URLSession을 활용한 비동기 프로그래밍 (with. NASA API)

728x90
반응형

URLSession의 구조나 원리, Json 구조, 비동기 프로그래밍에 대해 익숙해지고 싶은 마음과, 더욱 더 잘 활용하고 싶다는 생각에 각종 API들을 활용하여 데이터를 받아와 비동기적으로 처리하는 방법들을 차례대로 학습해보고자 합니다!

어떤 API가 재밌을까 고민하던 찰나에, NASA에서 매일 우주 사진과, 이에 대한 설명을 제공하는 API를 무료로 사용할 수 있다고 하여서 이를 바로 선택해서 진행했습니다!

대략 어떤 플로우로 진행될지 간략하게 보고 바로 시작해봅시다!

--------사전 준비--------
1. NASA Open API : key 발급
2. Json 구조 확인

---------XCode---------
3. 데이터 모델 정의
4. API Service 구현 (URLSession)
5. 뷰 그리기
6. 이미지 캐싱 (NSCache)
7. 데이터 로드 (Async)

아! 처음에는 MVC로 구현해볼 것입니다.

이후에 MVVM으로 리팩토링해보고, RxSwift까지 곁들여 총 세 단계 구성을 예상하고 있습니다.🧐


1. NASA Open API : key 발급

아래 사이트에 들어가면 키를 발급할 수 있는 화면을 볼 수 있습니다.

https://api.nasa.gov/index.html#recovery

 

NASA Open APIs

api.nasa.gov

기본적인 정보만 입력해주자.

정보를 입력하고 나면 바로 API Key를 받아볼 수 있습니다.

내 개인정보는 소중하니까...

여기서 중요한건 API KeyRequest URL입니다!

API 키는 말 그대로 발급받은 사람이 API를 이용할 수 있는 입장권 정도로 생각하면 되고, RequestURL에 그 키 값을 담아 이따가 볼 URLSession에 사용해주면 되는 것 입니다.

Request URL의 구조는 다음과 같습니다.
너무 간단해서 할 말이... 그냥 api_key 위치에 방금 발급받은 key를 넣어주면 되는 것입니다.

https://api.nasa.gov/planetary/apod?api_key=발급받은 키 자리

자 그럼 이제 요청을 통해 받아올 Json 구조를 살펴봅시다.

2. Json 구조 확인

어..? 아직 요청도 못보냈는데 Json 구조는 어떻게 확인할까..?
(아까 봤던 Request URL을 클릭하면 바로 Json 구조를 볼 수 있으니 걱정 말자.)

{"copyright":"Martin Pugh", 
    "date":"2021-09-09", 
    "explanation":"A star cluster around 2 million years young surrounded by natal clouds of dust and glowing gas, M16 is also known as The Eagle Nebula. This beautifully detailed image of the region adopts the colorful Hubble palette and includes cosmic sculptures made famous in Hubble Space Telescope close-ups of the starforming complex. Described as elephant trunks or Pillars of Creation, dense, dusty columns rising near the center are light-years in length but are gravitationally contracting to form stars. Energetic radiation from the cluster stars erodes material near the tips, eventually exposing the embedded new stars. Extending from the ridge of bright emission left of center is another dusty starforming column known as the Fairy of Eagle Nebula. M16 lies about 7,000 light-years away, an easy target for binoculars or small telescopes in a nebula rich part of the sky toward the split constellation Serpens Cauda (the tail of the snake).", 
    "hdurl":"https://apod.nasa.gov/apod/image/2109/M16SHO.jpg", 
    "media_type":"image", 
    "service_version": "v1", 
    "title":"M16 Cose Up", 
    "url":"https://apod.nasa.gov/apod/image/2109/M16SHO_1024.jpg" 
}


파라미터를 간략히 살펴봅시다.

  • copyright (optional) : 이미지의 저작권이 있다면(public domain) 데이터에 등장하고, 그렇지 않으면 보이지 않는다.
  • date : 오늘 날짜
  • explanation : 사진에 대한 설명
  • hdurl : HD url인 것 같다. 여기서 이미지를 받아올 수 있다. (사진 규격이 크고, 용량이 조금 더 나간다)
  • media_type : 이미지인지, 동영상인지 나타내준다.
  • service_version : 말 그대로 버전!
  • title : 사진의 제목을 알려준다.
  • url : 여기서도 마찬가지로 이미지를 얻을 수 있다. (1024 이미지)

자 뭐 여기서 더 살펴볼 건 없을 것 같습니다!
nested된 구조도 아니고 단일 딕셔너리 같은(?) 느낌이기에 바로 모델을 구성하러 가봅시다!

3. 데이터 모델 정의

자 앞서 봤던 Json 구조를 기반으로 모델을 정의해봅시다.

// SpaceData.swift 
import Foundation

struct SpaceData: Codable {
    let date: String
    let explanation: String
    let hdurl: String
    let mediaType: String
    let serviceVersion: String
    let title: String
    let url: String
    
    enum CodingKeys: String, CodingKey {
        case date, explanation, hdurl, title, url
        case mediaType = "media_type"
        case serviceVersion = "service_version"
    }
}

우선 Json 데이터를 원하는 형식으로 변환하기 위해 Codable 프로토콜을 채택해주었습니다. 그리고 다른 특이사항으로는 기존 파라미터 이름에 언더바(_)가 들어가는게 사용하기 귀찮아서 CodingKey를 통해 치환해주었다는 것입니다!

Json이 간단하니, 당연히 모델도 간단한 것!

4. API Service 구현 (URLSession)

어떻게 보면 이 글에서 핵심이 될 수 있는 네트워킹 부분입니다.
대략적인 플로우를 보고 바로 코드로 넘어가봅시다!

1. 아까 발급받은 key를 활용하여 requestURL 생성해주기
2. 데이터를 받아오기 위한 URLSession의 dataTask를 생성하기
3. 에러 처리, 데이터 및 리스폰스 확인, 디코딩 등등 해준다. (우선 간단하게 해볼 예정)

바로 코드로 궈궈!

// APIService.swift 
 
import Foundation

class APIHandler {
    func getNASAData(completion: @escaping (SpaceData) -> ()) {
        let key = "발급받은 키"
        // 1. 아까 발급받은 key를 활용하여 requestURL 생성해주기 
        let url = URL(string: "https://api.nasa.gov/planetary/apod?api_key=\(key)")!
        let requestURL = URLRequest(url: url)
        
        // 2. 데이터를 받아오기 위한 URLSession의 dataTask를 생성하기
        URLSession.shared.dataTask(with: requestURL) { data, response, error in
            // 3. 에러 처리, 데이터 및 리스폰스 확인, 디코딩 등등 해준다. (우선 간단하게 해볼 예정)
            guard error == nil else {
                print(error?.localizedDescription)
                return
            }
            
            if let data = data, let response = response as? HTTPURLResponse, response.statusCode == 200 {
                do {
                    let parsedData = try JSONDecoder().decode(SpaceData.self, from: data)
                    completion(parsedData)
                } catch {
                    print(error.localizedDescription)
                }
                
            }
        }.resume()
    }
}


위에서 부터 살펴보면 @escaping을 통해 함수가 끝나고 나서 실행할 부분을 적어주었습니다.
그리고 발급받은 키를 넣어 url을 완성해서 URLSession dataTask에 넘겨주었고, 그 이후 에러 처리, 데이터 & 리스폰스 확인한 것을 볼 수 있습니다.

디코딩도 문제없이 진행해주면되고...

여기서 깜빡하고 까먹을 수 있는게 ".resume( )"이라고 생각합니다!!!!

위에 코드 다 적어주더라도 .resume( ), 즉 실행시키지 않는다면 어떠한 결과도 받아볼 수가 없습니다.
그러니 까먹지 말고! 꼭 dataTask에 resume( )을 해줍시다!

자 그러면 네트워킹까지 살펴봤고, 이제 받아온 데이터를 출력해줄 화면을 그려봅시다!

5. 뷰 그리기

대략적인 뷰

우선 버튼을 클릭하면 이미지를 로드하여 패치하는 것 까지 수행할 수 있도록 했습니다.
최상단에 이미지를 배치하고, 차례대로 제목, 날짜, 설명 순으로 나열시켰습니다.

뷰는 뭐 어려운게 없으니! 바로 코드로 가봅시다!
(스토리보드 없이, 모두 코드로 진행했습니다)

// ViewController.swift 
import UIKit

class ViewController: UIViewController {
    
    // API 사용하기 위한 객체
    var apiHandler : APIHandler = APIHandler()

    // 데이터 모델 객체
    var spaceData: SpaceData? {
        didSet {
            print("data fetch completed")
        }
    }
    
    // 이미지 캐싱
    private let imageCache = NSCache<NSString, UIImage>()
    
    // 비동기 - 시간초 세줄 변수
    var counter = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        config()
        
        // 시간초 증가
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) {_ in
            self.counter += 1
            self.timeLabel.text = "\(self.counter)"
        }
    }

    // 불러오기 버튼
    let loadButton: 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
        
        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 = .black
        bt.addTarget(self, action: #selector(loadSpaceData), for: .touchUpInside)
        return bt
    }()
    
    // 클리어
    let clearButton: 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
        
        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(clear), for: .touchUpInside)
        return bt
    }()
    
    // 클리어 액션
    @objc func clear() {
        self.counter = 0
        self.spaceImage.image = nil
        self.explanationLabel.text = nil
        self.titleLabel.text = nil
        self.dateLabel.text = nil
    }
    
    // 시간초
    let timeLabel: UILabel = {
        let lb = UILabel()
        lb.textColor = .black
        lb.numberOfLines = 0
        lb.font = UIFont(name: "Helvetica", size: 15)
        return lb
    }()
    
    // 메인 이미지
    let spaceImage: UIImageView = {
        let img = UIImageView()
        img.layer.masksToBounds = false
        img.layer.cornerRadius = img.frame.width / 2
        return img
    }()
    
    // 제목
    let titleLabel: UILabel = {
        let lb = UILabel()
        lb.textColor = .black
        lb.numberOfLines = 0
        lb.font = UIFont(name: "Helvetica-Bold", size: 24)
        return lb
    }()
    
    // 날짜
    let dateLabel: UILabel = {
        let lb = UILabel()
        lb.textColor = .black
        lb.numberOfLines = 0
        lb.font = UIFont(name: "Helvetica", size: 14)
        return lb
    }()
    
    // 설명
    let explanationLabel: UILabel = {
        let lb = UILabel()
        lb.textColor = .black
        lb.numberOfLines = 0
        lb.font = UIFont(name: "Helvetica", size: 15)
        return lb
    }()
    
    // 로딩 중 표시
    let activityIndicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView()
        indicator.frame = CGRect(x: 0, y: 0, width: 50, height: 50)
        indicator.hidesWhenStopped = true
        indicator.style = UIActivityIndicatorView.Style.large
        indicator.color = .black
        return indicator
    }()
    
    // 불러오기 버튼 액션
    @objc func loadSpaceData() {
        // 불러옴과 동시에 인디케이터 시작
        activityIndicator.startAnimating()
        
        DispatchQueue.global(qos: .userInteractive).async {
            // API 통해 데이터 불러오기
            self.apiHandler.getNASAData { data in
                // 정의해둔 모델 객체에 할당
                self.spaceData = data
                
                // 데이터를 제대로 잘 받아왔다면
                guard let data = self.spaceData else {
                    return
                }
                
                // 이미지 로드
                ImageLoader.loadImage(url: data.url) { [weak self] image in
                    // 메인 쓰레드임
                    self?.spaceImage.image = image
                    self?.activityIndicator.stopAnimating()
                    self?.titleLabel.text = data.title
                    self?.explanationLabel.text = data.explanation
                    self?.dateLabel.text = data.date
                }
            }
        }
    }
    
    // 구성 요소들 AutoLayout 처리
    func config() {
        view.backgroundColor = .white
        
        [clearButton, loadButton, spaceImage, titleLabel, explanationLabel, dateLabel, activityIndicator, timeLabel].forEach { item in
            self.view.addSubview(item)
            item.translatesAutoresizingMaskIntoConstraints = false
        }
        
        NSLayoutConstraint.activate([
            spaceImage.topAnchor.constraint(equalTo: self.view.topAnchor),
            spaceImage.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
            spaceImage.trailingAnchor.constraint(equalTo: self.view.trailingAnchor),
            spaceImage.heightAnchor.constraint(equalToConstant: 400.0),
            
            titleLabel.topAnchor.constraint(equalTo: spaceImage.bottomAnchor, constant: 20),
            titleLabel.leadingAnchor.constraint(equalTo: spaceImage.leadingAnchor, constant: 20),
            titleLabel.trailingAnchor.constraint(equalTo: spaceImage.trailingAnchor, constant: -20),
            
            dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
            dateLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            dateLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
            
            explanationLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 20),
            explanationLabel.leadingAnchor.constraint(equalTo: dateLabel.leadingAnchor),
            explanationLabel.trailingAnchor.constraint(equalTo: dateLabel.trailingAnchor),
            explanationLabel.heightAnchor.constraint(equalToConstant: 200.0),
            
            loadButton.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: -50),
            loadButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
            loadButton.widthAnchor.constraint(equalToConstant: 110),
            
            clearButton.trailingAnchor.constraint(equalTo: loadButton.leadingAnchor, constant: -10),
            clearButton.centerYAnchor.constraint(equalTo: loadButton.centerYAnchor),
            clearButton.widthAnchor.constraint(equalToConstant: 60),
            
            timeLabel.leadingAnchor.constraint(equalTo: loadButton.trailingAnchor, constant: 20),
            timeLabel.centerYAnchor.constraint(equalTo: loadButton.centerYAnchor),
            
            activityIndicator.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: self.view.centerYAnchor)
            
        ])
    }

}

뷰에 대한 각 코드 설명은 주석으로 남겨두었으니 참고 바랍니다!
(MVC이다보니 확실히 VC가 뚱뚱해진다... 이후 MVVM으로 개선해볼 것입니다!)

자, 이제 버튼도 다 만들었고 이미지, 타이틀 등 텍스트들의 위치도 다 잡아주었습니다.
이제 버튼을 눌러 이미지를 다운로드 받으면 되는데, 매번 url에서 받아오게 되면 로딩이 매우 느릴 것입니다.

이 때 한 번 불러온 url이라면 캐싱하여 저장하하면 이후 이미지 로딩 속도를 개선할 수 있습니다!
이를 위해 NSCache를 사용해봅시다.

6. 이미지 캐싱 (NSCache)

이미지 캐싱은 딕셔너리, NSCache로 보통 구현하는데 이번에는 NSCache를 활용해봅시다!

// ImageLoader.swift 
import UIKit

class ImageLoader {
    private static let imageCache = NSCache<NSString, UIImage>()
    
    static func loadImage(url: String, completion: @escaping (UIImage?) -> Void) {
        // url이 비어있다면 nil처리
        if url.isEmpty {
            completion(nil)
            return
        }
         
        // URL 형식으로 변환
        let realURL = URL(string: url)!
        
        // 캐시에 있다면 바로 반환
        if let image = imageCache.object(forKey: realURL.lastPathComponent as NSString) {
            print("캐시에 존재 😎")
            // UI는 메인 쓰레드에서 진행
            DispatchQueue.main.async {
                completion(image)
            }
            return
        }
        
        // 캐시에 없다면
        DispatchQueue.global(qos: .background).async {
            print("캐시에 없음 🥲")
            // 데이터 타입 변환
            if let data = try? Data(contentsOf: realURL) {
                // 이미지 변환
                let image = UIImage(data: data)!
                // cache에 추가
                self.imageCache.setObject(image, forKey: realURL.lastPathComponent as NSString)
                // UI는 메인 쓰레드에서 진행
                DispatchQueue.main.async {
                    completion(image)
                }
            } else {
                DispatchQueue.main.async {
                    completion(nil)
                }
            }
        }
        
    }
}


캐시를 생성해주고, 캐시에 존재하지 않는 이미지라면 캐시에 새로 추가해주고, 기존에 가지고 있는 이미지라면 따로 Data 변환이나 이미지 변환없이 바로 캐싱하여 이미지를 반환해줍니다.

이 덕분에 처음 다운로드 이후부터는 빠르게 이미지를 받아올 수 있습니다.

이렇게 만든 ImageLoader의 loadImage는 "사진 불러오기" 버튼 클릭시 작동시켜주도록 합니다.

// 이미지 부분만 
ImageLoader.loadImage(url: data.url) { [weak self] image in 
    self?.spaceImage.image = image 
    // .. 
}

weak 키워드를 달아서 메모리 릭이 발생하지 않도록 방지해줍니다!
또한 completion 처리를 메인 쓰레드에서 해줬기 때문에 여기서는 따로 신경쓰지 않아도 됩니다.

이제 이미지 캐싱도 처리해줬으니, 데이터 로드하는 부분을 조금 더 자세하게 알아봅시다.

7. 데이터 로드 (Async)

데이터를 불러오는 부분만 따로 떼서 봐봅시다.

@objc func loadSpaceData() {
    // 불러옴과 동시에 인디케이터 시작
    activityIndicator.startAnimating()
    
    DispatchQueue.global(qos: .userInteractive).async {
        // API 통해 데이터 불러오기
        self.apiHandler.getNASAData { data in
            // 정의해둔 모델 객체에 할당
            self.spaceData = data
            
            // 데이터를 제대로 잘 받아왔다면
            guard let data = self.spaceData else {
                return
            }
            
            // 이미지 로드
            ImageLoader.loadImage(url: data.url) { [weak self] image in
                // 메인 쓰레드임
                self?.spaceImage.image = image
                self?.activityIndicator.stopAnimating()
                self?.titleLabel.text = data.title
                self?.explanationLabel.text = data.explanation
                self?.dateLabel.text = data.date
            }
        }
    }
}


우선 로딩 중임을 나타내기 위해 인디케이터를 시작시킨 것을 볼 수 있습니다.

그리고 바로 VC의 윗 부분에서 정의해준 apiHandler를 가지고 getNASAData 메서드를 통해 데이터를 저장하고 있습니다. 그 이후 데이터가 잘 받아와졌는지 한 번 확인한 후, 이미지 로드를 하게 됩니다.

ImageLoader의 loadImge에서 completion은 @escaping으로 처리되어 있고, completion은 메인 쓰레드에서 동작하기에 이미지가 아닌 다른 요소들을 설정하는 부분도 같이 적어주었습니다.


자 이제 7단계의 과정이 모두 끝났습니다! 이제 실행되는 화면을 봐봅시다!

보기 이전에!! 관전포인트를 기억하고 봐야합니다.

 

  • 캐싱 덕에 로딩 속도가 빨라졌는지??


이 부분은 "사진 불러오기" 버튼 옆에 타이머를 통해 비교할 수 있습니다. 처음 사진 불러오기를 클릭할 때 시간과, 클리어 이후 캐싱을 통해 이미지를 불러오는 그 시간을 비교해봅시다!

캐싱 없이 다운로드 했을 때
캐시에 없음 ㅠ

시간은 0.1초마다 1씩 증가하도록 했기에 대략 8초 정도 소요되는 것으로 보입니다.
자 이제 이미지가 캐싱되었기 때문에 클리어를 누르고, 캐싱했을 때 로딩에 소요되는 시간을 봅시다.

캐시에 존재!!

비교도 안되게 빠른게 보이시나요...?

거의 1~1.5초 사이에 데이터를 모두 로드한 것을 볼 수 있습니다!!!!
캐시의 힘이 엄청나다는 거죠...

이를 통해 앞으로 이미지나, 용량이 조금이라도 큰 데이터는 캐싱을 무조건 이용해야겠다 생각하게 되었습니다...


지금까지 API 키를 발급받기 시작하고, 최종 실행화면을 보기까지 여러 지식들을 접했습니다!

  • Open API 사용법
  • URLSession
  • NSCache
  • Async

소스 코드는 깃헙에 올려두도록 하겠습니다!
학습에 조금이라도 도움이 되셨다면 별⭐️ 하나... 부탁드립니다!
https://github.com/ChaminLee/SpacePicture

 

GitHub - ChaminLee/SpacePicture

Contribute to ChaminLee/SpacePicture development by creating an account on GitHub.

github.com


내용에 오류가 있다면 말씀 부탁드립니다 :)

끄읕!

728x90
반응형