← SwiftUI 14일 마스터 13 / 14 · 1부

Day 13 · 1부 · SwiftData로 저장하기

코드만으로 스키마를 만드는 @Model, Scene에 거는 컨테이너, #Predicate로 필요한 행만 읽는 @Query, 흔한 SwiftData 실수를 다룬다.
진행률
0 / 0 완료
UIKit에서 데이터를 남기려면 Core Data 스택을 세우고, 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).

📝 Apple 원문

"Models in SwiftData are the source of truth for your application's schema and drive the persistence experience." — WWDC23 · Meet SwiftData

→ 모델 클래스가 스키마의 원본이고, 저장 동작도 이 클래스가 이끈다. 별도의 스키마 파일과 코드가 어긋날 일이 없다.

역할 셋만 기억한다

@Model
무엇을 저장할지. 값 타입(String·Int·Date·Codable struct/enum)은 속성, 다른 @Model 참조는 관계가 된다.
ModelContainer
어디에 저장할지. 스키마 + 영구 저장 백엔드. URL·CloudKit·마이그레이션 옵션은 ModelConfiguration으로.
ModelContext
어떻게 다룰지. 변경 추적 · fetch · insert/delete · save · undo의 창구. 뷰에서는 환경의 \.modelContext로 받는다.
@Query
뷰에서 읽기. 한 줄로 fetch하고, 저장소가 바뀌면 뷰를 다시 그린다.
✅ 모델 정의 — 메타데이터는 필요한 곳에만
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
    }
}
✅ 앱 진입점 — 컨테이너를 Scene에 한 번 건다
import SwiftUI
import SwiftData

@main
struct TripsApp: App {
    var body: some Scene {
        WindowGroup {
            TripListView()
        }
        .modelContainer(for: [Trip.self, Activity.self])  // 환경에 modelContext가 들어간다
    }
}
App · WindowGroup .modelContainer(for: Trip.self) ModelContainer 스키마 + 영구 저장 백엔드 영구 저장소 (디스크) 앱을 껐다 켜도 남는다 save 환경으로 주입 ModelContext (\.modelContext) 변경 추적 · fetch · insert · save @Query 뷰 읽기 + 저장소가 바뀌면 자동 갱신 편집 뷰 (@Bindable) insert · delete · 프로퍼티 대입 fetch 변경 뷰는 컨테이너를 직접 만지지 않는다 — 환경의 modelContext와 @Query만 쓴다
컨테이너는 Scene에 한 번, 컨텍스트는 환경으로 흘러 내려간다. 뷰는 읽기(@Query)와 쓰기(modelContext)만 신경 쓴다.
Core Data (UIKit 시절)SwiftData달라진 점
.xcdatamodeld + NSManagedObject@Model 클래스스키마 = Swift 코드
NSPersistentContainerModelContainer.modelContainer 한 줄로 설정
NSManagedObjectContextModelContext뷰에서는 환경으로 받는다
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()를 부르지 않아도 된다.

✅ 목록 — 정렬된 Query + insert/delete
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가 알아서 갱신
                }
            }
        }
    }
}
✅ 편집 — @Model은 Observable이라 @Bindable로 바로 바인딩
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).

❌ 전부 가져와서 거르기 ✅ #Predicate로 요청하기 저장소: Trip (예: 1,000개) 1,000개 전부 읽음 (I/O ↑) 메모리: 1,000개 → .filter { } 995개는 버린다 화면: 5개 저장소: Trip (예: 1,000개) 조건에 맞는 5개만 읽음 메모리: 5개 화면: 5개 결과는 같아도 읽는 양과 메모리가 다르다
화면 결과가 같아도, 거르는 일을 저장소에 맡기면 읽는 양과 메모리가 줄어든다.
❌ 전부 읽고 메모리에서 거른다
struct UpcomingTripsView: View {
    @Query private var trips: [Trip]            // 전부 가져온다

    var body: some View {
        List(trips.filter { $0.startDate > .now }) { trip in   // body마다 다시 거른다
            Text(trip.name)
        }
    }
}
✅ init에서 Query를 조립한다 — 인자가 predicate로 들어간다
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 안에서는 값을 "잡아서" 쓴다

#Predicate 클로저는 저장소 쿼리로 번역된다. 부모에게 받은 값(date)은 init에서 캡처해 넣는다. 위의 UpcomingTripsView처럼 "뷰의 인자가 바뀌면 쿼리도 바뀐다"는 구조가 Apple 코드어롱의 TripCollectionView·SearchResultsListView 패턴이다.

🆕 최신 API iOS 27+

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 습관을 그대로 옮기는 것"이다.

❌ body에서 직접 fetch — 저장소가 바뀌어도 화면이 안 바뀐다
struct StaleTripsView: View {
    @Environment(\.modelContext) private var context

    var body: some View {
        let trips = (try? context.fetch(FetchDescriptor<Trip>())) ?? []   // 관찰 없음
        List(trips) { Text($0.name) }
    }
}
✅ 뷰에서는 @Query — 저장소를 계속 지켜본다
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는 한 번 읽고 끝이다. 뷰 밖(테스트·백그라운드 처리)에서만 쓴다.

🚨 didSet으로 "수정 시각" 갱신하기

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만 쓴다.
쉽게 말하면 @Model은 "책 양식", 컨테이너는 "도서관 건물", 컨텍스트는 "책을 넣고 빼 주는 사서"다.
@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)
id가 바뀔 때마다 이전 작업은 취소되고 새 작업이 시작된다. 그래서 마지막 검색어의 결과만 남는다. 취소된 작업의 Task.sleep이나 네트워크 호출은 CancellationError로 빠져나온다.
쉽게 말하면 심부름을 시켰다가 "아니, 이거 말고 저거!"라고 하면 앞 심부름은 멈추고 마지막 심부름만 한다.
➡️ 2부 예고

2부는 SwiftData가 아닌 작은 값(@AppStorage · @SceneStorage)을 어디에 둘지, 서비스를 환경으로 주입해 Preview·테스트에서 가짜로 바꾸는 법, Swift Testing으로 모델을 검증하는 법, Preview용 인메모리 컨테이너를 다룬다.

출처 · 참고자료