본문 바로가기

학습 노트/iOS (2021)

186. Managed Object and Managed Object Context

Managed Object and Managed Object Context

Core Data의 데이터 처리 단위는 관점에 따라 두 이름으로 불린다.

Model 관점 Context 관점
Entity Managed Object

Context 관점의 Managed Object가 이번 포스팅의 목적이다.

Managed Object는 NSManagedObject로 구현 돼있고,
해당 객체의 Life Cycle을 관리하는 주체가 Core Data이기 때문에 'Managed'라는 접두어가 붙는다.

NSManagedObject를 상속받아 Entity Class를 만들어 사용하게 되면,
일반적인 Core Data의 Key-Vlaue 방식식이 아닌 속성을 사용해 Attribute에 접근 할 수 있다.
이는 코드가 단순해 지고, 연산 속도의 향사오 기대할 수 있다.
Entity Class는 Xcode가 자동으로 생성하지만 필요에 의해 수동으로 생성하는 것도 가능하다.

Managed Object Context는 간단히 Context라고 부르는 경우가 많다.
대부분의 작업은 Context에서 진행되며, Managed Object는 해당 Context 내에 위치한다.

  • Managed Object를 생성할 때 Context를 지정해야 한다.
  • 지정된 Context에 Managed Object가 등록된다.
  • 저장된 Data를 가져오면 동일한 Managed Object를 생성하고, Context에 등록한다.
  • Context에서 진행되는 작업들은 영구저장소에 실시간으로 반영되지 않는다.

setValue(_:forKey:)

 

Apple Developer Documentation

 

developer.apple.com

기존의 방식은 위의 메서드를 사용해
첫번째 파라미터로 값을 전달하고, 두번째 파라미터로 대상 Attribute의 Key를 전달했다.
이 경우 다음과 같은 단점이 있다.

Compile 시점에 전달 값을 검증할 수 없다.
예를 들어 age에 문자열이 전달됐을 경우 실제 구동시에나 잘못된 점을 알 수 있다.

Attribute의 이름을 문자열로 전달하기 때문에 오탈자로 인한 버그가 발생할 가능성이 높다.

 

실습

이전 일지엔 프로젝트 생성시에 CoreData를 함께 생성해 자동으로 Data Model과 관련 파일이 생성되지만,
이번 포스팅에선 이미 생성된 프로젝트에 수동으로 생성하는 과정을 다룬다.

Data Model 추가, 편집

Data Model 파일을 추가하고, Entity와 Attribute를 추가한다.

Attribute Inspector에서 Type 아래의 Optional 속성을 사용해 특성을 변경할 수 있다.

  • Transient
    임시속성으로 사용하며, 다른 Attribute로 계산해 사용하는 경우 사용한다.
  • Optional
    필수 Attribute를 설정한다. 해제하면 반드시 전달돼야 한다.

Entity의 속성을 수정한다.

Entity Name과 Class Name은 같은 값이 기본값이다.
하지만, Entity가 늘어나는 경우 이 둘의 구분이 어려워 지기 때문에 Class의 이름을 변경해 주는 것이 좋다.

Codegen은 Class 파일의 생성 방식을 결정한다.

  • Class Definition
    Class 파일과 Extension 파일을 자동으로 생성한다.
  • Manual/None
    Class 파일과 Extension을 수동으로 생성한다.
  • Category/Extension
    Extension을 자동으로 생성한다.

Class 파일 생성하기

Data Model 파일을 선택한 상태에서
Editor > Create NSManagedObject Subclass 메뉴를 선택한다.
Class를 생성할 모델을 선택하고, Entity를 선택한 후 저장 경로를 설정하면
마지막 사진 같이 PersonEntity+CoreDataClass 파일과 PersonEntity+CoreDataProperties 파일 두개가 생성된다.
이는 DataModel의 업데이트에 대비하기 위한 조치이다.

 

기능 구현

Core Data Stack 초기화

이전 포스팅과 같이 프로젝트 생성시 CoreData를 사용하도록 생성하면 해당 코드는
AppDelegate에 자동으로 추가된다.
따라서 Stack에 접근하려는 경우 AppDelegate에 접근하는 코드가 반드시 필요하다.

이번과 같이 직접 추가하는 경우 CoreDataStack을 직접 초기화 한 후 Singletone으로 접근하도록 구현한다.

class DataManager {
   static let shared = DataManager()
   
   private init() { }
	
   var container: NSPersistentContainer?

   var mainContext: NSManagedObjectContext {
      fatalError("Not Implemented")
   }
	
   func saveMainContext() {
      fatalError("Not Implemented")
   }
}

container를 저장할 속성 container를 생성한다.
속성의 형식은 NSPersistentContainer이다.

class DataManager {
	static let shared = DataManager()
	
	private init() { }
	
	var container: NSPersistentContainer?
	
	var mainContext: NSManagedObjectContext {
		  fatalError("Not Implemented")
	}
	
	func setuo(modelName: String) {
	    container = NSPersistentContainer(name: modelName)
	    container?.loadPersistentStores(completionHandler: { (desc, error) in
	        if let error = error {
	            fatalError(error.localizedDescription)
	        }
	    })
	}
	
	func saveMainContext() {
	  fatalError("Not Implemented")
	}
}

Container를 초기화 하는 동작은 setup 메서드가 진행한다.
NSPersistentContainer 생성자로 container를 생성하고,
생성이 정상적으로 완료됐는지 loadPersistentStores 메서드로 검증한다.

class DataManager {
   static let shared = DataManager()
   
   private init() { }
	
   var container: NSPersistentContainer?

   var mainContext: NSManagedObjectContext {
	   guard let context = container?.viewContext else {
		   fatalError()
	   }
	   
	   return context
   }
   
	func setuo(modelName: String) {
		container = NSPersistentContainer(name: modelName)
		container?.loadPersistentStores(completionHandler: { (desc, error) in
			if let error = error {
				fatalError(error.localizedDescription)
			}
		})
	}
	
   func saveMainContext() {
      fatalError("Not Implemented")
   }
}

mainContext 속성에는 생성된 container로 생성한 context를 반환하도록 수정한다.
이렇게 생성된 mainContext는 읽기와 쓰기가 모두 가능하며, main thread에서 동작하게 된다.

class DataManager {
   static let shared = DataManager()
   
   private init() { }
	
   var container: NSPersistentContainer?

   var mainContext: NSManagedObjectContext {
	   guard let context = container?.viewContext else {
		   fatalError()
	   }
	   
	   return context
   }
   
	func setuo(modelName: String) {
		container = NSPersistentContainer(name: modelName)
		container?.loadPersistentStores(completionHandler: { (desc, error) in
			if let error = error {
				fatalError(error.localizedDescription)
			}
		})
	}
	
   func saveMainContext() {
	   mainContext.perform {
		   if self.mainContext.hasChanges {
			   do {
				   try self.mainContext.save()
			   } catch {
				   print(error)
			   }
		   }
	   }
   }
}

저장된 mainContext는 saveMainContext 메서드로 저장해 실제 CoreData에 반영되도록 한다.
동작 구조는 일반적인 context를 저장하는 매커니즘과 동일하다.
단, context의 perform 메서드를 사용해 context가 생성된 thread에서 실행되도록 해 문제를 방지한다.

이렇게 Container를 초기화 하는 작업은 경우에 따라 두 가지 실행 시점을 정할 수 있다.

  • App에서 하나의 DataModel만 사용하는 경우
    App 시작시 초기화
  • 특정 Scene에서 별도로 사용해야 하는 경우
    Scene 진입시 초기화

이번 포스팅에선 App 시작시에 초기화 하도록 구현한다.

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {   
   var window: UIWindow?
   
   func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
	   DataManager.shared.setuo(modelName: "Sample")
      
      return true
   }
}

AppDelegate에서 정의한 setup 메서드를 호출한다.
파라미터로는 해당되는 데이터 모델의 이름을 문자열로 전달한다.

 

CRUD 개선하기

동작을 구현하기 위한 extension을 생성한다.
파일 이름은 DataManager+Person 으로 이러한 형식은 extension이나 category 구현시 사용하는 이름 형식이다.

Create

import Foundation
import CoreData

extension DataManager {
	func createPerson(name: String, age: Int? = nil) {
		mainContext.perform {
			let newPerson = PersonEntity(context: self.mainContext)
		}
	}
}

CoreData 모듈을 import하고,
DataManager의 extension을 작성한다.

createPerson은 Person Entity의 Attribute 특성에 맞게 name은 반드시 저장해야하고,
age는 선택이다.

main thread에서 동작할 수 있도록 perform 메서드를 사용해 동작을 구현한다.

Managed Object를 생성하기 위해 NSManagedObject 생성자를 사용해야 하지만
해당 생성자는 NSManagedObject 자체를 생성하는 데엔 사용하지 못한다.
해당 클래스를 상속한 Subclass에서 선언해야 하므로 앞서 작성한 PersonEntity의 생성자를 사용한다.

import Foundation
import CoreData

extension DataManager {
	func createPerson(name: String, age: Int? = nil) {
		mainContext.perform {
			let newPerson = PersonEntity(context: self.mainContext)
			
			newPerson.name = name
			if let age = age {
				newPerson.age = Int16(age)
			}
			
			self.saveMainContext()
		}
	}
}

생성한 Managed Object Class를 사용하면 이전처럼 setValue() 메서드를 사용해
Key-Value 방식으로 값을 저장하지 않고 속성에 직접 저장할 수 있다.
값을 저장한 다음엔 Context의 변경 여부를 확인하고 CoreData에 반영할 수 있도록
saveMainContext 메서드를 호출한다.

Fetch

func fetchPerson() -> [PersonEntity]{
		var list = [PersonEntity]()
		
		mainContext.performAndWait {
			let request: NSFetchRequest<PersonEntity> = PersonEntity.fetchRequest()
			
			let sortByName = NSSortDescriptor(key: #keyPath(PersonEntity.name), ascending: true)
			request.sortDescriptors = [sortByName]
			
			do {
				list = try mainContext.fetch(request)
			} catch {
				print(error)
			}
		}
		return list
	}
  • fetchPerson 메서드의 반환형은 PersonEntity 배열이다.
  • 반환할 값을 저장할 PersonEntity 배열 list를 생성한다.
  • performAndWait를 사용해 Block의 동작이 완료된 후 반환할 수 있도록 한다.
  • request를 생성하고, name을 기준으로 정렬할 수 있도록 NSSortDescriptor를 정의 한다.
  • request와 sortDescriptor를 기반으로 fetch를 실행하고 이를 반환한다.

사용한 performAndWait는 이전에 사용한 perform과는 달리 Block이 완전히 종료 된 뒤에 값을 반환하게 된다는 특징이 있다.

Update

	func updatePerson(entity: PersonEntity,name: String, age: Int? = nil) {
		mainContext.perform {
			entity.name = name
			if let age = age {
				entity.age = Int16(age)
			}
			
			self.saveMainContext()
		}
	}

파라미터로 편집할 대상인 entity가 추가되는 것 외엔 create와 동일하다.

Delete

	func deletePerson(entity: PersonEntity) {
		mainContext.perform {
			self.mainContext.delete(entity)
			self.saveMainContext()
		}
	}

파리미터로 전달된 삭제 대상을 삭제한다.

위와 같이 Entity Class를 사용하면 Entity Class 없이 Key-Value로 사용하는 것 대비 장점이 존재한다.

  • Context 만으로 인스턴스 생성이 가능하다.
  • Key-Value 의존성이 없어져 코드가 단순해 진다.
  • Fetch Request를 쉽게 생성할 수 있다.

 

인터페이스

ManagedObject Scene은 Core Data에 저장 된 name 과 age를 표시하는 Table View를 가지고 있다.

따라서 Core Data에서 fetch를 통해 데이터를 읽어오고,
적절한 데이터를 textLabel과 detailTextLabel에 표시한다.

   override func viewDidLoad() {
      super.viewDidLoad()
	   
	   list = DataManager.shared.fetchPerson()
	   listTableView.reloadData()
      
      token = NotificationCenter.default.addObserver(forName: PersonComposeViewController.newPersonDidInsert, object: nil, queue: .main, using: { [weak self] (noti) in
      
      })
      
   }

Scene의 Class의 viewDidLoad에서 해당 동작을 실행하고,
TableView를 새로고침 하도록 구현한다.

extension ManagedObjectViewController: UITableViewDataSource {
   func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
	   return list.count
   }
   
   func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
	   
	   let person = list[indexPath.row]
	   
	   cell.textLabel?.text = person.name
	   cell.detailTextLabel?.text = "\(person.age)"
      
      return cell
   }
   
   func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
      switch editingStyle {
      case .delete:
         break
      default:
         break
      }
   }
}

TableView는 fetch의 반환 값의 수 만큼 Cell을 표시하고,
Cell의 textLabel과 detailTextLabel을 각각 측정 데이터와 연결한다.

extension ManagedObjectViewController: UITableViewDataSource {
   func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
	   return list.count
   }
   
   func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
	   
	   let person = list[indexPath.row]
	   
	   cell.textLabel?.text = person.name
	   cell.detailTextLabel?.text = "\(person.age)"
      
      return cell
   }
   
   func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
      switch editingStyle {
      case .delete:
		  let person = list.remove(at: indexPath.row)
		  DataManager.shared.deletePerson(entity: person)
		  tableView.deleteRows(at: [indexPath], with: .automatic)
      default:
         break
      }
   }
}

표시된 데이터를 스와이프 하면 삭제할 수 있다.
해당 기능이 호출 될 시 해당 indexPath에 해당하는 데이터를 지정하고,
해당 데이터를 deletePerson 메서드에 전달해 삭제한다.
데이터가 삭제된 후 TableView의 데이터를 새로고침 할 수 있도록 한다.

   @IBAction func save(_ sender: Any) {
      guard let name = nameField.text else { return }
      
      var age: Int?
      if let ageStr = ageField.text, let ageVal = Int(ageStr) {
         age = ageVal
      }
      
	   DataManager.shared.createPerson(name: name, age: age)
      
      
      NotificationCenter.default.post(name: PersonComposeViewController.newPersonDidInsert, object: nil)
      self.dismiss(animated: true, completion: nil)
   }

새 값을 생성할 때는 PersonComposeViewController의 Save 버튼을 사용하게 된다.
textField에 입력한 값을 사용해 entity를 생성하고,
생성이 완료되면 newPersonDidInsert Notification이 발생하게 된다.

   override func viewDidLoad() {
      super.viewDidLoad()
	   
	   list = DataManager.shared.fetchPerson()
	   listTableView.reloadData()
      
      token = NotificationCenter.default.addObserver(forName: PersonComposeViewController.newPersonDidInsert, object: nil, queue: .main, using: { [weak self] (noti) in
		  self?.list = DataManager.shared.fetchPerson()
		  self?.listTableView.reloadData()
      })
      
   }

다시 ManagedObjectViewController로 돌아와
Notification이 감지된 경우 다시 데이터를 불러 오고, TableView에 반영할 수 있도록 구성한다.

이 상태로도 동작 자체는 진행하지만 TableView에 반영이 되지는 않는다.
이는 작성한 CreatePerson의 구조에 문제가 있다.

	func createPerson(name: String, age: Int? = nil) {
		mainContext.perform {
			let newPerson = PersonEntity(context: self.mainContext)
			
			newPerson.name = name
			if let age = age {
				newPerson.age = Int16(age)
			}
			
			self.saveMainContext()
		}
	}

createPerson 메서드는 perform 속성을 사용하고 있고,
해당 속성은 Queue에 작업을 추가한 다음 바로 반환을 진행한다.
즉, 작업이 Queue에 등록되고, Notification을 발생하고, 작업이 완료되기 때문에
ManagedObjectViewController가 Notification을 감지하고 새로고침 하는 시점에는
변경된 데이터가 없는 것이다.

	func createPerson(name: String, age: Int? = nil,completion: (() -> ())? = nil) {
		mainContext.perform {
			let newPerson = PersonEntity(context: self.mainContext)
			
			newPerson.name = name
			if let age = age {
				newPerson.age = Int16(age)
			}
			
			self.saveMainContext()
			completion?()
		}
	}

 

이를 해결하기 위해 메서드의 파라미터에 Completion Handler를 추가한다.

추가된 파라미터를 전달하기 위해 PersonComposeViewController로 돌아와 작업을 이어 나간다.

   @IBAction func save(_ sender: Any) {
      guard let name = nameField.text else { return }
      
      var age: Int?
      if let ageStr = ageField.text, let ageVal = Int(ageStr) {
         age = ageVal
      }
      
	   DataManager.shared.createPerson(name: name, age: age) {
		   NotificationCenter.default.post(name: PersonComposeViewController.newPersonDidInsert, object: nil)
	   }
      
      self.dismiss(animated: true, completion: nil)
   }

createPerson 메서드의 파라미터에 completion을 전달하고,
해당 블록에 Notification 생성 코드를 작성한다.

이렇게 되면 createPerson을 호출하고,
작업 Queue에 등록한 다음,
Queue 프로세스가 완료 되면 Completion 블록을 실행하게 되므로
Notification이 생성되고 감지되는 시점은 Entity가 변경된 시점이 된다.

데이터 수정은 생성시 사용하는 편집기를 그대로 사용한다.

   override func viewDidLoad() {
      super.viewDidLoad()
      
	   if let target = target as? PersonEntity {
		   navigationItem.title = "Edit"
		   nameField.text = target.name
		   ageField.text = "\(target.age)"
	   } else {
		   navigationItem.title = "Create"
	   }
   }

PersonComposeViewController가 표시될 때 전달 된 target 값이 존재하는지의 여부에 따라
동일한 Scene을 편집기로 사용할 지 생성기로 사용할지를 분기하게 된다.

extension ManagedObjectViewController: UITableViewDelegate {
   func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
      tableView.deselectRow(at: indexPath, animated: true)
      
	   let person = list[indexPath.row]
	   
	   if let nav = storyboard?.instantiateViewController(withIdentifier: "ComposeNav") as? UINavigationController, let composeVC = nav.viewControllers.first as? PersonComposeViewController {
		   composeVC.target = person
		   present(nav, animated: true, completion: nil)
	   }
      
   }
}

ManagedObjectViewController에서 Cell을 선택하면 해당 데이터를 읽어와 편집기에 표시한다.
연결된 "ComposeNav"라는 이름의 Navigation Controller를 찾아 화면에 띄우게 된다.
해당 Navigation Contoller의 Relation View인 PersonComposeViewController의 target에 편집 대상을 전달한다.

	func updatePerson(entity: PersonEntity,name: String, age: Int? = nil, completion: (() -> ())? = nil) {
		mainContext.perform {
			entity.name = name
			if let age = age {
				entity.age = Int16(age)
			}
			
			self.saveMainContext()
			completion?()
		}
	}

updatePerson 메서드도 수정 이전의 createPerson 메서드의 동기화 문제를 가지고 있다.
따라서 Completion 파라미터를 추가해 주고, 작업이 완료된 후 Completion 블록을 실행할 수 있도록 수정한다.

   @IBAction func save(_ sender: Any) {
      guard let name = nameField.text else { return }
      
      var age: Int?
      if let ageStr = ageField.text, let ageVal = Int(ageStr) {
         age = ageVal
      }
	   
	   if let target = target as? PersonEntity {
		   DataManager.shared.updatePerson(entity: target, name: name, age: age) {
			   NotificationCenter.default.post(name: PersonComposeViewController.newPersonDidInsert, object: nil)
		   }
	   } else {
		   DataManager.shared.createPerson(name: name, age: age) {
			   NotificationCenter.default.post(name: PersonComposeViewController.newPersonDidInsert, object: nil)
		   }
	   }
      
      self.dismiss(animated: true, completion: nil)
   }

createPerson과 updatePerson을 호출하게 되는 주체인 save 메서드에서도 target의 여부에 따라
서로 다른 메서드를 호출할 수 있도록 편집하면 된다.

 

Entity Class의 생성 구조

앞서 NSManagedObject의 서브클래스 파일을 생성하면
PersonEntity+CoreDataClass와 PersonEntity+CoreDataProperties
두개의 파일이 생긴다고 언급했다.
이는 CoreData의 Data Model 업데이트에 대비하기 위함이라고 했었는데

실제로 새로운 Attribute를 생성하고 다시 NSManagedObject의 클래스 파일을 생성해 보면
Class 파일에 존재하는 doSomethingInClass 메서드는 여전히 존재하는 반면,
Properies 파일에 존재하는 domSomethingInExtension 메서드는 사라진 것을 볼 수 있다.

해당 과정은 Model 구조가 변경됐을 경우 수작업이 아닌 자동으로 새롭게 생긴 Attribute를 반영하는 작업이지만,
Attribute의 접근 방식은 Properties 파일에 존재하므로 해당 파일만 새로 만들게 된다.

따라서 Entity Class 내에 기능을 추가하게 되는 경우 Properies 파일이 아닌 Class 파일 내에 직접 추가할 수 있도록 해야한다.

 

'학습 노트 > iOS (2021)' 카테고리의 다른 글

187 ~ 189. Fetch Request  (0) 2022.02.25
187. Entity Hierarchy, Relationships  (0) 2022.02.18
185. CoreData  (0) 2022.02.05
183 ~ 184. NSCoding and Codable  (0) 2022.01.27
181 ~ 182. User Defaults and Property List  (0) 2022.01.22