NSFetchedResultsController 프로젝트에 적용하기 (feat. 공식문서 번역)

브로디·2023년 4월 30일
0

NSFetchedResultsController 프로젝트에 적용하기 (feat. 공식문서 번역)

학습내용

  • NSFetchedResultsController 번역하기
  • NSFetchedResultsController 프로젝트에 적용하기

NSFetchedResultsController 번역하기

CoreData fetch request에 대한 결과를 관리하고 사용자에게 데이터를 보여준다.

Overview

테이블 뷰가 여러가지 방법으로 사용되는동안 fetchResultController는 list view를 제공함으로써 도와준다. UITableView는 dataSource가 행에 필요한 셀들을 제공하는 것을 예상한다.
이를 fetch request를 사용하는 fetchedResultController를 사용하여 구성한다.
이 객체는 어떤타입의 엔티티를 fetch할 것인지와 결과를 어떻게 sort할 것인지를 명시한다. 또한 엔티티의 특정 인스턴스를 언제 포함할지와 같은 기준도 추가할 수 있다.

fetchedResultController는 효율적으로 fetch된 결과를 분석하고 결과집합의 섹션들을 계산한다. 또한 인덱스에 해당하는 정보들도 계산한다.

추가적인 기능은 다음과 같다.

  • 관련된 관리객체 컨텍스트안의 객체에 대한 변화를 선택적으로 감시하고 그 results 집합에서의 변화를 대리자에게 알린다.
  • 같은 데이터가 추후 다시 보여지는 경우 선택적으로 계산결과를 캐시할 수 있다.

따라서 이 컨트롤러에는 델리게이트를 가지고있는지, 캐시파일 이름이 설정되어있는지에 따라 결정되는 세 가지 모드를 가지고 있다.

  • No tracking: delegatenil로 설정됨. 컨트롤러는 단순히 fetch가 실행되었을 때 데이터를 제공하는 역할만 한다.
  • Memory-only tracking: delegatenil이 아니고 cache파일 이름은 nil이다. 컨트롤러는 결과 집합안의 객체들을 감시하고 변화에 반응하여 section을 업데이트하고 정보를 정렬한다.
  • Full persistent tracking: 델리게이트와 cache파일 이름이 nil이 아니다. 이 컨트롤러는 결과를 감시하고 섹션을 업데이트하고 관련된 변경사항에 반응하여 정보를 정렬한다. 그리고 그 결과를 캐시한다.

Important

변경을 추적하기 위해서 변경을 추적하는 델리게이트 메서드 중 하나가 구현되어야 한다.

Using NSFetchedResultsController

Creating the Fetched Results Controller

일반적으로 테이블 뷰 컨트롤러의 인스턴스 변수로서 NSFetchedResultsController인스턴스를 생성한다. 이를 초기화할 때 다음 네 가지 파라미터를 제공해야 한다.

  • fetch request: 결과를 정렬하기 위해 최소 하나의 sort descriptor를 제공해야 한다.
  • managed object context. 결과 컨트롤러는 이 컨텍스트를 사용해서 fetch request를 실행한다.
  • 선택적으로 섹션 이름을 반환하는 결과 객체의 key path를 제공한다. 결과 컨트롤러는 이 key path를 사용하여 결과를 섹션들로 나눈다.
  • 선택적으로 캐시파일 이름을 정의한다. 캐시를 사용하는 것은 섹션과 인덱스 정보를 계산하는 오버헤드를 피할 수 있게 만든다.

인스턴스를 생성한 이후에 performFetch()메서드를 호출해 실제로 패치를 실행한다.

let context = <#Managed object context#>
let fetchRequest = NSFetchRequest<AAAEmployeeMO>(entityName: "Employee")
// Configure the request's entity, and optionally its predicate
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "<#Sort key#>", ascending: true)]
let controller = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil)
do {
    try controller.performFetch()
} catch {
    fatalError("Failed to fetch entities: \(error)")
}

important

만약 캐시를 사용 중이라면 fetch request, predicate, sort descriptor를 변경하기 전에 deleteCache(withName:)을 꼭 실행해야 한다.
만약 cacheName을 nil로 하는 게 아니라면 같은 fetched results controller를 재사용하면 안 된다.

The Controller's Delegate

만약 fetchedResultsContoller에 대해 delegate를 설정했다면 그 컨트롤러는 관리 객체 컨텍스트로부터 오는 알림을 수신할 것이다.
컨텍스트 안의 어떠한 변화(결과 집합이나 섹션 정보에 영향을 미치는)가 처리될 것이고 그 결과는 그에 따라 업데이트 될 것이다.
이 resultController는 결과 객체의 위치가 변경되거나 섹션이 수정되었을 때 델리게이트에게 알린다.
일반적으로 이 델리게이트 메서드를 이용해서 테이블 뷰를 업데이트 할 수 있다.

The Cache

resultController는 캐시를 사용해서 반복적인 작업을 피할 수 있다. 캐시는 앱을 시작할 때부터 유지된다.

NSFetchedResultsController의 인스턴스를 초기화했다면 cacheName을 명시할 수도 있다.(만약 명시하지 않았다면 그 결과컨트롤러는 캐시데이터가 없다.) 결과컨트롤러를 만들 때 주어진 이름에 해당하는 존재하는 캐시를 찾는다.

  • 만약 결과컨트롤러가 적절한 캐시를 찾지못한다면 필요한 섹션과 섹션안의 정렬된 객체를 계산한다. 그 이후 이 정보를 디스크에 넣는다.
  • 만약 같은 이름의 캐시를 찾았다면 결과 컨트롤러는 그 캐시의 내용이 유효한지를 테스트한다. 이 과정에는 현재 엔티티 이름, 엔티티 버전, sort descriptor, section key-path등을 비교한다. 그리고 캐시파일과 영구저장소파일의 수정날짜도 비교한다.
  • 만약 캐시가 현재 정보와 일치한다면 결과컨트롤러는 이전의 계산된 정보를 재사용한다.
  • 만약 캐시가 현재 정보와 일치하지 않는다면 필요한 정보가 재계산되고 캐시가 업데이트된다.

섹션과 정렬된 정보가 변할때마다 캐시는 업데이트 된다.

만약 여러개의 fetchedResultsController를 가지고 있다면 다른 캐시이름을 줘야 한다.

Responding to Changes

일반적으로 NSFetchedResultsController는 결과 객체의 위치나 섹션이 변경되었을 때 대리자에게 호출함으로써 모델 레이어의 변화에 반응되도록 설계되었다.

만약 사용자가 테이블을 재정렬하는 것을 허용하도록 하려면 델리게이트 메서드를 고려해야 한다.

컨트롤러의 관리 객체 컨텍스트가 processPendingChanges메세지를 수신할 때까지 반영되지 않는다. 그러므로 만약 결과객체의 위치가 변경되도록 관리객체 어트리뷰트의 값을 변경했다면 그것의 인덱스는 현재 이벤트 사이클이 끝날때까지 변경되지 않을 것이다.
예를 들어 다음의 코드는 "same"을 찍는다.

let frc = <#A fetched results controller#>
let managedObject = <#A managed object in frc's fetchedObjects array#>
let beforeIndexPath = frc.indexPath(forObject: managedObject)
managedObject.setValue("Fred", forKey: "name")
let afterIndexPath = frc.indexPath(forObject: managedObject)
if beforeIndexPath?.compare(afterIndexPath!) == .orderedSame {
    print("same")
}

관리객체의 속성을 변경시켰는데 indexPath가 동일하다. 따라서 setValue뒤에는 frc.processPendingChanges가 필요하다. 이는 사용자에게 임시적으로 수정된 내용을 보여주고 싶을 때 사용하는 것으로 보인다. 영구적으로 저장하려면 save를 사용한다.

Modifying the Fetch Request

결과를 수정하기위해 fetch request를 변경할 수 있다. 만약 원한다면 다음의 순서를 따른다.

  • 만약 캐시를 사용한다면 지운다. 일반적으로 변경되는 fetch request를 사용하면 캐시를 사용안한다.
  • fetch request를 변경한다.
  • performFetch를 수행한다.

Handling Object Invalidation

관리 객체 컨텍스트가 개별 객체가 무효하다는 것을 알릴 때 결과컨트롤러는 이를 삭제된 객체로 취급하고 적절한 델리게이트 호출을 보낸다.

관리 객체 컨텍스트 안의 모든 객체들이 동시에 무효해지는 건 가능하다. (예를 들어 reset()호출의 결과나 만약 저장소가 persistent store coordinator에 의해 제거되었을 때) 이러한 현상이 일어났을 때 NSFetchedResultsController는 모든 객체를 무효화하지는 않는다. 그리고 객체 삭제에 대한 알림을 보내지도 않는다. 대신에 performFetch()메서드를 호출해서 컨트롤러의 상태를 reset시키고난 후에 reloadData로 리로드 한다.

NSFetchedResultsController 프로젝트에 적용하기

NSFetchedResultsController 적용하게 된 계기

1. 보일러 플레이트 코드 제거

기존 코어데이터를 이용해 테이블 뷰의 데이터를 삭제할 때는 다음 세 가지 보일러 플레이트 코드가 존재한다.

  • 코어데이터 저장소에 특정 ID를 전달해서 delete메서드를 호출하는 코드
  • 뷰컨에 존재하는 데이터배열의 index에 접근해서 제거하는 코드
  • 테이블 뷰를 업데이트하기 위해 CRUD하는 코드
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    CoreDataStack.shared.delete(itemArray[indexPath.row])
    itemArray.remove(at: indexPath.row)
    saveItems()
    tableView.deselectRow(at: indexPath, animated: true)  
}

이는 fetchedResultsController로 깔끔하게 제거할 수 있다.

왜냐하면 fetchedResultsControllerCoreData의 특정 저장소에 해당하는 context를 모니터링하고 있기 때문이다. 정확히는 NSFetchedResultsControllerDelegate가 이 일을 수행한다. 따라서 fetchedResultsController를 사용하는 뷰컨에서 이 델리게이트 메서드를 채택하면 모니터링할 수 있게 된다.

이 의미는 코어데이터가 변경되었을 때 자동으로 호출되는 메서드가 존재한다는 의미와 같다.

delete메서드를 호출해서 해당 객체가 지워지고 난 후에 save만 성공적으로 되면 UI처리는 이 델리게이트에서 처리한다는 것이다.

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                didChange anObject: Any,
                at indexPath: IndexPath?,
                for type: NSFetchedResultsChangeType,
                newIndexPath: IndexPath?) {

    switch type {
    case .insert:
        ahjaeTableView.insertRows(at: [newIndexPath!], with: .automatic)
    case .delete:
        ahjaeTableView.deleteRows(at: [indexPath!], with: .automatic)
    default:
        print("Unexpected NSFetchedResultsChangeType")
    }
}

2. CRUD 동기화, 역할 분리

NSFetchedResultsController를 적용하기 전에는 코어데이터를 지우는 메서드를 호출하고 나서 테이블 뷰의 행을 지우는 메서드를 호출했다.

하지만 코어데이터를 지우는 메서드를 호출했지만 모종의 이유로 만약 지워지지 않은 상태라면 데이터는 남아있지만 테이블 뷰에서는 지워질 것이다.
이를 해결하기 위해 Bool타입의 성공여부를 반환받아서 성공했을 때 테이블 뷰를 업데이트할 수 있을 것이다. (또는 Result타입을 사용할 수도, do catch를 사용할 수도 있겠다.)

하지만 하나의 didSelect 델리게이트 메서드에서 여러가지 일을 하는 건 왠지 모르게 찝찝하다.(데이터도 지우고, )

SRP를 준수하기 위해 코어데이터와 관련있는 UI에 관련된 객체를 따로 설정해두는 것이 더 효율적으로 보였다.

따라서 NSFetchedResultsControllerDelegate를 이용해서 CRUD동기화를 이쪽에서 적용시켜 역할을 분리했다.

NSFetchedResultsController를 사용하면 좋은 이유 중에는 캐시와 관련된 내용이 있지만 아직 다루지않았다. (추후 사용해 볼 예정)

CoreDataStack, CoreDataFetchedResults

CoreDataFetchedResults

패치된 결과를 관리하는 컨트롤러를 관리하는 객체.

처음에는 fetchedResultController를 뷰컨트롤러에서 직접 갖고 있었다. 하지만 여러개의 뷰컨에서 이를 사용할 때 performFetch를 실행하는 코드와 이에 실패했을 때 처리하는 코드가 뷰컨에 존재해야 했기 때문에 보일러 플레이트 코드를 줄이고자 CoreDataFetchedResults라는 객체를 만들어서 내부에 fetchedResultController를 정의했다.

CoreDataStack

CoreData에 필요한 객체들(PersistentConainer, NSManagedObjectContext)를 갖고있고 CRUD를 하는 객체

초기에는 CoreDataStack을 싱글톤이 아닌 일반적인 클래스로 만들었는데 보통 하나의 NSPersistentContainer를 사용하고 엔티티는 CRUD할 때 파라미터로 넣어주면 된다고 생각했기 때문에 싱글톤으로 구성해서 앱 전역에서 접근할 수 있도록 만들었다.

ViewController

// ViewController.swift
class ViewController: UIViewController {
    let categorySort = NSSortDescriptor(key: "createdAt", ascending: true)

    // FechedResults객체 생성과 동시에 `delegate`와 `managedContext` 설정
    lazy var fetchedAhjaeResults = CoreDataFetchedResults(
        ofType: Joke.self,
        entityName: "Joke",
        sortDescriptors: [categorySort],
        managedContext: CoreDataStack.shared.managedContext,
        delegate: self
    )
}


extension ViewController: UITableViewDataSource { 
    
    // fetchedResultController에는 코어데이터에서 엔티티에 해당하는 row를 가져온다.
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return fetchedAhjaeResults.fetchedResultsController.sections?[section].numberOfObjects ?? 0
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        let joke = fetchedAhjaeResults.fetchedResultsController.object(at: indexPath)
        cell.textLabel?.text = joke.body
        
        return cell
    }
}

기존의 방법에는 코어데이터에서 값을 가져오면 뷰컨에서 배열변수를 하나 가져야 했다. 하지만 fetchedResultController내에 전부 존재하기 때문에 변수를 사용하지 않고도 구성할 수 있게 되었다.

ViewController 내의 NSFetchedResultsControllerDelegate

이전에 NSFetchedResultsController의 대리자를 ViewController로 설정했다. 따라서 이 델리게이트 프로토콜을 채택하고 코어데이터가 변경되는 시점을 세분화해서 관리할 수 있다.

예를 들어 코어데이터가 변경될 때마다 호출되는 다음의 메서드가 존재한다.

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
                didChange anObject: Any,
                at indexPath: IndexPath?,
                for type: NSFetchedResultsChangeType,
                newIndexPath: IndexPath?) {

    switch type {
    case .insert:
        ahjaeTableView.insertRows(at: [newIndexPath!], with: .automatic)
    case .delete:
        ahjaeTableView.deleteRows(at: [indexPath!], with: .automatic)
    default:
        print("Unexpected NSFetchedResultsChangeType")
    }
}

이를 통해 코어데이터가 변경될 때 UI를 변경시키는 역할이 분리가 되었다.

sectionNameKeyPath

FetchedResultsController를 생성할 때 sectionNameKeyPath라는 것이 존재한다.

fetchedResultsController = NSFetchedResultsController(
    fetchRequest: fetchRequest,
    managedObjectContext: managedContext,
    sectionNameKeyPath: nil,
    cacheName: nil
)

직독직해를 해보면 섹션이름 + keyPath.
섹션을 어떤 KeyPath로 나눌 지를 결정하는 프로퍼티로 보인다.

공식문서의 설명에는 이 값이 nil이라면 하나의 section을 갖는다고 적혀있다. 바로 확인해보자.

날짜를 기준으로 섹션을 나눠야 한다면 keyPath가 날짜를 의미하는 "createdAt"이 되어야 할 것 같다. 그래서 sectionNameKeyPath = "createdAt"으로 변경해주었다.

이후 실행하고 메모를 추가했더니 앱이 크래시가 났다.


TableView의 데이터소스에서는 섹션의 수를 명시적으로 반환하고 있지 않기 떄문에 하나의 섹션만 존재한다.
그런데 createdAt값으로 인해 고유한 섹션이 하나 더 생기게 되어 resultController에는 두 개의 섹션이 생겨 테이블 뷰의 섹션개수가 불일치되어 생기는 것이다.

따라서 데이터소스의 section 수를 다음과 같이 설정해주었다.

func numberOfSections(in tableView: UITableView) -> Int {
    return fetchedAhjaeResults.fetchedResultsController.sections?.count ?? 0
}

하지만 또 아래 사진의 크래시가 났다.


현재 createdAt는 고유한 값이고 이를 기반으로 resultController가 생성되고 있기에 각각의 row가 section인 상태이다.
이 상황에서 resultController에 row가 추가되었기 때문에 section이 증가가 되었지만 화면에 section을 추가하는 코드가 존재하지 않기 때문에 크래시가 나는 것이었다.

하지만 신기한 사실은 lldb로 찍어봤을 때 save에서 실패했을 때 throw를 던지는 게 아닌 크래시를 낸다는 것이었다. 이에 대한 에러 핸들링을 할 수는 없었다.

그래서 다음과 같은 결론을 내렸다.

  • context.save메서드는 일반적인 경우에는 에러를 던지지만 fetchedResultController와 연결되어있는 경우에 변경된 데이터와 테이블 뷰 UI를 동기화하는 델리게이트 메서드가 존재하지 않는다면 에러를 던지는 게 아닌 크래시를 낸다.

FetchedResultsController가 감지하는 데이터 변경사항들에 대해 UI를 업데이트 할 수 있는 델리게이트 메서드를 구현해주어야 한다.

그래서 다음의 코드를 추가해줬다.

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
    let indexSet = IndexSet(integer: sectionIndex)

    switch type {
    case .insert:
        ahjaeTableView.insertSections(indexSet, with: .automatic)
    case .delete:
        ahjaeTableView.deleteSections(indexSet, with: .automatic)
    default:
        break
    }
}

이런... 또 크래시가 났다.


stackOverflow에 따르면 애플 공식문서에서 테이블뷰의 변경사항은 beginUpdatesendUpdates사이에 넣으라고 말한다.

beginUpdates는 테이블 뷰의 행과 섹션을 삽입, 삭제 또는 선택하는 일련의 메서드 호출을 시작하는 메서드이고 endUpdates는 그 반대이다.

이 두 메서드를 통해 테이블 뷰와 데이터소스를 동기화할 수 있게 만들 수 있다.

따라서 다음의 메서드를 추가했다.

func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    self.ahjaeTableView.beginUpdates()
}

func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    ahjaeTableView.endUpdates()
}

이렇게 문제를 해결했다!

참고문서

profile
햅삐햅삐 데이

0개의 댓글