# 프로젝트 생성

코어데이터를 사용할 프로젝트의 경우 생성시 use core data 체크박스를 체크한 뒤 생성해야한다. project_name.xcdatamodeld 파일이 자동으로 함께 생성되며, AppDelegate.swift파일에 코어데이터 스택이 추가로 만들어진다.

기존 MVC 모델의 경우 모델을 구조체나 클래스 형태로 정의했다. 코어데이터는 다른 방식으로 정의하게 되는데, xcdatamodeld 파일을 클릭하고 Add Entity 버튼을 클릭하여 모델을 정의해야 한다.

  1. 데이터 모델에 대한 이름을 먼저 좌측 ENTITIES 섹션에 자신이 생성한 엔티티 이름을 클릭하여 수정한다.
  2. 엔티티에 대한 Attributes를 추가한다. 이 속성들은 우리가 일반 구조체나 클래스로 정의했을 때처럼 속성 역할을 한다.
  3. 일반적으로 우측 인스펙터 메뉴에서 Class 섹션의 Codegen을 Manual/None으로 선택한 뒤, Xcode-Editor 메뉴의 Create NSManagedObject Subclass를 선택한다.
  4. 해당 메뉴를 선택하면 .swift 파일이 두개 생성된다. 하나는 엔티티에 대한 클래스이고 나머지 하나는 엔티티 내부의 속성들을 확장 안에 넣어둔 파일이다. 확장에 속성이 포함되므로, 저장속성이 아닌 계산속성이다.

# 코어데이터 스택

코어데이터 인스턴스의 체계를 스위프트에서는 코어데이터 스택이라고 표현한다.

코어데이터 스택을 이루는 요소는 아래와 같다.

  1. (영구)컨테이너
    1. 관리 객체 컨텍스트(임시 저장소) - ManagedObject Context
    2. 엔티티: 관리 객체 컨텍스트와 소통
    3. 영구 저장소 조율자 / 중재자 (Persistent Store Coordinator) - 관리 객체 모델과 소통(ManagedObject Model)
      • 영구저장소 조율자: 실제 데이터들을 영구저장소에서 저장하고 가져오는 역할
      • 관리 객체 모델: 저장 데이터들의 타입을 설명하는 모델
  2. 영구 저장소 - 하드디스크에 저장

# 사용 흐름

반드시 코어데이터 관리 주체는 MVC의 Model단에서 하도록 하자. 코어데이터 매니저 모델 클래스를 구성하는 요소를 하나씩 살펴보면 아래와 같다.

import UIKit
import CoreData

class CoreDataManager {

    // 싱글톤으로 만들기
    static let shared = CoreDataManager()
    private init() {}

    // 앱 델리게이트
    let appDelegate = UIApplication.shared.delegate as? AppDelegate

    // 임시저장소
    lazy var context = appDelegate?.persistentContainer.viewContext

    // 엔터티 이름 (코어데이터에 저장된 객체)
    let modelName: String = "ToDoData"

    // MARK: - [Read] 코어데이터에 저장된 데이터 모두 읽어오기
    func getToDoListFromCoreData() -> [ToDoData] {
        var toDoList: [ToDoData] = []
        // 임시저장소 있는지 확인
        if let context = context {
            // 요청서
            let request = NSFetchRequest<NSManagedObject>(entityName: self.modelName)
            // 정렬순서를 정해서 요청서에 넘겨주기
            let dateOrder = NSSortDescriptor(key: "date", ascending: false)
            request.sortDescriptors = [dateOrder]

            do {
                // 임시저장소에서 (요청서를 통해서) 데이터 가져오기 (fetch메서드)
                if let fetchedToDoList = try context.fetch(request) as? [ToDoData] {
                    toDoList = fetchedToDoList
                }
            } catch {
                print("가져오는 것 실패")
            }
        }

        return toDoList
    }
}
  1. appDelegate 속성 - UIApplicationDelegate에서 싱글톤 타입속성으로 관리된다. 엔티티 생성 후 CodeGen을 None으로 설정하고 NSManagedObject SubClass 파일을 생성하면 기존 AppDelegate.swift 코드 하단에 코어데이터 스택 영역 코드가 추가된다. 앱 델리게이트 속성을 통해 코어데이터 스택의 저장소를 불러와야한다.
  2. context 속성 - appDelegate속성으로부터 임시저장소에 접근하고 해당 저장소의 컨텍스트 속성에 다시 접근한다. 임시저장소의 내부 데이터 변경에 대해 다양한 동작을 지원한다. (저장, fetch 등)
  3. modelName 속성 - 실제 데이터 메타데이터에 대한 속성이다. 엔티티 이름을 저장해둔다.

아래부터는 코어데이터의 CRUD 과정이다.

# CRUD - Create

func appendTodo(todoText: String, color: Int64, completion: @escaping () -> Void){
    guard let context = context else {
        print("Context Load Error..")
        return
    }

    guard let entity = NSEntityDescription.entity(forEntityName: self.modelName, in: context) else {
        print("Entity description error..")
        return
    }

    guard let newTodoData = NSManagedObject(entity: entity, insertInto: context) as? TodoData else {
        print("NSManagedObject Error..")
        return
    }

    newTodoData.todoText = todoText
    newTodoData.date = Date()
    newTodoData.color = color

    if(context.hasChanges){
        do{
            try context.save()
            completion()
        }catch{
            print(error)
            completion()
        }
    }
}
  1. guard let 바인딩을 통해 컨텍스트를 로드한다.
  2. 새롭게 저장할 데이터 타입에 대한 엔티티 객체를 선언한다. NSEntityDescription.entity(forEntityName: 엔티티명, in: 컨텍스트 인스턴스) 메서드를 통해 생성한다.
  3. 실제로 저장할 객체를 생성한다. NSManagedObject(entity: 엔티티 디스크립션객체, insertInto: 삽입할 컨텍스트) as? 엔티티 타입 메서드를 호출하며, 반드시 생성한 객체는 dataProperties에 맞춰 타입캐스팅을 해줘야한다.
  4. 생성한 객체에 내부 저장속성들을 정의한다.
  5. 컨텍스트 객체의 hasChanges 속성을 활용하여 새로 객체가 저장되었는지 확인하여 컨텍스트를 세이브한다.
  6. 콜백 호출을 통해 코어데이터 로직을 호출한 뷰 단의 로직을 구현한다. 비즈니스 로직을 관리하는 Model단에서는 콜백 호출만 하고, 뷰에서 뷰 관련 로직을 작성하면 된다.
// 콜백을 통해 뷰의 pop 로직을 구현해둔 모습.
@IBAction func saveButtonTapped(_ sender: UIButton) {
    todoManager.appendTodo(todoText: textView.text, color: currentColor) {
        // 클로저에서는 self를 통한 명시적 접근을 해야함. 기억하자!!
        self.navigationController?.popViewController(animated: true)
    }
}

# CRUD - Read

 func getAllTodos() -> [TodoData]{
        var todos: [TodoData] = []
        guard let context = context else {return []}

        let request = NSFetchRequest<NSManagedObject>(entityName: self.modelName)

        let order = NSSortDescriptor(key: "date", ascending: false)
        request.sortDescriptors = [order]

        do{
            guard let fetchedTodos = try context.fetch(request) as? [TodoData] else {
                return []
            }
            todos = fetchedTodos
        }catch{
            print("Load Error..")
        }
        return todos
    }

코어데이터로부터 데이터를 가져와 리턴하는 함수이며 리턴 데이터타입은 엔티티에 정의해둔 모델명과 동일하다. create NSManagedObject Subclass로부터 생성된 파일중 dataProperties에 정의된 타입명이다.

  1. guard let바인딩을 통해 컨텍스트 저장소를 로드한다. (guard let으로 바인딩하면 exhaustive에 따라 매번 빈 데이터를 리턴해줘야 하는데, 동일 코드가 반복되므로 if let 바인딩이 더 나을수도 있겠다.)
  2. 요청서를 작성한다. NSFetchRequest타입이며, 생성자는 제네릭으로 정의되어 있으므로 원하는 타입을 전달하면 된다. 생성자 파라미터로 entityName이 있는데 이는 저장속성으로 정의해둔 엔티티 이름을 전달하면 된다.
    • dataProperties파일의 엔티티 타입명을 <NSManagedObject> 제네릭 타입에 전달해도 되지만, 일반적으로 코어데이터로부터 데이터 fetch시 가져온 데이터에 대해 타입캐스팅을 진행하는 방식으로 코드를 작성한다.
  3. 데이터를 여러개 배열 형태로 가져온다면 NSSortDescriptor로 정렬 기준을 설정해준다. 요청서의 정렬 기준을 배열안에 담아 request.sortDescriptors 속성에 저장한다.
  4. do - catch문을 통해 데이터 fetch를 시도한다.

데이터 GET 이후

데이터 GET 이후 일반적으로 가져온 데이터를 셀에 전달하게 될텐데, UITableViewDataSource 프로토콜 채택 후 셀의 UI요소에 직접 접근하는 방식이 아닌, 데이터를 전달하도록 하자.

셀 내에 데이터를 저장할 속성을 마련하고 didSet 속성감시자를 활용하여 UI를 새롭게 정의하는 패턴을 사용하자.

# CRUD - Update

기존데이터를 업데이트 하기 위해서는 다음과 같은 과정이 필요하다.

  1. 코어데이터 저장소로부터 저장된 데이터를 불러온다.
  2. 업데이트 대상 인스턴스를 새로운 인스턴스로 갈아끼운 뒤 컨텍스트를 저장한다.

업데이트에서 중요한 점은 기존 데이터를 불러온다는 부분이다. 전체 리스트를 GET해올때는 엔티티 키값을 NSSortDescriptor에 담아 가져왔었는데, UPDATE는 보통 특정 인스턴스 하나에 대해서 불러와야 하므로 다른 인스턴스들과 구분되는 요소를 통해 불러와야 한다.

가령 위의 투두리스트 같은 경우 date속성이 고유한 값으로 사용되므로 요소 접근을 위한 열쇠가 date가 되는 것이다.

코드를 살펴보자.

func updateTodo(newTodo: TodoData, completion: @escaping () -> Void){

    // 1. 아이디값 바인딩 추출
    guard let todoDate = newTodo.date else {
        completion()
        return
    }

    // 2. 컨텍스트 바인딩 추출
    guard let context = context else {
        print("context Load Error..")
        completion()
        return
    }

    // 3. 요청 인스턴스 생성
    let request = NSFetchRequest<NSManagedObject>(entityName: self.modelName)
    request.predicate = NSPredicate(format: "date = %@", todoDate as CVarArg)

    do{
        // 4. fetch 및 타입캐스팅
        guard let fetchedTodos = try context.fetch(request) as? [TodoData] else {
            completion()
            return
        }

        // 5. 실제 요소 바인딩 추출
        guard var targetTodo = fetchedTodos.first else {
            completion()
            return
        }

        // 6. referenceType 인스턴스에 대해 얕은복사
        targetTodo = newTodo

        if context.hasChanges{
            do {
                try context.save()

            }catch{
                print("context save error")

            }
        }
        completion()
    }catch{
        print("fetch error")
        completion()
        return
    }
}
  1. 함수호출 파라미터로부터 fetch할 데이터에 대한 아이디값을 guard let 바인딩하여 추출한다.
  2. 컨텍스트 바인딩 추출
  3. 요청 인스턴스 생성
    • NSFetchRequestNSManagedObject 제네릭 형태로 요청 인스턴스를 생성하는 부분은 일반 GET과 동일하게 처리한다.
    • NSPredicate 인스턴스 생성을 통해 실제로 찾아야할 데이터를 특정한다.
  4. NSPredicate에 따라 fetch 및 타입캐스팅
  5. 배열 제네릭으로부터 실제 타겟 데이터 바인딩 추출
  6. referenceType 얕은복사 후 컨텍스트의 데이터 수정

NSPredicate

스위프트에서 제공하는 NSPredicate 인스턴스는 메모리에 저장된 데이터를 fetch해올때 대상을 특정해줄 수 있다. 인스턴스 생성시 format 파라미터를 통해 엔티티 키에 대한 밸류값을 나머지 가변 파라미터에 전달되는 변수값과 비교하게 된다.

NSPredicate 생성자 함수 타입은 다음과 같다.

convenience init(format predicateFormat: String, _ args: CVarArg...)

format 파라미터를 사용하는 방법은 엔티티키값 = %@이 기본적인 형태인데, 엔티티 키에 대한 밸류값이 우측에 전달되는 %@와 동일한지 검사하게 된다.

이때 format 파라미터 외에 전달되는 CVarArg 가변 파라미터가 자동으로 해당 %@를 대체하여 들어가게 된다.

위의 예시 코드에서 NSPredicate(format: "date = %@", todoDate as CVarArg)은 엔티티 date속성의 값이 update 대상 인스턴스의 date속성값이 동일한 지에 대해 검사하게 되는 코드이다.

참고로, 검사 결과 반환되는 대상의 수가 단 한가지라도 context.fetch 메서드의 throws 결과 제네릭타입이 [T]로 제네릭 배열 타입으로 선언되어 있기 때문에 배열 관련 인덱싱 또는 메서드, 속성을 통해 원하는 값에 다시 접근해야한다.

아래 코드를 살펴보자.

guard var targetTodo = fetchedTodos.first else {
    completion()
    return
}

targetTodo = newTodo

배열 제네릭이 fetch 후 [TodoData]로 타입캐스팅 되어 있기 때문에 배열 내부 요소는 참조타입이다. 따라서 fetchedTodos.first로 요소 추출을 하여 변수에 할당하면 해당 변수는 얕은복사가 되어 동일한 데이터를 가리키게 된다. targetTodo 참조타입에 newTodo참조타입의 주소를 할당하게 되면 컨텍스트 입장에서는 자신이 관리하는 데이터가 다른 주소를 참조한다고 인식하게 되어 컨텍스트 자체에 변화가 일어났다고 생각하게 된다.

따라서 context.hasChanges 속성이 true값으로 변하고 컨텍스트 저장을 진행한다.

# Reference

  1. 앨런 Swift 문법 마스터스쿨 (opens new window)
  2. Examples of using NSPredicate to filter NSFetchRequest (opens new window)
  3. Generic type in Swift - Medium (opens new window)