[회고] 신입 iOS 개발자가 되기까지 feat. 카카오 자세히보기

🍎 Apple/SwiftData & CoreData

[SwiftData] ModelContainer, ModelContext

inu 2023. 8. 3. 18:37
반응형

본 게시글은 WWDC 2023의 Dive deeper into SwiftData 세션의 내용을 정리한 것입니다.

https://developer.apple.com/videos/play/wwdc2023/10196


@Model 을 통해 생성된 모델은 ModelContainer에 의해 실질적 스키마로서 동작하게 되며, 이를 기반으로 영구 저장 데이터가 생성되게 된다.

 

코드에서 Model 클래스의 인스턴스로 작업을 하게되면 해당 인스턴스는 메모리에서 해당 상태를 추적하고 관리하는 ModelContext에 연결된다.

ModelContainer

  • 스키마와 영구 저장 데이터간의 bridge 역할을 한다.
  • 객체가 메모리에 저장될지, 디스크에 저장될지 등 객체의 저장 방식에 대한 설명이 포함된다.
  • Versioning, Migration, Graph Separation 같은 저장소의 발전을 관리한다.
  • 하나의 타입만 전달하더라도 관련된 필요 데이터를 유추하여 가져온다.
// ModelContainer initialized with just Trip
let container = try ModelContainer(for: Trip.self)

// SwiftData infers related model classes as well
let container = try ModelContainer(
    for: [
        Trip.self, 
        BucketListItem.self, 
        LivingAccommodation.self
    ]
)
  • 추가적으로 Model Container의 기능을 활용하고 싶으면 ModelConfiguration 객체를 활용하자.

ModelConfiguration

  • ModelConfiguration을 통해 데이터가 저장되는 위치를 제어할 수 있다.
  • 사용자가 지정한 특정 URL을 사용하거나 앱의 권한을 기반으로 자동으로 Group Container(한 그룹에 속한 앱은 iCloud를 통해 데이터를 공유할 수 있음)같은 URL을 생성하여 사용할 수 있다.
  • 읽기 전용모드로 로드해서 민감한 데이터 혹은 템플릿 데이터에 데이터를 덮어씌우는 것을 방지할 수 있다.
  • 둘 이상의 CloudKit container를 사용하는 앱의 경우 이를 ModelConfiguration의 일부로 표현할 수 있다.

아래 코드는 Trip, BucketListItem, LivingAccommodations이 하나의 저장소에 있고, Person과 Address는 다른 저장소에 있는 경우의 예시이다.

let fullSchema = Schema([
    Trip.self,
    BucketListItem.self,
    LivingAccommodations.self,
    Person.self,
    Address.self
])

let trips = ModelConfiguration(
    schema: Schema([
        Trip.self,
        BucketListItem.self,
        LivingAccommodations.self
    ]),
    url: URL(filePath: "/path/to/trip.store"),
    cloudKitContainerIdentifier: "com.example.trips"
)

let people = ModelConfiguration(
    schema: Schema([Person.self, Address.self]),
    url: URL(filePath: "/path/to/people.store"),
    cloudKitContainerIdentifier: "com.example.people"
) 

let container = try ModelContainer(for: fullSchema, trips, people)

ModelContext

  • ModelContext를 통해 사용중인 객체를 추적한다.
  • ModelContainer에 변경사항을 알린다.
  • 롤백 혹은 리셋으로 변경사항을 지운다.
  • Undo/Redo 지원
  • 자동저장 지원

Main Context는 Scene 혹은 View에서 모델 오브젝트를 사용하기 위한 Main Actor를 기반으로 정렬된 Model Context이다. Environment를 기반으로 Context를 불러와 삭제, 추가와 같은 작업을 수행할 수 있다.

struct ContentView: View {
    @Query var trips: [Trip]
    @Environment(\\.modelContext) var modelContext
  
    var body: some View {
        NavigationStack (path: $path) {
            List(selection: $selection) {
                ForEach(trips) { trip in
                    TripListItem(trip: trip)
                        .swipeActions(edge: .trailing) {
                            Button(role: .destructive) {
                                modelContext.delete(trip)
                            } label: {
                                Label("Delete", systemImage: "trash")
                            }
                        }
                }
                .onDelete(perform: deleteTrips(at:))
            }
        }
    }
}

Model Context에는 변경사항이 지속적으로 스냅샷 형태로 기록되며, 이러한 변경사항은 context 저장 전까지 유지된다. 저장이 이루어지면 현재까지 기록된 기록들을 Clear하고 ModelContainer에 변경사항을 전달한다.

  • Undo, Redo는 Environment로부터 UndoManager를 가져와서 사용한다. 이는 시스템에서 제공해주는 것이다.
  • 이를 사용하면 추가 코드 구현없이 흔들어서 실행을 취소하거나, 세 손가락의 스와이프를 통해 변경사항을 다시 실행할 수 있다.
@main
struct TripsApp: App {
   @Environment(\\.undoManager) var undoManager
   var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Trip.self, isUndoEnabled: true)
    }
}

Main Context는 자동으로 저장된다. 애플리케이션이 Foreground 혹은 Background로 진입하는 등의 시스템 이벤트가 발생하면 저장된다. 그 외에도 주기적으로 저장을 수행한다.

 

이는 modelContainer modifier의 isAutosaveEnabled 옵션으로 조절할 수 있으며, default는 true이다.

 

cf. Main Context가 아닌 수동 생성 Context는 자동 저장기능이 활성화되어 있지 않다. (default false)

@main
struct TripsApp: App {
   var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Trip.self, isAutosaveEnabled: false)
    }
}

Model at scale

Background queue의 데이터 작업, 원격 서버 혹은 지속성 데이터와의 동기화, Batch Process 같은 작업들도 모두 모델 객체와 작업을 수행할 수 있다.

데이터를 가져오는 작업은 Main Context가 아닌 수동 Context로 작업한다.

let context = self.newSwiftContext(from: Trip.self)
var trips = try context.fetch(FetchDescriptor<Trip>())

#Predicate 매크로를 사용하면 아래와 같이 데이터를 가져올 수 있다. 조금 복잡한 쿼리도 Swift 문법으로 쉽게 작성되는 모습이다.

let context = self.newSwiftContext(from: Trip.self)
let hotelNames = ["First", "Second", "Third"]

var predicate = #Predicate<Trip> { trip in
    trip.livingAccommodations.filter {
        hotelNames.contains($0.placeName)
    }.count > 0
}

var descriptor = FetchDescriptor(predicate: predicate)
var trips = try context.fetch(descriptor)

이를 사용하면 컴파일러가 쿼리 내용을 검증할 수 있고, 컴파일러에게 결과에 대한 타입을 알려주고 사용가능한 속성을 파악할 수 있도록 해준다. offset, limit, faulting, prefetching 같은 추가 기능도 존재한다.

 

Model Context의 enumerate 기능을 활용하여 Batch Traversal과 Enumeration 과정을 효율적으로 다루도록 도와준다. 사용자는 이를 통해 별도의 최적화 작업없이 편리하게 데이터를 다룰 수 있게 된다.

 

배치 사이즈는 5000으로 설정되어 있었는데, 이를 10000으로 늘릴 경우 메모리 증가를 감수하더라도 Traversal 중 I/O 횟수를 줄일 수 있다. 이미지나 동영상, 기타 큰 데이터 덩어리를 포함하는 경우 배치 사이즈를 줄일 수 있을 것이다. 이렇게 할 경우 메모리는 감소하고 열거 중 발생하는 I/O는 증가한다.

let predicate = #Predicate<Trip> { trip in
    trip.bucketListItem.filter {
        $0.hasReservation == false
    }.count > 0
}

let descriptor = FetchDescriptor(predicate: predicate)
descriptor.sortBy = [SortDescriptor(\\.start_date)]

context.enumerate(
    descriptor,
    batchSize: 10000 // or 5000
) { trip in
    // Remind me to make reservations for trip
}

대규모 Batch 처리 중 성능 문제를 일으키는 가장 큰 원인은 Data Mutation이다. (Data Mutation : 데이터가 순회되거나 열거될 때, 해당 데이터가 어떤 방식으로든 처리되거나 접근되는 것을 의미하며, 때로는 이러한 처리 과정에서 데이터 자체에 변경이 가해질 수 있음)

Enumerate는 기본적으로 Mutation Guard를 기본으로 수행한다. (Mutation Guard : 데이터의 변경을 감지하고, 변경이 일어날 때 원하는 동작을 수행하는 메커니즘)

 

allowEscapingMutations 옵션은 이러한 동작이 의도적임을 나타내는 것으로 이를 설정하지 않을 경우 Enumerate를 수행하던 도중 Mutation이 발견되면 throw하고 이미 순회를 마친 객체가 해제되지 못하도록 한다.

context.enumerate(
    descriptor,
    batchSize: 500,
    allowEscapingMutations: true
) { trip in
    // Remind me to make reservations for trip
}
반응형