SwiftData 마이그레이션: 라이트웨이트 vs 커스텀, 그리고 V2가 필요 없는 경우
SwiftData의 스키마 마이그레이션 방식은 Core Data보다 구조적으로 개선되었지만, 팀들이 계속 빠지는 함정이 하나 있습니다. SwiftData가 인라인 기본값을 통해 자동으로 처리할 변경사항에 대해 새로운 VersionedSchema를 선언하는 것입니다. 그 결과는 코드가 올바르게 보이고 빌드도 깨끗하게 되었음에도 불구하고 디바이스에서 “Duplicate version checksums across stages detected” 크래시가 발생하는 것입니다. 프레임워크의 실제 마이그레이션 모델은 세 가지 요소(VersionedSchema, MigrationStage, SchemaMigrationPlan)와 세 가지 마이그레이션 유형(자동 라이트웨이트, 선언된 라이트웨이트, 커스텀)을 사용합니다1. 대부분의 스키마 변경은 자동입니다. 일부는 선언된 라이트웨이트 스테이지가 필요합니다. 소수는 willMigrate와 didMigrate 클로저가 있는 커스텀 스테이지가 필요합니다.
이 글은 Apple 문서에 비추어 마이그레이션 모델을 살펴보고, 각 마이그레이션 유형이 처리하는 사례를 명명하며, iOS 26의 새로운 클래스 상속 지원을 다룹니다. 프레임은 “내가 무엇을 선언해야 하는가 vs SwiftData가 나를 위해 무엇을 처리하는가”인데, 이 결정이 마이그레이션이 깨끗하게 출시되는지 아니면 첫 실행에서 크래시되는지를 결정하기 때문입니다.
TL;DR
- SwiftData 마이그레이션은 세 가지 프로토콜로 구성됩니다:
VersionedSchema(특정 버전의 모델 타입 스냅샷),MigrationStage(.lightweight또는.custom케이스를 가진 단일 fromVersion-to-toVersion 전환),SchemaMigrationPlan(스테이지의 순서 있는 목록)1. - 인라인 기본값(
var foo: Bool = false)을 가진 새@Model속성을 추가하는 것은 새로운VersionedSchema가 필요하지 않습니다. SwiftData는 추가를 라이트웨이트 마이그레이션으로 자동 처리합니다. 이를 위해 V2를 선언하면 “Duplicate version checksums across stages detected” 크래시가 발생합니다. - 라이트웨이트 마이그레이션이 처리하는 것: 엔티티, 속성, 관계의 추가/이름 변경/삭제; 관계 타입 변경; 이름 변경 추적을 위한
@Attribute(originalName:)선언; 삭제 규칙 지정. 대부분의 스키마 변경이 여기에 해당됩니다. - 커스텀 마이그레이션(
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))은 데이터 변환을 처리합니다: 한 컬럼을 둘로 분할, 파생 필드 계산, 모델 간 데이터 이동.willMigrate는 이전 컨텍스트를 가지고 있고;didMigrate는 새 컨텍스트를 가집니다. - iOS 26은
@Model타입에 클래스 상속을 추가합니다2. 상속을 채택하는 스키마는 이전 평면 모델 버전에서 라이트웨이트 스테이지로 새 버전으로 올라갑니다.
세 가지 요소 모델
SwiftData 마이그레이션은 세 가지 요소로 구성됩니다.
VersionedSchema
특정 스키마 버전의 모델 타입 스냅샷입니다1. 프로토콜은 다음을 요구합니다:
static var versionIdentifier: Schema.Version. 시맨틱 버전 트리플(Schema.Version(1, 0, 0)).static var models: [any PersistentModel.Type]. 이 버전의@Model타입 배열.
enum SchemaV1: VersionedSchema {
static let versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
final class Item {
var name: String
var createdAt: Date
init(name: String, createdAt: Date) {
self.name = name
self.createdAt = createdAt
}
}
}
enum 안에 중첩된 타입을 두는 패턴이 관례입니다. 각 VersionedSchema는 모델 클래스를 네임스페이스화하므로, 마이그레이션 동안 같은 모델 이름을 가진 여러 스키마가 코드베이스에서 공존할 수 있습니다.
MigrationStage
두 VersionedSchema 타입 간의 단일 전환입니다3. 두 가지 케이스가 있습니다:
.lightweight(fromVersion: any VersionedSchema.Type, toVersion: any VersionedSchema.Type). SwiftData가 앱 코드 없이 처리하는 전환을 선언합니다. 매개변수는 원시Schema.Version값이 아니라VersionedSchema타입 자체(예:SchemaV1.self)입니다..custom(fromVersion:toVersion:willMigrate:didMigrate:). 데이터 마이그레이션 전후에 실행되는 코드를 가진 전환을 선언합니다. 버전 인수의 매개변수 타입은.lightweight와 동일합니다.
SchemaMigrationPlan
이전 버전에서 현재 버전으로 스키마를 가져가는 스테이지의 순서 있는 목록입니다1.
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self, SchemaV3.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2, migrateV2toV3]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self
)
static let migrateV2toV3 = MigrationStage.custom(
fromVersion: SchemaV2.self,
toVersion: SchemaV3.self,
willMigrate: { context in
// Pre-migration: read old data, prepare it
try context.save()
},
didMigrate: { context in
// Post-migration: backfill new fields
let descriptor = FetchDescriptor<SchemaV3.Item>()
let items = try context.fetch(descriptor)
for item in items {
item.computedField = computeFromExisting(item)
}
try context.save()
}
)
}
ModelContainer는 현재 스키마와 마이그레이션 플랜을 모두 함께 설정합니다:
let container = try ModelContainer(
for: SchemaV3.Item.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration(...)
)
SwiftData는 컨테이너 생성 시 영속 저장소의 현재 스키마 버전을 읽고, 해당 버전에서 현재 버전까지 플랜의 스테이지를 순회하며, 각 스테이지를 순서대로 적용합니다.
라이트웨이트 마이그레이션이 자동으로 처리하는 것
대부분의 스키마 변경은 커스텀 스테이지가 필요하지 않습니다1:
- 기본값을 가진 속성 추가. 기존
@Model에var foo: Bool = false를 추가하는 것은 자동입니다. - 새 엔티티(모델 클래스) 추가. 새 타입은 해당
VersionedSchema가 현재 버전일 때 나타나며; 기존 데이터는 보존됩니다. - 속성 또는 엔티티 제거. SwiftData가 컬럼이나 테이블을 삭제합니다.
- 속성 또는 엔티티 이름 변경. 데이터를 보존하려면 속성에
@Attribute(originalName: "oldName")을 추가하세요; SwiftData가 이전을 새것으로 매핑합니다. - 관계 타입 변경. 1대다, 다대다 등.
- 삭제 규칙 지정.
@Relationship(deleteRule: .cascade)및 유사한 추가는 라이트웨이트입니다.
이 목록의 변경사항에 대해서는, 모델 타입이 그 외의 부분에서 변경되지 않았다면 새 VersionedSchema를 전혀 선언하지 않는 것이 올바른 패턴입니다. SwiftData는 기존 스키마에 대해 자동으로 라이트웨이트 마이그레이션을 수행합니다.
함정: 필드 추가에는 V2가 필요하지 않습니다
가장 흔한 SwiftData 마이그레이션 실수: 개발자가 인라인 기본값(var foo: Bool = false)을 가진 새 속성을 추가한 다음, SchemaV1과 동일한 모델 타입을 참조하는 SchemaV2를 선언하는 것입니다. 빌드는 깨끗합니다. 기존 V1 데이터가 있는 디바이스에서 첫 실행은 Duplicate version checksums across stages detected로 크래시됩니다. SchemaV1과 SchemaV2 모두 동일한 체크섬으로 해석되기 때문입니다(모델 타입이 SwiftData가 다르다고 인식하는 방식으로 변경되지 않았습니다).
올바른 패턴: 기존 VersionedSchema를 그대로 두고, 모델에 인라인 기본값을 가진 새 속성을 추가하고, SwiftData의 자동 라이트웨이트 마이그레이션이 처리하도록 하는 것입니다. MigrationPlan도, MigrationStage도, V2도 필요 없습니다.
// V1 schema
enum SchemaV1: VersionedSchema {
@Model
final class Item {
var name: String
// BEFORE: just these two properties
var createdAt: Date
// AFTER: add a third with inline default
var isFavorite: Bool = false // Lightweight, automatic
}
}
var isFavorite: Bool = false 변경은 MigrationStage 선언 없이 출시됩니다. migrationPlan:을 전달하지 않는 ModelContainer 이니셜라이저가 작동합니다:
let container = try ModelContainer(
for: SchemaV1.Item.self,
configurations: ModelConfiguration(...)
)
V2 스키마는 변경이 라이트웨이트가 될 수 없을 때만 필요합니다(데이터 변환, 모델 분할, 커스텀 로직이 필요한 상속 재구성). 그런 경우에 V2는 실제이고, SchemaMigrationPlan이 전환을 조율합니다.
커스텀 마이그레이션이 필요한 경우
커스텀 마이그레이션은 세 가지 경우에 그 복잡도를 정당화합니다:
1. 한 필드를 여러 개로 분할. "Last, First"를 담고 있는 String 필드가 두 필드, firstName과 lastName이 됩니다. 마이그레이션은 이전 값을 읽고, 파싱하고, 새 필드에 써야 합니다.
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: nil,
didMigrate: { context in
let descriptor = FetchDescriptor<SchemaV2.Person>()
let people = try context.fetch(descriptor)
for person in people {
let parts = person.fullName.split(separator: ", ", maxSplits: 1)
person.lastName = String(parts.first ?? "")
person.firstName = String(parts.dropFirst().first ?? "")
}
try context.save()
}
)
didMigrate 클로저는 새 스키마의 컨텍스트에 대해 실행되므로 새 필드에 접근할 수 있습니다. 이전 fullName은 새 필드가 채워질 때까지 제거를 미뤄야 할 수도 있습니다; 정리 작업은 후속 V2-to-V3 스테이지가 됩니다.
2. 파생 필드 계산. 기존 데이터에 의존하는 새로운 @Attribute는 마이그레이션 시점에 백필이 필요합니다.
3. 모델 간 데이터 이동. Item의 데이터가 Item과 새 Tag 모델 사이에 분할되는 재구성은 이전 데이터에서 태그를 할당하는 커스텀 로직이 필요합니다.
일반 패턴: 스키마 형태가 변경될 때는 라이트웨이트; 데이터 형태가 변경될 때는 커스텀.
willMigrate vs didMigrate
커스텀 스테이지는 서로 다른 시점에 호출되는 두 가지 클로저를 가집니다4:
willMigrate는 SwiftData가 스키마 마이그레이션을 적용하기 전에 실행됩니다. 클로저가 받는 모델 컨텍스트는 이전 스키마의 컨텍스트입니다. 이를 사용해 데이터를 캡처하거나, 비정규화하거나, 스키마가 변경되기 전에 보조 상태를 준비하세요.
didMigrate는 스키마 마이그레이션 후에 실행됩니다. 모델 컨텍스트는 새 스키마의 것입니다. 이를 사용해 새 필드를 백필하거나, 파생 데이터를 계산하거나, 마이그레이션을 마무리하세요.
필요하지 않으면 두 클로저 모두 nil이 될 수 있습니다. 대부분의 커스텀 마이그레이션은 didMigrate만 사용합니다; willMigrate는 마이그레이션이 스키마 변경 후 접근할 수 없는 이전 데이터를 읽어야 할 때 유용합니다.
클로저는 ModelContext를 받고 페치, 수정, 저장할 수 있습니다. 클로저는 throwing이며; 오류는 마이그레이션에서 전파되어 마이그레이션을 중단시킵니다.
iOS 26: @Model을 위한 클래스 상속
iOS 26은 SwiftData 모델에 클래스 상속을 도입합니다2. 모델은 이제 부모-자식 관계를 가질 수 있습니다:
@Model
class Vehicle {
var make: String
var year: Int
init(make: String, year: Int) {
self.make = make
self.year = year
}
}
@Model
final class Car: Vehicle {
var doorCount: Int
init(make: String, year: Int, doorCount: Int) {
self.doorCount = doorCount
super.init(make: make, year: year)
}
}
상속을 채택하는 스키마는 이전 평면 모델 버전에서 라이트웨이트 마이그레이션 스테이지로 새 버전으로 올라갑니다. 상속이 기존 속성을 보존하면 전환은 자동입니다; 서브클래스의 새 필드는 표준 인라인 기본값 패턴을 따릅니다.
이 패턴은 여러 @Model 타입이 특성을 공유하는 경우에 적합합니다: Car, Truck, Motorcycle 자식을 가진 Vehicle 부모; CheckingAccount, SavingsAccount 자식을 가진 Account 부모. 공유 속성은 부모에 있고; 특정한 것은 자식에 있습니다.
마이그레이션 테스트
컴파일되는 마이그레이션이 출시되는 마이그레이션은 아닙니다. 출시 전 실행해볼 만한 세 가지 테스트 패턴이 있습니다:
1. 프로덕션 데이터베이스 사본에서의 라운드트립 테스트. 최근 프로덕션 형태의 데이터베이스를 가져오거나(또는 테스트를 통해 합성 V1 데이터 생성), V2를 인식하는 컨테이너로 열고, 데이터가 올바르게 마이그레이션되는지 확인하세요. 이 테스트는 타입 체커가 잡을 수 없는 커스텀 마이그레이션 버그를 잡습니다.
2. 이전 버전이 여전히 실행됨. 이전 앱 버전을 빌드하고, 한 번 실행해 V1 데이터를 생성한 다음, 새 앱 버전을 빌드하고 크래시 없이 실행되는지 확인하세요. 이 테스트는 “Duplicate version checksums” 함정과 유사한 선언 실수를 잡습니다.
3. 마이그레이션 실패 복구. 마이그레이션이 throw하면 어떻게 됩니까? SwiftData의 동작은 컨테이너의 구성에 따라 다릅니다; 프로덕션 앱에서는 처리되지 않은 마이그레이션 오류가 사용자 데이터를 조용히 삭제해서는 안 됩니다. 실패 경로를 명시적으로 테스트하고 앱이 무엇을 할지 결정하세요(롤백, 프롬프트, 백업에서 복구).
클러스터의 Single Source of Truth 글은 SwiftData 저장소가 프로세스 간 동기화를 통해 교체될 때 어떤 일이 일어나는지에 대한 관련 질문을 다룹니다. 마이그레이션은 그 패턴의 로컬 진화 대응물입니다.
일반적인 실패 모드
SwiftData 실패 로그의 세 가지 패턴:
SwiftData가 자동으로 처리할 변경에 V2를 선언하는 것. “Duplicate version checksums” 크래시. 해결: 인라인 기본값 속성 추가에 새 스키마를 선언하지 마세요; SwiftData가 자동으로 처리하도록 두세요.
저장하지 않는 커스텀 마이그레이션 코드. 엔티티를 수정하지만 context.save()를 호출하지 않는 didMigrate 클로저는 한 번 실행되고, 작업을 버리고, 매 실행마다 다시 실행되는 마이그레이션을 만듭니다(마이그레이션이 미완료로 보이기 때문). 해결: 데이터를 수정하는 모든 클로저는 반환 전에 try context.save()를 해야 합니다.
@Attribute(originalName:) 없이 속성 이름 변경. SwiftData는 새 속성을 새것으로, 이전을 삭제된 것으로 취급합니다; 이전 속성의 기존 데이터는 삭제됩니다. 해결: SwiftData가 이름 변경을 통해 데이터를 매핑하도록 @Attribute(originalName: "oldName") var newName: ...를 선언하세요.
이 패턴이 iOS 26+ 앱에 의미하는 것
세 가지 핵심.
-
기본적으로
VersionedSchema사다리가 없도록 하세요. 인라인 기본값으로 속성 추가, 사용하지 않는 필드 삭제,@Attribute(originalName:)으로 이름 변경. 모두 라이트웨이트이고 자동입니다.VersionedSchema사다리는 SwiftData가 진정으로 자동으로 처리할 수 없는 변경(데이터 변환, 커스텀 로직, 상속 재구성)을 위한 것입니다. -
MigrationStage.custom은 데이터 변환에 사용하고, 스키마 형태 변경에는 사용하지 마세요.willMigrate와didMigrate클로저는 데이터에 작용하는 코드를 위한 것이지, 스키마가 변경되었다고 선언하기 위한 것이 아닙니다. 스키마 형태 변경은 라이트웨이트 스테이지를 통해 흐릅니다. -
합성 테스트 데이터가 아닌 실제 V1 데이터로 마이그레이션을 테스트하세요. 합성 라운드트립에서 통과하는 마이그레이션도 엣지 케이스가 있는 프로덕션 형태 데이터에서는 여전히 실패할 수 있습니다(스키마가 다루지 않은 nullable 필드, 타임아웃에 걸리는 큰 데이터셋 등). 테스트 비용은 작습니다; 첫 실행에서의 마이그레이션 크래시 비용은 실제입니다.
전체 Apple Ecosystem 클러스터: 타입화된 App Intents; MCP 서버; 라우팅 질문; Foundation Models; 런타임 vs 툴링 LLM 구분; 세 가지 표면; single source of truth 패턴; 두 개의 MCP 서버; Apple 개발을 위한 hooks; Live Activities; watchOS 런타임; SwiftUI 내부; RealityKit의 공간 정신 모델; SwiftData 스키마 규율; Liquid Glass 패턴; 멀티 플랫폼 출시; 플랫폼 매트릭스; Vision framework; Symbol Effects; Core ML 추론; Writing Tools API; Swift Testing; Privacy Manifest; 플랫폼으로서의 접근성; SF Pro 타이포그래피; visionOS 공간 패턴; Speech framework; 내가 쓰기를 거부하는 것. 허브는 Apple Ecosystem Series에 있습니다. AI 에이전트와 함께하는 더 광범위한 iOS 컨텍스트는 iOS Agent Development guide를 참조하세요.
FAQ
SchemaMigrationPlan이 항상 필요한가요?
아니요. 단일 스키마 버전을 가진 앱(초기 릴리스나 라이트웨이트 변경만 해온 앱)은 SchemaMigrationPlan이 필요하지 않습니다. ModelContainer 이니셜라이저는 스키마의 모델을 직접 받습니다. migrationPlan: 매개변수는 커스텀 마이그레이션 스테이지가 처음 선언될 때(또는 개발자가 명시적인 버전 사다리를 처음 선언하고 싶을 때) 필요해집니다.
내 변경이 라이트웨이트인지 어떻게 알 수 있나요?
Apple의 라이트웨이트 자격 목록1: 엔티티/속성/관계 추가, 제거, @Attribute(originalName:)으로 이름 변경, 관계 카디널리티 변경, 삭제 규칙 지정. 변경이 이 중 하나에 해당하고 모델 클래스 구조가 그 외의 부분에서 변경되지 않았다면, 마이그레이션은 자동이며 VersionedSchema 사다리가 필요하지 않습니다. 변경이 데이터 변환(계산, 분할, 데이터 이동)을 요구한다면 커스텀입니다.
willMigrate와 didMigrate를 모두 설정할 수 있나요?
예. 두 클로저는 개별적으로 선택사항이지만 둘 다 제공될 수 있습니다. willMigrate는 SwiftData가 마이그레이션하기 전에 이전 스키마의 컨텍스트에 대해 실행됩니다; didMigrate는 후에 새 스키마의 컨텍스트에 대해 실행됩니다. 두 가지가 각각 준비와 마무리를 담당합니다.
마이그레이션이 오류를 throw하면 어떻게 되나요?
오류는 ModelContainer 초기화에서 전파됩니다. 컨테이너는 열기에 실패합니다. 앱의 동작은 개발자가 오류를 어떻게 처리하느냐에 달려 있습니다: 어떤 앱은 복구 UI를 표시하고, 어떤 앱은 백업에서 복원을 시도하고, 어떤 앱은 손상된 저장소를 삭제하고 새로 시작합니다. SwiftData는 마이그레이션 실패 시 사용자 데이터를 조용히 삭제하지 않습니다; 실패는 앱이 처리해야 합니다.
프로덕션 데이터에 영향을 주지 않고 마이그레이션을 어떻게 테스트하나요?
임시 파일 URL을 가리키는 ModelContainer를 생성하고, V1 데이터로 채운 다음, 마이그레이션 플랜이 포함된 새 컨테이너로 여는 테스트 타겟을 빌드하세요. 마이그레이션된 데이터가 기대치와 일치하는지 확인하세요. 이 패턴은 단위 및 통합 테스트 모두에서 작동합니다; 가장 현실적인 결과를 위해 실제 프로덕션 형태 데이터베이스의 사본을 사용하세요.
iOS 26의 클래스 상속이 기존 스키마와 작동하나요?
예, 라이트웨이트 마이그레이션으로요. 상속을 채택하는 앱은 새 스키마 버전(예: V4)으로 올라가고 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)를 선언합니다. 평면 부모 클래스 속성은 유지되고, 서브클래스 특정 속성은 인라인 기본값으로 추가됩니다. SwiftData의 라이트웨이트 마이그레이션이 구조적 변경을 처리합니다.
References
-
Apple Developer Documentation:
VersionedSchema및SchemaMigrationPlan프로토콜 레퍼런스. 마이그레이션 모델. 전체 스키마 진화 내러티브를 위한 관련 가이드 Adopting SwiftData for a Core Data app도 참조하세요. ↩↩↩↩↩↩ -
Apple Developer: SwiftData: Dive into inheritance and schema migration (WWDC 2025 세션 291). iOS 26의 SwiftData 클래스 상속 도입. ↩↩
-
Apple Developer Documentation:
MigrationStage,.lightweight(fromVersion:toVersion:)및.custom(fromVersion:toVersion:willMigrate:didMigrate:)케이스. ↩ -
Apple Developer Documentation:
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:)케이스 시그니처. willMigrate-runs-against-old-context와 didMigrate-runs-against-new-context 시맨틱은 WWDC 2025 세션 291 SwiftData: Dive into inheritance and schema migration에 문서화되어 있으며, iOS 26 상속 추가에 대해 참조한 동일한 세션입니다. ↩