도입
SwiftData를 사용해서 앱을 출시한 후, 모델에 필드를 추가해야 할 때가 온다. Core Data 시절의 migration은 꽤 복잡했지만, SwiftData의 VersionedSchema와 SchemaMigrationPlan을 사용하면 훨씬 깔끔하게 처리할 수 있다.
이 글에서는 실제 가계부 앱에서 체크카드 지원을 위해 스키마를 확장한 경험을 바탕으로, SwiftData migration의 실전 과정을 정리한다.
기존 구조: SchemaV1
처음 출시할 때의 스키마는 VersionedSchema로 정의했다.
struct iobookSchemaV1: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Asset.self, AssetGroup.self, Transaction.self, ...]
}
static var versionIdentifier: Schema.Version = .init(1, 0, 0)
}
extension iobookSchemaV1 {
@Model
class Asset {
var isCreditCard: Bool?
var paymentAsset: Asset?
// ... 기타 필드 생략
}
@Model
class Transaction {
var asset: Asset?
// ... 기타 필드 생략
}
}
앱 전체에서 모델을 직접 참조하지 않고 typealias를 사용한다. 이게 나중에 migration할 때 핵심이 된다.
typealias Asset = iobookSchemaV1.Asset
typealias Transaction = iobookSchemaV1.Transaction
변경이 필요한 순간
체크카드를 지원하려면 두 가지 필드가 필요했다:
Asset에isDebitCard: Bool?— 체크카드 여부 식별Transaction에debitCard: Asset?— 이 거래가 어떤 체크카드를 통해 결제됐는지
둘 다 optional 필드 추가이므로 lightweight migration으로 충분하다.
Step 1: SchemaV2 생성
V1 파일을 복사해서 V2를 만든다. V1은 절대 수정하지 않는다. SwiftData가 migration 시 V1과 V2를 비교해서 차이를 파악하기 때문이다.
struct iobookSchemaV2: VersionedSchema {
static var models: [any PersistentModel.Type] {
[Asset.self, AssetGroup.self, Transaction.self, ...]
}
static var versionIdentifier: Schema.Version = .init(2, 0, 0)
}
extension iobookSchemaV2 {
@Model
class Asset {
// ... V1의 기존 필드 그대로 유지 ...
// 새로 추가
var isDebitCard: Bool?
@Relationship(inverse: \Transaction.debitCard)
var debitCardTransactions: [Transaction]?
}
@Model
class Transaction {
// ... V1의 기존 필드 그대로 유지 ...
// 새로 추가
var debitCard: Asset?
}
}
주의할 점:
- V2에 V1의 모든 모델 클래스를 다시 정의해야 한다 (변경 없는 모델 포함)
- 새 필드의 기본값을 반드시 지정한다 (lightweight migration 조건)
@Relationship의 inverse를 정확히 맞춘다
Step 2: Migration Plan 작성
enum iobookMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[iobookSchemaV1.self, iobookSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: iobookSchemaV1.self,
toVersion: iobookSchemaV2.self
)
}
Lightweight migration은 다음 변경만 지원한다:
- optional 필드 추가 (기본값 필요)
- 필드 삭제
- optional 관계 추가
- 인덱스 추가/삭제
필드 이름 변경, 타입 변경, non-optional 필드 추가 등은 MigrationStage.custom을 써야 한다.
Step 3: typealias와 ModelContainer 업데이트
// V1 → V2로 변경
typealias Asset = iobookSchemaV2.Asset
typealias Transaction = iobookSchemaV2.Transaction
// ModelContainer에 migrationPlan 추가
var modelContainer: ModelContainer = {
let schema = Schema([Asset.self, ...])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(
for: schema,
migrationPlan: iobookMigrationPlan.self, // 이 줄 추가
configurations: [config]
)
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
typealias 덕분에 앱 전체 코드에서 Asset, Transaction을 직접 쓰는 부분은 수정할 필요가 없다. V2의 클래스를 가리키도록 typealias만 바꾸면 된다.
Step 4: 검증
- 기존 데이터로 테스트: 시뮬레이터에서 V1 데이터가 있는 상태로 앱 실행 → crash 없이 정상 로드 확인
- 새 필드 확인: 기존 Asset의
isDebitCard이 nil(또는 false), Transaction의debitCard이 nil인지 확인 - 새 기능 테스트: 체크카드 생성 및 거래 등록 후 데이터 정합성 확인
실수하기 쉬운 부분
1. V1 파일을 수정해버리는 경우
V1은 "과거 스냅샷"이다. 수정하면 SwiftData가 실제 저장소의 스키마와 V1이 다르다고 판단해서 migration이 실패한다.
2. V2에 모든 모델을 정의하지 않는 경우
변경이 없는 AssetGroup, ExpenseCategory 등도 V2에 포함해야 한다. VersionedSchema의 models 배열에 빠진 모델이 있으면 런타임 에러가 발생한다.
3. stages 배열에 stage를 넣지 않는 경우
migrateV1toV2를 선언만 하고 stages 배열에 포함하지 않으면 migration이 실행되지 않는다.
// 틀린 코드
static var stages: [MigrationStage] { [] } // 비어있음!
// 맞는 코드
static var stages: [MigrationStage] { [migrateV1toV2] }
4. @Relationship inverse 불일치
새 관계를 추가할 때 inverse가 정확히 매칭되지 않으면 빌드는 되지만 런타임에 데이터가 꼬일 수 있다.
typealias 패턴을 추천하는 이유
처음부터 VersionedSchema + typealias 패턴으로 시작하면 나중이 편하다:
- Migration 시 앱 코드 변경 최소화
- 어떤 스키마 버전을 쓰고 있는지 한 곳에서 관리
- V3, V4로 계속 확장할 때도 같은 패턴 유지
Core Data의 .xcdatamodeld 파일에서 버전을 관리하던 것과 비슷한 개념인데, 코드로 명시적으로 관리하니까 훨씬 투명하다.
마무리
SwiftData의 migration은 Core Data보다 확실히 단순하다. VersionedSchema로 스키마를 버전 관리하고, SchemaMigrationPlan으로 migration 단계를 정의하고, lightweight migration이 안 되면 custom stage를 쓰면 된다.
핵심은:
- V1은 건드리지 않기
- V2에 모든 모델 정의하기
- optional 필드 + 기본값으로 lightweight migration 유지하기
- typealias로 앱 코드 영향 최소화하기