본문 바로가기

학습 노트/iOS (2021)

198. Concurrency with Context

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(_:)

 

Apple Developer Documentation

 

developer.apple.com

performAndWait(_:)

 

Apple Developer Documentation

 

developer.apple.com

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