[UIKit] NSFetchedResultsController

Emily·2025년 8월 13일
1

NSFetchedResultsControllerCoreData를 사용하여 데이터를 저장하는 프로젝트에서 데이터와 UI를 바인딩할 때 사용하는 컨트롤러로, 주로 UITableView 또는 UICollectionView와 함께 사용한다. 이전에는 context에 변경사항이 발생했을 경우 reloadData를 수동으로 구현/호출해야 했지만 이 컨트롤러를 사용하면 변경사항을 감지하고 알아서 reload를 해준다는 점에서 매우 편리하다. 또한 UI가 변할 때 자연스러운 애니메이션이 적용된다.

기본 설정

  • NSFetchedResultsController 구현 시 필수 구성요소에는 fetchRequestcontext가 있다.
  • 이 때 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 코드를 비교해보겠다.

  • 기존 코드 : repository에 fetch 메소드를 구현한 뒤 view controller에서 data source 변수를 정의하고 바인딩
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 분리

위에서는 section이 하나 뿐인 table view를 전제로 바인딩을 구현하였는데, 이번에는 section이 2개 이상 있는 table view를 다뤄보겠다. TodoEntityisDone: 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
}

그 다음 fetchedResultsControllersectionNameKeyPath 속성에 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)
}
profile
iOS Junior Developer

0개의 댓글