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
, todoFetchRequest
fetchRequest
의 경우 쓰임새에 따라 메소드 내에서 선언하여 사용하는 경우가 많은데, 나는 재사용을 위해 프로퍼티로 선언하였다.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
에서만 해봤는데 다 작은 프로젝트라 크게 문제 없었지만 프로젝트가 커질수록 비동기 처리가 중요시 되는 만큼 이게 뭔지, 어떻게 하는 건지 알고 싶었다. 나는 어떤 개념에 대해 늘 설명만 읽어서는 제대로 와닿지 않고 실습을 통해서야 비로소 이해하는 편이기 때문에 이번 경험이 굉장히 많은 도움이 되었다.