SWIFTUI/Others

SwiftUI : @FetchRequest 속성 래퍼 [Core Data #1]

서근 2021. 6. 5. 19:49
반응형

Core Data?

Core Data란 기본적으로 내부에 저장된 데이터 베이스입니다. 아이폰을 사용하여 데이터를 저장할 수 있습니다. 이 데이터는 세션 간에 유지되므로 사용자가 앱을 닫고 다시 열었을 때 데이터가 저장됩니다. 마치 @AppstorageUserDefault 같이 말이죠. 

 

SwiftUICore Data 작업을 위한 속성 래퍼를 제공하며 추가 로직을 작성하지 않고도 SwiftUI View에 데이터를 직접 포함할 수 있습니다.

 

UserDefaults : 앱의 Preference 를 저장할 때 주로 사용합니다.

CoreData : SQLite 에 직접 접근하는 방식만큼 유연함을 제공하면서도  앱과 데이터베이스의 동작 방식을 분리해줍니다.

 

앱이 많은 데이터를 필요로 하고 여러 다른 객체 간의 관계를 관리해야 하며 특정 객체나 객체의 그룹에 빠르고 쉽게 접근해야한다면 CoreData 를 사용하는 것이 좋습니다.

@FetchRequest

@FetchRequest에는 EntityData를 정렬할 sort descriptors의 두 가지 이상의 값을 제공해야 합니다. 필요에 따라 데이터를 필터링하는 술어[각주:1]를 제공할 수도 있습니다.

 

Core Data는 정말 유용 하기 때문에 바로 사용해보면서 알아보도록 하죠!

 

우선 새로운 프로젝트를 하나 생성해서 원하는 프로젝트 이름을 생성 후 Use Core Data의 체크박스를 활성화해주고 프로젝트를 만들어 주겠습니다.

처음 프로젝트를 실행하면 오류가 표시되어있는데 일단은 무시하고 캔버스를 실행해보겠습니다.

우리는 Use Core Data를 사용하여 프로젝트를 만들었었기 때문에 CoreDataViewApp으로 들어가게 되면 아래와 같은 코드가 생성 되어 었습니다.

import SwiftUI

@main
struct CoreDataViewApp: App {
    let persistenceController = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

이곳이 바로 싱글톤 글래스 입니다. shared에 마우스를 올리고 코멘트 클릭을 해서 Jump to definition을 눌러보겠습니다.

다음 게시글에서 위와같은 싱글톤 사용법에 대해 자세히 알아보도록 하겠습니다. 

 

다시 CoreDataViewApp로 돌아가 보면 environment를 확인해 볼 수 있습니다. 이 의미는 이 ContentView는 우리가 넣는 모든 것에 액세스 할 수 있다는 뜻이겠죠? 

 

그렇다면 궁금한게 하나 있습니다. PersistenceController란 무엇일까요?

기본적으로 구조를 한번 살펴보는 것이 좋은데  static let shared = PersistenceController()라는 싱글톤이 있고, 그 아래에 Preview 의리고 NSpersistentContainer이라는 매개변수를 포함하고 있는 container매개 변수가 있습니다. 

 

아래 init을 보면 

 container = NSPersistentContainer(name: "CoreDataView")

이라는 코드를 볼 수 있는데 name의 "CoreDataView" 가 CoreDataView.xcdatamodeld과 똑같은 이름을 가지고 있는 것을 알 수 있습니다. 

그리고 그아래 데이터를 저장하는  loadPersistentStores가 있습니다. 이것은 기본적으로 container에서 데이터를 로드하는 주요 기능이기 때문에 container는 모든 것을 담고 있는 데이터베이스라고 생각하면 됩니다.

container.loadPersistentStores(completionHandler: { (storeDescription, error) in

그리고 에러가 있는지 없는지 확인하는 로직이 있죠.

 

그럼 이제 ContentView로 가서 작성되어있는 코드를 한번 살펴보겠습니다.

List부분을 보면 캔버스를 구성하고있는 코드들이 정렬되어있습니다. 이 부분을 수정해가면서 화면을 구성해보도록 하겠습니다. 우선 코드를 보면 toolbar라는 수정자가 있지만 캔버스엔 보이지 않습니다. NavigationView를 추가하면 볼 수 있겠네요.

 

toolbar는 제거하고 아래와 같이 바디를 수정해줬습니다.

    var body: some View {
        NavigationView {
            List {
                ForEach(items) { item in
                    Text("Item at \(item.timestamp!, formatter: itemFormatter)")
                }
                .onDelete(perform: deleteItems)
            }
            
            .listStyle(PlainListStyle())
            .navigationBarTitle("Core Data")
            .navigationBarItems(
                leading: EditButton(),
                trailing:
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
            )
        }
    }

Add Item을 눌러보면 아이템이 하나씩 추가되는것을 확인할 수 있습니다. 그럼 AddItem함수를 한번 살펴볼게요.

    private func addItem() {
        withAnimation {
        //newItem 이라는 빈항목을 생성
            let newItem = Item(context: viewContext)
            //현재 날짜인 timestamp 수행
            newItem.timestamp = Date()
            
            
            //context를 호출하는 do state. save를 호출하고 오류가 발생하면 error 호출
            do {
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }

Delete Items함수도 한번 볼게요.

    private func deleteItems(offsets: IndexSet) {
        withAnimation {
        
        //데이터 배열에서 데이터를 찾는다. 그런다음 viewContenxt.delete를 호출하고 항목을 삭제
            offsets.map { items[$0] }.forEach(viewContext.delete)
            
            do {
            
            //아이템을 삭제하고 다시 저장을 호출한다.
            
                try viewContext.save()
            } catch {
                // Replace this implementation with code to handle the error appropriately.
                // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
                let nsError = error as NSError
                fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
            }
        }
    }

그래서 실제로 목록을 스와이프 하여 항목을 제거할 수 있죠. 그 후에 앱을 종료했다가 실행하면 정상적으로 저장되어있습니다.

 

이렇게 기본적인 틀을 알아보았으니 우리만의 콘텐츠를 하나 만들어보는 것이 좋겠죠?

Core Data 사용자화

 우선 NavigationBarTitle을 "음식"으로 수정하고 CoreDataView.xcdatamodeld 경로로 들어가서 몇 가지를 수정해주도록 하겠습니다.

FoodEntity를 생성하고 name이라는 과일 이름을 추가 후 타입은 String으로 지정해줍니다. 이제 Core Data에는 FoodEntity만 남아있으면 됩니다.

 

Persistence로 돌아가서 preview 쪽을 수정해줘야 하는데 우리는 방금 item entity를 삭제했기 때문에 그 부분을 수정해줘야 합니다.

let newItemitem삭제하고 FoodEntity로 수정해줘야 하는데 앱이 업데이터가 아직 되지 않아 아무런 표시가 되어있지 않습니다. 그렇기 때문에 앱을 한번 종료하고 다시 켜서 수정해줄게요.

    static var preview: PersistenceController = {
        let result = PersistenceController(inMemory: true)
        let viewContext = result.container.viewContext
        for item in 0..<10 {
        
                // let newItem = Item(context: viewContext) -> 수정해줘야 함
                
            let newFood = FoodEntity(context: viewContext)
            newFood.name = "김치찌개 \(item)"
            
        }

자 이렇게 프리뷰를 수정해줬습니다.  init부분은 우리는 여전히 CoreDataView.xcdatamodeld파일명을 수정해주지 않았기 때문에 수정할 부분이 없겠네요.

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "CoreDataView")

다음으로 수정해줘야 할 부분은 ContentView입니다.

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default)
    private var items: FetchedResults<Item>

더 이상 이 코드는 사용할 필요가 없기 때문에 아래에 새로운 @FetchRequest 속성 래퍼를 추가해주겠습니다.

entity부분에는 어떠한 종류의 entity를 가져올지를 넣어줘야 하기 때문에 FoodEntityentity()를 호출합니다. 그리고 sortDescriptors는 데이터를 정렬하는 부분인데 지금은 정렬할 필요가 없기 때문에 빈 배열을 넣어줍니다.

  @FetchRequest(entity: FoodEntity.entity(), sortDescriptors: []) var Food: FetchedResults<FoodEntity>

자 이제 첫 번째 @FetchRequest는 더 이상 사용하지 않기 때문에 지워주겠습니다.

 

그리고 List부분도 수정해줍니다. 수정하려고 보면 오류가 나있는 것을 볼 수 있는데 오류가 있는 함수부를 잠시 주석처리하는 것이 좋습니다.

import SwiftUI
import CoreData

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    
    @FetchRequest(entity: FoodEntity.entity(), sortDescriptors: []) var Food: FetchedResults<FoodEntity>
    
    var body: some View {
        NavigationView {
            List {
                ForEach(Food) { foods in
                    //과일에 이름이 없으면 빈문자열을 생성
                    Text(foods.name ?? "아이템 없음")
                }
//                .onDelete(perform: deleteItems)
            }
            
            .listStyle(PlainListStyle())
            .navigationBarTitle("음식")
            .navigationBarItems(
                leading: EditButton()
            )
        }
    }

이제 실행해볼까요?

성공적으로 김치찌개라는 아이템을 불러왔습니다. 이제 addItem부분도 수정해줍니다.

    private func addItem() {
        withAnimation {
    //수정 부분
        let newFood = FoodEntity(context: viewContext)
            newFood.name = "미역국"

이런데 addItem()함수와 deleteItems()함수를 보면 do {} 문이 동일한 것을 확인할 수 있습니다. 이 부분을 따로 함수를 만들어 깔끔하게 함수를 구현할 수 있습니다.

 

아주 간단합니다. private func saveItems()라는 함수를 생성 후 addItem에 있는 do {} 문을 복사해서 붙여 넣어주기만 하면 됩니다.

    private func saveItems() {
        do {
            try viewContext.save()
        } catch {
            // Replace this implementation with code to handle the error appropriately.
            // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }
    }
    private func addItem() {
        withAnimation {
            //수정 부분
            let newFood = FoodEntity(context: viewContext)
            newFood.name = "미역국"
            
            saveItems()
        }
    }
    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            //            offsets.map { items[$0] }.forEach(viewContext.delete)
            saveItems()
        }
    }

계속해서 deleteItems부분도 수정해주도록 하겠습니다.

    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            
            guard let index = offsets.first else { return }
            let FoodEntity = Food[index]
            viewContext.delete(FoodEntity)
            
            saveItems()
        }
    }
    var body: some View {
        NavigationView {
            List {
                ForEach(Food) { foods in
                    //과일에 이름이 없으면 빈문자열을 생성
                    Text(foods.name ?? "아이템 없음")
                }
                .onDelete(perform: deleteItems)
            }
            
            .listStyle(PlainListStyle())
            .navigationBarTitle("음식")
            .navigationBarItems(trailing: Button(action: addItem) {
                Label("아이템 추가", systemImage: "plus")
            }
            )
        }
    }

이제 캔버스를 실행하 보면 아이템을 삭제할 수 있고 addItems을 누르면 "미역국"이라는 아이템이 추가됩니다.

 

그런데 아이템을 추가하면 미역국이라는 아이템이 맨 위로 추가되는 것이 아닌 랜덤 한 위치에 추가되는 것이 확인됩니다. 그 이유는 우리가 sortDescriptors에 아무것도 추가해주지 않았기 때문이죠. sortDescriptors에 옵션 버튼을 누른 상태에서 클릭을 한번 해볼게요.

NSSortDescriptor을 사용해야 합니다.

    @FetchRequest(
        entity: FoodEntity.entity(),
         //FoodEntity name을 기준으로 오름차순으로 정렬이라는 의미
        sortDescriptors: [NSSortDescriptor(keyPath: \FoodEntity.name, ascending: true)])
    var Food: FetchedResults<FoodEntity>

이렇게 오름차순으로 설정을 해주고 다시 아이템을 추가해보죠.

성공적으로 이름의 오름차순에 맞게 추가되네요!

 

한 가지 기능을 더 추가해 주고 싶은데 바로 TextField를 만들고 저장 버튼을 누르면 아래에 제가 작성한 리스트가 보이도록 해주고 싶습니다. 바로 한번 구현해볼게요.

    var body: some View {
        
        NavigationView {
            
            VStack(spacing: 10) {
                TextField("", text: $textFieldTitle)
                    .padding()
                    .frame(maxWidth: .infinity)
                    .frame(height: 55)
                    .background(Color(UIColor.secondarySystemBackground).cornerRadius(10))
                    .padding(.horizontal, 10)
                
                Button(action: {addItem()}, label: {
                    Text("저장")
                        .padding()
                        .frame(maxWidth: .infinity)
                        .frame(height: 55)
                        .foregroundColor(.white)
                        .background(Color("Peach").cornerRadius(10))
                        .padding(.horizontal, 10)
                    
                })
                
                List {
                    ForEach(Food) { foods in
                        //과일에 이름이 없으면 빈문자열을 생성
                        Text(foods.name ?? "아이템 없음")
                    }
                    .onDelete(perform: deleteItems)
                }
                
                .listStyle(PlainListStyle())
                .navigationBarTitle("음식")
            }
            
        }
    }

자 이렇게 버튼과 textField까지 성공적으로 추가를 했고 이제 addItem 부분만 수정해주면 될 거 같아요.

    private func addItem() {
        withAnimation {
            //수정 부분
            let newFood = FoodEntity(context: viewContext)
            newFood.name = textFieldTitle
            //textFieldTitle을 사용 후 재설정
            textFieldTitle = ""
            
            saveItems()
        }
    }
    



목록 추가도 잘되고...! 삭제도 잘되고...! 저장도 잘되는 것을 확인할 수 있습니다 :)

 

읽어주셔서 감사합니다🤟

 

 

  1. true / false를 판단할 수 있는 식이나 boolean 값을 리턴하는 함수를 술어(predicate)라고 한다. [본문으로]