Core Data에서 SwiftData로의 마이그레이션: 단계별 가이드 2026
iOS 앱을 Core Data에서 SwiftData로 마이그레이션하기 위한 완전 가이드입니다. 실무 예제, 공존 전략 및 모범 사례를 다룹니다.

SwiftData는 Apple 플랫폼에서 데이터 영속성의 미래를 대표합니다. WWDC 2023에서 소개된 이 프레임워크는 네이티브 Swift 구문과 SwiftUI와의 매끄러운 통합을 제공합니다. 기존 Core Data 애플리케이션의 경우 마이그레이션은 보다 현대적이고 유지 관리하기 쉬운 코드로 나아가는 전략적 단계가 됩니다.
이 가이드는 Core Data에서 SwiftData로의 전체 마이그레이션 프로세스를 상세히 설명합니다. 호환성 평가, 모델 변환, 데이터 마이그레이션 전략, 그리고 점진적인 전환을 위한 공존 패턴을 다룹니다.
마이그레이션 실행 가능성 평가
마이그레이션을 시작하기 전에 철저한 평가를 통해 잠재적 장애물을 식별할 수 있습니다. Core Data와 SwiftData는 동일한 SQLite 영속성 엔진을 공유하기 때문에 데이터가 완전히 호환됩니다.
// Migration assessment checklist
/*
FEATURES SUPPORTED BY SWIFTDATA:
✅ Simple models with basic properties
✅ One-to-one and one-to-many relationships
✅ Optional properties and default values
✅ Transformable attributes (via Codable)
✅ CloudKit synchronization (basic)
✅ Automatic lightweight migrations
✅ Class inheritance (iOS 26+)
FEATURES REQUIRING ATTENTION:
⚠️ NSFetchedResultsController → @Query + manual observation
⚠️ NSCompoundPredicate → #Predicate with combined logic
⚠️ Dynamic predicates → Workarounds required
UNSUPPORTED FEATURES:
❌ Advanced CloudKit Sharing
❌ Derived attributes
❌ Fetched properties
*/
// Example of typical Core Data model to migrate
import CoreData
// Existing Core Data entity
class CDTask: NSManagedObject {
@NSManaged var id: UUID
@NSManaged var title: String
@NSManaged var isCompleted: Bool
@NSManaged var createdAt: Date
@NSManaged var priority: Int16
@NSManaged var category: CDCategory?
}
class CDCategory: NSManagedObject {
@NSManaged var id: UUID
@NSManaged var name: String
@NSManaged var color: String
@NSManaged var tasks: NSSet?
}데이터 수준의 호환성은 사용자가 마이그레이션 후에도 기존 정보를 유지한다는 것을 의미합니다. 프로세스를 올바르게 실행하면 데이터 손실이 발생하지 않습니다.
Core Data 모델을 SwiftData로 변환
첫 번째 구체적 단계는 Core Data 엔티티를 SwiftData 클래스로 변환하는 것입니다. Xcode가 자동 도구를 제공하지만 수동 프로세스를 이해하는 것이 여전히 중요합니다.
import SwiftData
// SwiftData equivalent of CDTask
@Model
final class Task {
// Properties with default values
var id: UUID = UUID()
var title: String = ""
var isCompleted: Bool = false
var createdAt: Date = Date()
var priority: Int = 0
// Optional relationship to Category
var category: Category?
// Explicit initializer recommended
init(
id: UUID = UUID(),
title: String,
isCompleted: Bool = false,
createdAt: Date = Date(),
priority: Int = 0,
category: Category? = nil
) {
self.id = id
self.title = title
self.isCompleted = isCompleted
self.createdAt = createdAt
self.priority = priority
self.category = category
}
}Core Data와의 주요 차이점에는 NSManagedObject 대신 @Model 매크로를 사용하는 것과 Objective-C 타입 대신 네이티브 Swift 타입을 사용하는 것이 포함됩니다.
import SwiftData
@Model
final class Category {
var id: UUID = UUID()
var name: String = ""
var color: String = "blue"
// Inverse relationship with delete rule
@Relationship(deleteRule: .cascade, inverse: \Task.category)
var tasks: [Task] = []
init(id: UUID = UUID(), name: String, color: String = "blue") {
self.id = id
self.name = name
self.color = color
}
}Core Data 타입은 직접 변환됩니다. Int16은 Int로, NSSet은 [Model]로 변환되며 Date는 Date 그대로 유지됩니다. Transformable 속성은 Codable 채택이 필요합니다.
ModelContainer 구성
SwiftData의 ModelContainer는 Core Data의 NSPersistentContainer를 대체합니다. 구성에 따라 데이터가 어디에 어떻게 저장되는지가 결정됩니다.
import SwiftData
import SwiftUI
@main
struct TaskManagerApp: App {
// SwiftData container configuration
var sharedModelContainer: ModelContainer = {
// Schema including all models
let schema = Schema([
Task.self,
Category.self
])
// Configuration with storage options
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
// Use the same store as Core Data
url: URL.applicationSupportDirectory
.appending(path: "TaskManager.sqlite")
)
do {
return try ModelContainer(
for: schema,
configurations: [modelConfiguration]
)
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}핵심 포인트는 store URL에 있습니다. Core Data와 동일한 SQLite 파일을 사용하면 SwiftData가 기존 데이터를 읽을 수 있습니다.
Core Data와 SwiftData의 공존 전략
복잡한 애플리케이션의 경우 두 프레임워크의 공존을 통한 점진적 마이그레이션이 가장 안전한 접근 방식입니다. 두 스택 모두 동일한 SQLite 파일에 접근할 수 있습니다.
import CoreData
import SwiftData
// Configuration for coexistence
class PersistenceController {
static let shared = PersistenceController()
// Shared store between Core Data and SwiftData
private let storeURL: URL = {
let appSupport = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)
.first!
return appSupport.appending(path: "TaskManager.sqlite")
}()
// MARK: - Core Data Stack (existing)
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "TaskManager")
// Configure to use shared store
let description = NSPersistentStoreDescription(url: storeURL)
description.setOption(
true as NSNumber,
forKey: NSPersistentHistoryTrackingKey
)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error = error as NSError? {
fatalError("Core Data error: \(error)")
}
}
return container
}()
// MARK: - SwiftData Stack (new)
lazy var swiftDataContainer: ModelContainer = {
let schema = Schema([Task.self, Category.self])
let config = ModelConfiguration(
schema: schema,
url: storeURL,
// Disable automatic migrations in coexistence
allowsSave: true
)
do {
return try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("SwiftData error: \(error)")
}
}()
}공존 모드에서는 한 프레임워크가 수행한 변경 사항이 다른 프레임워크에 즉시 반영되지 않습니다. 명시적인 다시 로드 또는 앱 재시작이 필요할 수 있습니다.
쿼리 마이그레이션: NSFetchRequest에서 @Query로
가장 중요한 차이점은 데이터를 가져오는 방법과 관련이 있습니다. SwiftUI는 @FetchRequest를 대체하기 위해 @Query 프로퍼티 래퍼를 사용합니다.
import SwiftUI
import SwiftData
// ❌ Old pattern with Core Data
struct OldTaskListView: View {
@FetchRequest(
sortDescriptors: [
NSSortDescriptor(keyPath: \CDTask.createdAt, ascending: false)
],
predicate: NSPredicate(format: "isCompleted == NO")
)
private var tasks: FetchedResults<CDTask>
var body: some View {
List(tasks) { task in
Text(task.title)
}
}
}
// ✅ New pattern with SwiftData
struct NewTaskListView: View {
// @Query with built-in sorting and filtering
@Query(
filter: #Predicate<Task> { !$0.isCompleted },
sort: \Task.createdAt,
order: .reverse
)
private var tasks: [Task]
var body: some View {
List(tasks) { task in
TaskRowView(task: task)
}
}
}
struct TaskRowView: View {
let task: Task
var body: some View {
HStack {
// Priority indicator
Circle()
.fill(priorityColor)
.frame(width: 8, height: 8)
VStack(alignment: .leading) {
Text(task.title)
.font(.headline)
if let category = task.category {
Text(category.name)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
// Date badge
Text(task.createdAt, style: .date)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
private var priorityColor: Color {
switch task.priority {
case 3: return .red
case 2: return .orange
case 1: return .yellow
default: return .gray
}
}
}iOS 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
동적 술어 처리
SwiftData에서 큰 도전 중 하나는 동적 술어와 관련이 있습니다. 술어를 즉석에서 수정할 수 있는 Core Data와 달리 @Query는 대안적인 접근 방식이 필요합니다.
import SwiftUI
import SwiftData
// Solution 1: Use @Query with custom init
struct FilteredTasksView: View {
@Query private var tasks: [Task]
// Create view with specific filter
init(showCompleted: Bool, categoryId: UUID?) {
// Build predicate based on parameters
var predicates: [Predicate<Task>] = []
if !showCompleted {
predicates.append(#Predicate { !$0.isCompleted })
}
if let categoryId {
predicates.append(#Predicate { task in
task.category?.id == categoryId
})
}
// Combine predicates
let combinedPredicate: Predicate<Task>?
if predicates.isEmpty {
combinedPredicate = nil
} else if predicates.count == 1 {
combinedPredicate = predicates[0]
} else {
// Manually combine for AND logic
combinedPredicate = #Predicate<Task> { task in
!task.isCompleted && task.category?.id == categoryId
}
}
_tasks = Query(
filter: combinedPredicate,
sort: \Task.createdAt,
order: .reverse
)
}
var body: some View {
List(tasks) { task in
TaskRowView(task: task)
}
}
}
// Solution 2: View-side filtering with all results
struct SmartTaskListView: View {
// Fetch all tasks
@Query(sort: \Task.createdAt, order: .reverse)
private var allTasks: [Task]
// Filter state
@State private var searchText = ""
@State private var showCompleted = false
@State private var selectedCategory: Category?
// Computed filtering
private var filteredTasks: [Task] {
allTasks.filter { task in
// Text filter
let matchesSearch = searchText.isEmpty ||
task.title.localizedCaseInsensitiveContains(searchText)
// Status filter
let matchesStatus = showCompleted || !task.isCompleted
// Category filter
let matchesCategory = selectedCategory == nil ||
task.category?.id == selectedCategory?.id
return matchesSearch && matchesStatus && matchesCategory
}
}
var body: some View {
NavigationStack {
List(filteredTasks) { task in
TaskRowView(task: task)
}
.searchable(text: $searchText)
.toolbar {
FilterMenu(
showCompleted: $showCompleted,
selectedCategory: $selectedCategory
)
}
}
}
}버전 관리 스키마 마이그레이션
데이터 모델이 진화할 때 SwiftData는 복잡한 마이그레이션을 관리하기 위해 VersionedSchema를 사용합니다.
import SwiftData
// Version 1: Initial schema
enum TaskSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Task.self, Category.self]
}
@Model
final class Task {
var id: UUID = UUID()
var title: String = ""
var isCompleted: Bool = false
var createdAt: Date = Date()
var category: Category?
init(title: String) {
self.title = title
}
}
@Model
final class Category {
var id: UUID = UUID()
var name: String = ""
@Relationship(deleteRule: .cascade, inverse: \Task.category)
var tasks: [Task] = []
init(name: String) {
self.name = name
}
}
}
// Version 2: Added priority and notes fields
enum TaskSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Task.self, Category.self]
}
@Model
final class Task {
var id: UUID = UUID()
var title: String = ""
var isCompleted: Bool = false
var createdAt: Date = Date()
// New properties with default values
var priority: Int = 0
var notes: String = ""
var category: Category?
init(title: String, priority: Int = 0) {
self.title = title
self.priority = priority
}
}
@Model
final class Category {
var id: UUID = UUID()
var name: String = ""
// New property
var color: String = "blue"
@Relationship(deleteRule: .cascade, inverse: \Task.category)
var tasks: [Task] = []
init(name: String, color: String = "blue") {
self.name = name
self.color = color
}
}
}마이그레이션 계획은 버전 순서와 필요한 사용자 정의 마이그레이션을 정의합니다.
import SwiftData
enum TaskMigrationPlan: SchemaMigrationPlan {
// Chronological order of schemas
static var schemas: [any VersionedSchema.Type] {
[TaskSchemaV1.self, TaskSchemaV2.self]
}
// Migration stages
static var stages: [MigrationStage] {
[migrateV1toV2]
}
// V1 → V2 migration: lightweight (properties with defaults)
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: TaskSchemaV1.self,
toVersion: TaskSchemaV2.self
)
}
// Container configuration with migration
@main
struct TaskManagerApp: App {
var sharedModelContainer: ModelContainer = {
do {
return try ModelContainer(
for: TaskSchemaV2.Task.self, TaskSchemaV2.Category.self,
migrationPlan: TaskMigrationPlan.self
)
} catch {
fatalError("Migration failed: \(error)")
}
}()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(sharedModelContainer)
}
}SwiftData는 경량 마이그레이션(기본값 포함 속성 추가, 이름 변경, 삭제)을 자동으로 처리합니다. 데이터 변환이 필요한 복잡한 마이그레이션은 MigrationStage.custom을 사용합니다.
NSFetchedResultsController 대체
섹션이 있는 목록이나 변경 사항의 세분화된 관찰을 위해 데이터 추출과 결합된 @Query가 NSFetchedResultsController를 대체합니다.
import SwiftUI
import SwiftData
struct SectionedTaskListView: View {
@Query(sort: \Task.createdAt, order: .reverse)
private var tasks: [Task]
// Grouping by category
private var tasksByCategory: [(Category?, [Task])] {
Dictionary(grouping: tasks) { $0.category }
.map { ($0.key, $0.value) }
.sorted { first, second in
// Tasks without category last
guard let firstName = first.0?.name else { return false }
guard let secondName = second.0?.name else { return true }
return firstName < secondName
}
}
var body: some View {
List {
ForEach(tasksByCategory, id: \.0?.id) { category, categoryTasks in
Section(header: SectionHeader(category: category)) {
ForEach(categoryTasks) { task in
TaskRowView(task: task)
}
}
}
}
}
}
struct SectionHeader: View {
let category: Category?
var body: some View {
HStack {
if let category {
Circle()
.fill(Color(category.color))
.frame(width: 12, height: 12)
Text(category.name)
} else {
Text("Uncategorized")
.foregroundStyle(.secondary)
}
}
}
}
// Alternative: Grouping by date
struct DateGroupedTasksView: View {
@Query(sort: \Task.createdAt, order: .reverse)
private var tasks: [Task]
private var tasksByDate: [(Date, [Task])] {
let calendar = Calendar.current
let grouped = Dictionary(grouping: tasks) { task in
calendar.startOfDay(for: task.createdAt)
}
return grouped
.map { ($0.key, $0.value) }
.sorted { $0.0 > $1.0 }
}
var body: some View {
List {
ForEach(tasksByDate, id: \.0) { date, dateTasks in
Section(header: Text(date, style: .date)) {
ForEach(dateTasks) { task in
TaskRowView(task: task)
}
}
}
}
}
}ModelContext를 통한 CRUD 작업
ModelContext는 모든 생성, 읽기, 업데이트 및 삭제 작업에서 NSManagedObjectContext를 대체합니다.
import SwiftUI
import SwiftData
struct TaskManagementView: View {
@Environment(\.modelContext) private var modelContext
@Query private var tasks: [Task]
@Query private var categories: [Category]
@State private var newTaskTitle = ""
@State private var selectedCategory: Category?
var body: some View {
NavigationStack {
VStack {
// Add form
AddTaskForm(
title: $newTaskTitle,
category: $selectedCategory,
categories: categories,
onAdd: addTask
)
// Task list
List {
ForEach(tasks) { task in
TaskRowView(task: task)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
deleteTask(task)
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
toggleCompletion(task)
} label: {
Label(
task.isCompleted ? "Todo" : "Done",
systemImage: task.isCompleted ? "circle" : "checkmark"
)
}
.tint(task.isCompleted ? .orange : .green)
}
}
}
}
.navigationTitle("Tasks")
}
}
// CREATE
private func addTask() {
guard !newTaskTitle.isEmpty else { return }
let task = Task(
title: newTaskTitle,
category: selectedCategory
)
// Insert into context
modelContext.insert(task)
// Explicit save (optional - autosave enabled by default)
do {
try modelContext.save()
} catch {
print("Save error: \(error)")
}
// Reset form
newTaskTitle = ""
selectedCategory = nil
}
// UPDATE
private func toggleCompletion(_ task: Task) {
// Direct modification - SwiftData tracks automatically
task.isCompleted.toggle()
// Automatic save handles persistence
}
// DELETE
private func deleteTask(_ task: Task) {
modelContext.delete(task)
}
}
struct AddTaskForm: View {
@Binding var title: String
@Binding var category: Category?
let categories: [Category]
let onAdd: () -> Void
var body: some View {
VStack(spacing: 12) {
TextField("New task...", text: $title)
.textFieldStyle(.roundedBorder)
HStack {
Picker("Category", selection: $category) {
Text("None").tag(nil as Category?)
ForEach(categories) { cat in
Text(cat.name).tag(cat as Category?)
}
}
.pickerStyle(.menu)
Button("Add", action: onAdd)
.buttonStyle(.borderedProminent)
.disabled(title.isEmpty)
}
}
.padding()
}
}SwiftData를 활용한 단위 테스트
견고한 테스트 전략은 마이그레이션 검증을 용이하게 합니다. SwiftData를 사용하면 테스트용 인메모리 컨테이너를 만들 수 있습니다.
import XCTest
import SwiftData
@testable import TaskManager
final class TaskModelTests: XCTestCase {
var container: ModelContainer!
var context: ModelContext!
override func setUpWithError() throws {
// In-memory container for tests
let config = ModelConfiguration(isStoredInMemoryOnly: true)
container = try ModelContainer(
for: Task.self, Category.self,
configurations: config
)
context = ModelContext(container)
}
override func tearDownWithError() throws {
container = nil
context = nil
}
func testCreateTask() throws {
// Given
let task = Task(title: "Test Task")
// When
context.insert(task)
try context.save()
// Then
let descriptor = FetchDescriptor<Task>()
let tasks = try context.fetch(descriptor)
XCTAssertEqual(tasks.count, 1)
XCTAssertEqual(tasks.first?.title, "Test Task")
}
func testTaskCategoryRelationship() throws {
// Given
let category = Category(name: "Work", color: "blue")
let task = Task(title: "Meeting", category: category)
// When
context.insert(category)
context.insert(task)
try context.save()
// Then
XCTAssertEqual(task.category?.name, "Work")
XCTAssertTrue(category.tasks.contains(task))
}
func testDeleteCategoryCascade() throws {
// Given
let category = Category(name: "Personal")
let task1 = Task(title: "Task 1", category: category)
let task2 = Task(title: "Task 2", category: category)
context.insert(category)
context.insert(task1)
context.insert(task2)
try context.save()
// When
context.delete(category)
try context.save()
// Then - cascade delete should remove tasks
let descriptor = FetchDescriptor<Task>()
let remainingTasks = try context.fetch(descriptor)
XCTAssertEqual(remainingTasks.count, 0)
}
func testFilteredFetch() throws {
// Given
let task1 = Task(title: "Completed", isCompleted: true)
let task2 = Task(title: "Pending", isCompleted: false)
let task3 = Task(title: "Also Pending", isCompleted: false)
[task1, task2, task3].forEach { context.insert($0) }
try context.save()
// When
var descriptor = FetchDescriptor<Task>(
predicate: #Predicate { !$0.isCompleted }
)
let pendingTasks = try context.fetch(descriptor)
// Then
XCTAssertEqual(pendingTasks.count, 2)
}
}완전한 마이그레이션 체크리스트
다음은 성공적인 마이그레이션을 위한 단계의 요약입니다.
/*
PHASE 1: PREPARATION
□ Audit Core Data features in use
□ Identify features not supported by SwiftData
□ Create dedicated migration branch
□ Back up test data
PHASE 2: MODEL CONVERSION
□ Convert NSManagedObject entities to @Model
□ Adapt relationships with @Relationship
□ Configure appropriate delete rules
□ Add required default values
PHASE 3: CONFIGURATION
□ Create ModelContainer with existing store URL
□ Configure versioned schema if needed
□ Define migration plan
□ Test in coexistence mode if applicable
PHASE 4: CODE MIGRATION
□ Replace @FetchRequest with @Query
□ Adapt predicates to #Predicate
□ Migrate NSFetchedResultsController to manual grouping
□ Convert CRUD operations to ModelContext
PHASE 5: VALIDATION
□ Run all unit tests
□ Test migration with real data
□ Verify performance with Instruments
□ Validate CloudKit sync (if applicable)
PHASE 6: DEPLOYMENT
□ Document breaking changes
□ Prepare rollback plan
□ Deploy to TestFlight
□ Monitor post-deployment crashes
*/결론
Core Data에서 SwiftData로의 마이그레이션은 현대적인 iOS 애플리케이션을 위한 자연스러운 진화를 나타냅니다. SQLite 스토어 수준의 호환성이 사용자 데이터의 보존을 보장하며, 네이티브 Swift 구문은 코드를 상당히 단순화합니다.
핵심 정리
- ✅ SwiftData와 Core Data는 동일한 SQLite 엔진을 공유합니다
- ✅ 공존을 통해 점진적인 마이그레이션이 가능합니다
- ✅
@Query는 더 간단한 구문으로@FetchRequest를 대체합니다 - ✅ 동적 술어는 대안 패턴이 필요합니다
- ✅
VersionedSchema는 스키마 진화를 관리합니다 - ✅ 인메모리 테스트가 검증을 용이하게 합니다
- ✅ iOS 26은 클래스 상속 지원을 제공합니다
- ✅ Core Data의 특정 요구 사항이 없는 한 새 프로젝트는 SwiftData로 시작하는 것이 바람직합니다
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
태그
공유
관련 기사

Swift에서 Combine vs async/await: 점진적 마이그레이션 패턴
Swift에서 Combine에서 async/await로 마이그레이션하는 완전한 가이드: 점진적 전략, 브리징 패턴, iOS 코드베이스의 패러다임 공존.

2026년 iOS 접근성 면접 질문: VoiceOver와 Dynamic Type
iOS 면접 대비를 위한 핵심 접근성 질문: VoiceOver, Dynamic Type, 시맨틱 traits, 접근성 감사.

Swift Macros: 메타프로그래밍 실전 예제
Swift Macros 완전 가이드: freestanding 및 attached 매크로 작성, swift-syntax를 활용한 AST 조작, 보일러플레이트를 줄이는 실전 예제 제공.