호댕의 iOS 개발

DiffableDataSource, 왜 써야 하고 어떻게 써야할까? 본문

Software Engineering/iOS

DiffableDataSource, 왜 써야 하고 어떻게 써야할까?

호르댕댕댕 2023. 12. 20. 16:28

왜 DiffableDataSource를 알아보게 되었을까?

기존에는 TableView나 CollectionView를 사용할 때 IndexPath를 통해 Cell을 가져오고 이에 해당하는 데이터를 Cell에 주입시켜주는 식으로 사용을 하게 된다. 

 

하지만 이렇게 사용하게 되면 비동기로 동작을 할 때 데이터를 업데이트하는 시점이 꼬이는 문제를 마주하게 된다. 

이렇게 되면 강제종료가 발생하게 되고, 이로 인해 사용자 경험이 저하된다는 문제가 있다. 

 

이런 문제는 사용하면서 항상 발생하는 문제가 아니라 간혹 데이터가 CollectionView / TableView가 업데이트되는 도중에 변경이 되면서 데이터의 수와 그려야 할 셀의 수가 차이가 나면서 발생하기 때문에 정확히 어떤 상황에서 발생하는 것인지 디버깅이 쉽지 않다. 

 

물론 이런 문제는 기존의 방식에서도 해결하는 방법이 있다. 

 

바로바로... 

reloadData()

를 통해 업데이트를 해주면 된다. 

 

이는 WWDC 19.에서도 소개를 하고 있다.

 

Advances in UI Data Sources - WWDC19 - Videos - Apple Developer

Use UI Data Sources to simplify updating your table view and collection view items using automatic diffing. High fidelity, quality...

developer.apple.com

 

하지만 이를 사용하게 되면 애니메이션이 존재하지 않고 화면이 변경된다. 

물론 강제종료가 발생하는 것보다는 낫지만 아쉬움이 남는다. 

 

또한 TableView / CollectionView를 구성하는 모든 데이터를 다시 로드한다는 점에서 비효율적이라는 생각이 들었다. 

 

DiffableDataSource

그래서 WWDC 19에서 새롭게 소개한 방식이 바로 DiffableDataSource이다.

이런 작업을 할 때에도 따로 performBatchUpdate(_:completion:) 함수를 사용하지 않고 apply 함수만 사용해 간단하게 애니메이션이 들어간 리스트의 변경을 처리를 할 수 있다. 

 

이때 NSDiffableDataSourceSnapshot를 사용하게 된다. 

이름에서도 드러나는 것처럼 현재 보여줘야 하는 UI의 상태를 찍어놓는다고 보면 된다. 

 

그래서 만약 업데이트가 필요하다면 apply 함수를 통해 새로운 Snapshot를 적용하는 것이다.

 

이때 IndexPath는 따로 쓰지 않고 Section과 Item에 고유한 Identifier가 존재한다. 이를 통해 데이터를 배치하기 때문에 이전에 IndexPath로 데이터를 배치하는 것과 차이가 있다. 

 

DiffableDataSource의 특징

정리해보면 DiffableDataSource의 특징은 다음과 같다.

  • 화면에 표시되어야 하는 UI는 NSDiffableDataSourceSnapshot를 사용하며 apply 함수를 통해 이를 적용
  • IndexPath가 아닌 고유한 Identifier를 통해 데이터를 화면에 배치

 

그렇다면 어떻게 적용할까?

지금까지 기존의 TableView / CollectionView를 구성하는 방식과 새로운 방식인 DiffableDataSource의 차이를 간단하게 알아봤다. 

하지만 제일 중요한 것은 그래서 어떻게 적용할 것인지일테니... 알아보자

 

기존에는 UITableViewDataSource / UICollectionViewDataSource 프로토콜을 채택하고

이런 필수 함수를 구현해서 리스트를 구현하게 된다. 

 

1. DataSource에 들어가는 요소 구성하기

우리는 이제 고유한 Identifier를 통해 데이터를 배치할 것이기 때문에 UICollectionViewDiffableDataSource UITableViewDiffableDataSource를 정의해서 사용하게 된다. 

 

@MainActor @preconcurrency
class UITableViewDiffableDataSource<SectionIdentifierType, ItemIdentifierType> : NSObject 
where SectionIdentifierType : Hashable, SectionIdentifierType : Sendable, ItemIdentifierType : Hashable, ItemIdentifierType : Sendable

이는 제네릭으로 정의되어 있다. 

 

동시성 상황에서도 안전하게 보내져야 하고 Identifier를 통해 정확한 위치에 정확한 Item이 들어갈 수 있도록 Hashable과 Sendable이 Section과 Item에 채택되어 있다. 

 

enum Section: CaseIterable {
    case basic
}

enum의 경우 연관값이 Hashable하다면 따로 Hashable을 채택하지 않더라도, Hashable하기 때문에 Section은 위 코드처럼 열거형으로 관리해주면 편하게 사용이 가능하다. 

 

애플의 예시 코드에서도 이런 식으로 Section을 구현하고 있다. 

https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/implementing_modern_collection_views

 

Implementing Modern Collection Views | Apple Developer Documentation

Bring compositional layouts to your app and simplify updating your user interface with diffable data sources.

developer.apple.com

 

 

이런 Section에 들어가게 되는 Item의 모델은 따로 Hashable을 채택해줘야 한다. 

struct Image: Decodable, Hashable {
    let id: String
    let createdAt: String
    let description: String
    let urls: ImageURL
    let likes: Int
    
    enum CodingKeys: String, CodingKey {
        case urls, likes, id
        case description = "alt_description"
        case createdAt = "created_at"
    }
    
    static func == (lhs: Image, rhs: Image) -> Bool {
        lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher) {
            return hasher.combine(id)
        }
}

struct ImageURL: Decodable {
    let regular: String
    let small: String
}

이를 통해 특정 객체가 완전히 동일한 객체인지 비교를 할 수 있게 되고 이를 통해 정확한 위치에 Item을 배치할 수 있게 된다. 

 

2. DataSource 구성하기

이는 기존 UITableViewDataSource / UICollectionViewDataSource 프로토콜을 채택하고 구현해주던 부분과 거의 유사하다. 

 

이전에는 register 함수와 cellForRowAt 함수를 통해 구현을 해줬다면 이제는 아래와 같은 방식으로 리스트를 구성할 DataSource를 구현해주면 된다. 

let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Int> { cell, indexPath, item in
    
    var contentConfiguration = cell.defaultContentConfiguration()
    
    contentConfiguration.text = "\(item)"
    contentConfiguration.textProperties.color = .lightGray
    
    cell.contentConfiguration = contentConfiguration
}

register는 더이상 register 함수를 사용하지 않고 CellRegistration을 사용한다. 

기존 register 함수의 경우 정말 사용할 셀만 등록하는 역할이었다면 여기서는 기존 cellForRowAt에서 해주던 것처럼 셀에 들어갈 콘텐츠도 클로저에서 넣어줄 수 있다. 

 

물론 기존처럼 UICollectionViewDiffableDataSource / UITableViewDiffableDataSource에서 넣어줄 수도 있으나, 공식문서의 예제 코드에서도 셀을 등록할 때 넣어주고 있어서 나 또한 동일한 방식으로 구현을 해주었다.

 

dataSource = UICollectionViewDiffableDataSource<Int, UUID>(collectionView: collectionView) {
    (collectionView: UICollectionView, indexPath: IndexPath, itemIdentifier: UUID) -> UICollectionViewCell? in
    // 여기서 셀을 dequeue 반환해주면 됨
}

 

private func configureDataSource() {
    let cellRegistration = UICollectionView.CellRegistration<ImageCell, Image> { cell, indexPath, image in // register
        cell.configure(item: image)
    }

    dataSource = UICollectionViewDiffableDataSource<Section, Image>( // cellForRowAt
        collectionView: collectionView,
        cellProvider: { collectionView, indexPath, image in
            let cell = collectionView.dequeueConfiguredReusableCell(
                using: cellRegistration,
                for: indexPath,
                item: image
            )

            return cell
    })
}

 

3. DataSource 적용하기

이제 구성한 DataSouce를 기반으로 데이터를 적용하려면 이전에 언급했던 NSDiffableDataSourceSnapshot을 정의하고 apply 함수를 사용하면 된다. 

private func fetchImageData(page: Int) {
    let api = ImageListAPI(page: page)

    NetworkManager().fetchData(api: api, decodingType: [Image].self)
        .withUnretained(self)
        .subscribe(onNext: { vc, image in
            var snapshot = NSDiffableDataSourceSnapshot<Section, Image>() // 띄워주게 될 데이터
            snapshot.appendSections([.basic])
            snapshot.appendItems(image)
            vc.images = image
            vc.dataSource?.apply(snapshot, animatingDifferences: true)
        })
    .disposed(by: disposeBag)
}

데이터를 받아왔다면 이를 통해 NSDIffableDataSourceSnapshotappendSections / appendItems를 통해 추가해주면 된다. 

 

그리고 dataSource에 이를 apply 함수를 해주면 화면에 데이터가 표시되게 된다. 

 

4. DataSource 수정하기

지금까지는 기본적으로 처음 데이터를 불러와서 데이터를 통해 리스트를 그려주는 것까지 살펴봤다.

 

하지만 나는 데이터가 변경될 때 강제 종료가 되는 문제로 인해 DiffableDataSource를 알아봤다. 

그런 의미에선 이 부분이 가장 중요할 것이다. 

 

수정하는 방법에는 2가지가 존재한다. 

  • 아예 새로운 Snapshot을 만들어서 apply 하는 방법
  • 기존 Snapshot을 불러와 수정 후 apply 하는 방법

 

아예 새로운 Snapshot을 만들어서 apply 하는 방법

이는 위에서 보았던 것과 거의 동일한 방식이다. 

 

만약 날짜 순으로 정렬을 해야 한다면 아래 코드처럼 진행하면 된다. 

@objc
private func sortSnapshot() {
    images = images.sorted(by: { $0.createdAt > $1.createdAt })

    var snapshot = NSDiffableDataSourceSnapshot<Section, Image>()
    snapshot.appendSections([.basic])
    snapshot.appendItems(images)

    dataSource?.apply(snapshot, animatingDifferences: false)
}

데이터를 정렬해준 후 다시 NSDIffableDataSourceSnapshot를 생성하고 appendSections / appendItems를 해준 후 다시 apply를 해주기만 하면 된다.

 

다른 화면을 그려야 하는 상황이라면 새롭게 보여줄 데이터 구성을 찍은 후 이를 적용하는 것이다. 

이는 기존 reloadData()와 유사한 방식으로 전체 데이터를 아예 새롭게 로드하는 방식이다.

 

기존 Snapshot을 불러와 수정 후 apply 하는 방법

이는 미리 정의해놓은 NSDIffableDataSourceSnapshotsnapshot() 함수를 통해 기존 Snapshot을 불러올 수 있다. 

이렇게 불러온 후 NSDIffableDataSourceSnapshot의 공식문서에 나오는 함수들을 이용해 

 

NSDiffableDataSourceSnapshot | Apple Developer Documentation

A representation of the state of the data in a view at a specific point in time.

developer.apple.com

중간에 아이템을 넣거나 이동시키거나 삭제하고 apply 함수를 사용해주면 된다. 

 


사실 이전에도 DiffableDataSource에 대해 학습하고 사용해본 적이 있었지만 그 당시에는 단순히 새롭게 TableView / CollectionView를 그리는 방식이 있다더라 해서 공부를 했던 것이었다보니 왜 이걸 써야하는지에 대한 이해도 부족했다.

 

하지만 실제 현업에서 일하다 보니 테스트할 때는 당연히 잘 되던 부분도 1% 미만의 사용자들에게 문제가 발생하는 경우가 종종 있었고, 여기에는 TableView / CollectionView를 새롭게 그리는 과정에서 발생하는 문제도 포함되어 있었다. 

 

그래서 이를 해결하는 방법을 찾다가 다시 DiffableDataSource를 만나게 되니 확실히 왜 DiffableDataSource를 써야하는지 고민해볼 수 있었고, 당시에는 어렵다고만 느껴졌던 방식이 왜 새로 나왔고 장점을 가지는 지 알 수 있었다.

 

역시 새로 나온 기술을 막연히 쫓기 보다는 왜 이 기술이 나왔는지 공감하고 학습하는 것이 중요하다고 느껴진다.

Comments