NSFetchedResultsController
는 CoreData
를 사용하여 데이터를 저장하는 프로젝트에서 데이터와 UI를 바인딩할 때 사용하는 컨트롤러로, 주로 UITableView
또는 UICollectionView
와 함께 사용한다. 이전에는 context
에 변경사항이 발생했을 경우 reloadData
를 수동으로 구현/호출해야 했지만 이 컨트롤러를 사용하면 변경사항을 감지하고 알아서 reload를 해준다는 점에서 매우 편리하다. 또한 UI가 변할 때 자연스러운 애니메이션이 적용된다.
NSFetchedResultsController
구현 시 필수 구성요소에는 fetchRequest
와 context
가 있다.fetchRequest
에 데이터 정렬 기준(sortDescriptor
)이 반드시 적용되어야 한다.NSFetchedResultsControllerDelegate
를 통해 데이터 변경사항을 감지하여 뷰와 바인딩하는 메소드를 구현하면 된다.final class TodoRepository {
private let coreDataManager = CoreDataManager.shared
var viewContext: NSManagedObjectContext {
return coreDataManager.viewContext
}
var todosFetchRequest: NSFetchRequest<TodoEntity> {
let fetchRequest: NSFetchRequest<TodoEntity> = TodoEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdDate", ascending: true)]
return fetchRequest
}
// ... //
}
class MainViewController: UIViewController {
private let repository: TodoRepository
private lazy var fetchedResultsController: NSFetchedResultsController<TodoEntity> = {
let controller = NSFetchedResultsController(
fetchRequest: repository.todosFetchRequest,
managedObjectContext: repository.viewContext,
sectionNameKeyPath: nil, // 데이터를 section으로 구분할 때 사용
cacheName: nil // 성능을 위한 캐싱 이름
)
}()
init(repository: TodoRepository) {
self.repository = repository
super.init(nibName: nil, bundle: nil)
}
// ... //
private let tableView: UITableView = {
// ... //
}()
}
extension MainViewController: NSFetchedResultsControllerDelegate {
// fetchedResultsController가 감지한 첫 번째 변경사항 전에 한 번 호출 : 업데이트의 시작을 알리며 insert/update/delete/move 등을 한꺼번에 UI에 반영할 준비
func controllerWillChangeContent(_ controller: NSFetchedResultsController<any NSFetchRequestResult>) {
tableView.beginUpdates()
}
// 데이터 변화가 일어날 때마다 호출 : 변화 종류는 insert, delete, update, move
func controller(_ controller: NSFetchedResultsController<any NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
switch type {
case .insert:
if let newIndexPath = newIndexPath {
tableView.insertRows(at: [newIndexPath])
}
case .update:
if let indexPath = indexPath {
tableView.reloadRows(at: [indexPath])
}
case .delete:
if let indexPath = indexPath {
tableView.deleteRows(at: [indexPath])
}
default:
break
}
}
// 모든 변화 처리가 끝난 뒤 한 번 호출 : 변화 적용을 마무리. endUpdates()를 함께 호출해야 UI 애니메이션이 함께 처리 된다.
func controllerDidChangeContent(_ controller: NSFetchedResultsController<any NSFetchRequestResult>) {
tableView.endUpdates()
}
}
쉬운 이해를 위해 사용 before/after 코드를 비교해보겠다.
final class TodoRepository {
// ... //
var todosFetchRequest: NSFetchRequest<TodoEntity> {
let fetchRequest: NSFetchRequest<TodoEntity> = TodoEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "createdDate", ascending: true)]
return fetchRequest
}
// fetch 메소드
func fetchTodos(completionHandler: @escaping (Result<[TodoEntity], CustomError>) -> Void) {
do {
let todoEntities = try viewContext.fetch(todosFetchRequest)
return completionHandler(.success(todoEntities))
} catch {
return completionHandler(.failure(CustomError.fetchFailed))
}
}
}
class MainListViewController: UIViewController {
private let repository: TodoRepository
// data source
private var todos: [TodoEntity] = []
override func viewDidLoad() {
super.viewDidLoad()
// binding
repository.fetchTodos { [weak self] result in
switch result {
case .success(let todos):
DispatchQueue.main.async {
self?.todos = todos
self?.tableView.reloadData() // 데이터 소스가 바뀔 때마다 호출해줘야 함
}
case .failure(let error):
DispatchQueue.main.async {
self?.todos = [] // fetch 실패 시에도 빈값으로라도 UI 업데이트는 필요
self?.tableView.reloadData()
print(error.debugMessage) // 에러 디버깅
}
}
}
}
}
extension MainListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return todos.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: TodoCell.identifier, for: indexPath) as? TodoCell else { return UITableViewCell() }
let todo = todos[indexPath.row]
cell.configure(with: todo)
return cell
}
}
NSFetchedResultsController
적용 코드 : view controller에서 fetchedResultsController를 사용하여 fetch 메소드와 data source 구현 (reload 호출 불필요)class MainListViewController: UIViewController {
private let repository: TodoRepository
private lazy var fetchedResultsController: NSFetchedResultsController<TodoEntity> = {
let controller = NSFetchedResultsController(
fetchRequest: repository.todosFetchRequest,
managedObjectContext: repository.viewContext,
sectionNameKeyPath: nil,
cacheName: nil
)
}()
override func viewDidLoad() {
super.viewDidLoad()
// fetch
do {
try fetchedResultsController.performFetch()
} catch {
print(error.debugMessage)
}
}
// data source
func numberOfRows() -> Int {
guard let section = fetchedResultsController.sections?.first else { return 0 }
return section.numberOfObjects
}
func object(at indexPath: IndexPath) -> TodoEntity {
return fetchedResultsController.object(at: indexPath)
}
}
extension MainListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return numberOfRows()
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: TodoCell.identifier, for: indexPath) as? TodoCell else { return UITableViewCell() }
let todo = object(at: indexPath)
cell.configure(with: todo)
return cell
}
}
코드는 간단한 예시를 위해 MVC 아키텍처를 전제로 작성한 것이다. CRUD 중 Read만 예시를 들었지만, Create / Update / Delete 시 변경이 발생한 데이터를 다시 fetch 하고 reload 하는 과정이 필요한 걸 고려하면 그 부분을 따로 구현할 필요 없는 NSFetchedResultsController
의 사용이 매우 편하다는 걸 알 수 있다. 게다가 UI에 부드러운 애니메이션까지 자동으로 적용된다는 이점도 있어 추천할 만하여 정리해보았다.
위에서는 section이 하나 뿐인 table view를 전제로 바인딩을 구현하였는데, 이번에는 section이 2개 이상 있는 table view를 다뤄보겠다. TodoEntity
의 isDone: Bool
변수를 기준으로 미완료 todo와 완료한 todo를 나눌 것이다.
우선 fetchRequest
에서 sortDescriptors
에 섹션을 나누려는 기준인 isDone
을 추가해야 한다.
var todosFetchRequest: NSFetchRequest<TodoEntity> {
let fetchRequest: NSFetchRequest<TodoEntity> = TodoEntity.fetchRequest()
fetchRequest.sortDescriptors = [
NSSortDescriptor(key: "isDone", ascending: true),
NSSortDescriptor(key: "createdDate", ascending: true)
]
return fetchRequest
}
그 다음 fetchedResultsController
의 sectionNameKeyPath
속성에 isDone
을 지정해준다.
private lazy var fetchedResultsController: NSFetchedResultsController<TodoEntity> = {
let controller = NSFetchedResultsController(
fetchRequest: repository.todosFetchRequest,
managedObjectContext: repository.viewContext,
sectionNameKeyPath: "isDone",
cacheName: nil
)
}()
바인딩 할 때는 데이터 소스에 섹션 관련 코드를 추가해준다.
func numberOfSections() -> Int {
return fetchedResultsController.sections?.count ?? 1
}
func numberOfRows(in section: Int) -> Int {
guard let sections = fetchedResultsController.sections else { return 1 }
return sections[section].numberOfObjects
}
func numberOfSections(in tableView: UITableView) -> Int {
return numberOfSections()
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return numberOfRows(in: section)
}