URL Loading System 실전

백상휘·2020년 9월 7일
0

iOS_Programming

목록 보기
2/10

해당 프로젝트는 영화진흥위원회의 오픈 API를 이용하여 만들었습니다.
오픈API 웹 주소 : https://www.kobis.or.kr/kobisopenapi/homepg/main/main.do
소스코드 깃허브 주소 : https://github.com/SangHwi-Back/K-Movie

앞에서 다룬 내용들을 토대로 실제 프로젝트를 만든 뒤 JSON 데이터를 이용하여 화면을 구성해보겠습니다.

대략적인 화면은 이렇습니다.

테이블 뷰가 처음 나오면 박스오피스 버튼을 클릭합니다. 그러면 영화진흥위원회의 API를 통해 JSON 데이터를 받아와서 디코딩을 하고 이를 이용하여 UICollectionView를 구성합니다.

우선, 최종적으로 만들어낸 URLSession 소스코드입니다. 추가로 URL에 파라미터를 업데이트하거나 추가하는 메소드까지 추가하여 API 요청을 간편하게 할 수 있도록 만들었습니다.

아키텍쳐는 요즘 공부중인 MVVM 아키텍쳐를 기반으로 만들게 되었습니다.

class MyURLSession: URLSession, URLSessionDataDelegate {
    var qualifiedURL: String?
    var requestMovieDataType: RequestMovieDataType = .dailyBoxOffice
    let decoder = JSONDecoder()

    private lazy var session: URLSession = {
        let conf = URLSessionConfiguration.default
        return URLSession(configuration: conf, delegate: self, delegateQueue: OperationQueue.main)
    }()

    private var willWaitForSignal: Bool = true {
        willSet {
            self.session.configuration.waitsForConnectivity = newValue
        }
    }
    
    func startLoad(requestDataType: RequestMovieDataType, _ completionHandler: @escaping (DailyBoxOfficeResult?) -> Void) {
        guard let qualifiedURL = qualifiedURL, let url = URL(string: qualifiedURL) else { return }
        
        let task = self.session.dataTask(with: url) { (data, response, error) in
            guard let response = response as? HTTPURLResponse,
                (200...299).contains(response.statusCode),
                let mimeType = response.mimeType,
                mimeType == "application/json" else {
                    return
            }
            
            // JSON데이터 호출부. 맨 마지막 completionHandler 를 호출함.
            DispatchQueue.main.async {
                if let data = data,
                    let product = try? self.decoder.decode(DailyBoxOfficeData.self, from: data) {
                    completionHandler(product.boxOfficeResult)
                }
            }
        }
        task.resume()
    }
    
    /**
     Update stored URL's parameter.
     If there is no parameter you asked, it will added.
     */
    @discardableResult
    func updateURLParameter(key: String, value: String) -> Bool {
        guard let url = qualifiedURL, let questionIndex = url.firstIndex(of: "?") else {
            return false
        }
        
        var parameters = Dictionary<String, String>()
        let parameterString = String(url[url.index(after: questionIndex)...])
        
        if parameterString.isEmpty {
            self.qualifiedURL = url + key + "=" + value
            return true
        }
        
        for item in parameterString.split(separator: "&") {
            if let splitIndex = item.firstIndex(of: "=") {
                parameters.updateValue(String(item[item.index(after: splitIndex)...]), forKey: String(item[item.startIndex..<splitIndex]))
            } else {
                return false
            }
        }
        
        if parameters.isEmpty {
            return false
        }
        
        parameters.updateValue(value, forKey: key)
        var resultParameters = parameters.reduce("?"){$0+$1.key+"="+$1.value+"&"}
        resultParameters.removeLast()
        
        self.qualifiedURL = String(self.qualifiedURL![self.qualifiedURL!.startIndex..<questionIndex]) + resultParameters
        
        return true
    }
}

이제 이걸 이용할 UseCase 파일을 생성합니다.

class BoxOfficeUseCases {
    
    var results: DailyBoxOfficeResult?
    
    var myURLSession: MyURLSession?
    var data: Data?
    private let url: String = Constants.kosbiRESTUrl +  Constants.dailyBoxOfficeURL
    let key: String? = UserDefaults.standard.value(forKey: "APIkey") as? String
    
    init() {
        myURLSession = (UIApplication.shared.delegate as? AppDelegate)?.myURLSession
    }
    
    // loadData. url 구성 후 URLSession 클래스의 startLoad함수 실행.
    // Key 값은 삭제 예정.
    func loadData(type: RequestMovieDataType, _ completionHandler: @escaping (DailyBoxOfficeResult?) -> Void) {
        myURLSession?.qualifiedURL = url
        myURLSession?.updateURLParameter(key: "key", value: "0e559fe678175166222903f2123ec644")
        myURLSession?.updateURLParameter(key: "targetDt", value: "20200903")
        self.myURLSession?.startLoad(requestDataType: .dailyBoxOffice, completionHandler)
    }
}

UseCase 에서는 실제 데이터를 불러오기 위한 작업을 할 목적으로 만들었기 때문에 여기서 URL을 구성합니다. 실제로는 위의 UserDefaults를 이용하여 자체 키 값을 저장할 수 있도록 할 예정입니다.

ViewModel을 만들겠습니다. 실제 View와 상호작용하며, Domain영역과의 상호작용을 담당합니다.

class BoxOfficeViewModel {
    
    private let useCases = BoxOfficeUseCases()
    var results: DailyBoxOfficeResult?
    
    func loadData(type: RequestMovieDataType, _ completionHandler: @escaping (DailyBoxOfficeResult?) -> Void) {
        self.useCases.loadData(type: .dailyBoxOffice, completionHandler)
    }
}

현재는 단순히 데이터를 받아오기만 하면 되기 때문에 특별한 작업은 불필요합니다.

class BoxOfficeViewController: UIViewController {
    private let viewModel = BoxOfficeViewModel()
    @IBOutlet var boxOfficeCollectionView: UICollectionView!
    var results: DailyBoxOfficeResult?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.navigationItem.title = "BoxOffice"
        
        // viewModel, useCases를 지나서 completionHandler를 URLSession fetch하는 소스까지 전달함.
        self.viewModel.loadData(type: .dailyBoxOffice) { (value) in
            self.results = value
            self.boxOfficeCollectionView.reloadData()
        }
        
        print(self.results ?? "aaa")
        let nib = UINib(nibName: "DailyViewCustomCell", bundle: nil)
        self.boxOfficeCollectionView.dataSource = self
        self.boxOfficeCollectionView.delegate = self
        self.boxOfficeCollectionView.register(nib, forCellWithReuseIdentifier: "boxOfficeViewCell")
    }
    
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        if let flowLayout = self.boxOfficeCollectionView.collectionViewLayout as? UICollectionViewFlowLayout {
            flowLayout.invalidateLayout()
        }
    }
}

extension BoxOfficeViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        if let count = results?.dailyBoxOfficeList.count {
            return count
        }
        return 0
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "boxOfficeViewCell", for: indexPath) as? DetailViewCustomCell else {
            return collectionView.dequeueReusableCell(withReuseIdentifier: "boxOfficeViewCell", for: indexPath)
        }
        
        if results != nil {
            let dailyBoxOfficeList = results!.dailyBoxOfficeList[indexPath.row]
            cell.customCellMovieNm.text = dailyBoxOfficeList.movieNm
        } else {
            cell.customCellMovieNm.text = "에러 발생!!!"
        }
        
        return cell
    }
}

extension BoxOfficeViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView,
           layout collectionViewLayout: UICollectionViewLayout,
           sizeForItemAt indexPath: IndexPath) -> CGSize {
        if UIDevice.current.orientation.isLandscape {
            return CGSize(width: collectionView.frame.width / 2 - 40,
                          height: collectionView.frame.height)
        } else {
            return CGSize(width: collectionView.frame.width - 20,
                          height: collectionView.frame.height / 4)
        }
    }
}

뷰 컨트롤러입니다. 중간의 loadData() 를 호출하는 함수의 completionHandler가 선언되어 있으며, 이 함수는 MyURLSession에서 데이터를 가져온 뒤 바로 실행됨을 알 수 있습니다. 데이터를 가져왔다면 collectionView의 데이터를 reloadData() 하여 뷰를 다시 그려주도록 합니다.

처음에 뷰컨트롤러에서 completionHandler를 전달한다는 것이 중요합니다. 이 completionHandler는 viewModel로 전달되면서 escaping closure의 형태를 갖습니다.

함수 내에서만 작동한다는 파라미터의 scope를 벗어나 함수가 끝난 뒤 실행된다는 이점을 가졌기 때문에 이 작업으로 정확한 데이터를 화면에 보여주는 것을 보장합니다.

다음에는 응용편으로 뵙겠습니다. 검색기능을 통해 다시 데이터를 검색할 수 있도록 하고, 상세정보 창을 이용하여 지속적인 request에서 가장 효율적인 작업을 할 수 있도록 할 예정입니다.

감사합니다.

profile
plug-compatible programming unit

0개의 댓글