SwiftData 마이그레이션: lightweight와 custom의 차이, 그리고 V2가 필요 없는 경우
SwiftData의 스키마 마이그레이션은 Core Data에 비해 구조적으로 개선되었습니다. 다만 팀들이 반복해서 빠지는 함정이 하나 있습니다. 인라인 기본값만 주면 SwiftData가 알아서 처리해 줄 변경에 대해 굳이 새 VersionedSchema를 선언하는 것입니다. 코드는 멀쩡해 보이고 빌드도 깨끗하게 통과하지만, 실기기에서는 “Duplicate version checksums across stages detected” 크래시가 발생합니다. 이 프레임워크의 실제 마이그레이션 모델은 세 가지 구성 요소(VersionedSchema, MigrationStage, SchemaMigrationPlan)와 세 가지 마이그레이션 유형(자동 lightweight, 선언된 lightweight, custom)으로 이루어집니다1. 대부분의 스키마 변경은 자동으로 처리됩니다. 일부는 선언된 lightweight 스테이지가 필요합니다. 그리고 아주 일부만이 willMigrate와 didMigrate 클로저를 갖춘 custom 스테이지를 필요로 합니다.
이 글은 Apple 문서를 따라가며 마이그레이션 모델을 짚고, 각 마이그레이션 유형이 어떤 경우를 담당하는지 정리합니다. 여기에 iOS 26의 클래스 상속 지원과 iOS 27 베타가 마이그레이션을 어디에 두고 있는지도 함께 다룹니다. 관점은 일관되게 “내가 무엇을 선언해야 하고, SwiftData가 무엇을 대신 처리해 주는가”입니다. 이 판단이 마이그레이션을 깔끔하게 출시하느냐, 첫 실행에서 크래시하느냐를 가르기 때문입니다. 이런 마이그레이션의 비용을 낮게 유지하려면 v1 스키마를 어떻게 설계해야 하는가라는 짝이 되는 질문은 SwiftData의 진짜 비용은 스키마 규율에서 다룹니다.
핵심 요약
- SwiftData 마이그레이션은 세 가지 프로토콜로 구성됩니다.
VersionedSchema(특정 버전 시점의 모델 타입 스냅숏),MigrationStage(.lightweight또는.custom케이스를 갖는, fromVersion에서 toVersion으로 가는 단일 전환), 그리고SchemaMigrationPlan(스테이지의 순서 있는 목록)입니다1. - 인라인 기본값을 가진 새
@Model프로퍼티(var foo: Bool = false)를 추가할 때는 새VersionedSchema가 필요 없습니다. SwiftData가 lightweight 마이그레이션으로 알아서 처리합니다. 여기에 V2를 선언하면 “Duplicate version checksums across stages detected” 크래시가 발생합니다. - lightweight 마이그레이션이 처리하는 범위는 엔티티, 속성, 관계의 추가와 이름 변경과 삭제, 관계 타입 변경, 이름 변경을 추적하기 위한
@Attribute(originalName:)선언, 삭제 규칙 지정입니다. 대부분의 스키마 변경이 여기에 들어옵니다. - custom 마이그레이션(
MigrationStage.custom(fromVersion:toVersion:willMigrate:didMigrate:))은 데이터 변환을 담당합니다. 하나의 컬럼을 둘로 나누기, 파생 필드 계산하기, 모델 사이에서 데이터 옮기기 같은 작업입니다.willMigrate에는 이전 컨텍스트가,didMigrate에는 새 컨텍스트가 전달됩니다. - iOS 26에서는
@Model타입의 클래스 상속이 추가되었습니다2. 상속을 도입하는 스키마는 새 버전으로 올리고, 이전의 평평한 모델 버전에서 lightweight 스테이지로 이어 줍니다.
세 가지 구성 요소로 이루어진 모델
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가 처리하는 전환을 선언합니다. 인자로 넘기는 것은VersionedSchema타입 자체이며(예:SchemaV1.self), 원시Schema.Version값이 아닙니다..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는 컨테이너를 생성할 때 영속 스토어의 현재 스키마 버전을 읽고, 그 버전부터 현재 버전까지 플랜의 스테이지를 따라가며 순서대로 적용합니다.
lightweight 마이그레이션이 자동으로 처리하는 범위
대부분의 스키마 변경에는 custom 스테이지가 필요하지 않습니다1.
- 기본값이 있는 속성 추가. 기존
@Model에var foo: Bool = false를 더하는 것은 자동으로 처리됩니다. - 새 엔티티(모델 클래스) 추가. 해당
VersionedSchema가 현재 버전이 되는 시점에 새 타입이 나타나며, 기존 데이터는 그대로 보존됩니다. - 속성이나 엔티티 제거. SwiftData가 해당 컬럼이나 테이블을 삭제합니다.
- 속성이나 엔티티 이름 변경. 데이터를 보존하려면 프로퍼티에
@Attribute(originalName: "oldName")을 붙입니다. SwiftData가 이전 이름을 새 이름으로 매핑합니다. - 관계 타입 변경. 일대다, 다대다 같은 변경입니다.
- 삭제 규칙 지정.
@Relationship(deleteRule: .cascade)처럼 추가하는 것은 lightweight입니다.
이 목록에 해당하는 변경이라면, 모델 타입에 다른 변화가 없는 한 새 VersionedSchema를 아예 선언하지 않는 것이 올바른 패턴입니다. SwiftData가 기존 스키마를 대상으로 lightweight 마이그레이션을 자동으로 수행합니다.
함정: 필드를 추가한다고 V2가 필요하지는 않습니다
SwiftData 마이그레이션에서 가장 흔한 실수는 이렇습니다. 개발자가 인라인 기본값을 가진 새 프로퍼티(var foo: Bool = false)를 추가한 뒤, SchemaV1과 같은 모델 타입을 참조하는 SchemaV2를 선언합니다. 빌드는 문제없이 통과합니다. 그런데 기존 V1 데이터가 있는 기기에서 처음 실행하면 Duplicate version checksums across stages detected로 크래시합니다. SchemaV1과 SchemaV2가 같은 체크섬으로 해석되기 때문입니다(SwiftData가 차이로 인식하는 방식으로는 모델 타입이 바뀌지 않았습니다).
올바른 패턴은 이렇습니다. 기존 VersionedSchema는 그대로 두고, 인라인 기본값을 가진 새 프로퍼티를 모델에 추가한 다음, SwiftData의 자동 lightweight 마이그레이션에 맡깁니다. 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 스키마가 필요한 경우는 변경이 lightweight로는 처리될 수 없을 때뿐입니다(데이터 변환, 모델 분할, 커스텀 로직이 필요한 상속 구조 개편 등). 그런 경우의 V2는 실체가 있으며, SchemaMigrationPlan이 전환을 지휘합니다.
custom 마이그레이션이 필요한 경우
custom 마이그레이션이 그 복잡성을 정당화하는 경우는 세 가지입니다.
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에서 V3로 가는 스테이지에서 처리합니다.
2. 파생 필드를 계산할 때. 기존 데이터에 의존하는 새 @Attribute는 마이그레이션 시점에 값을 채워 넣어야 합니다.
3. 모델 사이에서 데이터를 옮길 때. Item의 데이터를 Item과 새로운 Tag 모델로 나누는 개편에서는, 이전 데이터로부터 태그를 배정하는 커스텀 로직이 필요합니다.
일반적인 기준은 이렇습니다. 스키마의 모양이 바뀌면 lightweight, 데이터의 모양이 바뀌면 custom입니다.
willMigrate와 didMigrate의 차이
custom 스테이지는 두 개의 클로저를 가지며, 각각 다른 시점에 호출됩니다4.
willMigrate는 SwiftData가 스키마 마이그레이션을 적용하기 전에 실행됩니다. 클로저가 받는 모델 컨텍스트는 이전 스키마의 컨텍스트입니다. 발밑에서 스키마가 바뀌기 전에 데이터를 확보하거나, 비정규화하거나, 보조 상태를 준비하는 용도로 사용합니다.
didMigrate는 스키마 마이그레이션 이후에 실행됩니다. 모델 컨텍스트는 새 스키마의 것입니다. 새 필드를 채우거나, 파생 데이터를 계산하거나, 마이그레이션을 마무리하는 데 사용합니다.
필요하지 않다면 두 클로저 모두 nil로 둘 수 있습니다. 대부분의 custom 마이그레이션은 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)
}
}
상속을 도입하는 스키마는 새 버전으로 올리고, 이전의 평평한 모델 버전에서 lightweight 마이그레이션 스테이지로 이어 줍니다. 상속이 기존 프로퍼티를 그대로 유지한다면 전환은 자동입니다. 서브클래스에 추가되는 새 필드는 앞서 본 표준 인라인 기본값 패턴을 따릅니다.
이 패턴은 여러 @Model 타입이 특성을 공유하는 경우에 잘 맞습니다. Vehicle을 부모로 두고 Car, Truck, Motorcycle을 자식으로 두는 구성, Account를 부모로 두고 CheckingAccount, SavingsAccount를 자식으로 두는 구성이 그렇습니다. 공유 프로퍼티는 부모에, 개별 프로퍼티는 자식에 둡니다.
iOS 27: 마이그레이션 모델은 그대로, 스토어는 observable로
iOS 27 베타는 마이그레이션 기계장치 자체에는 아무것도 바꾸지 않습니다. VersionedSchema, MigrationStage, SchemaMigrationPlan은 그대로 이어지고, 위의 모든 패턴은 쓰인 그대로 통합니다. iOS 27이 더한 것은 마이그레이션 안쪽이 아니라 그 옆에 자리합니다. ResultsObserver와 HistoryObserver 두 타입으로 이루어진 새로운 “Data store observation” 영역, 그리고 프로퍼티를 Codable 표현으로 저장하는 @Attribute(.codable) 옵션입니다6.
이 가운데 두 가지는 마이그레이션 가이드에서 언급할 만합니다.
@Attribute(.codable)은 앞으로의 마이그레이션 압력을 줄여 줍니다. Codable 값 타입을 선언적으로 저장할 수 있으면, 구조체를 여러 컬럼으로 펼쳐 두었다가 나중에 custom 스테이지를 작성해 다시 조립하는 상황이 줄어듭니다. 새 프로퍼티에 이 옵션을 도입하는 스키마는 앞서 설명한 표준 인라인 기본값 규칙을 따릅니다. 이것은 속성 옵션일 뿐, 스키마 모양의 변경이 아닙니다6.
HistoryObserver는 custom 마이그레이션 이후의 고리를 닫아 줍니다. didMigrate에서의 백필은 앱의 나머지 부분(그리고 스토어를 지켜보는 위젯이나 익스텐션)이 알아야 할 행들을 씁니다. iOS 27에서는 HistoryObserver로 영속 히스토리를 관찰하는 옵저버가 마이그레이션의 트랜잭션이 도착하는 것을 보고, ModelContext.fetchHistory를 호출해 모델 타입과 트랜잭션 작성자로 필터링하면서 무엇이 바뀌었는지만 정확히 읽어낼 수 있습니다. 전체를 다시 페치할 필요가 없습니다6. observation의 전체 이야기는 iOS 27의 SwiftData: observation과 history에서 다룹니다.
계획 관점에서의 결론은 이렇습니다. 27 베타에는 스키마 버전 인상을 강제하는 요소가 없고, 기존 마이그레이션 코드를 다시 쓸 일도 없습니다. 마이그레이션 이후의 대조 로직에서 폴링이나 재페치를 하던 자리에 새 observation 타입을 도입하십시오.
마이그레이션 테스트하기
컴파일되는 마이그레이션과 출시할 수 있는 마이그레이션은 다릅니다. 릴리스 전에 실행해 볼 가치가 있는 테스트 패턴이 세 가지 있습니다.
1. 프로덕션 데이터베이스 사본으로 왕복 테스트하기. 최근의 프로덕션 형태 데이터베이스를 가져오거나(또는 테스트로 합성 V1 데이터를 생성해), V2를 아는 컨테이너로 열어 데이터가 제대로 마이그레이션되는지 검증합니다. 타입 체커가 잡지 못하는 custom 마이그레이션 버그를 이 테스트가 잡아냅니다.
2. 이전 버전에서의 실행 확인. 이전 앱 버전을 빌드해 한 번 실행하여 V1 데이터를 만든 다음, 새 앱 버전을 빌드해 크래시 없이 실행되는지 확인합니다. 이 테스트는 “Duplicate version checksums” 함정과 그와 비슷한 선언 실수를 잡아냅니다.
3. 마이그레이션 실패로부터의 복구. 마이그레이션이 throw하면 어떻게 될까요? SwiftData의 동작은 컨테이너 구성에 따라 달라집니다. 프로덕션 앱이라면 처리되지 않은 마이그레이션 오류가 사용자 데이터를 조용히 지워서는 안 됩니다. 실패 경로를 명시적으로 테스트하고, 앱이 무엇을 할지(롤백, 사용자에게 확인, 백업으로부터 복구) 정해 두십시오.
같은 클러스터의 Single Source of Truth 글은 프로세스 간 동기화로 SwiftData 스토어가 교체될 때 무슨 일이 일어나는지를 다룹니다. 마이그레이션은 그 패턴을 로컬에서의 진화로 옮겨 놓은 것입니다.
프로세스를 넘나드는 출시와 진행 상황 표시
문서가 앞세우지는 않지만 SwiftData 팀이 WWDC 2026에서 짚은 운영상의 세부 사항이 두 가지 있습니다5. 앱에 위젯이나 익스텐션이 있을 때 마이그레이션이 어디에서 실행되는가, 그리고 마이그레이션이 도는 동안 진행 UI를 어떻게 움직이는가입니다.
마이그레이션을 소유하는 프로세스는 하나뿐입니다. 위젯과 익스텐션은 메인 앱과 같은 런타임 자원을 받지 못하므로 마이그레이션을 안전하게 수행할 수 없습니다. 지침은 SchemaMigrationPlan1을 위젯과 익스텐션 타깃에서 완전히 빼고, 그쪽에서는 절대 마이그레이션하지 않는 것입니다. 데이터베이스의 소유자가 될 프로세스를 하나, 보통은 메인 앱으로 정하십시오. 위젯이 컨테이너를 열었을 때 디스크상의 스토어가 버전이 없는(더 오래된) 스키마 상태라면 열기는 오류가 됩니다. 그 오류를 마이그레이션이 필요하다는 신호로 다루십시오. 사용자에게 메인 앱을 열도록 요청하는 UI를 보여 주고, 앱이 마이그레이션을 수행하게 한 다음, 마이그레이션된 스키마 버전을 공유 UserDefault에 기록하게 합니다. 위젯은 다음번에 그 값을 읽어, 앱이 이미 마이그레이션해 둔 버전으로 컨테이너를 엽니다. 이 패턴은 쓰기 주체를 하나로 유지해, 두 프로세스가 같은 파일을 두고 경쟁하며 진화시키는 상황을 막아 줍니다.
진행률은 벽시계 시간이 아니라 스테이지 개수로 계산합니다. SwiftData에는 마이그레이션 전용 진행률 API가 없습니다5. 진행 표시기를 움직이려면 플랜에 있는 custom 마이그레이션 스테이지의 총개수를 세고, 스테이지별 didMigrate 핸들러를 오버라이드해4 각 스테이지가 “M개 중 N번째”라는 위치를 보고하게 하십시오. 이 숫자는 완료된 스테이지 수이지 경과 시간이 아니므로, 막대는 매끄럽게가 아니라 단계별로 끊어져 나아갑니다. 짝이 되는 디자인 결정은 마이그레이션 동안 앱이 무엇을 보여 주느냐입니다. 밋밋한 스피너는 멈춘 것처럼 읽히고, 사용자는 이탈합니다. 데이터가 허락하는 한 앱을 부분적으로라도 쓸 수 있게 유지하거나, 최소한 각 스테이지가 무엇을 더하고 있는지(그 마이그레이션이 열어 주는 새 기능)를 설명하십시오. 그래야 기다림이 죽은 시간이 아니라 무언가를 향한 진전으로 읽힙니다.
흔한 실패 유형
SwiftData 실패 기록에서 나온 세 가지 패턴입니다.
SwiftData가 자동으로 처리할 변경에 V2를 선언하기. “Duplicate version checksums” 크래시입니다. 해결: 인라인 기본값 프로퍼티 추가를 위해 새 스키마를 선언하지 말고, SwiftData가 자동으로 처리하게 두십시오.
저장하지 않는 custom 마이그레이션 코드. 엔티티를 수정해 놓고 context.save()를 호출하지 않는 didMigrate 클로저는, 한 번 실행되고 결과를 버린 뒤 실행할 때마다 다시 도는 마이그레이션을 만듭니다(마이그레이션이 끝나지 않은 것처럼 보이기 때문입니다). 해결: 데이터를 수정하는 클로저는 반환 전에 반드시 try context.save()를 실행해야 합니다.
@Attribute(originalName:) 없이 프로퍼티 이름을 바꾸기. SwiftData는 새 프로퍼티를 신규로, 이전 프로퍼티를 삭제로 취급하므로 이전 프로퍼티에 있던 기존 데이터는 사라집니다. 해결: @Attribute(originalName: "oldName") var newName: ...으로 선언해 SwiftData가 이름 변경을 가로질러 데이터를 매핑하게 하십시오.
iOS 26+ 앱에 이 패턴이 의미하는 것
정리하면 세 가지입니다.
-
기본적으로
VersionedSchema사다리를 만들지 마십시오. 인라인 기본값을 가진 프로퍼티 추가, 쓰지 않는 필드 삭제,@Attribute(originalName:)을 통한 이름 변경. 모두 lightweight이며 자동입니다.VersionedSchema사다리는 SwiftData가 정말로 자동 처리할 수 없는 변경(데이터 변환, 커스텀 로직, 상속 구조 개편)을 위한 것입니다. -
MigrationStage.custom은 데이터 변환에 쓰고, 스키마 모양의 변경에는 쓰지 마십시오.willMigrate와didMigrate클로저는 데이터를 다루는 코드를 위한 것이지, 스키마가 바뀌었다고 선언하기 위한 것이 아닙니다. 스키마 모양의 변경은 lightweight 스테이지를 통과시킵니다. -
마이그레이션은 합성 테스트 데이터만이 아니라 실제 V1 데이터로 테스트하십시오. 합성 데이터 왕복에서는 통과해도, 프로덕션 형태의 데이터에서는 엣지 케이스(스키마가 고려하지 않은 널 허용 필드, 타임아웃에 걸리는 대용량 데이터 등) 때문에 실패할 수 있습니다. 테스트 비용은 작지만, 첫 실행에서 마이그레이션이 크래시하는 비용은 실제 손실입니다.
Apple Ecosystem 클러스터 전체는 다음과 같습니다. 타입 기반 App Intents, MCP 서버, 라우팅 문제, Foundation Models, 런타임 LLM과 툴링 LLM의 구분, 세 가지 표면, single source of truth 패턴, 두 개의 MCP 서버, Apple 개발을 위한 hooks, Live Activities, watchOS 런타임, SwiftUI 내부, RealityKit의 공간 멘탈 모델, SwiftData 스키마 규율, Liquid Glass 패턴, 멀티 플랫폼 출시, 플랫폼 매트릭스, Vision 프레임워크, Symbol Effects, Core ML 온디바이스 추론, Writing Tools API, Swift Testing, Privacy Manifest, 플랫폼 기능으로서의 접근성, SF Pro 타이포그래피, visionOS 공간 패턴, Speech 프레임워크, 내가 쓰지 않기로 한 것들. 허브는 Apple Ecosystem 시리즈에 있습니다. iOS와 AI 에이전트를 함께 다루는 더 넓은 맥락은 iOS Agent Development 가이드를 참고하십시오.
자주 묻는 질문
SchemaMigrationPlan이 항상 필요합니까?
아닙니다. 스키마 버전이 하나뿐인 앱(최초 릴리스이거나, 지금까지 lightweight 변경만 해 온 앱)에는 SchemaMigrationPlan이 필요 없습니다. ModelContainer 이니셜라이저는 스키마의 모델을 직접 받습니다. migrationPlan: 인자가 필요해지는 시점은 custom 마이그레이션 스테이지를 처음 선언할 때(또는 명시적인 버전 사다리를 처음 선언하고 싶어질 때)입니다.
내 변경이 lightweight인지 어떻게 알 수 있습니까?
Apple이 제시하는 lightweight 대상 목록은 이렇습니다1. 엔티티, 속성, 관계의 추가와 제거, @Attribute(originalName:)을 사용한 이름 변경, 관계 카디널리티 변경, 삭제 규칙 지정입니다. 변경이 이 가운데 하나에 해당하고 모델 클래스 구조에 다른 변화가 없다면, 마이그레이션은 자동으로 이루어지며 VersionedSchema 사다리는 필요 없습니다. 변경에 데이터 변환(계산, 분할, 데이터 이동)이 필요하다면 custom입니다.
willMigrate와 didMigrate를 둘 다 설정할 수 있습니까?
그렇습니다. 두 클로저는 각각 선택 사항이지만 둘 다 제공할 수도 있습니다. willMigrate는 SwiftData가 마이그레이션하기 전에 이전 스키마의 컨텍스트에서 실행되고, didMigrate는 그 이후에 새 스키마의 컨텍스트에서 실행됩니다. 앞의 것이 준비를, 뒤의 것이 마무리를 맡습니다.
마이그레이션이 오류를 throw하면 어떻게 됩니까?
오류는 ModelContainer 초기화 밖으로 전파되고, 컨테이너 열기는 실패합니다. 그 다음 앱의 동작은 개발자가 오류를 어떻게 처리하는지에 달려 있습니다. 복구 UI를 보여 주는 앱도 있고, 백업으로부터 복원을 시도하는 앱도 있으며, 손상된 스토어를 지우고 새로 시작하는 앱도 있습니다. SwiftData는 마이그레이션 실패 시 사용자 데이터를 조용히 지우지 않습니다. 실패를 다루는 일은 앱의 몫입니다.
프로덕션 데이터에 영향을 주지 않고 마이그레이션을 테스트하려면 어떻게 합니까?
임시 파일 URL을 가리키는 ModelContainer를 만드는 테스트 타깃을 준비하고, 거기에 V1 데이터를 채운 다음, 마이그레이션 플랜을 포함한 새 컨테이너로 엽니다. 마이그레이션된 데이터가 기대와 일치하는지 검증하십시오. 이 패턴은 유닛 테스트와 통합 테스트 모두에서 통합니다. 가장 현실에 가까운 결과를 원한다면 실제 프로덕션 형태 데이터베이스의 사본을 사용하십시오.
iOS 26의 클래스 상속은 기존 스키마에서도 동작합니까?
그렇습니다. lightweight 마이그레이션과 함께 동작합니다. 상속을 도입하는 앱은 새 스키마 버전(예: V4)으로 올리고 MigrationStage.lightweight(fromVersion: V3.self, toVersion: V4.self)를 선언합니다. 평평했던 부모 클래스의 프로퍼티는 그대로 남고, 서브클래스 고유의 프로퍼티는 인라인 기본값과 함께 추가됩니다. 이 구조적 변화는 SwiftData의 lightweight 마이그레이션이 처리합니다.
참고 문헌
-
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가 이전 컨텍스트에서, didMigrate가 새 컨텍스트에서 실행된다는 의미론은 WWDC 2025 세션 291 SwiftData: Dive into inheritance and schema migration에 문서화되어 있으며, iOS 26 상속 추가에서 참조한 것과 같은 세션입니다. ↩↩↩ -
WWDC 2026 SwiftData Group Lab (세션 8017). WWDC 2026 SwiftData Group Lab을 로컬에서 전사한 녹음을 요약한 내용입니다. Apple은 랩의 공식 자막을 공개하지 않습니다. 위젯과 익스텐션의 마이그레이션 제한(마이그레이션을 소유하는 프로세스는 하나, 오류가 마이그레이션 신호, 마이그레이션된 버전은
UserDefault에 저장)과 스테이지 개수 기반 진행률 기법(전용 진행률 API가 없으므로 스테이지별didMigrate핸들러를 오버라이드해 M개 중 N번째를 보고)은 SwiftData 엔지니어링 패널이 설명한 내용입니다.SchemaMigrationPlan과MigrationStage.custom의didMigrate심벌은 1과 4에서 인용한 Apple Developer 문서와 대조해 확인했습니다. 전용 진행률 API가 없다는 점은 랩에서 패널이 직접 밝힌 내용을 반영한 것입니다. ↩↩ -
Apple Developer Documentation:
ResultsObserver와HistoryObserver(둘 다 iOS 27.0 베타, SwiftData의 “Data store observation” 주제), 그리고Schema.Attribute.Option.codable(iOS 27.0 베타), “프로퍼티를 저장할 때 그 codable 표현을 사용한다”라고 설명되어 있습니다. WWDC26 세션 274 What’s new in SwiftData에 따르면,HistoryObserver는 새 트랜잭션이 도착할 때 증가하는 observable한eventCounter를 노출하며, 코드는 모델 타입과 트랜잭션 작성자 필터를 지정해ModelContext.fetchHistory를 호출하는 방식으로 반응합니다. ↩↩↩