본문 바로가기
IOS App Programming/트러블 슈팅

[SwiftUI/Realm] Delete 시 invalidated 오류 해결 방안

by B_Tori 2024. 4. 3.

프로젝트 진행 시 로컬 데이터베이스로 Realm을 사용해 보기로 하였고 간단한 Todo app이었기에 내가 SwiftUI를 진행하면서 늘 해오던 방식대로 ContentView -> TodoView(List) -> RowView로 연결되어 있었고 세 뷰는 가장 상위 앱단위에서

StateObject로 선언한 TodoStore 즉 뷰 모델을 environmentObject로 주입시켜준 상태였다.

 

Realm 가이드를 참조하여 기존 Todo 모델을 Object 클래스로 변경하고 ViewModel에 있던 Todo 리스트를 Object 리스트로 변경 후 CRUD를 구현했을 때 ADD까지는 진행이 되었으나 삭제 시 다음과 같은 에러가 발생하면서 앱이 중단되는 상황이 발생했다.

Terminating app due to uncaught exception 'RLMException', reason: 'Object has been deleted or invalidated.'

 

이런저런 시도를 해보았고 오류를 해결하는데 일주일이나 걸렸던.......

그래서 왜 이러한 오류가 발생했는지 찾아본 원인과 다양한 구글링을 통해 시도한 방법들 그리고 최종 방법들을 정리해볼까 한다.

 

원인

우선 말 그대로 삭제한 데이터를 어디선가 참조하고 있어서 발생하는 오류이다.

SwiftUI라는 선언형 언어 특성 그리고 뷰 구조 특성상 다음과 같은 원인으로 참조가 남아있을 수 있다고 한다.

  1. 연계되어 있는 뷰 -> 예를 들면 하위뷰인 로우뷰가 삭제한 Todo를 가지고 있기 때문에 참조가 남아있을 가능성이 있음
  2. 선언형 언어 특성상 이전 상태와 현 상태의 차이점을 만들기 위해 삭제한 데이터의 사본을 가지고 있음

이러한 복합적인 이유로 나는 여러 가지 시도를 해도 실패를 했었고 결국 결론 먼저 말하자면 Realm 데이터용 class TodoObject와 뷰에 그려질 데이터 struct Todo 두 가지 모델을 생성 후 뷰 동작과 데이터 동작을 분리하면서도 둘을 동기화시켜 데이터를 유지하는 방법을 선택하여 해결하였다.

 

첫 번째 시도 - Row뷰의 데이터 초기화

1. View Model delete 함수 호출 후 Todo 값 변경

버튼을 눌렀을 때 함수를 호출한 뒤 todo = Todo() 코드를 추가하였다. 

하지만 똑같은 오류가 발생하였다.

2. inout 파라미터로 뷰모델에서 Todo 값 변경

row view에서 delete 버튼을 눌렀을 시 viewModel로 inout 파라미터를 통해 뷰모델 함수 내부에서 row뷰의 todo를 변경할 수 있도록 하였다.

하지만 이 또한 실패하였다.

 

두 번째 시도 - 데이터베이스 삭제 코드와 Published 배열 삭제코드 순서 변경

기존 코드는 데이터베이스의 데이터를 삭제 후 Published에 담겨있는 Object배열의 요소를 삭제하여 뷰를 업데이트하였다.

나는 이를 통해 이미 삭제한 후 뷰를 업데이트하여 참조가 꼬였지 않았을까라는 판단을 하고 두 가지의 순서를 바꿔 진행해 보았다.

삭제할 데이터를 변수에 담아두고 배열의 요소를 먼저 삭제하여 뷰를 업데이트한 뒤 담아둔 데이터를 통해 데이터베이스 삭제를 진행하면 오류가 해결될 것만 같았다. 하지만 이 또한 실패했다.

 

마지막 시도 - 데이터 베이스용 Object와 뷰 용 Model 분리 후 Observer 연결

같은 구조의 데이터를 두 가지 만드는 것이 맞는 것일까 라는 판단에 마지막의 마지막까지 보류했던 방법이다.

결국 이 방법으로 나는 성공을 했다..

 

모델 코드

// 뷰용 struct Model
struct Todo: Identifiable, Codable {
    var id = UUID().uuidString
    let title: String
    let deadline: Double?
    let createDate : Double
    var isChecked: Bool
    
    init(id: String = UUID().uuidString, title: String, deadline: Double? = nil, createDate: Double = Date().timeIntervalSince1970, isChecked: Bool) {
        self.id = id
        self.title = title
        self.deadline = deadline
        self.createDate = createDate
        self.isChecked = isChecked
    }
    
    init(todoObj: TodoObject) {
        self.id = todoObj.id.stringValue
        self.title = todoObj.title
        self.deadline = todoObj.deadline
        self.createDate = todoObj.createDate
        self.isChecked = todoObj.isChecked  
    }

	... // 기타 코드
}

// Realm 데이터베이스용 Object
class TodoObject: Object, Identifiable {
    @Persisted(primaryKey: true) var id: ObjectId
    @Persisted var title: String
    @Persisted var deadline: Double?
    @Persisted var createDate : Double
    @Persisted var isChecked: Bool  
}

 

실제 프로젝트에는 몇 가지 더 프로퍼티들이 있지만 오류 해결 방법에 집중하기 위해 간단하게 추려보았다.

뷰에 사용될 struct 기존 모델과 Realm에 저장될 class Object를 만들고 기존 모델에는 Object를 입력받아서 생성할 수 있는 이니셜라이저를 추가해 주었다. ( struct는 기본이니셜라이저(멤버와이즈이니셜라이저)가 제공되지만 사용자 이니셜라이저를 작성하는 순간 제공이 안되니 기본 이니셜라이저도 작성해줘야 한다.)

 

ViewModel 코드

class TodoStore: ObservableObject {
    @ObservedResults(TodoObject.self) var todoObjects
    @Published var todos: [Todo] = []
	
    private var token: NotificationToken?
    
    init() {
        setupObserver()
    }
    
    deinit {
        token?.invalidate()
    }
    
    private func setupObserver() {
        do {
            let realm = try Realm()
            let results = realm.objects(TodoObject.self)
            
            token = results.observe({ [weak self] changes in
                self?.todos = results.map(Todo.init)
                    .sorted(by: { $0.createDate < $1.createDate })
            })
        } catch let error {
            print("옵저버 셋팅 실패")
            print(error.localizedDescription)
        }
    }
    
    func addTodo(todo: TodoObject) {
        $todoObjects.append(todo)
    }
    
    func deleteTodo(todoId: String) {
        do {
            let realm = try Realm()
            let objectId = try ObjectId(string: todoId)
            if let todo = realm.object(ofType: TodoObject.self, forPrimaryKey: objectId) {
                try realm.write {
                    realm.delete(todo)
                }
            }
        } catch {
            print("데이터 삭제 실패 : \(error)")
        }
    }
    
    func checkTodo(todoId: String) {
        do {
            let realm = try Realm()
            let objectId = try ObjectId(string: todoId)
            let todo = realm.object(ofType: TodoObject.self, forPrimaryKey: objectId)
            if let todo = todo {
                try realm.write {
                    todo.isChecked.toggle()
                    realm.add(todo, update: .modified)
                }
            }
        } catch {
            print("업데이트 실패(할일 체크 실패): \(error.localizedDescription)")
        }
    }
}

기본적인 데이터 핸들링은 데이터베이스 Object인 TodoObject를 이용해 진행한다. 

왜냐하면 데이터 바인딩 덕분에 todos는 todoObjects가 변경되면 그에 따라 바로 반영되기 때문이다. 
따라서 옵저빙 부분에 대해서 설명하면 다음과 같다.

  1. ObservedResults인 todoObjects 객체를 생성해 데이터 베이스의 변화가 있을 때마다 업데이트받을 수 있도록 함
  2. Published로는 Todo 모델 리스트로 선언하여 변경되면 뷰가 업데이트될 수 있도록 함
  3. setupObserver() 함수를 통해 todoObjects와 todos를 바인딩시켜주는 역할을 함. 
    이때 NotificationToken이 변경사항이 생기면 알려주는 역할을 하고 observe를 통해 데이터베이스 객체를 옵저빙 하여 변경사항이 생기면 이를 Todo로 매핑하여 todos에 반영해 주어 데이터 변경사항을 동기화해 주었다.

댓글