스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftData 기록으로 모델 변경 사항 추적하기
SwiftData로 모델의 변경 기록을 검토해 보세요. History API를 사용하여 데이터 저장소에 변경이 발생한 시점을 파악하고, 이러한 정보를 사용하여 원격 서버 동기화 등의 기능을 빌드하는 방법과 아웃 오브 프로세스 변경 사항을 앱으로 제출하는 방법을 살펴보세요. 자체 데이터 저장소에 History API를 위한 지원을 빌드하는 방법도 소개합니다.
챕터
- 0:00 - Introduction
- 0:45 - Fundamentals
- 5:18 - Transactions and changes
- 12:37 - Custom stores
리소스
관련 비디오
WWDC24
-
다운로드
인사드립니다! 제 이름은 David입니다 SwiftData 팀의 엔지니어죠 SwiftData History는 앱에서 데이터 변경을 추적할 수 있게 해 주는 새로운 기술입니다 History를 사용하면 서버와 동기화하거나 앱 확장 프로그램의 변경에 응대하는 등 변경 처리에 필요한 기능을 빌드할 수 있습니다 이 비디오에서는 SwiftData History의 기본 사항을 다루고 History 트랜잭션과 변경을 사용해 샘플 앱에서 새로운 기능을 빌드하겠습니다
마지막으로, 맞춤형 데이터 저장소에서의 History 지원에 대한 몇 가지 고려 사항을 다루겠습니다
SwiftData History가 무엇인지 왜 그걸 사용하려고 하는지 설명하겠습니다
앱을 사용하면 SwiftData에서 저장한 콘텐츠가 점차 변경됩니다 예를 들어 앱이 실행될 때 모델을 생성하거나 원격 서버에서 가져온 모델을 삽입할 수 있습니다
모델 컨텍스트가 저장될 때 보류 중인 변경 내용은 모두 데이터 저장소에 저장됩니다
시간이 흐르면서
사용자가 앱과 다양한 기능을 사용하면
이런 모델 중 일부는 변경되거나 삭제될 수 있습니다
앱은 언제든지 저장소의 데이터를 쿼리할 수 있습니다 하지만 쿼리 결과는 현재 데이터 저장소에 있는 것만 나타냅니다 기록이나 수동 비교 없이는 이전 쿼리 이후 어느 모델이 추가 삭제 또는 업데이트되었는지 쿼리로 알 방법이 없습니다 SwiftData History는 시간 경과에 따른 데이터 저장소의 변경을 추적하는 쉽고 효율적인 방법을 제공합니다
이를 사용해 수많은 다양한 기능을 빌드할 수 있습니다 가령 앱이 오프라인 상태일 때 발생하는 시간순 변경 로그를 확보하고 싶을 수 있습니다 나중에 이런 변경은 원격 서버와 효율적으로 동기화될 수 있습니다
위젯 확장 프로그램 같은 다른 프로세스에서 발생한 데이터 변경을 알아내고자 할 수도 있습니다 그런 변경을 앱에 올바르게 반영할 수 있도록 말이죠
혹은 단순히 런타임에 상태를 업데이트하기 위해 이전 쿼리 이후 삽입되었거나 삭제된 모델을 알아내는 효율적인 방법을 원할 수 있습니다 작동 방법을 살펴보겠습니다
SwiftData History를 사용하면 변경이 발생한 순서대로 변경을 쿼리하고 처리할 수 있습니다
모델이 저장될 때마다 SwiftData History는 모든 변경에 대한 메타데이터를 포함한 트랜잭션을 기록합니다
SwiftData History는 Transaction 및 Change로 구성됩니다 트랜잭션은 ModelContext를 저장할 때와 같은 경계선에서 데이터 저장소에 발생한 모든 변경을 함께 그룹화합니다 트랜잭션은 발생한 시점을 기준으로 정렬됩니다
트랜잭션에 포함된 변경 세트도 트랜잭션 내에 각각 발생한 순서대로 보존됩니다 각 변경은 삽입되거나 업데이트되거나 삭제된 모델을 나타내며 PersistentModel에 의해 매개변수로 표현되므로 KeyPath로 PersistentModel의 속성을 참조할 수 있습니다
SwiftData History는 토큰 개념을 사용하고 토큰은 History에서 트랜잭션의 책갈피 기능을 합니다 토큰은 앱이 History 스트림에서 처리한 마지막 트랜잭션을 추적하는 데 도움이 될 수 있습니다
토큰은 관련된 데이터 저장소에서만 유효합니다 SwiftData의 모델 컨텍스트를 통해 기록 정보가 삭제될 수 있습니다
기록 정보가 삭제되면 삭제된 기록 부분의 토큰이 만료되고 가져오기에 사용될 수 없습니다
만료된 토큰과 관련된 SwiftData History 작업에서 historyTokenExpired 오류가 발생합니다 이 경우 토큰이 더 이상 유효하지 않으므로 폐기하고 새 토큰을 가져옵니다
모델이 삭제되면 모델의 데이터도 폐기됩니다 따라서 식별자 같은 필수 데이터가 손실되어 기록 정보를 처리할 때 앱에 충분한 정보를 제공하지 못합니다 이 문제를 해결하기 위해 SwiftData History는 모델의 특정 속성을 유지할 수 있게 합니다 모델이 삭제되면 해당 속성은 삭제된 값으로 보존되어 삭제된 모델에 대한 기록 정보를 처리할 수 있게 합니다
.preserveValueOnDeletion 한정자로 표시된 PersistentModel 속성은 삭제된 레코드에 보존됩니다 삭제된 레코드는 PersistentModel에 의해 매개변수로 표현되는데 이는 KeyPath를 사용해 삭제된 값을 검색하거나 원할 때 반복할 수 있도록 하기 위해서죠
SwiftData의 기록은 소모되기 쉬우며 Swift의 풍부한 유형 시스템 위에 빌드됩니다 앱에서 사용하는 방법을 살펴보겠습니다 작동하는 모습을 보기 위해 SampleTrips 앱에 새로운 기능을 추가하겠습니다 이 앱을 사용해 저는 좋아하는 여행을 모두 기록해서 다음 휴가 계획을 세우는 데 도움을 받습니다
안 읽은 변경 사항이 있는 여행에 배지를 표시하는 새로운 기능을 추가해 여행을 검토하려고 합니다 이 작업은 원격 서버로부터 동기화되는 앱 외부나 앱의 위젯에서 수행할 수 있습니다
저는 홈 화면의 위젯에서 바로 숙박 시설을 확인하는 기능을 추가하겠습니다 SwiftData History를 사용하면 이런 기능을 빌드하여 데이터를 언제, 누가 변경했는지 알아내 UI 업데이트가 가능합니다
이를 위해 작업을 다음 세 단계로 나눌 것입니다 SwiftData History 가져오기
변경의 속성을 확인해 변경 처리하기 마지막으로, 사용자 인터페이스 업데이트하기입니다 먼저, 토큰 매개변수와 작성자를 바탕으로 데이터 저장소의 트랜잭션을 가져오는 함수를 빌드합니다 이 경우, 토큰의 유형은 DefaultHistoryToken입니다 앱이 SwiftData의 DefaultStore를 사용하기 때문이죠
다음, 요청에 대해 제약 조건을 구성할 수 있는 HistoryDescriptor를 만듭니다
트랜잭션을 제공된 토큰 이후에 발생한 것으로 제한하는 술어를 빌드하는 거죠
이 함수로 위젯의 변경만 노출하기를 원하므로 지정된 작성자가 작성한 변경을 가져오는 제약 조건도 추가합니다 토큰 없이 이 함수를 호출하면 사용 가능한 모든 기록을 가져올 것입니다
다음, 처리에 필요한 모든 트랜잭션을 포함할 배열을 만듭니다 설명자를 사용해 ModelContext에 대한 fetchHistory를 호출합니다 그러면 제가 반복할 수 있는 DefaultHistoryTransactions 세트를 제공할 것입니다 이제 관심있는 트랜잭션을 검색할 수 있으니까 이를 처리하는 다른 함수를 정의하겠습니다 이 함수는 다수의 트랜잭션을 받아들이고 읽지 않은 변경 사항이 있어 배지를 표시해야 하는 여행 세트와 토큰을 반환합니다 함수가 실행될 때마다 다음에 변경 사항을 찾고 싶을 때 사용할 수 있는 새로운 토큰이 반환됩니다
먼저 ModelContext와 읽지 않은 변경 사항이 있는 여행을 저장할 세트를 정의합니다
각 트랜잭션 및 트랜잭션의 변경에 대해 History API가 영구적 모델 ID를 제공합니다 여행에 대한 모델 인스턴스를 가져오기 위해 영구적 모델 ID로 LivingAccommodation에 대해 가져오기 설명자를 빌드합니다 그런 다음 모델 컨텍스트에서 모델을 가져오고 LivingAccommodation과 연관된 여행을 저장합니다
트랜잭션의 변경이 삽입 업데이트 또는 삭제를 나타내는지 판단하려고 switch문을 사용해 변경 유형을 확인합니다
위젯이 LivingAccommodation 모델을 삽입, 변경 또는 삭제하는 경우 앱에서 UI에 배지를 적용하려고 합니다 이를 위해 먼저 LivingAccommodation에 대해 DefaultHistoryInsert 유형의 변경이 있는지 확인합니다 이 유형의 변경에 해당하면 LivingAccommodation 삽입이니까 이 여행을 세트에 추가합니다 DefaultHistoryInsert라고 하는 유형임을 다시 한번 유의하세요 이 경우에는 모델에 대해 DefaultStore를 사용하기 때문이죠 또한 LivingAccommodation의 DefaultHistoryUpdate 유형인 케이스를 추가해 업데이트가 있는지 확인합니다 업데이트한 변경이면 세트에 있는 여행을 업데이트합니다
앱 인터페이스에서 삭제된 여행에 대해 수행해야 할 작업은 없습니다 이를 처리하기 위해 LivingAccommodation의 DefaultHistoryDelete 유형인 케이스를 추가하고 세트에서 여행을 제거합니다
마지막으로, 향후 이 함수를 호출할 때 해당 트랜잭션 이후에 발생한 변경만 반환하도록 여행 세트와 함께 마지막 토큰을 반환합니다
이제 앱이 SwiftData History를 사용해 마지막 변경을 확인한 이후 위젯에서 변경된 여행을 알아낼 수 있습니다 이제 마지막으로 변경을 알아낸 이후에 변경된 것만 고려하도록 이 토큰을 저장해야 합니다 이를 위해 세 번째 함수를 정의하고 UserDefaults를 사용해 최신 토큰을 저장합니다 findUnreadTrips 함수에서 토큰이 있는 경우 토큰을 가져오고 토큰으로 findTransactions 함수를 호출하기 전에 JSON에서 토큰을 디코딩합니다 제가 지정하려는 작성자는 위젯이므로 TransactionAuthor.widget과 동일한 .author 속성을 위젯에서 ModelContext에 설정했습니다
findTrips 호출 후 UserDefaults로 다시 반환된 토큰을 저장합니다 이제 findUnreadTrips를 호출할 때마다 마지막으로 호출된 이후 배지를 표시해야 하는 여행만 반환합니다
기능이 거의 준비되었습니다 두 부분만 추가하면 됩니다
첫째, 앱을 열 때 안 읽은 여행이 있는지 확인합니다 둘째, 여행을 탭할 때 배지가 사라지도록 안 읽은 여행 세트에서 해당 여행을 제거합니다 SwiftUI 보기에서 scenePhase가 활성화될 때마다 findUnreadTripIdentifiers를 호출하면 배지를 표시해야 하는 새 여행으로 인터페이스가 업데이트됩니다
그런 다음 여행을 선택할 때 배지가 사라지도록 식별자를 unreadTripIdentifiers 세트에서 제거합니다
마지막으로 unreadTripIdentifiers 세트에 포함된 여행에 배지를 추가합니다
이제 필요한 코드가 모두 구현되었으니 앱을 빌드하고 실행하겠습니다 이미 앱에 입력된 여행이 있으니 홈 화면에서 바로 위젯에 있는 숙박 시설을 확인하고자 합니다
Accommodation을 탭하면 UI가 바뀌어 확인되었음을 나타냅니다 다음에 여행 앱을 실행하면 Formation Flyover로의 여행에 변경이 있었음을 나타내는 파란색 읽지 않은 배지가 표시됩니다 여행을 살펴보고 나면 배지가 제거됩니다
SwiftData로 맞춤형 데이터 저장소를 구축하는 분들을 위해 맞춤형 저장소에서도 기록을 지원할 수 있습니다 기반이 되는 모델이 기록을 지원하는 경우 저장소 구현에도 같은 작업흐름을 지원할 수 있습니다 맞춤형 데이터 저장소에 기록을 추가하려면 나만의 유형을 구현해 데이터 저장소에 대한 SwiftData History API의 기본 요소를 표현해야 합니다 여기에는 트랜잭션 각 변경 유형 트랜잭션 사이에서 책갈피 역할을 하는 토큰이 포함됩니다 또한 맞춤형 데이터 저장소는 HistoryProviding을 준수해야 하죠
트랜잭션의 경계를 잘 정의해야 합니다 데이터 저장소에서 쓰기 작업을 합치고 정렬해야 하기 때문이죠 기본 저장소에서 ModelContext의 모든 모델 인스턴스 변경 사항은 저장 시 단일 트랜잭션으로 그룹화됩니다
트랜잭션 유형을 만들 때 영속성 백엔드 내에 트랜잭션을 고유하게 식별하는 방법을 정의해야 합니다 트랜잭션과 마찬가지로 변경의 경계를 정하는 것도 잘 정의해야 합니다 DefaultStore에서는 변경의 경계가 개별 모델 인스턴스로 범위가 정해집니다
이런 변경의 세분성을 추적할 수 있는 식별자를 선택하세요
앱에 기존 변경 유형이 모두 필요하지는 않을 수도 있고 다른 변경 유형이 필요할 수도 있습니다 예를 들어 앱이 시계열 로그로 모델을 삽입만 하는 경우 업데이트 및 삭제 변경 유형은 필요하지 않을 수 있습니다 추가로 고려할 사항은 앱에서 삭제 시, 값 보존과 삭제된 값을 저장하는 방법을 지원할 필요가 있는지 여부입니다
맞춤형 저장소는 기록을 팔려면 HistoryProviding 프로토콜을 구현해야 합니다 이렇게 하려면 저장소에서 트랜잭션과 변경을 정의하는 행들을 끌어올 수 있어야 합니다
어떤 행이 트랜잭션의 일부인지 식별한 후 특정 모델 세트를 빌드해야 합니다
기본 데이터 저장소는 기록 레코드의 TTL을 관리합니다 맞춤형 제공자는 기록을 삭제할 시기를 결정해야 합니다 SwiftData History는 강력하고 대량의 기록 데이터를 처리할 수 있지만 어떤 특정한 경우에는 기록을 삭제하고자 할 수 있습니다 예를 들어 앱에서 모델을 제거하는 경우 해당 모델에 대해 앞으로는 사용하지 않을 기록 데이터가 있을 수 있습니다 이 경우, 데이터 저장소에서 기록을 삭제하고자 할 수 있습니다
마지막으로, 맞춤형 저장소에 기록 지원을 추가할 때 맞춤형 토큰 유형을 만들어야 합니다 HistoryToken은 토큰의 기본 프로토콜입니다 트랜잭션 스트림에서 위치를 고유하게 식별하려면 상태가 필요합니다
앱에서 관련 저장소를 여러 개 사용하는지 고려하세요 맞춤형 토큰은 트랜잭션에 사용된 모든 저장소의 상태를 포함합니다
History는 변경 사항을 쿼리할 수 있게 해 주는 강력한 기능이어서 Trips 앱의 위젯에서 업데이트를 알아낼 수 있습니다 SwiftData는 Swift의 표현력이 풍부한 유형 시스템을 사용해 각 모델 변경이 앱에서 어떻게 사용되는지 이해하기 쉽게 합니다 SwiftData History로 앱에서 유쾌한 경험을 구축할 수 있습니다 지속적인 기록의 이점을 누리기 위해 Core Data를 함께 사용하신 분들은 이제 대신 SwiftData History로 마이그레이션할 수 있습니다 맞춤형 저장소를 빌드하는 경우 나만의 기록 유형을 만들어 기록 추적의 모든 기능을 지원할 수 있습니다 시청해 주셔서 감사합니다
-
-
4:57 - Preserve values in history on deletion
// Add .preserveValueOnDeletion to capture unique columns import SwiftData @Model class Trip { #Unique<Trip>([\.name, \.startDate, \.endDate]) @Attribute(.preserveValueOnDeletion) var name: String var destination: String @Attribute(.preserveValueOnDeletion) var startDate: Date @Attribute(.preserveValueOnDeletion) var endDate: Date var bucketList: [BucketListItem] = [BucketListItem]() var livingAccommodation: LivingAccommodation? }
-
6:26 - Fetch transactions from history
private func findTransactions(after token: DefaultHistoryToken?, author: String) -> [DefaultHistoryTransaction] { var historyDescriptor = HistoryDescriptor<DefaultHistoryTransaction>() if let token { historyDescriptor.predicate = #Predicate { transaction in (transaction.token > token) && (transaction.author == author) } } var transactions: [DefaultHistoryTransaction] = [] let taskContext = ModelContext(modelContainer) do { transactions = try taskContext.fetchHistory(historyDescriptor) } catch let error { print(error) } return transactions }
-
7:34 - Process history changes
private func findTrips(in transactions: [DefaultHistoryTransaction]) -> (Set<Trip>, DefaultHistoryToken?) { let taskContext = ModelContext(modelContainer) var resultTrips: Set<Trip> = [] for transaction in transactions { for change in transaction.changes { let modelID = change.changedPersistentIdentifier let fetchDescriptor = FetchDescriptor<Trip>(predicate: #Predicate { trip in trip.livingAccommodation?.persistentModelID == modelID }) let fetchResults = try? taskContext.fetch(fetchDescriptor) guard let matchedTrip = fetchResults?.first else { continue } switch change { case .insert(_ as DefaultHistoryInsert<LivingAccommodation>): resultTrips.insert(matchedTrip) case .update(_ as DefaultHistoryUpdate<LivingAccommodation>): resultTrips.update(with: matchedTrip) case .delete(_ as DefaultHistoryDelete<LivingAccommodation>): resultTrips.remove(matchedTrip) default: break } } } return (resultTrips, transactions.last?.token) }
-
10:19 - Save and use a history token
private func findUnreadTrips() -> Set<Trip> { let tokenData = UserDefaults.standard.data(forKey: UserDefaultsKey.historyToken) var historyToken: DefaultHistoryToken? = nil if let tokenData { historyToken = try? JSONDecoder().decode(DefaultHistoryToken.self, from: tokenData) } let transactions = findTransactions(after: historyToken, author: TransactionAuthor.widget) let (unreadTrips, newToken) = findTrips(in: transactions) if let newToken { let newTokenData = try? JSONEncoder().encode(newToken) UserDefaults.standard.set(newTokenData, forKey: UserDefaultsKey.historyToken) } return unreadTrips }
-
11:30 - Update the user interface
struct ContentView: View { @Environment(\.scenePhase) private var scenePhase @State private var showAddTrip = false @State private var selection: Trip? @State private var searchText: String = "" @State private var tripCount = 0 @State private var unreadTripIdentifiers: [PersistentIdentifier] = [] var body: some View { NavigationSplitView { TripListView(selection: $selection, tripCount: $tripCount, unreadTripIdentifiers: $unreadTripIdentifiers, searchText: searchText) .toolbar { ToolbarItem(placement: .topBarLeading) { EditButton() .disabled(tripCount == 0) } ToolbarItemGroup(placement: .topBarTrailing) { Spacer() Button { showAddTrip = true } label: { Label("Add trip", systemImage: "plus") } } } } detail: { if let selection = selection { NavigationStack { TripDetailView(trip: selection) } } } .task { unreadTripIdentifiers = await DataModel.shared.unreadTripIdentifiersInUserDefaults } .searchable(text: $searchText, placement: .sidebar) .sheet(isPresented: $showAddTrip) { NavigationStack { AddTripView() } .presentationDetents([.medium, .large]) } .onChange(of: selection) { _, newValue in if let newSelection = newValue { if let index = unreadTripIdentifiers.firstIndex(where: { $0 == newSelection.persistentModelID }) { unreadTripIdentifiers.remove(at: index) } } } .onChange(of: scenePhase) { _, newValue in Task { if newValue == .active { unreadTripIdentifiers = await DataModel.shared.findUnreadTripIdentifiers() } else { // Persist the unread trip names for the next launch session. await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers) } } } #if os(macOS) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { unreadTripIdentifiers = await DataModel.shared.findUnreadTripIdentifiers() } } .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in Task { await DataModel.shared.setUnreadTripIdentifiersInUserDefaults(unreadTripIdentifiers) } } #endif } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.