SwiftData는 메크로와 프로퍼티 래퍼를 사용하여 앱의 데이터 영구적으로 데이터베이스에 저장할 때 사용하는 프레임워크입니다
Core Data의 입증된 지속성 기술과 Swift의 현대적인 동시성 기능을 결합하여, SwiftData는 최소한의 코드 및 외부 종속성 없이 앱에 지속성을 빠르게 추가할 수 있도록 해줍니다. macros와 같은 현대적인 언어 기능을 사용하여, SwiftData는 앱의 전체 모델 레이어(또는 객체 그래프)를 나타낼 수 있도록 하여 코드를 빠르고 효율적이며 안전하게 작성할 수 있습니다. 이 프레임워크는 기본 model data의 저장 및 선택적으로 해당 데이터를 여러 기기 간에 동기화하는 작업을 처리합니다.
SwiftData는 로컬에서 생성된 콘텐츠를 지속하는 데 그칠 뿐만 아니라 다양한 용도로 활용될 수 있습니다. 예를 들어, 원격 웹 서비스에서 데이터를 가져오는 앱은 SwiftData를 사용하여 가벼운 캐싱 메커니즘을 구현하고 제한된 오프라인 기능을 제공할 수 있습니다.
SwiftData는 설계에 의해 방해받지 않고 앱의 기존 모델 클래스를 보완합니다. 모델 클래스에 Model() 매크로를 추가하여 지속 가능하도록 만들 수 있습니다. Attribute(_:originalName:hashModifier:) 및 Relationship(_:deleteRule:minimumModelCount:maximumModelCount:originalName:inverse:hashModifier:) 매크로를 사용하여 해당 모델의 속성 동작을 사용자 정의할 수 있습니다. ModelContext 클래스를 사용하여 해당 모델의 인스턴스를 insert, update 및 delete하고 저장되지 않은 변경 사항을 디스크에 기록할 수 있습니다.
SwiftUI 뷰에서 모델을 표시하려면 Query() 매크로를 사용하고 조건자나 검색 설명자를 지정하면 됩니다. SwiftData는 뷰가 나타날 때 검색을 수행하고 이후에 발생하는 모델 변경 사항을 SwiftUI에 알려 뷰가 그에 맞게 업데이트되도록 합니다. 모든 SwiftUI 뷰에서 모델 컨텍스트에 액세스하려면 modelContext environment value을 사용하고 특정 모델 컨테이너나 컨텍스트를 뷰에 대해 지정하려면 modelContainer(_:) 및 modelContext(_:) 뷰 수정자를 사용하면 됩니다.
SwiftData 프레임워크 사용하기
SwiftData 프레임워크는 데이터베이스를 관리하기 위한 컨테이너를 포함한 시스템을 만들어냅니다. context는 컨테이너와 영구적 저장소인 데이터베이스에 데이터를 저장하거나 데이터를 가져오는데 사용합니다.
Model Context
: 이 객체는 앱이 container에 의해 관리되는 데이터를 접근하고 수정할 수 있게 해줍니다.
Model Container
: 이 객체는 데이터베이스를 관리하는 것을 책임지고 데이터를 파일로 영구적으로 저장합니다.
간단히 말해서, Model Context는 현재 앱 세션 동안의 데이터 변경 사항을 처리하고, Model Container는 앱의 데이터 구조와 영구 데이터 저장을 담당합니다. Model Context는 Model Container에 의존하여 앱의 스키마와 영구 저장소에 대한 정보를 얻습니다.
Model
Model은 데이터베이스를 이용하기 위해서 시스템에게 어떤 타입의 값을 저장할 것인지 정의하는 메크로 입니다.
SwiftData의 절차는 간단합니다. 먼저 유저의 데이터를 저장할 프로퍼티들이 들어있는 클래스를 정의하여 data model를 나타내는데 사용됩니다. 이런 목적으로 SwiftData는 @Model 메크로를 제공합니다. @Model은 class 앞에 붙여서 사용합니다.
import SwiftUI
import SwiftData
@Model
class Book: Identifiable {
var id: UUID = UUID()
var title: String = ""
var author: String = ""
var cover: String = ""
var year: Int = 0
init(title: String, author: String, cover: String, year: Int) {
self.title = title
self.author = author
self.cover = cover
self.year = year
}
var displayYear: String {
get {
let value = year > 0 ? String(year) : "Undefined"
return value
}
}
}
위에처럼 @Model 메크로는 데이터 모델을 정의하여 해당 모델의 데이터를 저장할 수 있도록 합니다.
Container
Container는 간단하게 Model로 선언된 데이터를 Context로 만들었다가 저장된 데이터를 Context가 데이터베이스에 저장하고 싶을 때 Container로 해당 데이터를 보낸다고 생각하면 됩니다. Container는 데이터베이스를 관리하는 역할을 합니다.
Model 메크로는 클로새 앞에 선언되어 데이터를 저장할 구조체를 정의합니다. 정의되면 Model 메크로로 선언된 객체를 만들어 context에 넣게 되고 그러면 영구적으로 데이터베이스에 저장됩니다. 하지만 container를 만들어 데이터를 가공하고 데이터베이스를 관리해야 합니다.
SwiftUI는 다음과 같은 modifer를 제공하여 container를 만들고 environment에 넣을 수 있습니다.
modelContainer(for: [Type], inMemory: Bool, isAutosaveEnabled: Bool, isUndoEnabled: Bool, onSetup: Closure)
추가로 다른 데이터 모델들과 같이 SwiftData 모델은 모든 뷰에서 접근 가능해야 하므로 window 또는 main view에서 environment에 넣어야 합니다.
import SwiftUI
import SwiftData
@main
struct SwiftDataTest: App {
@State private var appData = ApplicationData()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appData)
.modelContainer(for: [Book.self])
}
}
}
Model Context
Model Context는 간단하게 사용자가 데이터를 데이터베이스에 영구 저장하기 위해 일시적으로 사용하는 메모리 저장소입니다.
Model Container Modifier는 container를 만들지만 애플리케이션은 반드시 context와 상호작용을 해야합니다. container에 할당된 context에 접근하기 위해서는 environment는 다음과 같은 프로퍼티를 제공합니다. 이후 context에 접근을 얻게 됐다면 데이터를 읽기, 저장, 수정, 삭제 객체를 사용할 수 있습니다.
@Environment(\.modelContext) var dbContext
시스템은 context의 변화를 감지하며 container에 자동으로 저장하여 데이터베이스에 영구적으로 저장합니다.
데이터를 읽을 때는 프로세스는 반대로 수행됩니다. container는 객체를 데이터베이스로부터 가져오고 가져온 데이터를 context로 옮깁니다. context로 객체를 가져오면 해당 객체의 프로퍼티들을 읽을 수 있고 값을 수정 또는 삭제할 수 있습니다.
struct ContentView: View {
@Environment(ApplicationData.self) private var appData
@Environment(\.modelContext) var dbContext
@Query var listBooks: [Book]
var body: some View {
NavigationStack(path: Bindable(appData).viewPath) {
List(listBooks) { book in
CellBook(book: book)
}
.listStyle(.plain)
.navigationTitle("Books")
.toolbarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink(value: "Add Book", label: {
Image(systemName: "plus")
})
}
}
.navigationDestination(for: String.self, destination: { viewID in
if viewID == "Add Book" {
AddBook()
}
})
}
}
}
데이터베이스로부터 데이터를 가져올 때 문제는 엄청난 양의 데이터이 저장되어 있을 때 데이터를 가져올 때 모든 데이터를 가져오게 된다면 시간이 걸립니다. 이런 문제를 해결하기 위해서 다음과 같이 유저가 원하는 데이터를 쿼리로 설정하여 가져올 수 있습니다.
@Query(filter: Predicate?, sort: KeyPath, order: SortOrder, transaction: Transaction?)
@Query(FetchDescriptor, transaction: Transaction?)
만약 특정 데이터가 아닌 데이터베이스에 존재하는 모든 데이터를 가져오려면 다음처럼 코드를 만들면 됩니다.
@Query var listBooks: [Book]
데이터베이스에 데이터 저장하기
데이터 베이스에 저장하기 위해서는 environment(\.modelContext)를 저장하여 Model Container에 전송하기 전에 영구 저장할 데이터를 저장할 프로퍼티를 만들어줍니다.
@Environment(\.modelContext) var dbContext
다음으로는 저장할 데이터를 준비하여 modelContext에 삽입합니다. 이후 삽입된 데이터는 자동적으로 container에 의해 데이터베이스에 저장됩니다.
func storeBook() {
let title = titleInput.trimmingCharacters(in: .whitespaces)
let author = authorInput.trimmingCharacters(in: .whitespaces)
if let year = Int(yearInput), !title.isEmpty && !author.isEmpty {
let newBook = Book(title: title, author: author, cover: "nocover", year: year)
dbContext.insert(newBook)
appData.viewPath.removeLast()
}
}
@Query 메크로는 뷰와 동기화를 유지하므로 만약 저장된 데이터를 뷰에 사용된다면 뷰가 업데이트됩니다.
@Query var listBooks: [Book]
Attributes 제약조건
클래스 안에 있는 프로퍼티들은 데이터베이스에 어떤 타입의 값들을 저장하고 싶은지를 말해줍니다. 데이터베이스는 지정된 값을 저장할 수 있으며 또한 특별한 attribute들을 할당할 수 있습니다. 예를들어 만일 값이 보안상 문제로 암호화 되었거나 효율성 문제로 서로 다른 파일에 저장되어 있을 때 사용될 수 있습니다.
@attached(peer)
macro Attribute(
_ options: Schema.Attribute.Option...,
originalName: String? = nil,
hashModifier: String? = nil
)
이런 Attribute를 적용하기 위해서는 다음과 같은 메크로를 사용합니다.
@Attribute(Schema.Attribute.Option, originalName: String?, hashModifier: String?)
제약조건 종류들
Schema.Attribute.Option
- allowsCloudEncryption: 속성 값을 암호화된 형태로 저장합니다. 이 옵션은 데이터 보안을 강화하고자 할 때 유용하게 활용될 수 있습니다.
- externalStorage: 모델 저장 공간 바로 옆에 이진 데이터로 속성 값을 저장합니다. 이는 대용량 파일이나 데이터를 효율적으로 관리할 필요가 있을 때 적합한 옵션입니다[1].
- preserveValueOnDeletion: 컨텍스트가 소유 모델을 삭제할 때, 속성의 값이 영구 기록에 보존되게 합니다. 이 옵션은 데이터의 이력 관리가 중요할 때 주로 사용됩니다[1].
- spotlight: 속성의 값을 인덱싱하여 Spotlight 검색 결과에 표시할 수 있게 합니다. 사용자의 데이터 검색 경험을 향상시키고자 할 때 유용합니다.
- unique: 해당 속성의 값을 모델의 같은 타입 전반에 걸쳐 유일하게 보장합니다. 데이터의 고유성을 유지하는 데 필요한 옵션입니다.
- transformable(by: ValueTransformer.Type) 또는 transformable(by: String): 속성의 값을 메모리 내에서 사용하는 형태와 저장되는 형태 사이에서 변환합니다. 커스텀 형태의 데이터를 저장하고자 할 때 활용될 수 있습니다.
@Attribute(.unique) var id: UUID = UUID() // 사용예시
CloudKit 과 제약조건 사용 시 주의
CloudKit은 Core Data의 고유 unique 제약 조건을 지원하지 않습니다.
Realtionships
두 모델간의 관계를 설정할 수 있는 메크로입니다.
macro Relationship(
_ options: Schema.Relationship.Option...,
deleteRule: Schema.Relationship.DeleteRule = .nullify,
minimumModelCount: Int? = 0,
maximumModelCount: Int? = 0,
originalName: String? = nil,
inverse: AnyKeyPath? = \Schema.Relationship.inverseKeyPath,
hashModifier: String? = nil
)
- options: 속성에 적용할 옵션 리스트로, 해당 속성의 동작을 맞춤화합니다. 가능한 값에 대해서는 Schema.Relationship.Option을 참조하면 됩니다.
- deleteRule: 관계의 소유하는 영구 모델을 삭제할 때 적용되는 규칙을 나타냅니다. 가능한 값에 대해서는 Schema.Relationship.DeleteRule을 참조할 수 있으며, 기본값은 Schema.Relationship.DeleteRule.nullify입니다.
예를 들어 A를 참조하고 있는 B가 삭제되었을 때 A에서 B를 어떻게 처리할지를 결정하는 룰을 설정하는 것 입니다. - minimumModelCount: 관계가 참조할 수 있는 모델의 최소 수를 나타냅니다. 기본값은 0입니다.
- maximumModelCount: 관계가 참조할 수 있는 모델의 최대 수를 나타냅니다. 기본값도 0입니다.
- originalName: 현재 스키마 버전과 다른 경우, 속성의 이전 이름을 나타냅니다. 기본값은 nil입니다.
- inverse: 이 관계의 역 관계를 나타내는 키 경로입니다. 기본은 nil입니다.
- hashModifier: 주석이 달린 속성의 가장 최근 버전을 나타내는 고유한 해시 값입니다. 기본값은 nil입니다.
Schema.Relationship.DeleteRule 종류
- cascade
: 관련 모델을 삭제할 때 사용하는 규칙입니다.
: 만약 여러 단계에 걸친 관계가 있고, cascade 삭제 규칙이 설정되어 있다면, SwiftData는 최하위 단계까지 모든 관계를 계속해서 삭제합니다. - deny
: 모델이 하나 이상의 다른 모델을 참조할 때 해당 모델의 삭제를 방지하는 규칙입니다.
: Deny 삭제 규칙이 설정된 관계에서 한 엔티티가 삭제되려고 할 때, 관련 객체가 있으면 오류가 발생할 수 있습니다. - noAction
: 관련 모델에는 아무런 변경도 하지 않는 규칙입니다.
: 즉, 모델을 삭제해도, 관련 모델의 데이터에는 영향을 미치지 않습니다. - nullify
: 삭제된 모델에 대한 관련 모델의 참조를 null로 설정하는 규칙입니다.
: nullify를 사용하면, 모델이 삭제되었을 때 관련 모델이 삭제된 모델을 참조하지 않도록 할 수 있습니다.
: 예를 들어, Author와 Book 모델 간의 관계에서 @Relationship(deleteRule: .nullify)를 설정하고, 특정 Author 객체가 삭제되면, 해당 작가가 작성한 Book 객체들의 author 속성은 nil로 설정됩니다.
사용 예제
@Model
class Author: Identifiable {
@Attribute(.unique) var id: UUID = UUID()
var name: String = ""
@Relationship(deleteRule: .nullify) var books: [Book]? = []
init(name: String, books: [Book]) {
self.name = name
self.books = books
}
}
Author에서 사용하고 있는 Book 모델을 Relationship으로 관계를 만들어 놓은 예제 코드입니다. 다음 Book 클래스에서는 inverse를 통해서 역참조를 하고 있음을 명시합니다. 만약 삭제되었을 때 .nullify로 설정했기에 참조만 Book의 Authro과의 참조를 해제되어 author 속성은 nil로 설정되고 데이터는 삭제되지 않습니다.
@Model
class Book: Identifiable {
@Attribute(.unique) var id: UUID = UUID()
var title: String = ""
@Relationship(deleteRule: .nullify, inverse: \Author.books) var author: Author?
var cover: String = ""
var year: Int = 0
init(title: String, author: Author?, cover: String, year: Int) {
self.title = title
self.author = author
self.cover = cover
self.year = year
}
var displayYear: String {
get {
let value = year > 0 ? String(year) : "Undefined"
return value
}
}
}
Delete
데이터를 삭제하는 방법은 간단합니다. modelContext.delete(삭제하고자 하는 데이터)를 사용하면 됩니다. 만약 삭제하고자 하는 데이터가 context로부터 삭제됐다면 해당 변화는 데이터베이스에 적용되며 Query로 선언시킨 변수가 있으면 UI에 바로 적용됩니다.
@Query var listBooks: [Book]
dbContext.delete(listBooks[index])
저장된 데이터 정렬
@Query 메크로에 sort 와 order를 사용하면 정렬된 데이터를 데이터베이스로부터 가져올 수 있습니다. 더 복잡한 정렬 조건을 사용하고 싶으면 SortDescriptor(KeyPath, comparator: SandardComparator, order: SortOrder)를 사용하면 됩니다.
@Query(sort: \Book.title, order: .forward) private var listBooks: [Book]
다음은 2개의 조건을 설정하여 정렬하는 코드입니다.
@Query(sort: [SortDescriptor(\Book.author?.name, order: .forward), SortDescriptor(\Book.year)]) private var listBooks: [Book]
저장된 데이터를 필터링해서 가져오기
@Query 메크로는 추가로 filter 인자를 추가하여 설정한 특정 데이터만 가져올 수 있습니다. filter 인자는 #Predicate(Closure)를 가지게 되며 이 Predicate를 사용하여 특정 조건을 설정할 수 있습니다.
다음과 같이 Book 클래스에 정의되어 있는 year 프로퍼티의 값이 1986인 데이터를 가져오도록 설정하는 것처럼 사용할 수 있습니다.
@Query(filter: #Predicate<Book> { $0.year == 1986 }) private var listBooks: [Book]
비교 연산자
추가로 비교 연산자는 <, <=, >, >=, ==, != 를 사용할 수 있고 논리 연산자는 &&, ||, ! 를 사용할 수 있습니다.
@Query에서 제공하는 메서드들
allSatisfy()
- 설명: 컬렉션의 모든 요소가 주어진 조건(클로저로 제공)을 만족하는지를 검사합니다. 모든 요소가 조건을 만족하면 true를, 하나라도 만족하지 않으면 false를 반환합니다.
filter()
- 설명: 컬렉션의 요소 중에서 특정 조건을 만족하는 요소들만을 골라내 새로운 컬렉션을 생성하여 반환합니다. 이 메서드는 특정 조건에 따라 데이터를 필터링할 때 유용합니다.
@Query(filter: #Predicate<Book> { $0.year > 1986 && $0.author == "John Doe" })
private var filteredBooks: [Book]
contains(where:)
- 설명: 컬렉션의 요소 중에서 주어진 조건을 만족하는 요소가 하나라도 있는지를 검사합니다. 만족하는 요소가 있으면 true, 없으면 false를 반환합니다. 이 메서드는 컬렉션 내 해당 조건을 만족하는 요소의 존재 여부만을 알고 싶을 때 사용됩니다.
@Query(filter: #Predicate<Book> {
$0.author?.name.Contains("Stephen") == true
}) private var listBooks: [Book]
starts(with:)
- 설명: 컬렉션의 요소들이 특정 시퀀스로 시작하는지 여부를 판단합니다. 이 메서드는 주어진 서브시퀀스와 정확히 일치해야 true를 반환하며, 그렇지 않으면 false를 반환합니다. 주로 배열이나 리스트가 특정 패턴으로 시작하는지 확인할 때 사용됩니다.
@Query(filter: #Predicate<Book> {
$0.author?.name.starts(with:"Stephen") == true
}) private var listBooks: [Book]
localizedStandardContains()
- 설명: 이 메서드는 문자열이 특정 문자열을 포함하고 있는지를 검사하되, 현지화와 대소문자를 구분하지 않는 방식으로 포함 여부를 판단합니다. 주로 유사 검색이나 비정형적인 쿼리에 사용되며, 사용자가 입력한 문자열이 대소문자나 지역에 관계없이 검색 결과에 포함되어 있는지 확인할 때 유용합니다.
@Query(filter: #Predicate<Book> {
$0.author?.name.localizedStandardContains("Stephen") == true
}) private var listBooks: [Book]
저장된 데이터 동적 쿼리를 통해서 가져오기
데이터를 Query를 통해서 가져올 수 있습니다, Query를 사용하면 UI를 실행하면서 동시에 원하는 값을 뷰에서 상호작용을 통해 Query를 수정하여 데이터를 다시 가져올 수 있으며 이는 버튼을 눌렀을 때 리스트의 오름차순, 내림차순 정렬의 순서를 바꿀 수 있는 것을 가능하게 합니다.
struct ListBooksView: View {
@Query var listBooks: [Book]
init(orderBooks: SortOrder) {
_listBooks = Query(sort: \Book.title, order: orderBooks)
}
var body: some View {
List {
ForEach(listBooks) { book in
CellBook(book: book)
}
}
}
}
위처럼 설정된 Query를 아래와 같이 init으로 사용해주면 실시간으로 orderBooks의 값에 따라 정렬 순서가 바뀌게 됩니다.
ListBooksView(orderBooks: orderBooks)
저장된 데이터 가져오기
다음은 책의 저자를 저장하는 함수입니다. 다음 함수에서는 predicate을 만들어 Author 모델에서 가져올 데이터의 조건을 설정합니다. 해당 predicate를 데이터베이스에서 저장된 데이터를 가져오는 FetchDescriptor 메서드에 predicate 인자값으로 사용하여 predicate로 설정된 데이터를 가져옵니다.
추가로 아래 fetchCount 메서드를 사용하면 저장된 데이터에 해당하는 갯수를 가져올 수 있습니다.
func storeAuthor() {
let name = nameInput.trimmingCharacters(in: .whitespaces)
if !name.isEmpty {
let predicate = #Predicate<Author> { $0.name == name }
let descriptor = FetchDescriptor<Author>(predicate: predicate)
if let count = try? dbContext.fetchCount(descriptor), count > 0 {
openAlert = true
} else {
let newAuthor = Author(name: name, books: [])
dbContext.insert(newAuthor)
appData.selectedAuthor = newAuthor
appData.viewPath.removeLast(2)
}
}
}
저장된 데이터 수정하기
데이터베이스에 저장된 데이터를 수정하기 위해서는 해당 데이터 객체를 container로부터 가져온 후 뷰로 가져와 사용자가 프로퍼티 값을 수정할 수 있게합니다.
프로퍼티를 수정하게 되면 SwiftData는 데이터베이스에 해당 객체를 저장하고 시스템에 변화를 알려주어 뷰가 적절하게 업데이트 되도록 합니다. 코드는 저장하는 코드와 같습니다. Query 메크로를 통해서 가져온 데이터를 수정해서 저장하면 됩니다.
func storeAuthor() {
let name = nameInput.trimmingCharacters(in: .whitespaces)
if !name.isEmpty {
let predicate = #Predicate<Author> { $0.name == name }
let descriptor = FetchDescriptor<Author>(predicate: predicate)
if let count = try? dbContext.fetchCount(descriptor), count > 0 {
openAlert = true
} else {
let newAuthor = Author(name: name, books: [])
dbContext.insert(newAuthor)
appData.selectedAuthor = newAuthor
appData.viewPath.removeLast(2)
}
}
}
Preview
Preview에서 SwiftData를 사용하기 위해서는 ModelContainer를 만들어야 합니다.
ModelConfiguration에서 isStoredInMemoryOnly 인자값
- true: 디스크(영구)가 아닌 메모리(임시)를 통해 데이터를 저장하여 Preview를 사용할 수 있게 합니다.
- false: 메모리(임시)가 아닌 디스크(영구)를 통해 데이터를 저장하여 Preview를 사용할 수 있게 합니다.
추가로 ModelContainer.mainContext.insert 를 통해서 Preview에서 사용될 데이터를 미리 입력하여 만들어 놓을 수 있습니다.
@MainActor
let previewContainer: ModelContainer = {
do {
let container = try ModelContainer(
for: FavoriteMovie.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let modelContext = container.mainContext
if try modelContext.fetch(FetchDescriptor<FavoriteMovie>()).isEmpty {
// Favorite.contents.forEach { container.mainContext.insert($0) }
}
return container
} catch {
fatalError("Failed to create container")
}
}()
'SwiftUI' 카테고리의 다른 글
Bindable (0) | 2024.03.20 |
---|---|
Referencing instance method 'setValue(forKey:to:)' on 'Optional' requires that conform to 'PersistentModel' (0) | 2024.03.18 |
WCSession 애플워치 아이폰 간 데이터 전송 (0) | 2024.03.14 |
애플워치로 실시간 심박수 가져오기, 애플워치 타깃 추가하기 (0) | 2024.03.14 |
사용자의 운동 경로 기록 가져오기 (0) | 2024.03.13 |