SwiftData 완벽 가이드: @Model, ModelContainer, 관계 설정, 스키마 마이그레이션 (iOS 17~26)

SwiftData의 @Model, ModelContainer, 관계 설정, 스키마 마이그레이션을 실전 코드 예제와 함께 배우는 완벽 한국어 가이드입니다. iOS 17부터 iOS 26의 클래스 상속 기능까지 모두 다룹니다.

SwiftData 완벽 가이드 2026: @Model & 마이그레이션

SwiftData는 Apple이 WWDC 2023에서 선보인 Swift 네이티브 영구 저장 프레임워크입니다. 오랫동안 iOS 개발의 표준이었던 Core Data를 대체하도록 설계되었는데, 솔직히 처음 써보면 "이게 이렇게 간단해도 되나?" 싶을 만큼 편리합니다. Swift 매크로를 활용한 선언적 API 덕분에 보일러플레이트 코드가 크게 줄었고, SwiftUI와의 통합도 자연스럽습니다. iOS 17 이상을 지원하는 신규 프로젝트라면 충분히 채택을 고려할 만한 프레임워크입니다. iOS 26에서는 클래스 상속까지 지원하면서 그 활용 범위가 한층 넓어졌고요.

이 가이드에서는 @Model 매크로를 사용한 모델 정의부터 ModelContainer, ModelContext, 관계 설정, 그리고 운영 환경에서 필수인 스키마 마이그레이션까지 실전 코드 예제와 함께 단계별로 살펴봅니다. 자, 바로 시작해볼까요.

SwiftData란 무엇이고 왜 Core Data를 대체하는가?

Core Data는 강력하지만 배우기 어렵고 코드가 장황하다는 단점이 있었습니다. NSManagedObject, NSFetchRequest, NSPersistentContainer를 모두 이해해야 간단한 데이터 저장도 가능했으니까요. SwiftData는 이 복잡성을 Swift 매크로와 최신 언어 기능으로 깔끔하게 압축했습니다.

  • 선언적 모델 정의: @Model 매크로 하나로 데이터베이스 스키마를 정의합니다.
  • SwiftUI 통합: @Query를 사용해 뷰에서 직접 데이터를 가져옵니다.
  • 자동 UI 동기화: 데이터 변경 시 연결된 뷰가 자동으로 업데이트됩니다.
  • CloudKit 지원: 별도의 복잡한 설정 없이 iCloud 동기화가 가능합니다.
  • Swift Concurrency 호환: async/await와 자연스럽게 함께 사용 가능합니다.
  • 타입 안전 쿼리: #Predicate 매크로로 컴파일 타임에 쿼리를 검증합니다.

SwiftData 스택의 세 가지 핵심 개념

SwiftData를 올바르게 사용하려면 스택을 구성하는 세 가지 핵심 클래스를 이해해야 합니다. Core Data에서 NSPersistentContainer가 혼자 담당하던 역할이 더 명확하게 분리됐다고 생각하면 이해하기 쉽습니다.

ModelContainer: 데이터베이스 파일 관리자

ModelContainer는 실제 데이터베이스 파일을 생성하고 관리합니다. 앱 실행 시 한 번만 생성하며, 일반적으로 @main App 구조체에서 .modelContainer(for:) modifier를 통해 설정합니다.

import SwiftUI
import SwiftData

@main
struct TodoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [TodoItem.self, Category.self])
    }
}

modelContainer(for:)에 배열로 모델을 전달하면 SwiftData가 연관된 모든 관계 모델을 자동으로 스키마에 포함시킵니다. 연결 관계에 있는 모델이라면 하나만 전달해도 충분합니다 (저도 처음엔 매번 모두 나열해줘야 하는 줄 알았습니다).

ModelContext: 변경사항 추적기

ModelContext는 메모리에서 객체의 생성, 수정, 삭제를 추적합니다. SwiftUI 환경에서는 @Environment(\.modelContext)를 통해 자동으로 주입됩니다. 기본적으로 오토세이브가 활성화되어 있어 명시적인 save() 호출 없이도 변경사항이 저장됩니다. 처음에는 이게 오히려 불안하게 느껴질 수도 있는데, 실제로 쓰다 보면 꽤 편리합니다.

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

    var body: some View {
        List(items) { item in
            Text(item.title)
        }
        .toolbar {
            Button("추가") {
                let newItem = TodoItem(title: "새 할 일")
                modelContext.insert(newItem)
                // 오토세이브 덕분에 별도 save() 불필요
            }
        }
    }
}

ModelConfiguration: 저장 방식 세부 설정

기본 설정 외에 커스텀 저장 경로, 읽기 전용 모드, CloudKit 컨테이너 지정이 필요할 때 사용합니다. 특히 테스트 환경에서 인메모리 저장소를 구성할 때 정말 유용합니다.

// 커스텀 컨테이너 구성 예시
let config = ModelConfiguration(
    schema: Schema([TodoItem.self, Category.self]),
    isStoredInMemoryOnly: false,   // true: 테스트용 인메모리 저장
    cloudKitDatabase: .automatic   // iCloud 자동 동기화
)

let container = try ModelContainer(
    for: TodoItem.self,
    configurations: config
)

@Model 매크로로 데이터 모델 정의하기

@Model은 SwiftData의 핵심 매크로입니다. 일반 Swift 클래스에 적용하면 해당 클래스가 데이터베이스 엔티티로 변환됩니다. 내부적으로는 PersistentModel 프로토콜을 채택하고 관찰 가능한 속성으로 변환하는 코드가 컴파일 타임에 생성됩니다. Core Data의 그것과 비교하면 놀라울 만큼 깔끔합니다.

import SwiftData

@Model
final class TodoItem {
    var title: String
    var isCompleted: Bool
    var createdAt: Date
    var priority: Int

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

모든 저장 속성은 자동으로 데이터베이스 컬럼이 됩니다. Core Data와 달리 @Published 없이도 변경사항이 추적되며, SwiftUI 뷰가 해당 속성을 읽고 있다면 변경 시 자동으로 리렌더링됩니다.

@Attribute: 컬럼 속성 세부 제어

고유값 제약이 필요하거나 대용량 바이너리 데이터를 외부에 저장해야 하는 경우 @Attribute를 사용합니다.

@Model
final class User {
    @Attribute(.unique) var email: String         // 이메일 유일성 보장
    var username: String
    @Attribute(.externalStorage) var avatar: Data? // 큰 이미지는 외부 파일로 저장
    @Attribute(originalName: "full_name") var fullName: String // DB 컬럼명 유지

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

originalName: 파라미터는 속성 이름을 변경할 때 데이터 손실 없이 마이그레이션하기 위해 사용합니다. 이전 컬럼명을 명시하면 SwiftData가 자동으로 데이터를 보존합니다. 이 파라미터를 알고 나서 마이그레이션이 훨씬 편해졌습니다.

@Transient: 저장하지 않는 속성

계산된 값이나 UI 상태처럼 데이터베이스에 저장할 필요 없는 속성은 @Transient로 표시합니다. 뷰 레이어에서 임시로 사용하는 상태값에 활용하세요.

@Model
final class TodoItem {
    var title: String
    var isCompleted: Bool
    @Transient var isBeingEdited: Bool = false  // 저장 안 됨, 편집 UI 상태용

    init(title: String) {
        self.title = title
        self.isCompleted = false
    }
}

@Query로 데이터 조회하기

@Query는 SwiftUI 뷰에서 데이터를 실시간으로 가져오는 프로퍼티 래퍼입니다. Core Data의 @FetchRequest에 해당하지만 훨씬 간결하고 타입 안전합니다. 데이터가 변경되면 뷰가 자동으로 업데이트되고요.

struct TodoListView: View {
    // 기본 쿼리: 모든 TodoItem
    @Query private var allItems: [TodoItem]

    // 정렬 옵션 포함
    @Query(sort: \TodoItem.createdAt, order: .reverse)
    private var sortedItems: [TodoItem]

    // 필터 조건: 미완료 항목만
    @Query(filter: #Predicate { $0.isCompleted == false })
    private var pendingItems: [TodoItem]

    var body: some View {
        List(pendingItems) { item in
            HStack {
                Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle")
                Text(item.title)
            }
        }
    }
}

#Predicate 매크로는 컴파일 타임에 타입 안전성을 검사하므로 런타임 오류를 사전에 방지할 수 있습니다. Swift 문법으로 복잡한 조건도 자연스럽게 표현됩니다.

// 복합 조건: 미완료이면서 우선순위 높은 항목
@Query(filter: #Predicate { item in
    item.isCompleted == false && item.priority > 1
}, sort: \TodoItem.priority, order: .reverse)
private var urgentItems: [TodoItem]

동적 필터 적용하기

사용자 입력에 따라 필터를 바꿔야 할 때는 init 파라미터로 predicate를 주입하는 패턴을 사용합니다. 처음에는 좀 낯설 수 있지만, 이 패턴 하나만 익혀두면 대부분의 동적 필터링 요구사항을 해결할 수 있습니다.

struct FilteredTodoList: View {
    @Query private var items: [TodoItem]

    init(showCompleted: Bool) {
        let predicate = #Predicate { item in
            showCompleted ? true : item.isCompleted == false
        }
        _items = Query(filter: predicate, sort: \TodoItem.createdAt)
    }

    var body: some View {
        List(items) { item in
            Text(item.title)
        }
    }
}

관계(Relationships) 설정하기

실제 앱의 데이터 모델은 항상 서로 연결되어 있습니다. SwiftData는 Swift 타입 시스템을 활용해 관계를 직관적으로 표현합니다. Core Data의 그래픽 에디터가 그리웠던 분이라도 코드만으로도 충분히 명확하게 관계를 정의할 수 있습니다.

일대다(One-to-Many) 관계

@Model
final class Category {
    var name: String
    var colorHex: String
    // Category는 여러 TodoItem을 가짐 — cascade: 카테고리 삭제 시 항목도 함께 삭제
    @Relationship(deleteRule: .cascade) var items: [TodoItem] = []

    init(name: String, colorHex: String = "#007AFF") {
        self.name = name
        self.colorHex = colorHex
    }
}

@Model
final class TodoItem {
    var title: String
    var isCompleted: Bool
    // 역방향 관계: 각 TodoItem은 하나의 Category에 속함 (optional)
    var category: Category?

    init(title: String) {
        self.title = title
        self.isCompleted = false
    }
}

deleteRule에는 .cascade(연결 객체 함께 삭제), .nullify(관계를 nil로 설정, 기본값), .deny(연결 객체가 있으면 삭제 거부), .noAction(아무 처리 안 함) 네 가지가 있습니다. 데이터 무결성 설계 시 신중하게 선택하세요 — 특히 .cascade는 예상치 못한 데이터 삭제를 일으킬 수 있어 주의가 필요합니다.

다대다(Many-to-Many) 관계

@Model
final class Article {
    var title: String
    var body: String
    // 게시글은 여러 태그를 가질 수 있음
    @Relationship(inverse: \Tag.articles) var tags: [Tag] = []

    init(title: String, body: String) {
        self.title = title
        self.body = body
    }
}

@Model
final class Tag {
    var name: String
    // 태그도 여러 게시글에 속할 수 있음
    var articles: [Article] = []

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

관계 데이터 삽입 및 조회 예시

// 카테고리와 할 일 항목 함께 저장
func addSampleData(context: ModelContext) {
    let workCategory = Category(name: "업무", colorHex: "#FF3B30")
    let item1 = TodoItem(title: "분기 보고서 작성")
    let item2 = TodoItem(title: "팀 미팅 준비")

    item1.category = workCategory
    item2.category = workCategory

    context.insert(workCategory)
    // item1, item2는 관계를 통해 자동으로 저장됨
}

// 특정 카테고리의 미완료 항목 조회
func pendingItems(in category: Category) -> [TodoItem] {
    return category.items.filter { !$0.isCompleted }
}

스키마 마이그레이션: 앱 업데이트 시 데이터 보존하기

앱을 업데이트하면서 모델 구조를 변경할 때 기존 사용자의 데이터를 잃지 않으려면 스키마 마이그레이션이 필수입니다. 마이그레이션 계획 없이 모델을 변경하면 앱이 크래시하거나 기존 데이터가 손실될 수 있습니다. 이 부분은 정말 중요하니 꼭 짚고 넘어가야 합니다.

효과적인 마이그레이션 전략을 세우기 전에 Swift Testing 프레임워크 완벽 가이드를 참고해 마이그레이션 전후 데이터 무결성을 검증하는 테스트를 먼저 작성하는 것을 권장합니다.

1단계: VersionedSchema 정의

각 스키마 버전을 별도의 열거형으로 캡슐화합니다. 이전 버전의 모델 정의를 보존해야 SwiftData가 마이그레이션 경로를 추적할 수 있습니다.

import SwiftData

// 버전 1: 초기 출시 스키마
enum SchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] { [TodoItem.self] }

    @Model
    final class TodoItem {
        var title: String
        var isCompleted: Bool

        init(title: String) {
            self.title = title
            self.isCompleted = false
        }
    }
}

// 버전 2: priority 필드 추가
enum SchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] { [TodoItem.self] }

    @Model
    final class TodoItem {
        var title: String
        var isCompleted: Bool
        var priority: Int  // 신규 추가

        init(title: String, priority: Int = 0) {
            self.title = title
            self.isCompleted = false
            self.priority = priority
        }
    }
}

2단계: SchemaMigrationPlan 정의

경량 마이그레이션(lightweight)은 필드 추가/삭제/이름 변경처럼 단순한 변경에 사용합니다. 데이터 변환 로직이 필요한 복잡한 변경에는 커스텀 마이그레이션을 사용합니다.

enum TodoMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] {
        [SchemaV1.self, SchemaV2.self]
    }

    static var stages: [MigrationStage] {
        [migrateV1toV2]
    }

    // 경량 마이그레이션: priority는 기본값 0으로 채워짐
    static let migrateV1toV2 = MigrationStage.lightweight(
        fromVersion: SchemaV1.self,
        toVersion: SchemaV2.self
    )
}

기존 데이터를 기반으로 새 필드 값을 계산해야 한다면 커스텀 마이그레이션을 사용합니다.

// 커스텀 마이그레이션: 제목에 "[긴급]"이 있으면 높은 우선순위 자동 부여
static let migrateV1toV2 = MigrationStage.custom(
    fromVersion: SchemaV1.self,
    toVersion: SchemaV2.self,
    willMigrate: nil,
    didMigrate: { context in
        let allItems = try context.fetch(FetchDescriptor())
        for item in allItems {
            item.priority = item.title.hasPrefix("[긴급]") ? 3 : 0
        }
        try context.save()
    }
)

3단계: ModelContainer에 마이그레이션 계획 적용

@main
struct TodoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(
            for: SchemaV2.TodoItem.self,
            migrationPlan: TodoMigrationPlan.self
        )
    }
}

iOS 26 SwiftData 새 기능: 클래스 상속 지원

iOS 26에서 SwiftData는 클래스 상속을 정식 지원합니다. 이를 통해 공통 속성을 부모 모델에 정의하고 서브클래스로 특화된 데이터를 추가할 수 있어 콘텐츠 플랫폼, 미디어 앱 등 다형성 데이터 구조가 필요한 앱에서 특히 유용합니다. 개인적으로 이 기능이 추가되면서 복잡한 콘텐츠 타입을 다루는 앱 설계가 훨씬 자연스러워졌다고 느낍니다.

// iOS 26+: 부모 모델 클래스
@Model
class BaseContent {
    var title: String
    var createdAt: Date
    var authorId: String

    init(title: String, authorId: String) {
        self.title = title
        self.createdAt = Date()
        self.authorId = authorId
    }
}

// 글 콘텐츠 서브클래스
@Model
final class Article: BaseContent {
    var body: String
    var readTimeMinutes: Int

    init(title: String, body: String, authorId: String) {
        self.body = body
        self.readTimeMinutes = max(1, body.split(separator: " ").count / 200)
        super.init(title: title, authorId: authorId)
    }
}

// 비디오 콘텐츠 서브클래스
@Model
final class VideoContent: BaseContent {
    var videoURL: URL
    var durationSeconds: TimeInterval

    init(title: String, videoURL: URL, duration: TimeInterval, authorId: String) {
        self.videoURL = videoURL
        self.durationSeconds = duration
        super.init(title: title, authorId: authorId)
    }
}

상속 계층이 있는 스키마에서 기존 버전으로부터 마이그레이션할 때는 경량 MigrationStage를 추가해야 합니다. iOS 26의 Foundation Models와 SwiftData를 조합해 AI 기반 앱을 만드는 방법은 iOS 26 Foundation Models 완벽 가이드를 참고하세요.

실전 팁: SwiftData 성능과 안정성

백그라운드 컨텍스트로 대량 데이터 처리

네트워크에서 수백 개의 데이터를 가져와 저장할 때는 백그라운드 컨텍스트를 사용해 메인 스레드를 블로킹하지 않아야 합니다. 메인 스레드에서 대량 삽입을 하면 UI가 버벅이는 현상을 경험하게 되는데, 이 패턴으로 해결할 수 있습니다.

func importItems(from remote: [RemoteItem], container: ModelContainer) async throws {
    let context = ModelContext(container)
    for remoteItem in remote {
        let item = TodoItem(title: remoteItem.title, priority: remoteItem.urgency)
        context.insert(item)
    }
    try context.save()
}

단위 테스트용 인메모리 컨테이너

func makeTestContainer() throws -> ModelContainer {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    return try ModelContainer(for: TodoItem.self, configurations: config)
}

FetchDescriptor로 코드에서 직접 쿼리

뷰 외부(서비스 계층, 백그라운드 작업 등)에서 데이터를 가져올 때는 FetchDescriptor를 사용합니다. @Query는 SwiftUI 뷰에서만 사용할 수 있어서, 비즈니스 로직 계층에서는 이 방식이 표준입니다.

func fetchHighPriority(context: ModelContext) throws -> [TodoItem] {
    let descriptor = FetchDescriptor(
        predicate: #Predicate { $0.priority >= 3 },
        sortBy: [SortDescriptor(\TodoItem.createdAt, order: .reverse)]
    )
    return try context.fetch(descriptor)
}

자주 묻는 질문

SwiftData와 Core Data를 같은 앱에서 함께 사용할 수 있나요?

네, 가능합니다. Apple은 점진적 마이그레이션을 위해 두 스택을 동시에 실행할 수 있도록 설계했습니다. NSPersistentContainerModelContainer를 모두 앱 초기화 시점에 구성한 뒤, 뷰나 기능별로 어느 스택을 사용할지 선택하면 됩니다.

SwiftData는 iOS 16에서도 사용할 수 있나요?

아니요, SwiftData는 iOS 17, macOS 14, watchOS 10, tvOS 17 이상에서만 사용 가능합니다. iOS 16 이하를 지원해야 한다면 Core Data나 SQLite 기반 라이브러리를 사용해야 합니다.

스키마를 변경했을 때 앱이 크래시하는 이유는 무엇인가요?

마이그레이션 계획 없이 스키마를 변경하면 SwiftData가 기존 데이터베이스 파일과 새 스키마의 불일치를 감지해 ModelContainer 생성에 실패합니다. VersionedSchemaSchemaMigrationPlan을 반드시 정의하고, 개발 중에는 앱을 재설치해 데이터베이스를 초기화하는 방법으로 우회할 수 있습니다.

@Query에서 런타임에 동적으로 필터를 변경하는 방법은?

상위 뷰에서 필터 조건을 init 파라미터로 자식 뷰에 주입하는 패턴을 사용합니다. 자식 뷰의 init에서 _items = Query(filter: predicate)처럼 Query를 초기화하면 부모에서 전달된 조건이 반영됩니다.

SwiftData는 CloudKit과 자동으로 동기화되나요?

네, ModelConfiguration에서 cloudKitDatabase: .automatic을 설정하고 Xcode의 Signing & Capabilities에서 CloudKit을 활성화하면 iCloud 동기화가 자동으로 설정됩니다. 단, @Attribute(.unique)가 적용된 속성은 CloudKit과 호환되지 않을 수 있어 주의가 필요합니다.

저자 소개 Mei-Lin Chen

Mei-Lin joined Robinhood in 2020 as an iOS engineer on the Crypto team and stayed through the SwiftUI rewrite of the order-entry flow before leaving in 2025. She also did a two-year stint at Asana earlier in her career working on the iPad app and the Mac Catalyst port. She writes about the parts of Apple's frameworks that the WWDC talks gloss over - what Observable actually does to your view-update graph, why @Bindable bindings tear in some animation contexts, and the surprisingly deep rabbit hole of Swift macros for boilerplate elimination. She has shipped two indie apps to the App Store, one of which hit #4 in the Health & Fitness category for a week in 2023. Mei-Lin is based in Seattle and has been writing Swift for 8 years.