[iOS/UIKit] Realm을 DiffableDataSource와 함께 사용할때 발생할 수 있는 오류

Realm을 통해 DB를 관리하는 프로젝트에서 DiffableDataSource를 이용해 CollectionView를 구성하고 있었다.

 

원하는 상품을 위시리스트에 담고, 그 리스트를 보여주는 화면이었으며,

각 Cell을 탭하면 위시리스트에서 제거되는 로직을 구성하는 중이었다.

 

그런데, Realm에 있는 데이터를 불러오고 데이터를 추가하는데는 문제가 없었으나, Cell을 탭하는 동작, 즉 삭제하는 과정에서 다음과 같은 런타임 에러가 발생했다.

 

Object has been deleted or invalidated

 

이 오류는 Realm에서는 데이터가 삭제됐는데, view에서는 삭제되지 않은 상태로 남아있을 때 발생하는 오류였다.

그런데, 뷰를 업데이트하는 로직도 문제 없었고, Realm의 DB 자체에도 문제는 없어보였다.

 

이번 에서는 이 오류가 발생하는 이유와 해결방법 두 가지를 함께 알아보도록 하겠다.


에러 발생

먼저, snapshot을 업데이트 하는 부분의 코드를 살펴보겠다.

private func updateSnapshot() {
    let data = repository.fetchAll()
    wishlistList = Array(data)
    
    var snapshot = NSDiffableDataSourceSnapshot<Section, Wishlist>()
    snapshot.appendSections(Section.allCases) // 섹션
    snapshot.appendItems(wishlistList, toSection: .main) // 셀
    
    dataSource?.apply(snapshot)
}

realm에서 fetch해온 데이터를 배열 형태로 만들어서 각 cell에 표시하고 있다.

 

다음은 각 Cell을 탭했을 때 데이터를 삭제하는 부분이다.

extension WishlistViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let data = wishlistList[indexPath.item]
        repository.deleteItem(data: data)
        
        updateSnapshot()
    }
}

실제 구현 구현 코드는 레포지토리패턴으로 따로 분리를 해놓은 상태이며,

Cell을 탭했을때는 해당 삭제 로직을 실행하고 스냅샷을 업데이트 하는 동작이 일어나고 있다.

 

그런데 `updateSanpshot()` 메서드 내부에서 위의 사진에서와 같은 에러가 발생하며 앱이 강제종료됐다.


문제의 원인

다음의 블로그를 비롯해 구글링을 통해 찾아본 결과, 이 문제는 나 혼자만 겪고있는 문제가 아니었다.

그리고 사실 Realm의 공식문서에도 이 문제에 대한 내용이 있었다...!!

https://velog.io/@maddie/iOS-Diffable-Realm-Delete-Error

 

쉽게 정리하자면 문제의 원인은 다음과 같다

  • Diffable이 View를 업데이트 하는 방식은 Snapshot방식으로, 데이터가 변화하기 이전의 모습을 캡쳐하고, 이후의 모습을 캡쳐해서 바뀐 부분을 업데이트 한다.
  • 삭제버튼을 누르면 객체를 relam에서 지우는 동작이 일어난다.
  • 이 이후에 snapshot을 통해 이전의 데이터와 현재의 데이터를 비교하려고 하는데 이전의 데이터는 이미 삭제된 이후이다!
  • 즉, 비교할 이전의 데이터가 없기 때문에 에러가 발생하는 것이다!

 

그렇다면 이 문제를 해결하기 위해 어떻게 삭제 기능을 구현해야할까?

 

해결방법은 크게 두 가지 정도가 있으며, 사실 어떻게 구현하느냐에 따라 더 많은 방법이 존재할 수 있을 것으로 보인다.

다음으로 두 가지 해결방법을 소개하겠다.


reloadData 방식 사용

`UICollectionViewDataSource`에서 사용하던 방식인 reloadData를 통해 해결할 수 있다.

 

Diffable에서도 위와같은 문제가 많이 발생하는걸 알고있기 때문에 별도의 메서드를 이용해서 snapshot방식이 아닌 reloadData방식을 이용할 수 있는데, 코드는 다음과 같다.

private func updateSnapshot() {
    let data = repository.fetchAll()
    wishlistList = Array(data)
    
    var snapshot = NSDiffableDataSourceSnapshot<Section, Wishlist>()
    snapshot.appendSections(Section.allCases) // 섹션
    snapshot.appendItems(wishlistList, toSection: .main) // 셀
    
    // 여기 메서드 수정
    dataSource?.applySnapshotUsingReloadData(snapshot)
}

`dataSource?.apply(snapshot)`에서 apply 대신 applySnapshotUsingReloadData라는 메서드로 바꿔주면 해결된다!

 

단, 이 방법을 사용하면 Diffable의 장점인 애니메이션이나 Diff연산 등을 사용할 수 없다는 아쉬운점이 있다.

그럼에도 불구하고 단 한줄의 코드만 수정함으로써 문제를 해결할 수 있어서 급할때 쓰기 좋을것 같았다.


isDeleted로 삭제상태 관리하기

삭제를 한 이후에 데이터에 접근하는게 문제라면, 삭제하지 않고 접근한 이후 접근하면 되지 않을까?

단, 사용자가 눈으로 보기에는 삭제된것처럼 보이게 하는 방법이다.

 

먼저 Realm Object에 isDeleted라는 Bool타입 칼럼을 추가해준다.

// Wishlist

final class Wishlist: Object {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var name: String
    @Persisted var date: Date
    // 이 칼럼을 만들어줌
    @Persisted var isDeleted: Bool = false
    
    convenience init(name: String) {
        self.init()
        self.name = name
        self.date = Date()
    }
}

 

그리고, 삭제버튼을 눌렀을때, 실제로 데이터를 삭제하는게 아니라 이 변수를 true로 설정해주는 것이다.

// WishlistRepository

final class WishlistTableRepository: WishlistRepository {
    private let realm = try! Realm()
    
		// ...
    
    // 삭제 처리를 하기 위한 메서드
    func updateItem(data: Wishlist) {
        do {
            try realm.write {
                realm.create(Wishlist.self, value: [
                    "id": data.id,
                    "isDeleted": true
                ], update: .modified)
            }
        } catch {
            print("렘 데이터 수정 실패")
        }
    }
}

 

데이터를 fetch해올때는 isDeleted가 false인 애들만 필터링해서 가져오면

사용자가 보기에는 삭제된것처럼 보이게 된다.

func fetchInFolder(folder: WishFolder) -> Results<Wishlist> {
    let data = realm.objects(Wishlist.self)
        .sorted(byKeyPath: "date", ascending: false)
        .where { $0.isDeleted == false }
        .where { $0.folder == folder }

    return data
}

 

 

마지막으로, 이렇게 삭제한것처럼 보이게 한 이후에, delete 메서드를 통해 실제 realm에 남아있는 데이터를 삭제하면 realm에 남아있는 데이터도 삭제되게 된다.

 

위의 코드들을 적용하면 다음과 같다.

// VC
private var wishlistList: [Wishlist] = []
private let repository: WishlistRepository = WishlistTableRepository()

override func viewDidLoad() {
    super.viewDidLoad()
    
    configureView()
    configureDataSource()
    updateSnapshot()
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    
    repository.getFileURL()
    updateSnapshot()
}

// ...

private func updateSnapshot() {
    let data = repository.fetchAll()
    wishlistList = Array(data)
    
    var snapshot = NSDiffableDataSourceSnapshot<Section, Wishlist>()
    snapshot.appendSections(Section.allCases) // 섹션
    snapshot.appendItems(wishlistList, toSection: .main) // 셀
    
    // 여기 코드는 그대로
    dataSource?.apply(snapshot)
}

// 셀을 탭해서 삭제하는 코드
extension WishlistViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let data = wishlistList[indexPath.item]
	       
        // 실제 삭제가 아닌 isDeleted 칼럼을 true로 만들어줌
        repository.updateItem(data: data)
        
        updateSnapshot()
        
        // snapshot 비교가 끝난 이후에 실제 DB에서 삭제
        repository.deleteItem(data: data)
    }
}

 

이렇게 해결하면, Diffable의 장점은 살리면서 에러도 해결할 수 있다는 장점이 있다.

 

이 외에도 다양한 방법이 있을 수 있을것같다.

어쨌든 문제의 핵심은 "객체가 삭제된 이후에 접근하지 말 것" 이걸 기준으로 다양한 방법을 시도해보는것도 좋을것 같다.


※ 참고자료

https://velog.io/@maddie/iOS-Diffable-Realm-Delete-Error

 

[iOS] Diffable + Realm Delete Error

🚨 에러 내용이틀 내내 이거 해결 못하고 있었다.우선 DiffableDataSource와 Realm을 함께 사용했고,Diffable은 데이터가 변하면, 이전 상태와 비교해서 뷰를 갱신하는 방식으로 동작한다.따라서 Realm 데

velog.io