June 8, 2021, TIL (Today I Learned) - 네트워크를 통해 받아온 데이터만큼 cell의 갯수 구현하기

Inwoo Hwang·2021년 8월 26일
2
post-thumbnail

학습내용


네트워크를 통해 받아온 데이터만큼 cell의 갯수 구현하기

Swift를 공부하면서 네트워크 작업을 해야 하는 경우가 많습니다. 그럴 경우 아래와 같이 urlSession 또는 alamofire를 많이들 활용합니다.

final class NetworkManager: NetworkManageable {
    let urlSession: URLSessionProtocol
    
    init(urlSession: URLSessionProtocol = URLSession.shared) {
        self.urlSession = urlSession
    }
    
    func getItemList(page: Int, completionHandler: @escaping (_ result: Result <OpenMarketItemList, Error>) -> Void) {
      // 데이터 파싱, response처리, 에러 처리 등등
    }

제가 진행했던 프로젝트에서는 오픈마켓의 API를 통해 상품 목록의 데이터를 가져와서 cell에 구현해 줘야 했습니다. 그래서 위와 같이 NetworkManager라는 클래스를 통해 비동기적으로 네트워크 작업을 했습니다.

해당 작업을 cell에 적용시키는 방법은 비교적 간단합니다.

class OpenMarketViewController: UIViewController {
    private var layoutType = LayoutType.list
    private let networkManager: NetworkManageable = NetworkManager()
}

적용하고자하는 ViewController에 networkManager의 인스턴스를 생성한 뒤

extension OpenMarketViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 8
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        switch layoutType {
        case .list:
            guard let cell: OpenMarketListCollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: OpenMarketListCollectionViewCell.identifier, for: indexPath) as? OpenMarketListCollectionViewCell else {
                return UICollectionViewCell()
            }
            networkManager.getItemList(page: 1) { result in
                switch result {
                case .success(let itemList):
                    DispatchQueue.main.async {
                        guard let cellIndex = collectionView.indexPath(for: cell),
                              cellIndex == indexPath else { return }
                        cell.configure(itemList, indexPath: indexPath.row)
                    }
                case .failure(let error):
                    print(error)
                }
            }
            return cell

UICollectionViewDataSource 를 채택한 뒤 cellForItemAtIndexPath메서드에서 직접 네트워크 작업을할 수 있습니다.

하지만 위와 같은 방법은 좋지 못한 방법이라고 생각합니다.

첫 번째 이유는 cellForItemAtIndexPath 메서드에게 너무 많은 책임을 전가하는 것이 바람직하지 않기 때문입니다. 해당 메서드는 재사용가능한 cell을 생산하는 것이 주 목적이어야 하는데 여기에 덧붙여 네트워크 작업까지 할당하는 것이 해당 메서드의 목적성에 맞지 않습니다. 메서드는 한 가지 일만 해야합니다.

두 번째 이유는 첫 번째 이유의 연장선입니다. cellForItemAtIndexPath 에서 네트워크 작업을 하다 어떠한 이유로 cell을 재활용하는데 문제가 생기면 네트워크 작업까지 문제가 생길 수 있습니다. 책임을 분리할 필요가 있어보입니다.

세 번째 이유는 이렇게 cellForItemAtIndexPath 메서드에서 네트워크 작업을 하면 numberOfItemsInSection에서 유동적으로 cell의 갯수를 지정해 줄 수 가 없어집니다. cell의 갯수는 불러오는 데이터 객체의 갯수와 같아야 합니다만 cellForItemAtIndexPath 메서드에서 네트워킹과 cell에게 데이터를 넘겨버리는 작업을 한다면 정작 numberOfItemsInSection 에서는 데이터의 정보를 얻을 수가 없기 때문에 네트워킹을 하고자 하는 API에서 각 cell에 보여줘야할 데이터의 수량을 일일이 파악하여야 하고 API의 데이터가 늘어나면 매 번 수정을 해줘야 한다는 번거로움이 있습니다.

이러한 문제점을 해결하기 위해 먼저 데이터를 받아 온 뒤 받아온 데이터를 활용하여 cell에 적용하는 방법을 택했습니다.

먼저 네트워크 작업을 cellForItemAtIndexPath로 부터 분리 시켰습니다.

override func viewDidLoad() {
  networkManager.getItemList(page: nextPageToLoad, isCurrentlyLoading: false) { [weak self] result in
                                                                               // 네트워크 작업
}
 func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        switch layoutType {
        case .list:
            // reusable cell dequeue
            }
            return cell

그리고 ViewController에 데이터를 담아둘 프로퍼티를 선언하였습니다.

class OpenMarketViewController: UIViewController {
  private var openMarketItemList: OpenMarketItemList?
}

ViewDidLoad() 에서 네트워크 작업이 끝난 뒤 성공적으로 파싱된 OpenMarketItemList타입의 객체를 itemList 라고 상수로 선언한 뒤 해당 객체를 비동기적으로 OpenMarketViewController의 프로퍼티에게 할당 시켜주었습니다 self?.openMarketItemList = itemList

networkManager.getItemList(page: nextPageToLoad, isCurrentlyLoading: false) { [weak self] result in
            switch result {
            case .success(let itemList):
                self?.openMarketItemList = itemList
            case .failure(let error):
                print(error.localizedDescription)
            }
        }

이제 cell의 갯수 또한 유동적으로 지정해 줄 수 있게되었습니다. 받아온 데이터의 count 만큼 cell의 갯수를 지정 해 주면 되니까요!!

extension OpenMarketViewController: UICollectionViewDataSource {
    
    // MARK: - Cell Data
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        guard let validitemList = openMarketItemList else { return 0}
      	
      	// 데이터의 count 만큼 cell 생성
        return validitemList.items.count
    }
  func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        switch layoutType {
        case .list:
            guard let cell: OpenMarketListCollectionViewCell = collectionView.dequeueReusableCell(withReuseIdentifier: OpenMarketListCollectionViewCell.identifier, for: indexPath) as? OpenMarketListCollectionViewCell else {
                return UICollectionViewCell()
            }
          	// 받아온 데이터를 활용해서 cell의 UI 구현
            cell.configure(openMarketItemList, indexPath: indexPath.row)
            return cell

이렇게 설정하고 나면 저는 당연히 제대로 collectionView의 cell이 구현될 줄 알았습니다.

네트워크 작업이 끝난 뒤 데이터를 프로퍼티로 할당 해 주고

할당된 데이터를 가지고 cell 작업을 당연히 할 줄 알았는데... 돌아 온건 빈 collectionView더라구요..

시점의 중요성과 ReloadData()

위에 나열했던 순서대로 일이 동기적으로 처리되면 얼마나 좋을까요..

네트워크 작업 자체가 urlSession을 활용한 비동기적인 작업이기 때문에 네트워크 작업이 끝나기 전에 벌써 numberOfItemsInSection 메서드가 호출되어서 cell의 갯수를 지정 해 주는 것을 breakpoint를 통해 확인할 수 있었습니다.

당연히 네트워크 작업이 끝나지 않았기 때문에 프로퍼티 OpenMarketItemList 에는 아무런 데이터가 저장되어있지 않고 numberOfItemsInSection 메서드는 아무런 데이터가 없기 때문에 cell을 구현하지 않는 것이었습니다.

그럼 어떻게 하면 cell의 갯수를 지정해주는 메서드가 네트워크 작업이 끝난 뒤 호출될 수 있도록 할 수 있을까요? 임의의 시간 지연을 활용할 수 있겠지만 모든 작업의 소요시간을 예측하는 것은 거의 불가능하기도 하고 잘못하면 앱의 성능저하로 이어질 수 있기 때문에 저는 사용하지 않았습니다.

대신에 저는 이러한 시점 문제를 reloadData() 를 통해 해결할 수 있었습니다.

먼저 reloadData에 대해서 잠깐 살펴보자면

reloadData()란?

Reloads the rows and sections of the table view.

공식문서에 따르면 해당 메서드를 호출하므로써 테이블 또는 컬렉션을 구성하는 cell, section, header, footer, index array, 그리고 placeholder 를 포함하는 데이터를 다시 불러온다고 합니다. 효율성을 위해 뷰는 사용자에게 보여지는 화면만을 다시 재구성하고 만약 reload 후 layout의 변화가 있다면 해당 뷰의 오프셋을 조정한다고 합니다.

warning: 해당 메서드는 item을 추가하거나 제거하는 도중에 불리면 안됩니다.

ReloadData()를 호출하면 collectionView에서는 어떤 일이 벌어지나요?

  1. 아이템 수만큼의 cell 크기를 계산합니다.
collectionView(_:layout:sizeForItemAt:)
collectionView(_:layout:insetForSecttionAt:)
collectionView(_:layout:minimumLineSpacingForSectionAt:)
  1. 화면에 표시될 만큼의 cell을 만듭니다.
    1. 먼저 cell의 갯수를 지정하고
    2. 지정된만큼의 재사용가능한 cell을 dequeue합니다.
collectionView(_:numberOfItemsInsection)
collectionView(_:cellForItemAt:)
  1. collectionView와 cell의 layoutSubviews()가 호출됩니다.
layoutSubviews() of collectionView
layoutSubviews() of cell

reloadData()를 활용하면 네트워크 비동기처리보다 먼저 진행되는 cell의 크기계산과 갯수 지정을 막을 수는 없지만 네트워크 비동기처리가 끝난 뒤 받아온 데이터를 가지고 cell의 세팅을 다시 한 번 해 줄 수 있는 것입니다.

그래서 적용 해 봤습니다.

override func viewDidLoad() {
        super.viewDidLoad()

        networkManager.getItemList(page: nextPageToLoad, isCurrentlyLoading: false) { [weak self] result in
            switch result {
            case .success(let itemList):
                self?.openMarketItemList = itemList
                DispatchQueue.main.async {
                    self?.openMarketCollectionView.reloadData()
                }
            case .failure(let error):
                print(error.localizedDescription)
            }
        }
    }

기존과 똑같이 viewDidLoad()에서 네트워크 작업을 하는데요 달라진 점이 보이실 거에요.

DispatchQueue.main.async {
                    self?.openMarketCollectionView.reloadData()
                }

네트워크 작업이 끝난 뒤 openMarketCollectionView.reloadData() 를 통해 받아온 데이터를 활용하여 다시금 cell의 세팅을 하도록 설정 했습니다. 여기서 주의 해야 할 점은 reloadData() 는 무조건 main thread 에서 해야 한다는 것입니다. cell의 크기와 갯수를 설정하는 것은 UI작업이고 UI 작업은 무조건 main thread에서 해줘야 하기 때문입니다.

이렇게 수정한 뒤 에뮬레이터를 다시 돌리니까 원하던대로 collectionView가 작동하더라구요!! 🤩

[참고]:

UICollectionView.reloadData()가 완료된 시점을 알아내는 안전한 방법

Swift: Infinite Scroll & Pagination TableView - iOS Academy

profile
james, the enthusiastic developer

0개의 댓글