Concurrency with Context
대부분의 경우 하나의 Context로도 부족함이 없지만,
처리량이 많아지는 경우 Background Context를 활용한 동시처리(Concurrency)가 요구될 수 있다.
MainQueueConcurrencyType과 PrivateQueueConcurrencyType으로 구분되며,
각각 Main Thread와 Background Thread에서 작동한다.
Concurrency Context를 구현할때의 주의점은 다음과 같다.
- Context는 Thread에 안전하지 않다.
Context에서 일어나는 모든 작업은 Context를 생성한 Thread에서 진행하고, 완료해야 한다. - 다른 Context로 ManagedObject를 전달할 수 없다.
구조적으로 불가능한 부분으로 대신 ManagedObjectID를 전달해 사용할 수는 있다.
Context를 생성할 때는 Container가 제공하는 기본 속성과 메서드를 사용하거나
NAManagedObjectContext를 사용해 직접 생성한다.
이렇게 생성한 Context는 개별로 사용하거나 Parent-Child 관계로 연결해 사용한다.
DataManager+Concurrency.swift
func batchInsert(in context: NSManagedObjectContext) {
context.perform {
let start = Date().timeIntervalSinceReferenceDate
let departmentList = DepartmentJSON.parsed()
let employeeList = EmployeeJSON.parsed()
for dept in departmentList {
guard let newDeptEntity = DataManager.shared.insertDepartment(from: dept, in: context) else {
fatalError()
}
let employeesInDept = employeeList.filter { $0.department == dept.id }
for emp in employeesInDept {
guard let newEmployeeEntity = DataManager.shared.insertEmployee(from: emp, in: context) else {
fatalError()
}
newDeptEntity.addToEmployees(newEmployeeEntity)
newEmployeeEntity.department = newDeptEntity
}
do {
try context.save()
} catch {
dump(error)
}
}
let otherEmployees = employeeList.filter { $0.department == 0 }
for emp in otherEmployees {
_ = DataManager.shared.insertEmployee(from: emp, in: context)
}
do {
try context.save()
} catch {
dump(error)
}
let end = Date().timeIntervalSinceReferenceDate
print(end - start)
}
}
실습용 JSON 파일에서 배열로 데이터를 받아 새로 Entity를 생성해 저장하도록 구현돼있다.
performBackgroundTask 사용하기
BackgroundTaskTableViewController.swift
@IBAction func insertData(_ sender: Any) {
DataManager.shared.container?.performBackgroundTask({ (context) in
DataManager.shared.batchInsert(in: context)
})
}
Scene에 연결돼 있는 '+' 버튼이
DataManager+Concurrency.swift에서 구현한 batchInsert를 호출하도록 위와 같이 구현한다.
container에서 제공하는 performBackgroundTask를 사용해 파라미터로 Context를 전달하면
간단하게 Background Threads에서 동작하도록 구현할 수 있다.
결과
메서드가 정상적으로 동작하고,
콘솔에 연산 시간까지 성공적으로 출력되지만 화면에는 방영되지 않는다.
BackgroundTaskTableViewController.swift
lazy var resultController: NSFetchedResultsController<NSManagedObject> = { [weak self] in
let request = NSFetchRequest<NSManagedObject>(entityName: "Employee")
let sortByName = NSSortDescriptor(key: "name", ascending: true)
request.sortDescriptors = [sortByName]
request.fetchBatchSize = 30
let controller = NSFetchedResultsController(fetchRequest: request, managedObjectContext: DataManager.shared.mainContext, sectionNameKeyPath: nil, cacheName: nil)
controller.delegate = self
return controller
}()
이는 TableView에 출력하기 위한 데이터를 소유하는 resultController가 MainContext에 연결돼 있기 때문이다.
따라서 TableView를 업데이트하기 위한 FetchedController는 MainContext에
Background에서 CoreData를 업데이트하는 batchInsert는 BackgroundContext에 존재하며,
이 둘은 동일한 EmployeeEntity에 연결돼 있지만 연동 자체는 되어있지 않아
데이터가 바뀌었는지, 어느 시점의 데이터인지 알 방법이 없다.
Context는 각자가 독립돼 작업을 진행하고, 따라서 Context 끼리의 동기화가 필요하다.
따라서 Context는 자신이 관리하는 객체(ManagedObject)가 업데이트되거나,
Context를 저장할 때 notification을 발생해 다른 Context가 이 시점을 알아차릴 수 있도록 한다.
override func viewDidLoad() {
super.viewDidLoad()
do {
try resultController.performFetch()
} catch {
print(error.localizedDescription)
}
token = NotificationCenter.default.addObserver(forName: NSNotification.Name.NSManagedObjectContextDidSave, object: nil, queue: OperationQueue.main, using: { (noti) in
DataManager.shared.mainContext.mergeChanges(fromContextDidSave: noti)
})
}
notification을 감지할 observer를 생성하고,
mergeChanges를 호출해 준다.
Context가 ManagedObject를 변경할 때 발생하는 notification에는 변경된 내용이 포함돼있어,
mergeChanges의 파라미터로 전달하는 것으로 동작이 가능하다.
결과
이렇게 수정된 코드는 정상적으로 batchInsert의 결과를 TableView에 표시한다.
batchInsert는 CoreData에 대량의 데이터의 저장을 여러 번 수행하게 되는데,
이때마다 'Notification 발생 > Observer 감지 > mergeChanges'의 사이클이 수행된다.
따라서 TableView가 순차적으로 변경되는 결과를 확인할 수 있다.
새로운 backgroundContext 만들기
Shared > DataManager.swift
lazy var backgroundContext: NSManagedObjectContext = {
guard let context = container?.newBackgroundContext() else {
fatalError()
}
return context
}()
지연 속성을 사용해 새로운 backgroundContext를 생성한다.
이때 context에 저장하는 것은 container의 viewContext가 아닌 newBackgroundContext 메서드이다.
BackgroundContextTableViewController.swift
@IBAction func insertData(_ sender: Any) {
let context = DataManager.shared.backgroundContext
DataManager.shared.batchInsert(in: context)
}
새로운 Scene의 '+' 버튼에 할당된 메서드를 구현한다.
새로 생성한 backgroundContext를 사용해 context를 구성하고,
batchInsert를 호출한다.
insertData 메서드 자체는 MainThread에서 동작하지만,
backgroundContext로 구성한 context는 BackgroundThread에서 동작해야 한다.
이러한 경우 사용할 수 있는 방법은 두 가지다.
perform(_:)
performAndWait(_:)
perform 메서드는 실행할 코드를 전달하고 바로 반환되고,
performAndWait 메서드는 실행할 코드를 전달하고 실행이 완료까지 된 다음 반환된다는 차이가 있다.
DataManager+Concurrency.swift
func batchInsert(in context: NSManagedObjectContext) {
context.perform {
let start = Date().timeIntervalSinceReferenceDate
let departmentList = DepartmentJSON.parsed()
let employeeList = EmployeeJSON.parsed()
for dept in departmentList {
guard let newDeptEntity = DataManager.shared.insertDepartment(from: dept, in: context) else {
fatalError()
}
let employeesInDept = employeeList.filter { $0.department == dept.id }
for emp in employeesInDept {
guard let newEmployeeEntity = DataManager.shared.insertEmployee(from: emp, in: context) else {
fatalError()
}
newDeptEntity.addToEmployees(newEmployeeEntity)
newEmployeeEntity.department = newDeptEntity
}
do {
try context.save()
} catch {
dump(error)
}
}
let otherEmployees = employeeList.filter { $0.department == 0 }
for emp in otherEmployees {
_ = DataManager.shared.insertEmployee(from: emp, in: context)
}
do {
try context.save()
} catch {
dump(error)
}
let end = Date().timeIntervalSinceReferenceDate
print(end - start)
}
}
DataManager에 정의해 둔 batchInsert 메서드는
이미 perform 메서드를 사용해 동작하도록 구현이 돼 있어
MainThread에 존재하는 insertData에서 BackgroundContext를 파라미터로 전달해도
문제없이 BackgroundThread에서 실행될 수 있다.
결과
BackgroundTask와 BackgroundContext 모두 mainContext와 연동되지 않기 때문에,
지금은 ManagedObject의 변경 시 발생하는 notification을 활용하는 방식으로 동기화를 진행한다.
하지만 다른 방법도 존재한다.
Parent-Child 구성하기
ChildContextTableViewController.swift
@IBAction func insertData(_ sender: Any) {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = DataManager.shared.mainContext
DataManager.shared.batchInsert(in: context)
}
이 경우 context 생성 시 NSManagedObjectContext(concurrencyType:) 생성자를 사용한다.
context가 어떤 context에 해당하는지 파라미터로 전달하고 이는 아래와 같다.
- mainQueueConcurrencyType
mainThread에서 동작하는 Context 생성한다. - privateQueueConcurrencyType
backgroundThread에서 동작하는 Context를 생성한다.
context의 parent 속성에 대상으로 삼을 context를 지정하고,
사용하고자 하는 context 메서드를 호출하면 된다.
단 이렇게 상속 관계가 되는 경우 childContext에서 시도하는 ManagedObject의 수정은
parentContext의 ManagedObject에 반영되는 것을 의미한다.
따라서 childContext에서 ManagedObject를 수정하면 parentContext가 다시 한번 이를 CoreData에 반영하도록 구현해야 한다.
DataManager+Concurrency.swift
do {
try context.save()
if let parent = context.parent, parent.hasChanges {
try parent.save()
}
} catch {
print(error)
}
동일하게 context를 저장한 뒤,
context의 parent를 바인딩하고, 해당 parentContext가 변경됐음을 hasChanges로 확인해
다시 한번 parentContext에서 저장하도록 구현했다.
결과
구현 방식이 대단히 간단하다는 장점이 있다.
단, 지금의 상황과 다르게 parent-child의 계층이 깊다면,
그 수만큼 따라 올라가면서 전부 저장을 해 줘야 한다는 주의점이 존재한다.
'학습 노트 > iOS (2021)' 카테고리의 다른 글
200. Performance & Debugging (0) | 2022.06.30 |
---|---|
199. Context Synchronization (0) | 2022.06.29 |
197. Batch Processing with CoreData (0) | 2022.06.03 |
196. Data Validation (0) | 2022.05.30 |
195. Faulting & Uniquing (0) | 2022.05.24 |