CoreData는 기본적으로 메인 스레드에서 실행되는데, 이 상태 그대로 대량 데이터를 처리할 경우 UI가 멈추는 현상 등이 발생할 수 있다. 이 현상을 방지하기 위해서는 메인 스레드에서 동작하는 viewContext 외에 backgroundContext에서 작업하도록 비동기 처리를 하게 된다. 아직 대량 데이터를 다룰 일은 없지만, 경험 삼아 Todo 앱에 적용해봤다. 비동기 처리를 하는 김에 async/await를 사용하여 구현했다. Swift Concurrency에 대한 정리는 포스팅이 따로 있다.
CoreDataManager는 이름 그대로 CoreData를 관리하는 객체다. 여러 개의 PersistentContainer 생성을 방지하기 위해 싱글톤으로 관리한다. 앱 전체에서 하나의 공통된 viewContext를 사용해야 안정적으로 데이터를 관리할 수 있고, 두 개 이상의 context를 사용할 경우 같은 container를 통해 생성해야 공유된 context 병합 설정을 유지하기 때문에 더더욱 단일 객체가 보장되는 것이 좋다.
CoreDataManager의 역할
1.PersistentContainer생성 및 관리 : 데이터 모델(.xcdatamodeld)을 기반으로 container 초기화
2.viewContext제공 : UI 및 메인 스레드에서 사용하는 context
3.backgroundContext제공 : 백그라운드 스레드에서 데이터 작업 시 사용하는 context.backgroundContext는viewContext와 달리 하나의 context를 유지하며 계속 사용하는 게 아니라, 작업 단위로 새로 생성하고 작업이 완료되면viewContext에 변경사항을 병합한다.
4.viewContext로의 자동 병합 여부 설정
5.MergePolicy설정 : context 병합 정책 설정
import CoreData
final class CoreDataManager {
// 싱글톤 선언
static let shared = CoreDataManager()
private init() {}
// container 생성
private lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: Key.container)
container.loadPersistentStores { _, error in
if let error = error {
print("[Core Data] failed to load persistent stores: \(error)")
}
}
// 자동 병합 설정 : background context의 변경사항을 자동으로 view context에 병합
container.viewContext.automaticallyMergesChangesFromParent = true
// 병합 정책 설정 : viewContext의 내용보다 persistent 저장소의 내용을 우선시
container.viewContext.mergePolicy = NSMergePolicy.mergeByPropertyStoreTrump
return container
}()
// viewContext 제공 : repository에서 접근하여 사용한다
var viewContext: NSManagedObjectContext {
return persistentContainer.viewContext
}
// backgroundContext 제공 : 메소드를 통해 새로운 context를 생성하여 repository에 제공한다
func newBackgroundContext() -> NSManagedObjectContext {
let context = persistentContainer.newBackgroundContext()
// 병합 정책 설정 : persistent 저장소보다 backgroundContext의 변경사항을 우선시
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
return context
}
}
extension CoreDataManager {
struct Key {
static let container = "Todo"
}
}
병합 정책을 제대로 알기 위해서는 코어데이터의 데이터 저장 및 병합 흐름을 먼저 이해하는 것이 중요하다.
backgroundContext에서 entity 생성, 수정, 삭제 등 작업backgroundContext.save() 호출 : persistent store(저장소)에 변경사항 저장Notification(NSManagedObjectContextDidSave) 발생 : Core Data는 Notification을 통해 변경사항을 viewContext에 전달viewContext.automaticallyMergesChangesFromParent가4-1. false인 경우 : 수동으로 save()를 감지하여 병합 필요
final class CoreDataManager {
static let shared = CoreDataManager()
private init() {
setupContextSaveNotification()
}
// ... //
private func setupContextSaveNotification() {
NotificationCenter.default.addObserver(
self,
selector: #selector(backgroundContextDidSave(notification:)),
name: .NSManagedObjectContextDidSave,
object: nil // 특정 context를 지정하지 않으면 모든 context의 save를 감지한다
)
}
@objc private func backgroundContextDidSave(notification: Notification) {
// 변경사항 save한 context가 backgroundContext일 때 병합 처리
guard
let context = notification.object as? NSManagedObjectContext,
context != viewContext
else { return }
viewContext.perform { [weak self] in
self?.viewContext.mergeChanges(fromContextDidSave: notification)
}
}
// 싱글톤 객체는 앱 생명주기 동안 deinit이 사실상 호출되지 않아(앱 종료까지 살아있음) observer를 꼭 제거하지 않아도 큰 문제는 없다
deinit {
NotificationCenter.default.removeObserver(self)
}
}
4-2. true인 경우 : 저장소에 저장된 변경사항이 viewContext에 자동으로 병합
자동으로 해준다는데 true로 안 할 이유가 없어보인다
이제 병합 정책의 종류를 알아보겠다. 병합 정책은 context.mergePolicy로 지정한다.
.mergeByPropertyObjectTrump: 내context의 값이 우선. 병합 중 충돌이 있으면 다른 context의 값을 덮어씀.mergeByPropertyStoreTrump:persistent store의 값이 우선. 내 context에 있는 변경사항을 버리고 저장소의 값으로 덮어씀.overwrite: 내 context의 값을 무조건 저장소에 덮어씀. 충돌 무시.rollback: 충돌 시 내 context의 변경사항을 무조건 취소하고, 저장소의 값으로 되돌림
viewContext의 경우 주로 메인 스레드에서 UI와 바인딩할 데이터를 fetch 할 때 사용하는 것을 고려하면 저장소의 값을 우선시 하는 .mergeByPropertyStoreTrump로 지정하는 게 타당해 보인다. backgroundContext는 주로 변경사항이 발생하는 context인 만큼 저장소의 값보다 context 내에서의 변경사항이 우선시 되어야 할 것이다. 따라서 .mergeByPropertyObjectTrump로 지정하는 게 적절하다.
persistentContainer.performBackgroundTask : 비동기 context를 자동으로 생성하고 클로저에서 작업을 수행하는 방법. performBackgroundTask 메소드가 async throws로 선언되어 있기 때문에 try await 키워드로 실행해야 한다. 일회성 작업에 적합하며, 작업 후 context는 사라진다.try await persistentContainer.performBackgroundTask { context in
let fetchRequest: NSFetchRequest<TodoEntity> = TodoEntity.fetchRequest()
let todo = TodoEntity(context: context)
todo.title = "go to gym"
try context.save()
}
backgroundContext.perform : persistentContainer.newBackgroundContext() 메소드를 통해 새로운 context를 직접 생성한 뒤 perform 메소드의 클로저 내에서 작업을 수행하는 방법. 역시 try await 키워드로 실행해야 한다. context 재사용이 가능하며, 같은 context에서 여러번 저장/병합/관찰 등 복잡한 작업이 필요할 경우 유용하다. perform 클로저가 끝나도 context가 메모리에 유지되며, backgroundContext 변수가 더이상 참조되지 않는 상황이어야(함수 스코프에서 벗어나거나 nil 할당) 메모리에서 해제된다.let backgroundContext = persistentContainer.newBackgroundContext()
try await backgrouncContext.perform {
let fetchRequest: NSFetchRequest<TodoEntity> = TodoEntity.fetchRequest()
let todo = TodoEntity(context: context)
todo.title = "go to gym"
try context.save()
}
데이터 영구 저장소(PersistentContainer)로의 데이터 CRUD를 담당하는 객체
coreDataManager, viewContext, todoFetchRequestfetchRequest의 경우 쓰임새에 따라 메소드 내에서 선언하여 사용하는 경우가 많은데, 나는 재사용을 위해 프로퍼티로 선언하였다.final class TodoRepository {
// core data manager 선언
private let coreDataManager = CoreDataManager.shared
// coreDataManager가 제공하는 viewContext에 접근
var viewContext: NSManagedObjectContext {
return coreDataManager.viewContext
}
// 저장소가 가진 모든 "Todo" entity를 불러와 orderIndex attribute 순서대로 정렬해달라는 내용의 fetch 요청 객체
var todoFetchRequest: NSFetchRequest<TodoEntity> {
let fetchRequest: NSFetchRequest<TodoEntity> = TodoEntity.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "orderIndex", ascending: true)]
return fetchRequest
}
fetchRequest를 통해 모든 TodoEntity를 불러와 배열로 반환func fetchTodos() -> [TodoEntity] {
do {
return try context.fetch(todoFetchRequest)
} catch {
print("[Repository] Failed to fetch lists: \(error.localizedDescription)")
return []
}
}
fetch 메소드를 async로 정의하지 않은 이유는 Create, Update, Delete와 달리 Read 동작은 viewContext에서 이루어져 굉장히 속도가 빠르기 때문이다. 또한 do-catch 구문을 통해 fetch 성공 시 작업과 실패 실패 시 작업을 분기하였는데, error 발생을 감지하지만 throw 처리를 하지 않은 이유는 fetch 시 에러가 발생하는 경우가 매우 드물다고 하기 때문이다. 만의 하나 에러가 발생하더라도 UI는 빈 배열을 반환 받아 빈 테이블 뷰로 업데이트가 될 것이다.
참고로, 대량의 데이터를 fetch 하거나 복잡한 predicate가 걸려있어 작업이 오래 걸릴 것으로 예상되는 경우 backgroundContext에서 하기도 한다.
func createTodo(input: String) async throws {
// context 생성
let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform {
// 현재 entity 개수 fetch
let fetchRequest: NSFetchRequest<TodoEntity> = TodoEntity.fetchRequest()
let currentCount = try backgroundContext.count(for: fetchRequest)
// 새로운 todo entity 생성 및 attribute 지정
let todo = TodoEntity(context: backgroundContext)
todo.title = input
todo.orderIndex = Int64(currentCount) // 기존 entity 개수 = index
// 변경사항 persistent 저장소에 저장
try backgroundContext.save()
}
}
func updateTodo(objectID: NSManagedObjectID, newTitle: String) async throws {
// context 생성
let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform {
// id로 obejct 검색
let managedObject = try backgroundContext.existingObject(with: objectID)
// NSManagedObject를 TodoEntity로 캐스팅
guard let todo = managedObject as? TodoEntity else {
throw CoreDataError.castingObjectFailed
}
// entity 수정
todo.title = newTitle
// 변경사항 persistent 저장소에 저장
try backgroundContext.save()
}
}
func deleteTodo(objectID: NSManagedObjectID) async throws {
// context 생성
let backgroundContext = coreDataManager.newBackgroundContext()
try await backgroundContext.perform {
// id로 object 검색
let managedObject = try backgroundContext.existingObject(with: objectID)
// NSManagedObject를 TodoEntity로 캐스팅
guard let todo = managedObject as? TodoEntity else {
throw CoreDataError.castingObjectFailed
}
// entity 삭제
backgroundContext.delete(todo)
// 변경사항 persistent 저장소에 저장
try backgroundContext.save()
}
}
호출은 TodoRepository를 의존하는 controller 객체에서로 전제를 한다.
@MainActor
func loadTodos() {
let todos = repository.fetchTodos()
// view와 바인딩
self.todos = todos
tableView.reloadData()
}
func createTodo(input: String) {
Task {
do {
try await repository.createTodo(input: input)
loadTodos()
} catch {
showAlert(with: error.localizedDescription) // @MainActor 메소드
}
}
}
참고 : @MainActor 선언이 되어있지 않은 메소드여도 MainActor.run {}을 통해 호출하면 된다.
지금까지 CoreData를 구현할 때 모든 CRUD 작업을 viewContext에서만 해봤는데 다 작은 프로젝트라 크게 문제 없었지만 프로젝트가 커질수록 비동기 처리가 중요시 되는 만큼 이게 뭔지, 어떻게 하는 건지 알고 싶었다. 나는 어떤 개념에 대해 늘 설명만 읽어서는 제대로 와닿지 않고 실습을 통해서야 비로소 이해하는 편이기 때문에 이번 경험이 굉장히 많은 도움이 되었다.