🇺🇸 EN

💾 SwiftData 완전정복

Core Data를 대체하는 현대적 데이터베이스. Swift 매크로로 간편하게 영구 저장소를 구현합니다.

⭐ 난이도: ⭐⭐ ⏱️ 예상 시간: 1-2h 📂 App Frameworks

✨ SwiftData란?

SwiftData는 iOS 17+에서 사용 가능한 새로운 데이터 영속화 프레임워크입니다. Core Data를 완전히 대체하며, Swift 매크로를 활용해 훨씬 간단합니다.

📦 Model 정의

Todo.swift
import SwiftData
import Foundation

@Model
final class Todo {
    var title: String
    var isCompleted: Bool
    var createdAt: Date

    init(title: String, isCompleted: Bool = false) {
        self.title = title
        self.isCompleted = isCompleted
        self.createdAt = Date()
    }
}

🏗️ App 설정

TodoApp.swift
import SwiftUI
import SwiftData

@main
struct TodoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Todo.self)
    }
}

📝 데이터 읽기 (@Query)

ContentView.swift
import SwiftUI
import SwiftData

struct ContentView: View {
    @Query private var todos: [Todo]
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        NavigationStack {
            List {
                ForEach(todos) { todo in
                    HStack {
                        Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                            .foregroundStyle(todo.isCompleted ? .green : .gray)

                        Text(todo.title)
                            .strikethrough(todo.isCompleted)
                    }
                    .onTapGesture {
                        todo.isCompleted.toggle()
                    }
                }
                .onDelete(perform: deleteTodos)
            }
            .navigationTitle("할 일")
            .toolbar {
                Button("추가", systemImage: "plus") {
                    addTodo()
                }
            }
        }
    }

    func addTodo() {
        let newTodo = Todo(title: "새 할 일")
        modelContext.insert(newTodo)
    }

    func deleteTodos(at offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(todos[index])
        }
    }
}

🔍 Query 필터링 & 정렬

FilteredView.swift
struct FilteredTodoView: View {
    // 완료되지 않은 할 일만, 최신순 정렬
    @Query(
        filter: #Predicate<Todo> { todo in
            !todo.isCompleted
        },
        sort: \Todo.createdAt,
        order: .reverse
    )
    private var incompleteTodos: [Todo]

    var body: some View {
        List(incompleteTodos) { todo in
            Text(todo.title)
        }
    }
}

// 여러 정렬 조건
@Query(sort: [
    SortDescriptor(\Todo.isCompleted),
    SortDescriptor(\Todo.createdAt, order: .reverse)
])
private var sortedTodos: [Todo]

🔗 관계(Relationship) 정의

Models.swift
import SwiftData

@Model
final class Category {
    var name: String
    @Relationship(deleteRule: .cascade, inverse: \Todo.category)
    var todos: [Todo] = []

    init(name: String) {
        self.name = name
    }
}

@Model
final class Todo {
    var title: String
    var isCompleted: Bool
    var category: Category?

    init(title: String, category: Category? = nil) {
        self.title = title
        self.isCompleted = false
        self.category = category
    }
}

💾 수동 저장 & 롤백

ManualSave.swift
struct TodoEditView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var title = ""

    var body: some View {
        Form {
            TextField("제목", text: $title)

            HStack {
                Button("저장") {
                    save()
                }

                Button("취소", role: .cancel) {
                    rollback()
                }
            }
        }
    }

    func save() {
        let todo = Todo(title: title)
        modelContext.insert(todo)

        do {
            try modelContext.save()
        } catch {
            print("저장 실패: \(error)")
        }
    }

    func rollback() {
        modelContext.rollback()
    }
}

🔧 ModelContext 직접 사용

FetchData.swift
import SwiftData

func fetchTodos(context: ModelContext) throws -> [Todo] {
    let descriptor = FetchDescriptor<Todo>(
        predicate: #Predicate { !$0.isCompleted },
        sortBy: [SortDescriptor(\Todo.createdAt, order: .reverse)]
    )

    return try context.fetch(descriptor)
}

// 개수만 가져오기
func countTodos(context: ModelContext) throws -> Int {
    let descriptor = FetchDescriptor<Todo>()
    return try context.fetchCount(descriptor)
}

🗑️ 삭제 규칙 (Delete Rule)

DeleteRule.swift
@Model
final class Project {
    var name: String

    // cascade: Project 삭제 시 Task도 모두 삭제
    @Relationship(deleteRule: .cascade)
    var tasks: [Task] = []

    // nullify: Project 삭제 시 Task의 project를 nil로
    // @Relationship(deleteRule: .nullify)

    // deny: Task가 있으면 Project 삭제 불가
    // @Relationship(deleteRule: .deny)

    init(name: String) {
        self.name = name
    }
}

@Model
final class Task {
    var title: String
    var project: Project?

    init(title: String, project: Project? = nil) {
        self.title = title
        self.project = project
    }
}

🎯 Unique 제약 조건

UniqueModel.swift
import SwiftData

@Model
final class User {
    @Attribute(.unique)
    var email: String

    var name: String

    init(email: String, name: String) {
        self.email = email
        self.name = name
    }
}

// 동일한 email로 insert 시 자동으로 update됨

🧪 테스트용 In-Memory Container

PreviewContainer.swift
import SwiftData

extension ModelContainer {
    static var preview: ModelContainer {
        let schema = Schema([Todo.self])
        let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try! ModelContainer(for: schema, configurations: configuration)

        // 샘플 데이터 추가
        let context = container.mainContext
        context.insert(Todo(title: "할 일 1"))
        context.insert(Todo(title: "할 일 2"))

        return container
    }
}

// Preview에서 사용
#Preview {
    ContentView()
        .modelContainer(ModelContainer.preview)
}

💡 HIG 가이드라인
✅ @Query는 자동으로 UI 업데이트
✅ ModelContext는 자동 저장
✅ Core Data보다 80% 적은 코드
✅ 타입 안전성 보장
✅ Swift 매크로로 보일러플레이트 제거

📦 학습 자료

💻
GitHub 프로젝트
🍎
Apple 공식 문서
🎥
WWDC23 세션

📎 Apple 공식 자료

📘 공식 문서 💻 샘플 코드 🎬 WWDC 세션