[iOS] CoreData 비동기 처리

Emily·2025년 7월 21일
1

CoreData는 기본적으로 메인 스레드에서 실행되는데, 이 상태 그대로 대량 데이터를 처리할 경우 UI가 멈추는 현상 등이 발생할 수 있다. 이 현상을 방지하기 위해서는 메인 스레드에서 동작하는 viewContext 외에 backgroundContext에서 작업하도록 비동기 처리를 하게 된다. 아직 대량 데이터를 다룰 일은 없지만, 경험 삼아 Todo 앱에 적용해봤다. 비동기 처리를 하는 김에 async/await를 사용하여 구현했다. Swift Concurrency에 대한 정리는 포스팅이 따로 있다.

CoreDataManager

CoreDataManager는 이름 그대로 CoreData를 관리하는 객체다. 여러 개의 PersistentContainer 생성을 방지하기 위해 싱글톤으로 관리한다. 앱 전체에서 하나의 공통된 viewContext를 사용해야 안정적으로 데이터를 관리할 수 있고, 두 개 이상의 context를 사용할 경우 같은 container를 통해 생성해야 공유된 context 병합 설정을 유지하기 때문에 더더욱 단일 객체가 보장되는 것이 좋다.

CoreDataManager의 역할

1. PersistentContainer 생성 및 관리 : 데이터 모델(.xcdatamodeld)을 기반으로 container 초기화
2. viewContext 제공 : UI 및 메인 스레드에서 사용하는 context
3. backgroundContext 제공 : 백그라운드 스레드에서 데이터 작업 시 사용하는 context. backgroundContextviewContext와 달리 하나의 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"
    }
}

NSMergyPolicy (병합 정책)

병합 정책을 제대로 알기 위해서는 코어데이터의 데이터 저장 및 병합 흐름을 먼저 이해하는 것이 중요하다.

  1. backgroundContext에서 entity 생성, 수정, 삭제 등 작업
  2. backgroundContext.save() 호출 : persistent store(저장소)에 변경사항 저장
  3. Notification(NSManagedObjectContextDidSave) 발생 : Core DataNotification을 통해 변경사항을 viewContext에 전달
  4. 이 때 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로 지정하는 게 적절하다.

backgroundContext에서 작업 수행하는 방법

  1. 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()
}
  1. 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()
}

TodoRepository

데이터 영구 저장소(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
    }
  • Read : 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에서 하기도 한다.

  • Create : 새로운 entity를 생성한 뒤 저장
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()
    }
}
  • Update : id로 entity 검색 후 수정
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()
    }
}
  • Delete : id로 entity 검색 후 삭제
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 객체에서로 전제를 한다.

  • Read
@MainActor
func loadTodos() {
	let todos = repository.fetchTodos()
    // view와 바인딩
    self.todos = todos
    tableView.reloadData()
}
  • Create / Update / Delete : 같은 형식일 것이므로 Create만 정리해보겠다.
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에서만 해봤는데 다 작은 프로젝트라 크게 문제 없었지만 프로젝트가 커질수록 비동기 처리가 중요시 되는 만큼 이게 뭔지, 어떻게 하는 건지 알고 싶었다. 나는 어떤 개념에 대해 늘 설명만 읽어서는 제대로 와닿지 않고 실습을 통해서야 비로소 이해하는 편이기 때문에 이번 경험이 굉장히 많은 도움이 되었다.

profile
iOS Junior Developer

0개의 댓글