SwiftUI Core Data 재고 관리 앱 만들기

Xcode는 Core Data 지원을 위한 프로젝트를 구성하고 이 메뉴에서 Core Data 옵션을 선택할 때 Core Data가 작동하는 모습을 보여주는 간단한 앱을 구현하기 위한 코드를 생성합니다

📚 0
📅 2024-01-18

이제 Core Data의 예제 앱 프로젝트를 만들어 보겠습니다. 이 프로젝트 튜토리얼에서는 Core Data를 사용하여 제품 이름과 수량을  저장하는 간단한 재고 관리 앱을 만듭니다.  여기에는 데이터베이스 항목을 추가, 삭제 및 검색하는 기능을 작성 합니다. 

 

CoreDataDemo 프로젝트 생성

Xcode를 실행하고 새 프로젝트를 생성하는 옵션을 선택한 후 다음 버튼을 클릭하기 전에 다중 플랫폼 앱 템플릿을 선택합니다. 프로젝트 옵션 화면에서 프로젝트 이름을 CoreDataDemo로 지정하고 앱을 고유하게 식별하는 조직 식별자를 선택합니다. 이는 이후 장에서 프로젝트에 CloudKit 지원을 추가할 때 중요합니다.

 Xcode는 Core Data 지원을 위한 프로젝트를 구성하고 이 메뉴에서 Core Data 옵션을 선택할 때 Core Data가 작동하는 모습을 보여주는 간단한 앱을 구현하기 위한 코드를 생성합니다. 이 템플릿을 사용하는 대신 이 튜토리얼에서는 Core Data 작동 방식을 더 잘 이해할 수 있도록 프로젝트에 Core Data 지원을 수동으로 추가하는 단계를 안내합니다. 이러한 이유로 다음 버튼을 클릭하기 전에 저장소 옵션이 없음으로 설정되어 있는지 확인하십시오.

 

 

마침 버튼을 클릭하기 전에 프로젝트를 저장할 적절한 위치를 선택하세요.

 

엔터티 설명 정의
이 예에서 엔터티는 제품 재고를 구성할 이름과 수량을 보유하도록 설계된 데이터 모델의 형태를 취합니다. 파일 -> 새로 만들기 -> 파일... 메뉴 옵션을 선택하고 템플릿 대화 상자 내에서 그림 49-2와 같이 핵심 데이터 섹션에 있는 데이터 모델 항목을 선택한 후 다음 버튼을 클릭합니다.

 

 

파일 이름을 Products로 지정하고 만들기 버튼을 클릭하여 파일을 생성합니다. 파일이 생성되면 아래와 같이 엔터티 편집기 내에 나타납니다.

 

모델에 새 엔터티를 추가하려면 위의 그림 49-3에서 A로 표시된 엔터티 추가 버튼을 클릭합니다. Xcode는 모델에 새 엔터티(Entity라는 이름)를 추가하고 이를 엔터티 제목(B) 아래에 나열합니다. 새 엔터티를 클릭하고 이름을 Product로 변경합니다.

이제 엔터티가 생성되었으므로 다음 단계는 이름 및 수량 속성을 추가하는 것입니다. 첫 번째 속성을 추가하려면 메인 패널의 속성 섹션 아래에 있는 + 버튼을 클릭하세요. 그림 49-5와 같이 새 속성 이름을 지정하고 유형을 문자열로 변경합니다. 이 단계를 반복하여 수량이라는 문자열 유형의 두 번째 속성을 추가합니다. 이 단계가 완료되면 속성 패널이 그림 49-6과 일치해야 합니다.

 Persistence Controller 생성

프로젝트의 다음 요구 사항은 NSPersistantContainer 인스턴스를 생성하고 초기화하는 지속성 컨트롤러 클래스입니다. 파일 -> 새로 만들기 -> 파일... 메뉴 옵션을 선택하고 템플릿 대화 상자 내에서 Swift 파일 템플릿 옵션을 선택하고 Persistence.swift로 저장하세요. 코드 편집기에 새 파일을 로드한 후 다음과 같이 수정합니다.

 

import CoreData

struct PersistenceController {

    static let shared = PersistenceController()

    let container: NSPersistentContainer

    init(inMemory: Bool = false) {

        container = NSPersistentContainer(name: "Products")

        if inMemory {

            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")

        }

        container.loadPersistentStores { (storeDescription, error) in

            if let error = error as NSError? {

                fatalError("Container load failed: \(error)")

            }

        }

         container.viewContext.automaticallyMergesChangesFromParent = true

    }

}

 

뷰 컨텍스트 설정
이제 영구 컨트롤러를 만들었으므로 이를 사용하여 뷰 컨텍스트에 대한 참조를 얻을 수 있습니다. 이 작업을 수행하기에 이상적인 위치는 CoreDataDemoApp.swift 파일 내에 있습니다. 앱을 구성할 뷰에서 컨텍스트에 액세스할 수 있도록 다음과 같이 뷰 계층 구조에 환경 개체로 삽입합니다.

 

import SwiftUI

@main

struct SwiftUIStockExampleApp: App {

    let persistenceController = PersistenceController.shared

    var body: some Scene {

        WindowGroup {

            ContentView()

                .environment(\.managedObjectContext,

                             persistenceController.container.viewContext)

        }

    }

}

 

ContentView 수정

앱 사용자 인터페이스를 디자인하기 위해 뷰를 추가하기 전에 ContentView.swift 파일 내에서 다음과 같은 초기 변경이 필요합니다:

import SwiftUI

import CoreData

 

struct ContentView: View {

    @State var name: String = ""

    @State var quantity: String = “”

    @Environment(\.managedObjectContext) private var viewContext

    @FetchRequest(entity: Product.entity(), sortDescriptors: [])

    private var products: FetchedResults<Product>

    var body: some View {

        VStack {

            Image(systemName: "globe")

                .imageScale(.large)

                .foregroundStyle(.tint)

            Text("Hello, world!")

        }

        .padding()

    }

}
 

CoreData 라이브러리를 가져오는 것 외에도 사용자가 입력할 때 제품 이름과 수량을 저장하는 두 개의 상태 개체도 선언했습니다. 또한 CoreDataDemoApp.swift 파일에서 생성된 뷰 컨텍스트 환경 개체에 액세스했습니다.

@FetchRequest 속성 래퍼는 Core Data가 데이터베이스에 저장된 최신 제품 데이터를 저장할 제품이라는 변수를 선언하는 데에도 사용됩니다.

 

User Interface 작성

대부분의 준비 작업이 완료되었으므로 이제 기본 콘텐츠 보기의 레이아웃 디자인을 시작할 수 있습니다. ContentView.swift 파일에 남아서 다음과 같이 ContentView 구조의 본문을 수정합니다.

var body: some View {

         NavigationStack {

             VStack {

                 TextField("Product name", text: $name)

                 TextField("Product quantity", text: $quantity)

                 

                 HStack {

                     Spacer()

                     Button("Add") {

                         

                     }

                     Spacer()

                     Button("Clear") {

                         name = ""

                         quantity = ""

                     }

                     Spacer()

                 }

                 .padding()

                 .frame(maxWidth: .infinity)

                 

                 List {

                     ForEach(products) { product in

                         HStack {

                             Text(product.name ?? "Not found")

                             Spacer()

                             Text(product.quantity ?? "Not found")

                         }

                     }

                 }

                 .navigationTitle("Product Database")

             }

             .padding()

             .textFieldStyle(RoundedBorderTextFieldStyle())

         }

     }

 

레이아웃은 처음에 두 개의 TextField 보기, 두 개의 버튼 및 목록으로 구성되며 다음과 같이 미리 보기 캔버스 내에서 렌더링되어야 합니다.

 

 

데이터 저장

추가 버튼을 클릭하면 제품 이름 및 수량 텍스트 필드에 입력된 데이터가 핵심 데이터에 의해 영구 저장소에 저장되도록 더 많은 코드 변경이 필요합니다. 이 기능을 추가하려면 ContentView.swift 파일을 다시 한 번 편집하세요.

 

    var body: some View {

         NavigationStack {

             VStack {

                 TextField("Product name", text: $name)

                 TextField("Product quantity", text: $quantity)

                 

                 HStack {

                     Spacer()

                     Button("Add") {

                         addProduct()

                     }

                     Spacer()

                     Button("Clear") {

                         name = ""

                         quantity = ""

                     }

                     Spacer()

                 }

                 .padding()

                 .frame(maxWidth: .infinity)

                 

                 List {

                     ForEach(products) { product in

                         HStack {

                             Text(product.name ?? "Not found")

                             Spacer()

                             Text(product.quantity ?? "Not found")

                         }

                     }

                 }

                 .navigationTitle("Product Database")

             }

             .padding()

             .textFieldStyle(RoundedBorderTextFieldStyle())

         }

     }

    

    private func addProduct() {

        withAnimation {

            let product = Product(context: viewContext)

            product.name = name

            product.quantity = quantity

            

            saveContext()

        }

    }

    

    private func saveContext() {

        do {

            try viewContext.save()

        } catch {

            let error = error as NSError

            fatalError("An error occurred: \(error)")

        }

    }

 

첫 번째 변경 사항은 다음과 같이 선언된 addProduct()라는 함수를 호출하도록 추가 버튼을 구성했습니다.


    private func addProduct() {

        withAnimation {

            let product = Product(context: viewContext)

            product.name = name

            product.quantity = quantity

            

            saveContext()

        }

    }

addProduct() 함수는 새로운 Product 엔터티 인스턴스를 생성하고 제품 이름 및 수량 상태 속성의 현재 콘텐츠를 해당 엔터티 속성에 할당합니다. 그런 다음 다음 saveContext() 함수가 호출됩니다.

    private func saveContext() {

        do {

            try viewContext.save()

        } catch {

            let error = error as NSError

            fatalError("An error occurred: \(error)")

        }

    }

saveContext() 함수는 "do.. try .. catch" 구문을 사용하여 현재 viewContext를 영구 저장소에 저장합니다. 테스트 목적으로 저장 작업이 실패하면 앱을 종료하는 치명적인 오류가 발생합니다. 일반적으로 프로덕션 품질 앱에는 보다 포괄적인 오류 처리가 필요합니다.

데이터를 저장하면 최신 데이터를 가져와 제품 데이터 변수에 할당합니다. 그러면 목록 보기가 최신 제품으로 업데이트됩니다. 이 업데이트를 시각적으로 매력적으로 만들기 위해 addProduct() 함수의 코드가 withAnimation 호출에 배치됩니다.

 

Testing the addProduct() Function


장치 또는 시뮬레이터에서 앱을 컴파일 및 실행하고 몇 가지 제품 및 수량 항목을 입력하고 해당 항목이 추가될 때 목록 보기에 나타나는지 확인합니다. 텍스트 필드에 정보를 입력한 후 지우기 버튼을 클릭하면 현재 항목이 지워지는지 확인하세요.

튜토리얼의 이 시점에서 실행 중인 앱은 일부 제품이 추가된 후 그림 49-8에 표시된 것과 유사해야 합니다.

 

목록을 보다 체계적으로 구성하려면 제품 항목을 이름 속성을 기준으로 알파벳 오름차순으로 정렬해야 합니다. 이를 구현하려면 아래에 설명된 대로 @FetchRequest 정의에 정렬 설명자를 추가하세요. 이를 위해서는 키로 선언된 name 속성과 true로 설정된 오름차순 속성으로 구성된 NSSortDescriptor 인스턴스를 생성해야 합니다.

 

    @FetchRequest(entity: Product.entity(),

               sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)])

    private var products: FetchedResults<Product>

앱이 실행되면 상품 목록이 알파벳 오름차순으로 정렬됩니다.

 

Deleting Products

이제 앱에는 데이터베이스에 제품 항목을 추가하는 메커니즘이 있으므로 더 이상 필요하지 않은 항목을 삭제하는 방법이 필요합니다. 이 프로젝트에서는 SwiftUI 목록 및 탐색이라는 장에서 설명한 것과 동일한 단계를 사용합니다. 이렇게 하면 사용자가 목록 항목을 스와이프하고 삭제 버튼을 탭하여 항목을 삭제할 수 있습니다. 기존 addProduct() 함수 아래에 다음과 같은 deleteProduct()라는 새 함수를 추가합니다.
 

    private func deleteProducts(offsets: IndexSet) {

        withAnimation {

            offsets.map { products[$0] }.forEach(viewContext.delete)

                saveContext()

            }

    }

메소드가 호출되면 사용자가 삭제하기 위해 선택한 항목의 위치를 나타내는 목록 항목 내의 오프셋 세트가 전달됩니다. 위의 코드는 삭제된 각 항목에 대해 viewContext delete() 함수를 호출하여 이러한 항목을 반복합니다. 삭제가 완료되면 saveContext() 함수 호출을 통해 변경 사항이 데이터베이스에 저장됩니다.

 

이제 deleteProduct() 함수를 추가했으므로 List 뷰를 수정하여 onDelete() 수정자를 통해 이를 호출할 수 있습니다.

                 List {

                     ForEach(products) { product in

                         HStack {

                             Text(product.name ?? "Not found")

                             Spacer()

                             Text(product.quantity ?? "Not found")

                         }

                     }

                     .onDelete(perform: deleteProducts)

                 }

                 .navigationTitle("Product Database")

 

앱을 실행하고 목록 항목을 왼쪽으로 스와이프하면 삭제 옵션이 표시되고 이를 클릭하면 목록에서 항목이 제거되는지 확인합니다.

 

Adding the Search Function


프로젝트에 추가할 마지막 기능을 사용하면 이름 텍스트 필드에 입력한 텍스트와 일치하는 제품을 데이터베이스에서 검색할 수 있습니다. 결과는 ResultsView라는 두 번째 보기에 포함된 목록에 표시됩니다. ContentView에서 호출되면 ResultsView에는 이름 상태 속성의 현재 값과 viewContext 개체에 대한 참조가 전달됩니다.

 

다음과 같이 ResultsView 구조를 ContentView.swift 파일에 추가하여 시작합니다.

struct ResultsView: View {

    

    var name: String

    var viewContext: NSManagedObjectContext

    @State var matches: [Product]?

 

    var body: some View {

       

        return VStack {

            List {

                ForEach(matches ?? []) { match in

                    HStack {

                        Text(match.name ?? "Not found")

                        Spacer()

                        Text(match.quantity ?? "Not found")

                    }

                }

            }

            .navigationTitle("Results")

        }

    }

}

name 및 viewContext 매개변수 외에도 선언에는 일치하는 제품 검색 결과가 배치되고 목록 보기 내에 표시되는 match라는 상태 속성도 포함됩니다.

이제 검색을 수행하기 위해 일부 코드를 추가해야 하며 VStack 컨테이너 뷰에 task() 수정자를 적용하여 이를 수행합니다. 이렇게 하면 검색이 비동기적으로 수행되고 검색이 실행되기 전에 뷰의 모든 속성이 초기화됩니다.

struct ResultsView: View {

    

    var name: String

    var viewContext: NSManagedObjectContext

    @State var matches: [Product]?

 

    var body: some View {

       

        return VStack {

            List {

                ForEach(matches ?? []) { match in

                    HStack {

                        Text(match.name ?? "Not found")

                        Spacer()

                        Text(match.quantity ?? "Not found")

                    }

                }

            }

            .navigationTitle("Results")

        }

        .task {

                let fetchRequest: NSFetchRequest<Product> = Product.fetchRequest()

                

                fetchRequest.entity = Product.entity()

                fetchRequest.predicate = NSPredicate(

                    format: "name CONTAINS %@", name

                )

                matches = try? viewContext.fetch(fetchRequest)

    }

}

 

검색 시 지정된 텍스트가 포함된 모든 제품을 찾을 수 있도록 조건자는 CONTAINS 키워드를 사용하여 구성됩니다. 이는 부분 일치를 찾아 LIKE 키워드를 사용하여 정확히 일치 검색을 수행하는 것보다 더 많은 유연성을 제공합니다.

task() 수정자의 클로저에 있는 코드는 Product 엔터티에서 NSFetchRequest 인스턴스를 획득하고 name 변수와 name product 엔터티 속성 사이의 일치 항목을 찾도록 구성된 NSPredicate 인스턴스를 할당합니다. 그런 다음 가져오기 요청은 뷰 컨텍스트의 fetch() 메서드로 전달되고 결과는 match 상태 개체에 할당됩니다. 그러면 목록이 일치하는 제품으로 다시 렌더링됩니다.

검색 기능을 테스트하기 전 마지막 작업은 ResultsView에 탐색 링크를 추가하는 것입니다. 이때 ResultsView는 이름 상태 개체와 viewContext에 대한 참조가 전달될 것으로 예상한다는 점을 염두에 두어야 합니다. 이는 다음과 같이 추가 및 지우기 버튼 사이에 배치되어야 합니다.

 

                    HStack {

                        Spacer()

                        Button("Add") {

                            addProduct()

                        }

                        Spacer()

                        NavigationLink(destination: ResultsView(name: name,

                                                                viewContext: viewContext)) {

                            Text("Find")

                        }

                        Spacer()

                        Button("Clear") {

                            name = ""

                            quantity = ""

                        }

                        Spacer()

                    }

 

미리보기 캔버스를 확인하여 그림 49-10과 같이 탐색 링크가 나타나는지 확인하세요.

 

 

Testing the Completed App

앱을 다시 실행하고 일부 제품을 추가하세요. 일부 제품에는 동일한 단어가 포함된 것이 좋습니다. 이름 텍스트 필드에 일반 단어를 입력하고 찾기 링크를 클릭합니다. ResultsView 화면에 일치하는 항목 목록이 나타나야 합니다. 예를 들어 그림 49-11에서는 "Milk"라는 단어에 대해 수행된 검색을 보여줍니다.
 

이 글과 함께 연습해보세요

읽은 내용을 Play Lab에서 바로 실행해볼 수 있어요.

Coding Quest · 빈칸

이름 입력 받기

사용자가 입력한 이름을 출력하세요.

바로 연습하기
Coding Quest · 빈칸

Hello와 World 함께 출력하기

두 단어가 한 줄에 출력되도록 함수 이름을 채워보세요.

바로 연습하기
Coding Quest · 빈칸

name 변수 만들기

name에 Mina를 저장하세요.

바로 연습하기
Coding Quest · 빈칸

target 찾기

target과 같은 값을 찾으면 found를 True로 바꾸세요.

바로 연습하기
Coding Quest · 빈칸

리스트에 문자열 추가

names에 Mina를 추가하세요.

바로 연습하기