What's new in swiftData

Park Jong Ho·2024년 12월 18일
0

Adopt SwiftData

  • iOS 17에서 소개된 SwiftData는 Schema를 swift code로 쉽게 작성할 수 있고, Swift의 매크로, Attribute를 사용해 제약조건을 설정할 수 있으며 SwiftUI에 쉽게 적용할 수 있다. 또한 iCloud 동기화를 지원하는 등 강력한 기능을 가진 영속성 지원 프레임워크이다.
  • 이번 글에서는 새로 추가된 SwiftData의 기능들에 대해 소개한다.
  • WWDC 2023 영상에도 있지만, 새로운 기능을 소개하기에 앞서 먼저 SwiftData를 사용하는 방법을 알아보자.

1. Schema 설정하기

@Model 매크로를 swift의 class에 추가해 Schema를 설정할 수 있다. 이 때 class안에는 Codable한 Data만 존재해야 한다. 또한 @Model 매크로엔 @Observable 매크로가 포함되어있다.

2. ModelContainer 구성하기

ModelContainer는 @Model 클래스를 관리하며 앱의 실제 Database와의 연결을 설정한다. 우리는 ModelContainer를 통해 데이터를 실제 DB에서 읽고 저장하고 삭제할 수 있다.

만약 SwiftUI를 사용하는 경우 modelContainer modifier를 사용해 쉽게 ModelContainer를 설정할 수 있다.

3. DB에 저장된 데이터 읽어오기

앞서 ModelContainer를 SwiftUI의 WindowGroup 내 뷰 계층구조에 전달했으므로, 해당 계층구조에 존재하는 모든 SwiftUI View는 @Query 매크로를 통해 DB에 저장된 데이터를 쉽게 불러올 수 있다.

Customize the schema

앞서 영속화될 수 있는 class에 @Model 매크로를 사용해서 스키마를 구성했다.
또 스키마를 구성할 때 다양한 매크로와 Attribute들을 사용해서 우리가 원하는 제약조건을 추가할 수 있다.

Ex)

  • @Attribute(.unique): 프로퍼티가 ModelContainer 내에서 유일해야하는 제약조건을 추가. 만약 동일한 프로퍼티 값을 가지는 다른 모델이 추가되면, 기존 모델을 UPSERT
  • @Transient: 특정 프로퍼티는 이 Attribute를 사용해 Schema에서 제외할 수 있음

iOS18에서는 Schema를 커스텀할 수 있는 새로운 매크로들이 추가되었다.

1. Unique macro

  • 데이터 중복을 방지하기 위해 따로 id 프로퍼티를 추가하고 @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 조합이 고유해야하므로 데이터 중복을 쉽게 방지할 수 있다.

2. History API

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 영상에서 다루고 있으며, 다음 기회에 다루어보겠다.

Tailor a Model Container

SwiftUI의 Modifier를 사용해서 modelContainer를 쉽게 구성했었는데, 추가적으로 ModelContainer를 커스텀하는 방법을 알아보자.

1. modifier을 통해 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를 커스텀했다.

  1. 먼저 ModelContainer에 Trip Schema를 제공하고,
  2. 원래 ModelContainer가 관리하는 데이터는 디스크에 저장되는데, memory에만 저장되도록 변경할 수도 있으며
  3. ModelContainer를 통해 새로운 Trip을 추가하거나, 업데이트 삭제 시 해당 변경사항이 자동으로 저장되도록 하며 (원래 ModelContainer는 자동 저장을 제공한다. 자동 저장은 User의 event에 의해 트리거되는데, 예를 들어 App이 background로 갈 때 ModelContainer는 변경사항을 디스크에 저장한다.)
  4. 변경사항을 Undo, redo 하는 기능을 켜거나 끌 수 있다.

2. 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)
    }
}
  1. ModelContainer의 설정을 초기화한 뒤에, (여기선 Schema를 제공하고, 데이터가 실제로 저장될 파일 url을 커스텀했다)
  2. ModelContainer에 해당 설정을 넘겨서 초기화할 수 있다.

3. 나만의 데이터 저장소 만들기

데이터를 저장하는 나만의 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)
    }
}
  1. 그럴 경우 JSONStoreConfiguration를 사용할 수 있다. 이제 DB는 fileURL 경로에 존재하는 JSON 파일이 되는데, 이를 통해 쉽게 데이터를 export 하고 다른 곳에서 import 할 수 있을 것 같다.

데이터는 JSON 형식으로 저장되더라도, 앱 내부에선 기존의 SwiftData API를 사용해 데이터를 CRUD 할 수 있다.

더 자세한 내용은 SwiftData로 자체 데이터 저장소 만들기 영상에서 제공한다.

4. Xcode previews

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)
    }
}
  1. 프리뷰용 데이터는 디스크에 저장할 필요가 없으므로 ModelContainer는 InMemory로 생성하도록 한다.
  2. 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)
}

Optimize queries

@Query 매크로를 사용해 ModelContainer에서 저장된 모델을 정렬 및 필터링해서 쉽게 fetch 할 수 있다. 또한 @Query 매크로를 사용하면 ModelContainer의 변경사항에 자동으로 반응한다.

Query 매크로를 단독으로 사용해 ModelContainer에 저장된 모든 Persistent Model을 가져올 수도 있지만,

Query 매크로에 Predicate을 제공해서 특정 조건을 만족하는 Persistent Model만 불러올 수 있다.

Query 생성자

1. 모델을 필터링해서 가져오기

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)
    }
}

2. 새롭게 추가된 Expression macro

여기서 중요한 건 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
}
  1. 우선 여행이 진행중이어야 하므로, 현재 날짜가 Trip의 startDate와 endDate 사이에 있어야한다.
  2. 그 다음 Trip의 BucketList 아이템 중 계획하지 않은 (!isInPlan)아이템의 개수를 반환하는 Expression macro를 만든다.
  3. 이 Expression을 Predicate에서 evaluate 하면된다.

3. Index 매크로

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
}
profile
iOS 개발자입니다.

0개의 댓글