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더라구요..
위에 나열했던 순서대로 일이 동기적으로 처리되면 얼마나 좋을까요..
네트워크 작업 자체가 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을 추가하거나 제거하는 도중에 불리면 안됩니다.
collectionView(_:layout:sizeForItemAt:)
collectionView(_:layout:insetForSecttionAt:)
collectionView(_:layout:minimumLineSpacingForSectionAt:)
collectionView(_:numberOfItemsInsection)
collectionView(_:cellForItemAt:)
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가 작동하더라구요!! 🤩
[참고]: