iOS Core Data → SwiftData 마이그레이션: 현대적 영속성으로 전환

모바일 개발

SwiftDataCore DataiOS마이그레이션영속성

이 글은 누구를 위한 것인가

  • Core Data의 복잡한 설정 코드를 없애고 싶은 iOS 개발자
  • SwiftData가 무엇인지, Core Data와 어떻게 다른지 알고 싶은 팀
  • 기존 Core Data 앱을 SwiftData로 점진적으로 전환하려는 엔지니어

들어가며

SwiftData는 Swift 매크로 기반의 현대적 영속성 프레임워크다. Core Data 위에 구축됐지만, NSManagedObject나 .xcdatamodeld 파일 없이 @Model 하나로 시작할 수 있다.

이 글은 bluefoxdev.kr의 SwiftData 완전 가이드 를 참고하여 작성했습니다.


1. SwiftData vs Core Data 비교

[코드 비교]

Core Data:
  .xcdatamodeld 파일 필요
  NSManagedObject 상속
  @NSManaged 속성
  NSFetchRequest + NSPredicate
  NSManagedObjectContext 수동 관리

SwiftData:
  코드만으로 모델 정의
  @Model 매크로 하나
  일반 Swift 속성
  #Predicate 타입 안전 술어
  ModelContext 자동 관리

[SwiftData 지원 버전]
  iOS 17+, macOS 14+, watchOS 10+
  iOS 16 지원 필요하면 Core Data 유지

[마이그레이션 전략]
  1. 신규 앱: SwiftData 사용
  2. 기존 앱 iOS 17 최소 지원: SwiftData로 전환
  3. iOS 16 지원 필요: Core Data + SwiftData 공존
     또는 Core Data 유지

[데이터 마이그레이션]
  Stage 1: SwiftData 모델 정의 (Core Data와 동일 스키마)
  Stage 2: 앱 시작 시 Core Data → SwiftData 데이터 이전
  Stage 3: Core Data 코드 제거

2. SwiftData 구현 및 마이그레이션

import SwiftData
import SwiftUI

// Core Data 방식 (기존)
// @objc(Task)
// class Task: NSManagedObject {
//     @NSManaged var title: String
//     @NSManaged var isDone: Bool
//     @NSManaged var createdAt: Date
// }

// SwiftData 방식 (새로운)
@Model
final class Task {
    var title: String
    var isDone: Bool
    var createdAt: Date
    var priority: Int
    
    // 관계 (to-one)
    var category: Category?
    
    init(title: String, priority: Int = 0) {
        self.title = title
        self.isDone = false
        self.createdAt = Date()
        self.priority = priority
    }
}

@Model
final class Category {
    var name: String
    var colorHex: String
    
    // 관계 (to-many)
    @Relationship(deleteRule: .cascade, inverse: \Task.category)
    var tasks: [Task] = []
    
    init(name: String, colorHex: String = "#007AFF") {
        self.name = name
        self.colorHex = colorHex
    }
}

// ModelContainer 설정
struct AppConfiguration {
    static let schema = Schema([Task.self, Category.self])
    
    static func makeContainer(inMemory: Bool = false) -> ModelContainer {
        let config = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: inMemory,
            allowsSave: true,
        )
        
        do {
            return try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("ModelContainer 생성 실패: \(error)")
        }
    }
}

// SwiftUI 앱 진입점
@main
struct TaskApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(AppConfiguration.makeContainer())
    }
}

// 뷰에서 사용
struct TaskListView: View {
    @Environment(\.modelContext) private var context
    
    // #Predicate: 타입 안전한 술어 (NSPredicate 대체)
    @Query(
        filter: #Predicate<Task> { !$0.isDone },
        sort: [SortDescriptor(\Task.createdAt, order: .reverse)],
    )
    private var tasks: [Task]
    
    var body: some View {
        List(tasks) { task in
            TaskRow(task: task)
        }
        .toolbar {
            Button("추가") { addTask() }
        }
    }
    
    private func addTask() {
        let task = Task(title: "새 할 일")
        context.insert(task)
        // context.save()는 대부분 자동 처리
    }
}

struct TaskRow: View {
    let task: Task
    @Environment(\.modelContext) private var context
    
    var body: some View {
        HStack {
            Image(systemName: task.isDone ? "checkmark.circle.fill" : "circle")
                .onTapGesture {
                    task.isDone.toggle()  // 자동 감지 및 저장
                }
            Text(task.title)
            Spacer()
        }
        .swipeActions {
            Button("삭제", role: .destructive) {
                context.delete(task)
            }
        }
    }
}

// Core Data → SwiftData 데이터 마이그레이션
class DataMigrationManager {
    
    static func migrateIfNeeded(
        coreDataStack: CoreDataStack,
        modelContext: ModelContext,
    ) async {
        guard !UserDefaults.standard.bool(forKey: "swiftdata_migrated") else {
            return
        }
        
        let cdContext = coreDataStack.viewContext
        
        // Core Data에서 데이터 읽기
        let fetchRequest = NSFetchRequest<NSManagedObject>(entityName: "Task")
        guard let cdTasks = try? cdContext.fetch(fetchRequest) else { return }
        
        // SwiftData로 변환
        for cdTask in cdTasks {
            let task = Task(
                title: cdTask.value(forKey: "title") as? String ?? "",
                priority: cdTask.value(forKey: "priority") as? Int ?? 0,
            )
            task.isDone = cdTask.value(forKey: "isDone") as? Bool ?? false
            task.createdAt = cdTask.value(forKey: "createdAt") as? Date ?? Date()
            modelContext.insert(task)
        }
        
        try? modelContext.save()
        UserDefaults.standard.set(true, forKey: "swiftdata_migrated")
    }
}

// 고급: 스키마 버전 마이그레이션
enum AppSchemaV1: VersionedSchema {
    static var versionIdentifier = Schema.Version(1, 0, 0)
    static var models: [any PersistentModel.Type] = [Task.self]
    
    @Model final class Task {
        var title: String
        init(title: String) { self.title = title }
    }
}

enum AppSchemaV2: VersionedSchema {
    static var versionIdentifier = Schema.Version(2, 0, 0)
    static var models: [any PersistentModel.Type] = [Task.self]
    
    @Model final class Task {
        var title: String
        var priority: Int  // 새로 추가
        init(title: String, priority: Int = 0) {
            self.title = title
            self.priority = priority
        }
    }
}

enum AppMigrationPlan: SchemaMigrationPlan {
    static var schemas: [any VersionedSchema.Type] = [
        AppSchemaV1.self,
        AppSchemaV2.self,
    ]
    
    static var stages: [MigrationStage] = [
        MigrationStage.lightweight(
            fromVersion: AppSchemaV1.self,
            toVersion: AppSchemaV2.self,
        )
    ]
}

class CoreDataStack {
    var viewContext: NSManagedObjectContext { fatalError("stub") }
}

마무리

SwiftData는 iOS 17+에서만 사용 가능하다. 점진적 마이그레이션 순서: ①SwiftData 모델 정의(Core Data 스키마와 동일) → ②데이터 이전 코드 작성 → ③뷰 코드 변환(@FetchRequest → @Query) → ④Core Data 코드 제거. @Query는 뷰 리렌더링을 자동으로 처리해 NSFetchedResultsController가 필요 없다.