@Model
매크로를 swift의 class에 추가해 Schema를 설정할 수 있다. 이 때 class안에는 Codable한 Data만 존재해야 한다. 또한 @Model
매크로엔 @Observable
매크로가 포함되어있다.
ModelContainer는 @Model
클래스를 관리하며 앱의 실제 Database와의 연결을 설정한다. 우리는 ModelContainer를 통해 데이터를 실제 DB에서 읽고 저장하고 삭제할 수 있다.
만약 SwiftUI를 사용하는 경우 modelContainer
modifier를 사용해 쉽게 ModelContainer를 설정할 수 있다.
앞서 ModelContainer를 SwiftUI의 WindowGroup 내 뷰 계층구조에 전달했으므로, 해당 계층구조에 존재하는 모든 SwiftUI View는 @Query
매크로를 통해 DB에 저장된 데이터를 쉽게 불러올 수 있다.
앞서 영속화될 수 있는 class에 @Model
매크로를 사용해서 스키마를 구성했다.
또 스키마를 구성할 때 다양한 매크로와 Attribute들을 사용해서 우리가 원하는 제약조건을 추가할 수 있다.
Ex)
@Attribute(.unique)
: 프로퍼티가 ModelContainer 내에서 유일해야하는 제약조건을 추가. 만약 동일한 프로퍼티 값을 가지는 다른 모델이 추가되면, 기존 모델을 UPSERT
함@Transient
: 특정 프로퍼티는 이 Attribute를 사용해 Schema에서 제외할 수 있음iOS18에서는 Schema를 커스텀할 수 있는 새로운 매크로들이 추가되었다.
@Attribute(.unique)
를 사용해도 되지만, iOS 18에선 #Unique
매크로를 사용해 항상 고유해야하는 프로퍼티의 조합을 SwiftData에 알릴 수 있다.// Before
@Model final class Trip {
@Attribute(.unique) var id: UUID
var name: String
var startData: Date
var endDate: Date
}
// After
@Model final class Trip {
#Unique<Trip>([\.name, \.startDate, \.endDate])
var name: String
var startDate: Date
var endDate: Date
}
위 예제에선 Trip의 name, startDate, endDate 조합이 고유해야하므로 데이터 중복을 쉽게 방지할 수 있다.
Trip의 name, startDate, endDate가 모델의 정체성을 나타내기 때문에, 해당 프로퍼티들에 @Attribute(.preserveValueOnDeletion)
를 추가해 SwiftData의 History API를 사용할 수 있다.
@Model final class Trip {
#Unique<Trip>([\.name, \.startDate, \.endDate])
@Attribute(.preserveValueOnDeletion)
var name: String
@Attribute(.preserveValueOnDeletion)
var startDate: Date
@Attribute(.preserveValueOnDeletion)
var endDate: Date
}
SwiftData의 History API를 통해 시간의 흐름에 따라 추가되거나 업데이트 혹은 삭제된 model에 대한 기록을 확인할 수 있다.
이 때 모델이 삭제되어도 보존하도록 설정된 프로퍼티들은 History 정보에 tombstone 값으로 유지되어 변경 사항을 처리하는데 필요한 정보들을 제공한다.
이 부분에 대한 자세한 내용은 Track model changes with SwiftData history 영상에서 다루고 있으며, 다음 기회에 다루어보겠다.
SwiftUI의 Modifier를 사용해서 modelContainer를 쉽게 구성했었는데, 추가적으로 ModelContainer를 커스텀하는 방법을 알아보자.
@main
struct TripsApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer (
// 1
for: Trip.self,
// 2
inMemory: true,
// 3
isAutosaveEnabled: true,
// 4
isUndoEnabled: true
)
}
}
}
여기서는 modifier의 새로운 이니셜라이저를 사용하여 ModelContainer를 커스텀했다.
당연히 Modifier를 통해서 ModelContainer를 초기화하는 대신, 직접 ModelContainer 인스턴스를 생성할 수도 있다.
@main
struct TripsApp: App {
var container: ModelContainer = {
do {
// 1
let configuration = ModelConfiguration (schema: Schema ([Trip.self]), url: fileURL)
// 2
return try ModelContainer (for: Trip.self, configurations: configuration)
}
catch {...}
}( )
var body: some Scene {
WindowGroup {
ContentView()
}
•modelContainer (container)
}
}
데이터를 저장하는 나만의 ModelContainer를 만들 수도 있는데, 예를 들어 데이터를 JSON file로 관리할 수 있다.
@main
struct TripsApp: App {
var container: ModelContainer = {
do {
// 1
let configuration = JSONStoreConfiguration (schema: Schema ([Trip.self]), url: fileURL)
return try ModelContainer (for: Trip.self, configurations: configuration)
}
catch {...}
}( )
var body: some Scene {
WindowGroup {
ContentView()
}
•modelContainer (container)
}
}
데이터는 JSON 형식으로 저장되더라도, 앱 내부에선 기존의 SwiftData API를 사용해 데이터를 CRUD 할 수 있다.
더 자세한 내용은 SwiftData로 자체 데이터 저장소 만들기 영상에서 제공한다.
InMemory ModelContainer를 사용해 SwiftUI 프로젝트의 모든 프리뷰에 샘플 데이터를 저장한 ModelContainer를 제공할 수 있다.
iOS 18에서는 PreviewModifier라는 새로운 프로토콜이 제공되는데, PreviewModifier를 이용해 모든 View에 동일한 ModelContainer를 제공할 수 있다.
protocol PreviewModifier {
associatedType Context = Void
associatedType Body: View
typealias Content = PreviewModifierContent
// 1
@MainActor static func makeSharedContext() throws -> Context
// 2
@ViewBuilder @MainActor func body(content: Self.Content, context: Self.Context) -> Self.Body
}
PreviewModifier는 2가지 요구사항이 있는데, 첫 번째는 프리뷰에 제공할 shared Context를 생성하는 함수를 구현해야하고, 두 번째는 shared Context를 프리뷰에서 제공하는 뷰에 적용하는 함수를 구현해야한다.
여기서 우리는 하나의 Shared ModelContainer를 프리뷰에 제공하고 싶으니까, Context를 ModelContainer로 지정해야한다.
struct SampleData: PreviewModifier {
static func makeSharedContext() throws -> ModelContainer {
// 1
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: Trip.self, configurations: config)
Trip.makeSampleTrips(in: container)
return container
}
func body(content: Content, context: ModelContainer) -> some View {
// 2
content.modelContainer(context)
}
}
makeSharedContext
함수에서 생성한 ModelContainer가 body 함수에 전달되며, 우리는 전달된 modelContainer를 modifier를 통해 Preview에 제공하면된다.그리고 아래 extension 까지 추가하면, Preview에서 쉽게 Sample 데이터가 저장된 ModelContainer를 사용할 수 있다.
extension PreviewTrait where T == Preview.ViewTraits {
@MainActor static var sampleData: Self = .modifier(SampleData())
}
// PreviewTrait extension에 선언한 sampleData를 적용
#Preview(traits: .sampleData) {
ContentView()
}
여기서 Shared ModelContainer는 단 한 번만 생성되며, 모든 프리뷰에서 Shared ModelContainer를 공유하게된다.
또한 Query를 하지않고 외부에서 데이터를 주입받는 View의 경우엔, @Previewable
매크로와 @Query
매크로를 사용할 수 있다.
#Preview(traits: •sampleData) {
@Previewable @Query var trips: [Trip]
BucketListItemView(trip: trips.first)
}
@Query
매크로를 사용해 ModelContainer에서 저장된 모델을 정렬 및 필터링해서 쉽게 fetch 할 수 있다. 또한 @Query
매크로를 사용하면 ModelContainer의 변경사항에 자동으로 반응한다.
Query 매크로를 단독으로 사용해 ModelContainer에 저장된 모든 Persistent Model을 가져올 수도 있지만,
Query 매크로에 Predicate을 제공해서 특정 조건을 만족하는 Persistent Model만 불러올 수 있다.
DB에서 필터링된 모델을 가져오려면 우선 Predicate을 작성해야한다. 예를 들어서 검색기능을 구현해서 입력한 텍스트를 이름이나 목적지에 포함하는 Trip만 가져오려고 할 때 매크로를 사용해 Predicate을 작성할 수 있다.
let predicate = #Predicate<Trip> {
searchText.isEmpty ? true :
$0.name.localizedStandardContains(searchText) ||
$0.destination.localizedStandardContains(searchText)
}
해당 Predicate을 Query에 포함하면, 이제 사용자가 입력한 텍스트가 있을 때는 해당 텍스트를 포함하는 Trip만 화면에 표시될 것이다.
struct TripListView: View {
@Query(sort: \Trip.startDate, order: .forward)
var trips: [Trip]
init() {
// ...
_trips = Query(filter: predicate, sort: \Trip.startDate)
}
}
여기서 중요한 건 Predicate에서는 표현식 Bool 값을 제외한 arbitrary type을 생성하는 표현식을 사용할 수 없었는데 (직전 Predicate에서는 Bool 값을 생성하는 표현식만 사용), Expression macro에서는 arbitrary type을 지원한다.
arbitrary type이란 특정한 데이터 타입에 구애받지 않고, 다양한 데이터 타입을 처리할 수 있는 일반적인 개념을 의미
새롭게 추가된 #Expression 매크로를 사용해 더욱 복잡한 쿼리를 생성할 수 있다.
예를 들어 Trip 모델이 BucketList 아이템을 배열로 갖고 있다고 생각해보자.
@Model final class Trip {
// ...
var bucketList: [BucketListItem]
}
@Model final class BucketListItem {
var isInPlan: Bool
}
사용자는
1. 현재 진행중인 Trip 중에
2. 아직 계획되지않은 BucketList 아이템을 하나라도 가지고 있는
Trip을 필터링하고 싶다고 가정해보자.
그렇다면 아래와 같이 Predicate을 작성할 수 있다.
// 2
let unplannedItemsExpression = #Expression<[BucketListItem], Int> { items in
items.filter {
!$0.isInPlan
}.count
}
let today = Date.now
let tripsWithUnplannedItems = #Predicate<Trip> { trip in
// 1
(trip.startDate ..‹ trip.endDate).contains (today) &&
// 3
unplannedItemsExpression.evaluate(trip.bucketList) > 0
}
!isInPlan
)아이템의 개수를 반환하는 Expression macro를 만든다.Persistent Model의 특정 프로퍼티에 대한 Index를 생성하는 새로운 #Index
매크로로 Query의 성능을 개선할 수 있다.
단일 프로퍼티, 혹은 복합 프로퍼티에 대한 Index를 생성할 수 있으며, 정렬이나 조회에 필요한 프로퍼티에 대한 인덱스를 생성하면 Query가 훨씬 빨라질 수 있다.
예를 들어서 특정 keyword를 가지는 Trip을 검색할 때나, 아니면 특정 기간 내에 포함되는 Trip들을 검색할 때 아래와 같이 name, startDate, endDate에 대한 복합 Index를 생성할 수 있다.
@Model final class Trip {
#Unique<Trip>([\.name, \.startDate, \.endDate])
#Index<Trip>([\.name], [\.startDate], [\.endDate], [\.name, \.startDate, \.endDate])
var name: String
var startDate: Date
var endDate: Date
}