NSFetchedResultsController 델리게이트로 테이블을 갱신하고, 설정은 UserDefaults 싱글턴에 넣고, 서비스는 싱글턴으로 꺼내 썼다. SwiftUI에서는 이 네 가지가 각각 @Model · @Query, @AppStorage/@SceneStorage, 환경(Environment)으로 주입으로 바뀐다. 오늘은 "어디에 무엇을 저장하고, 무엇을 어떻게 꽂아 넣고, 어떻게 테스트하나"를 한 장의 앱 구조로 정리한다. 1부는 SwiftData로 앱 데이터를 저장하고 읽고 고치는 법이다.1부 목표 · 19분
- Swift 클래스에
@Model을 붙이고.modelContainer를 걸어 앱을 껐다 켜도 데이터가 남게 만든다 @Query에 정렬·#Predicate를 넣어 필요한 행만 읽고,modelContext로 insert/delete 한다- body 안에서
context.fetch를 부르는 코드를 보고 화면이 갱신되지 않는 이유를 찾아@Query로 고친다
1. SwiftData 3종 세트 — @Model · ModelContainer · ModelContext 🔥 핵심
SwiftData는 코드만으로 스키마를 만든다. .xcdatamodeld 파일이 없다. 클래스에 @Model을 붙이면 저장 프로퍼티가 영구 저장 프로퍼티로 바뀌고, 그 클래스가 곧 스키마다(WWDC23 · Meet SwiftData).
"Models in SwiftData are the source of truth for your application's schema and drive the persistence experience." — WWDC23 · Meet SwiftData
→ 모델 클래스가 스키마의 원본이고, 저장 동작도 이 클래스가 이끈다. 별도의 스키마 파일과 코드가 어긋날 일이 없다.
역할 셋만 기억한다
@Model 참조는 관계가 된다.ModelConfiguration으로.\.modelContext로 받는다.import Foundation
import SwiftData
enum Season: String, Codable, CaseIterable {
case spring, summer, autumn, winter
}
@Model
final class Trip {
@Attribute(.unique) var name: String // 이름 중복 금지
var destination: String
var startDate: Date
var season: Season // Codable enum → 그대로 저장
@Relationship(deleteRule: .cascade, inverse: \Activity.trip)
var activities: [Activity] = [] // 여행을 지우면 활동도 지운다
@Transient var isHighlighted = false // 저장하지 않는 값
init(name: String, destination: String, startDate: Date, season: Season = .summer) {
self.name = name
self.destination = destination
self.startDate = startDate
self.season = season
}
}
@Model
final class Activity {
var name: String
var isComplete = false
var trip: Trip?
init(name: String) {
self.name = name
}
}import SwiftUI
import SwiftData
@main
struct TripsApp: App {
var body: some Scene {
WindowGroup {
TripListView()
}
.modelContainer(for: [Trip.self, Activity.self]) // 환경에 modelContext가 들어간다
}
}| Core Data (UIKit 시절) | SwiftData | 달라진 점 |
|---|---|---|
.xcdatamodeld + NSManagedObject | @Model 클래스 | 스키마 = Swift 코드 |
NSPersistentContainer | ModelContainer | .modelContainer 한 줄로 설정 |
NSManagedObjectContext | ModelContext | 뷰에서는 환경으로 받는다 |
NSFetchRequest + NSPredicate 문자열 | FetchDescriptor + #Predicate | 타입 검사·자동완성 |
NSFetchedResultsController 델리게이트 | @Query | 갱신 코드 0줄 |
Apple 코드어롱에서 실제로 난 빌드 에러다(WWDC26 · Code-along: Add persistence with SwiftData). ① let 저장 프로퍼티 — SwiftData가 DB에서 읽은 값을 런타임에 채워야 하므로 var여야 한다. ② Codable이 아닌 커스텀 타입 — 모든 속성은 DB 칼럼으로 직렬화되므로 enum·struct에 Codable을 붙인다. 또 @Model은 Observable 적합성을 자동 생성하므로 @Observable을 같이 붙이지 않는다.
2. @Query로 읽고, modelContext로 쓴다
읽기는 @Query, 쓰기는 modelContext다. .modelContainer를 걸면 자동 저장이 기본으로 켜진다(ModelContext.autosaveEnabled 문서: "SwiftData automatically sets this property to true for the model container’s mainContext"). 그래서 insert/delete 뒤에 매번 save()를 부르지 않아도 된다.
import SwiftUI
import SwiftData
struct TripListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Trip.startDate, order: .reverse) private var trips: [Trip]
var body: some View {
NavigationStack {
List {
ForEach(trips) { trip in
NavigationLink(trip.name, destination: TripEditor(trip: trip))
}
.onDelete { offsets in
for index in offsets {
context.delete(trips[index]) // 삭제 표시 → 자동 저장
}
}
}
.toolbar {
Button("추가", systemImage: "plus") {
let trip = Trip(name: "여행 \(UUID().uuidString.prefix(4))", // name은 .unique — 겹치면 새로 추가되지 않고 기존 행을 덮어쓴다(upsert)
destination: "서울", startDate: .now)
context.insert(trip) // 넣기만 하면 Query가 알아서 갱신
}
}
}
}
}struct TripEditor: View {
@Bindable var trip: Trip // 복사본·편집용 모델 없이 원본에 직접
var body: some View {
Form {
TextField("이름", text: $trip.name)
TextField("목적지", text: $trip.destination)
DatePicker("출발", selection: $trip.startDate, displayedComponents: .date)
}
}
}Apple 코드어롱은 이 덕분에 "편집용 모델(TripEditModel)과 DataSource 클래스를 통째로 지웠다"고 말한다. SwiftUI 뷰가 SwiftData 모델에 직접 바인딩하고 편집이 실시간으로 반영되기 때문이다(WWDC26 · Code-along).
필요한 행만 요청한다 — #Predicate
저장소는 메모리보다 훨씬 느린 곳에 있다. Apple은 이것을 도서관에 비유한다. "모든 책을 다 가져오게 한 뒤 내가 고르기" 대신 "그 작가 책만 가져다 달라고 사서에게 처음부터 말하기"가 predicate다(WWDC26 · Code-along).
struct UpcomingTripsView: View {
@Query private var trips: [Trip] // 전부 가져온다
var body: some View {
List(trips.filter { $0.startDate > .now }) { trip in // body마다 다시 거른다
Text(trip.name)
}
}
}struct UpcomingTripsView: View {
@Query private var trips: [Trip]
init(after date: Date) {
_trips = Query(filter: #Predicate<Trip> { $0.startDate > date },
sort: \.startDate)
}
var body: some View {
List(trips) { trip in Text(trip.name) }
}
}
struct RecentTripsView: View {
static var recent: FetchDescriptor<Trip> {
var descriptor = FetchDescriptor<Trip>(sortBy: [SortDescriptor(\.startDate, order: .reverse)])
descriptor.fetchLimit = 5 // 최근 5개만
return descriptor
}
@Query(RecentTripsView.recent) private var trips: [Trip]
var body: some View {
List(trips) { trip in Text(trip.name) }
}
}#Predicate 클로저는 저장소 쿼리로 번역된다. 부모에게 받은 값(date)은 init에서 캡처해 넣는다. 위의 UpcomingTripsView처럼 "뷰의 인자가 바뀌면 쿼리도 바뀐다"는 구조가 Apple 코드어롱의 TripCollectionView·SearchResultsListView 패턴이다.
WWDC26 · What's new in SwiftData에서 세 가지가 추가됐다. 모두 Apple 문서 기준 iOS 27+다. ① @Query의 sectionBy: 인자 + _trips.sections로 섹션 단위 조회. ② @Attribute(.codable) — MapKit 타입처럼 내가 소유하지 않은 Codable 클래스를 저장하는 비상구. 단, predicate·정렬에 못 쓰고 마이그레이션도 안 된다. ③ ResultsObserver/HistoryObserver — SwiftUI 뷰 밖에서 Query처럼 결과를 관찰한다. Apple은 그래도 "뷰 안에서는 Query가 첫 선택"이라고 말한다.
3. SwiftData 흔한 실수
SwiftData 코드에서 자주 나오는 실수는 "읽기를 관찰 없이 한 번만 하는 것"과 "옛 Core Data 습관을 그대로 옮기는 것"이다.
struct StaleTripsView: View {
@Environment(\.modelContext) private var context
var body: some View {
let trips = (try? context.fetch(FetchDescriptor<Trip>())) ?? [] // 관찰 없음
List(trips) { Text($0.name) }
}
}struct LiveTripsView: View {
@Query private var trips: [Trip] // 저장소 변화 → 결과 갱신 → 뷰 다시 그림
var body: some View {
List(trips) { Text($0.name) }
}
}@Query는 뷰가 나타날 때 fetch하고, 이후 결과에 영향을 주는 저장소 변화를 계속 감시한다(WWDC26 · What's new in SwiftData). context.fetch는 한 번 읽고 끝이다. 뷰 밖(테스트·백그라운드 처리)에서만 쓴다.
Apple 코드어롱은 @Model로 바꾸면서 name·isComplete의 didSet을 지웠다. 이유는 "property observers and computed properties are not always compatible"(WWDC26 · Code-along). iOS 17 기준으로는 수정 시각을 값을 바꾸는 동작(버튼 핸들러·모델 메서드)에서 함께 대입하는 편이 안전하다.
툴바 버튼을 Button("추가", systemImage: "plus")처럼 제목과 아이콘을 함께 만들면 화면에는 아이콘만 보여도 VoiceOver는 "추가"라고 읽는다. Image(systemName:)만 넣으면 이 이름표가 없다.
✅ 복습 · 5분
@Model · ModelContainer · ModelContext는 각각 무엇을 맡나?
@Model은 저장할 모양(스키마), ModelContainer는 저장 장소와 설정(백엔드), ModelContext는 변경 추적·fetch·insert/delete·save를 하는 작업 창구다. 뷰는 환경의 \.modelContext와 @Query만 쓴다.@Query와 body 안의 context.fetch는 둘 다 데이터를 읽는데, 무엇이 다른가?
@Query는 뷰가 나타날 때 fetch하고, 그 뒤로도 결과에 영향을 주는 저장소 변화를 계속 지켜보다가 뷰를 다시 그린다. context.fetch는 한 번 읽고 끝이라, 다른 곳에서 insert/delete해도 화면이 그대로다. 뷰에서는 @Query, 테스트·백그라운드 처리처럼 뷰 밖에서는 fetch를 쓴다.@Query는 계속 켜 둔 CCTV이고, fetch는 한 번 찍은 사진이다. 사진은 방이 바뀌어도 그대로다.🔮 코드 예측: 1부의 TripListView(@Query(sort: \Trip.startDate, order: .reverse))에서 "추가"를 누르면 새 여행은 목록 어디에, 언제 나타나나? save()는 부르지 않았다.
startDate는 .now라 가장 늦은 날짜이고, 정렬이 .reverse(최신순)이기 때문이다. context.insert만 해도 @Query가 변화를 보고 결과를 갱신하며, .modelContainer가 켠 자동 저장이 디스크 저장까지 맡는다.🔁 지난 Day 복습: .task(id: model.query)에서 검색어가 빠르게 세 번 바뀌면 무슨 일이 일어나나? (Day 12)
Task.sleep이나 네트워크 호출은 CancellationError로 빠져나온다.2부는 SwiftData가 아닌 작은 값(@AppStorage · @SceneStorage)을 어디에 둘지, 서비스를 환경으로 주입해 Preview·테스트에서 가짜로 바꾸는 법, Swift Testing으로 모델을 검증하는 법, Preview용 인메모리 컨테이너를 다룬다.