SwiftData의 진짜 비용은 스키마 규율입니다
Get Bananas의 ShoppingItem은 SwiftData의 스키마 규율이 왜 중요한지 보여 주는 대표적인 사례입니다. 최초 스키마에는 lastModified 타임스탬프가 없었고, 이를 나중에 추가하려니 특정한 형태의 마이그레이션이 필요했습니다. 기존 데이터가 이미 디스크에 저장되어 있었기 때문입니다. 그리고 이 필드가 옵셔널인 이유는, 처음에 논옵셔널로 추가했을 때 발생한 마이그레이션 크래시를 고치기 위해서였습니다.1
SwiftData의 API는 매크로 두 개뿐입니다. 클래스에 붙이는 @Model이 그 타입을 영속 타입으로 만듭니다. 프로퍼티에 붙이는 @Attribute(.unique)가 고유성 제약을 부여합니다. 프레임워크는 Core Data의 스택 관리, value transformer를 다루는 번거로운 절차, NSManagedObjectContext 보일러플레이트를 감춰 줍니다. 프레임워크가 감춰 주지 않는 것이 스키마 마이그레이션입니다. 프레임워크는 마이그레이션을 명령형에서 선언형으로 바꿔 줄 뿐입니다. 마이그레이션에 주의를 기울이지 않은 대가는, 평범한 업데이트에서 사용자의 데이터를 날려 버리는 버그로 돌아옵니다.
이 글의 주장은 이렇습니다. SwiftData는 시작하기에는 값싸고 마이그레이션을 대충 하면 값비싼 프레임워크입니다. 지켜야 할 규율은 네이밍, 옵셔널 여부, 그리고 첫날부터 도입하는 VersionedSchema입니다. “진작 할 걸 그랬다”라고 깨닫는 날에 시작해서는 늦습니다.
TL;DR
@Model매크로는 클래스를 SwiftData의 영속 타입으로 바꿉니다. 프레임워크는 컴파일 시점에 프로퍼티 선언으로부터 스키마를 생성합니다.- 옵셔널 프로퍼티를 추가하는 것은 사실상 아무 일도 일어나지 않는 마이그레이션입니다. SwiftData의 lightweight migration이 알아서 처리합니다. 기존 스키마에 논옵셔널 프로퍼티를 추가하려면
VersionedSchema와 더불어, 기존 행의 새 필드를 어떻게 채울지 프레임워크에 알려 주는MigrationPlan이 필요합니다. - 첫날부터
VersionedSchema를 두지 않은 대가는, v2에서 조금이라도 복잡한 스키마 변경을 할 때마다 사용자의 데이터베이스를 잃을 위험이 생긴다는 점입니다. lightweight 경로는 보수적이라서 마이그레이션을 추론할 수 없으면 그대로 중단하기 때문입니다. @Attribute(.unique)는 자연 키(직접 생성한UUID, 가져온 외부 ID)에 알맞은 도구입니다.@Relationship은 부모/자식 참조에 알맞은 도구입니다. 둘 다 매크로이며, 내부적으로 적절한 Core Data 배관을 생성해 줍니다.2
@Model이 실제로 하는 일
SwiftData의 타입은 @Model 매크로를 적용한 Swift 클래스입니다. Get Bananas의 ShoppingItem이 그 전형적인 형태입니다.
import Foundation
import SwiftData
@Model
final class ShoppingItem {
@Attribute(.unique) var id: UUID
var name: String
var amount: String
var section: String
var isChecked: Bool
var isOptional: Bool
var sortOrder: Int
var lastModified: Date?
init(id: UUID = UUID(), name: String, amount: String, section: String,
isOptional: Bool = false, sortOrder: Int = 0) {
self.id = id
self.name = name
self.amount = amount
self.section = section
self.isChecked = false
self.isOptional = isOptional
self.sortOrder = sortOrder
self.lastModified = Date()
}
}
이 형태에서 API가 감추고 있는 세부 사항이 세 가지 있습니다.
@Model은 영속 저장소용 스키마 선언을 따로 요구하지 않습니다. SwiftData는 컴파일 시점에 클래스 정의를 읽어 스키마를 합성합니다. 클래스의 프로퍼티가 모델의 속성이 되고, 그 Swift 타입이 컬럼 타입이 됩니다. 관리해야 할 .xcdatamodeld 파일은 없습니다(다만 Core Data의 기반인 NSManagedObjectModel은 여전히 존재하며, 런타임에 스키마를 떠받치는 것은 바로 이쪽입니다).2
@Attribute(.unique)는 단일 컬럼에 대한 제약이지 PRIMARY KEY 선언이 아닙니다. SwiftData의 영속 신원은 PersistentIdentifier가 담당하며, 행마다 자동으로 생성됩니다. @Attribute(.unique) 선언은 “이 컬럼은 값 하나당 최대 한 행만 저장한다”라고 프레임워크에 알려 주는 것입니다. 이미 존재하는 .unique 값을 가진 모델을 삽입하면 SwiftData는 upsert를 수행합니다. 기존 행이 거부되는 것이 아니라 갱신됩니다. 이 의미론은 제품 코드에서 중요합니다. .unique는 중복 입력의 제출을 막아 주는 UI 수준의 검증이 아니라, 조용히 병합하는 “최대 한 행”이라는 저장소 차원의 보장일 뿐입니다. 위의 id: UUID 패턴은 프로세스 간 동기화에서 권장되는 형태입니다(프로세스 내부의 PersistentIdentifier가 사라져도 살아남는 안정적인 식별자가 필요한 경우입니다). 그리고 같은 UUID가 두 개의 동기화 경로로 들어올 때, 이 upsert 동작이 바로 우리가 원하는 동작입니다.
@Model 클래스는 값 타입이 아니라 참조 타입입니다. ShoppingItem 인스턴스의 프로퍼티를 변경하면 SwiftData의 변경 추적이 작동합니다. 프레임워크가 그 변경을 등록하고 다음 컨텍스트 저장 시점에 영속화합니다. @Query를 통한 SwiftUI 통합에서는 해당 predicate를 관찰하는 뷰가 다시 렌더링됩니다. 이 패턴은 @Observable(SwiftUI는 무엇으로 만들어졌는가에서 다룹니다)과 비슷하며, 그 위에 영속성이 한 겹 얹힌 형태입니다.
옵셔널 필드는 값싼 마이그레이션입니다
ShoppingItem의 lastModified: Date? 필드는 옵셔널이고, 그 옵셔널 여부가 구조를 떠받칩니다. 이 필드는 v1 출시 이후에 기기 간 동기화와 충돌 해결을 지원하기 위해 추가되었습니다. 사용자 기기에 있는 기존 행에는 lastModified 값이 없었습니다. 기본값이 없는 옵셔널 필드라면 SwiftData의 lightweight migration이 마이그레이션 코드를 한 줄도 쓰지 않고 추가를 처리해 줍니다. 기존 행에는 nil이 들어가고, 새 행에는 init이 설정한 값이 들어갑니다.3
lightweight migration 경로는 프레임워크가 예의를 갖춰 걷는 길입니다. SwiftData는 새 스키마와 영속 저장소를 살펴보고, 호환 가능한 가장 작은 변경을 추론해 적용합니다. 마이그레이션은 자동으로 이루어지고, 사용자는 아무것도 보지 못하며, 앱은 기존 데이터 위에서 평소처럼 실행됩니다. lightweight 경로가 깔끔하게 처리하는 경우는 다음과 같습니다.
- 옵셔널 프로퍼티 추가
- 프로퍼티 제거(데이터는 버려지고, 기존 읽기에서 해당 컬럼이 더 이상 보이지 않습니다)
- 프레임워크가 힌트로 대응시킬 수 있는 속성의 이름 변경(
@Attribute(originalName: ...)사용) - 프레임워크가 대응시킬 수 있는
@Model클래스의 이름 변경(@Model.originalName또는 힌트 사용)
lightweight 경로가 중단하는 경우는 다음과 같습니다.
- 기존 스키마에 기본값 없는 논옵셔널 프로퍼티를 추가하는 경우(기존 행에는 채워 넣을 값이 없습니다)
- 프로퍼티의 타입을 바꾸는 경우(예:
Int→String) - 모델 하나를 둘로 나누거나 둘을 하나로 합치는 경우
- 마이그레이션에 커스텀 로직이 필요한 모든 경우
lightweight 경로가 중단할 때 안전한 동작은 마이그레이션을 실패시키는 것입니다. 안전하지 않은 동작은 데이터베이스를 버리고 처음부터 다시 시작하는 것일 텐데, 프레임워크는 보수적이라 그런 일을 조용히 하기를 거부합니다. 사용자는 실행 시 마이그레이션 오류로 앱이 죽는 모습을 보고, 개발자는 스키마 불일치를 가리키는 스택 트레이스를 봅니다. 데이터를 잃는 사람은 없지만 모두가 신뢰를 잃습니다.
첫날부터 VersionedSchema를 생략한 대가는 v2 → v3 경계에서 드러납니다. lightweight 경로가 감당하는 범위를 넘어서는 스키마 변경을 동반한 세 번째 기능을 추가할 때입니다.
VersionedSchema와 MigrationPlan: 첫날부터 지켜야 할 규율
VersionedSchema는 모델 스키마의 특정 버전을 선언합니다. MigrationPlan은 한 버전에서 다음 버전으로 어떻게 옮겨 갈지를 선언합니다.4 형태는 다음과 같습니다.
import SwiftData
enum SchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] = [ShoppingItemV1.self]
}
enum SchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] = [ShoppingItemV2.self]
}
enum AppMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] = [
SchemaV1.self,
SchemaV2.self,
]
static var stages: [MigrationStage] = [
MigrationStage.lightweight(fromVersion: SchemaV1.self, toVersion: SchemaV2.self)
]
}
모델 클래스 자체는 버전이 붙은 스키마의 네임스페이스 안으로 옮겨 갑니다.
extension SchemaV1 {
@Model
final class ShoppingItemV1 { /* v1 fields */ }
}
extension SchemaV2 {
@Model
final class ShoppingItemV2 { /* v2 fields, including lastModified */ }
}
ModelContainer는 마이그레이션 플랜과 함께 구성합니다.
let container = try ModelContainer(
for: ShoppingItemV2.self,
migrationPlan: AppMigrationPlan.self,
configurations: ModelConfiguration("ShoppingList")
)
마이그레이션 플랜은 스키마가 어떻게 진화하는지를 담은 타입이 붙은 그래프를 프레임워크에 제공합니다. v2를 출시한 앱이 v1 데이터베이스를 상대로 실행되면, 프레임워크는 마이그레이션 플랜을 따라가며 지정된 스테이지를 적용하고 데이터베이스를 v2로 끌어올립니다. v3을 출시할 때는 schemas에 SchemaV3.self를 추가하고 v2와 v3 사이에 새 MigrationStage를 넣습니다. 어떤 변경이 자동으로 처리되고 어떤 변경에 명시적인 스테이지가 필요한지, 그리고 필요하지도 않은 V2를 선언했을 때 마주치는 체크섬 크래시까지 포함한 마이그레이션 모델 전체는 자매편인 SwiftData 마이그레이션: lightweight와 custom에서 다룹니다.
지켜야 할 규율은 버전이 하나뿐일 때에도 v1에서 VersionedSchema를 출시하는 것입니다. 그 비용은 파일 하나와 enum 선언 하나입니다. 그렇게 하지 않았을 때의 비용은, v2에서 처음으로 복잡한 스키마 변경을 할 때 v1을 거슬러 올라가 VersionedSchema로 감싸는 작업입니다. 불가능하지는 않지만, 프레임워크가 기존 데이터를 SchemaV1으로 식별할 수 있도록 v1의 형태를 한 치의 오차도 없이 맞춰야 하므로 세심함이 필요합니다. v2를 작업하는 미래의 자신이 그 세금을 내게 됩니다. 지금의 자신이라면 한 번 내고 잊어버릴 수 있습니다.
어려운 경우를 위한 커스텀 MigrationStage
lightweight migration은 추가 성격의 변경 대부분을 감당합니다. 타입 변경, 분할, 병합, 조건부 값 채우기에는 MigrationStage.custom이 필요합니다.
static var stages: [MigrationStage] = [
MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
// Read v1 rows; stage any derived state to a transient store
// (UserDefaults / temp file) since the v1 and v2 contexts do
// not share state, and didMigrate cannot read v1.
let v1Items = try context.fetch(FetchDescriptor<ShoppingItemV1>())
stageDerivedState(from: v1Items)
},
didMigrate: { context in
// Populate v2-only fields on existing rows
let v2Items = try context.fetch(FetchDescriptor<ShoppingItemV2>())
for item in v2Items where item.lastModified == nil {
item.lastModified = Date()
}
try context.save()
}
)
]
두 클로저는 프레임워크가 구조적 마이그레이션을 적용하기 전과 후에 실행됩니다. willMigrate는 v1 스키마를 상대로, didMigrate는 v2 스키마를 상대로 동작합니다. 클로저 본문은 평범한 SwiftData 코드(fetch descriptor, model context 저장 등 실행 중인 앱에서 쓰는 것과 같은 API)이며, 마이그레이션 중에만 존재하는 임시 컨텍스트를 상대로 동작합니다.
프로덕션에서 살아남는 패턴은 willMigrate를 비워 두고 값을 채우는 로직을 전부 didMigrate에 두는 것입니다. willMigrate 안에서 v1 데이터를 읽는 것 자체는 허용되지만, 프레임워크 관점에서는 v2 스키마가 아직 존재하지 않습니다. 따라서 어떤 계산 결과든 didMigrate 클로저가 읽을 수 있는 임시 저장소에 미리 옮겨 두어야 합니다. 더 간단한 규칙은 이렇습니다. 구조적 마이그레이션은 프레임워크의 일이고, 기존 행에 v2에만 있는 필드를 채우는 것은 didMigrate의 일입니다.
@Attribute와 @Relationship이 제 이름값을 하는 순간
@Model 클래스에서 스키마를 꾸미는 일의 대부분은 두 매크로가 담당합니다.
@Attribute는 단일 프로퍼티에 제약이나 힌트를 부여합니다.
@Attribute(.unique)는ShoppingItem.id처럼 고유성을 강제합니다@Attribute(.externalStorage)는 큰Data블롭(이미지 데이터, 오디오 버퍼)을 데이터베이스 바깥에 저장합니다@Attribute(originalName: "old_field_name")은 마이그레이션 중에 프로퍼티를 이름이 바뀐 컬럼에 대응시킵니다@Attribute(.transformable(by: ...))는 Codable이 아닌 타입에ValueTransformer를 적용합니다
지켜야 할 규율은 이렇습니다. 정말로 고유해야 하는 필드(직접 생성한 UUID, 외부 ID)에만 .unique를 씁니다. 몇 KB를 넘는 블롭에는 .externalStorage를 씁니다. v2에서 프로퍼티 이름을 바꾸면 v1 데이터를 잃게 되는 상황에는 originalName을 씁니다.
@Relationship은 다른 @Model 클래스나 그 컬렉션을 가리키는 프로퍼티를 꾸밉니다.
@Model
final class List {
var name: String
@Relationship(deleteRule: .cascade, inverse: \ShoppingItem.list)
var items: [ShoppingItem] = []
}
@Model
final class ShoppingItem {
var name: String
var list: List?
}
deleteRule: .cascade는 부모인 List를 삭제하면 자식 ShoppingItem 행이 모두 삭제된다는 뜻입니다. inverse: 파라미터는 자식의 어떤 프로퍼티가 부모를 되가리키는지 프레임워크에 알려 주며, 프레임워크는 이를 예측 가능한 양방향 유지 관리에 사용합니다. SwiftData가 역방향 관계를 자동으로 추론할 수 있는 경우도 있고, 명시적으로 단방향인 관계에는 inverse: nil도 지원되지만, 추론이 모호해질 수 있는 상황에서는 언제나 inverse:를 선언해 두는 것이 안전한 기본값입니다.5
지켜야 할 규율은 이렇습니다. 관계는 명시적인 deleteRule과 함께 선언합니다(기본값은 .nullify인데, 그것이 원하는 동작인 경우는 드뭅니다). 그리고 양방향 관계에서는 프레임워크의 추론에 기대지 말고 언제나 inverse:를 선언합니다. 암묵적인 기본값은 대개 틀립니다. 명시적으로 쓰는 쪽은 파라미터 하나만큼의 수고로 버그 하나를 영원히 막아 줍니다.
액터 경계를 넘을 때: 그래프가 아니라 식별자를 보내십시오
@Model 클래스는 Sendable이 아니며, 올바른 선택은 Sendable로 만들려는 시도를 그만두는 것입니다. 인스턴스는 ModelContext가 붙들고 있는 살아 있는 객체 그래프에 대한 참조이고, 프레임워크는 그 그래프를 다른 액터에서 읽어도 안전하다고 약속할 수 없습니다. 그래서 이 타입은 의도적으로 Sendable이 아닌 채로 남아 있습니다. 억지로 준수를 붙여도 데이터 레이스는 사라지지 않습니다. 감춰질 뿐입니다.7
제대로 동작하는 패턴은 신원과 평범한 값만 보내고 반대편에서 다시 조회하는 것입니다. PersistentIdentifier는 Sendable이므로 경계를 깔끔하게 넘어갑니다. 받는 쪽이 필요로 하는 스칼라 값(이름, 플래그, 작은 구조체에 담은 델타)을 뽑아내어 식별자와 함께 전달하고, 받는 액터는 그 식별자로 자신의 컨텍스트에서 모델을 다시 조회하게 합니다.
// On the source actor: extract identity + plain values, never the model.
let id: PersistentIdentifier = item.persistentModelID
let snapshot = ItemSnapshot(name: item.name, isChecked: item.isChecked)
// On the destination actor: re-fetch from this context, then mutate.
let fetched = destinationContext.model(for: id) as? ShoppingItem
피해야 할 실패 방식은 모델 그래프 자체를 넘기는 것입니다. 그래프의 일부가 경계를 넘어가면, 받는 쪽은 저편에서 부분적으로만 실체화되는 모델을 손에 쥐게 됩니다. 원래 컨텍스트에서 폴트인되지 않았던 관계와 지연 로딩 프로퍼티가 엉뚱한 컨텍스트를 상대로 해석되거나 아예 해석되지 않고, 그 뒤에 따라오는 버그는 조용한 종류입니다. 안전한 계약은 식별자와 뽑아낸 값이지 그래프가 아닙니다. ModelActor는 컨텍스트를 직접 소유하고 인스턴스가 아니라 값을 내어 주는 방식으로 이 규율을 캡슐화합니다.7
CloudKit 동기화와 App Group entitlement의 함정
위젯이나 익스텐션이 읽을 수 있도록 SwiftData 저장소를 App Group 컨테이너로 옮기는 작업은 CloudKit 동기화와 맞물려, 출시된 뒤의 앱을 물어뜯습니다. 나머지가 따라오는 전제 두 가지가 있습니다.
첫째, 저장소의 위치입니다. 기본 ModelConfiguration을 쓰면, 앱이 App Group 없는 상태에서 있는 상태로 발전할 때 SwiftData가 기존 저장소를 App Group 컨테이너로 복사해 줍니다. Apple의 표현으로는 SwiftData가 “기존 저장소를 app group 컨테이너로 복사한다”라고 되어 있습니다.8 커스텀 저장소 URL을 쓰면 위치는 본인 책임입니다. 파일을 새 컨테이너로 복사하고 설정이 그곳을 가리키게 하는 일을 직접 해야 합니다. 기본 경로가 편리한 이유는 바로 프레임워크가 복사를 해 주기 때문입니다. 커스텀 경로는 그 편리함을 제어권과 맞바꿉니다.
둘째, entitlement입니다. CloudKit으로 동기화되는 저장소를 읽는 App Group 구성원은 모두 같은 CloudKit entitlement를 지녀야 합니다. 그 프로세스들이 저마다 자기 몫으로 그 컨테이너를 동기화하려 하기 때문입니다. 이 요구 사항이 바로 함정입니다. 위젯이나 익스텐션에는 장황한 동기화를 끌고 갈 만한 런타임 여유도, 포그라운드 시간도 없습니다. 그런데 CloudKit entitlement를 쥐여 주면 기어이 시도하게 됩니다. 해법은 ModelConfiguration을 둘로 나누는 것입니다. 동기화되는 저장소(CloudKit entitlement를 지니며 메인 앱이 소유)와, 위젯과 익스텐션이 동기화 없이 읽기만 하는 App Group 컨테이너 안의 로컬 저장소입니다. 동기화는 포그라운드에 있는 앱이 제대로 해낼 수 있는 곳에 두고, 읽기용으로 공유하는 데이터는 동기화 경로 바깥에 두십시오.8
다시 만든다면 이렇게 하겠습니다
이 클러스터의 앱들이 실제로 쓰고 있거나, 썼더라면 좋았겠다고 생각하는 패턴이 세 가지 있습니다.
v1부터 VersionedSchema를 출시하십시오. 출시되는 모든 @Model 클래스는 첫날부터 VersionedSchema 안에 살아야 합니다. 비용은 스키마 버전마다 감싸는 enum 하나입니다. 이득은 v2의 첫 번째 복잡한 변경이 이틀짜리 소급 리팩터링이 아니라 MigrationPlan.schemas에 한 줄을 더하는 일로 끝난다는 점입니다.
모든 타임스탬프를 옵셔널로 만드십시오. 기기 간 동기화나 충돌 해결을 위해 존재하는 lastModified, createdAt, updatedAt 같은 필드는, v1 제품이 필요로 하지 않는다면 v1에서 옵셔널이어야 합니다. 옵셔널이면 (정말 필요해지는) v2로의 마이그레이션이 값싸게 유지됩니다. didMigrate에서 기존 행을 채우는 것은 반복문 하나면 됩니다. v1부터 논옵셔널로 두는 것은 사용자 데이터의 백필을 망가뜨릴 수 있는 제약입니다.
자연 키로는 PersistentIdentifier가 아니라 UUID를 쓰십시오. SwiftData의 PersistentIdentifier는 프로세스 내부용입니다. 기기 간 동기화, MCP 연동(에이전트 생태계 둘, 장보기 목록 하나에서 다룹니다), 그리고 프로세스 바깥의 모든 참조에는 안정적인 식별자가 필요합니다. @Attribute(.unique)를 붙인 UUID가 알맞은 형태이고, 프로세스 내부용인 PersistentIdentifier는 프로세스 경계를 넘는 그 어떤 용도에도 맞지 않는 형태입니다.
@Model이 답이 아닐 때
SwiftData가 알맞은 도구가 아닌 경우가 세 가지 있습니다.
단일 레코드 키/값 상태. 앱 설정, 사용자가 선택한 언어, 마지막 동기화 시각 같은 것들입니다. UserDefaults나 NSUbiquitousKeyValueStore를 쓰십시오(Apple 플랫폼 다섯 개, 공유 파일 세 개에서 다룹니다). 행 하나를 위해 SwiftData의 오버헤드를 감수하는 것은 헛된 격식이고, 키-값 저장소가 알맞은 토대입니다.
오프라인 쓰기가 없는, 서버가 권위인 데이터. REST API에서 가져와 읽기 전용으로 보여 주는 목록 같은 것입니다. 진실의 출처가 서버이고 로컬 캐시가 그저 캐시일 뿐이라면 SwiftData는 과합니다. Documents/에 두는 단순한 Codable 스냅숏과 메모리에 캐시한 배열이면 충분합니다. 하드 리셋을 넘어 살아남을 필요가 없는 데이터라면 SwiftData의 마이그레이션 세금을 낼 가치가 없습니다.
여러 프로세스에 걸친 조율. SwiftData는 프로세스 안에서 동작합니다. iOS 앱 바깥에서 실행되는 MCP 서버는 앱의 SwiftData 컨테이너를 읽지도 쓰지도 못합니다. 프로세스를 넘나드는 상태에는 다른 형태가 필요합니다. iCloud Drive의 JSON 파일, 공유 App Group 컨테이너, 또는 프로세스 사이를 이어 주는 명시적인 동기화 계층 같은 것입니다. (Get Bananas가 SwiftData와 iCloud Drive JSON을 함께 쓰는 이유가 정확히 이것입니다.)6
데이터가 좀처럼 바뀌지 않는 큰 블롭인 경우. 10MB짜리 오디오 파일, 50MB짜리 이미지 데이터셋 같은 것입니다. 블롭이 SwiftData 행 안에 있다면 @Attribute(.externalStorage)를 쓰고, 그렇지 않다면 파일 시스템을 직접 쓰면서 SwiftData에는 파일 URL을 가리키는 메타데이터를 두십시오.
그래도 Core Data가 이기는 경우
SwiftData는 Core Data 위에 얹힌 한 겹이지 그 전부를 대체하는 물건이 아니며, 3년이 지난 지금도 특정한 일들은 여전히 오래된 프레임워크의 몫입니다. 이런 일에 Core Data를 고르는 것은 레거시 선택이 아니라 현재로서 올바른 선택입니다.
데이터베이스 쪽에서 수행하는 집계. SwiftData에는 Core Data의 NSExpression 기반 조회, 곧 sum, average, min, max를 SQLite로 밀어 넣어 행을 적재하지 않고 데이터베이스가 계산하게 하는 그 방식에 해당하는 것이 없습니다9. SwiftData에서는 조회해서 메모리에서 리듀스하게 되는데, 큰 테이블에서는 그 목적 자체가 무너집니다. 문서화된 탈출구는 공존입니다. Apple은 “완전히 분리된 두 개의 영속 스택, 곧 하나의 Core Data 스택과 하나의 SwiftData 스택이 같은 영속 저장소와 대화하는” 구성을 설명하며9, 이를 통해 Core Data 쪽이 SwiftData가 소유한 파일을 상대로 SQL에 밀어 넣은 집계를 실행할 수 있습니다. NSPersistentHistoryTrackingKey 요구 사항을 포함한 구체적인 메커니즘은 SwiftData 성능은 스토리지 문제입니다에서 다룹니다.
공유와 CloudKit 퍼블릭 데이터베이스. SwiftData의 자동 iCloud 동기화는 내부적으로 NSPersistentCloudKitContainer 위에서 돌아가며, 그것이 구성하는 컨테이너는 저장소를 사용자의 프라이빗 CloudKit 데이터베이스로 미러링합니다10. CKShare를 통한 서로 다른 iCloud 사용자 간 협업이나 퍼블릭 데이터베이스로의 게시는 Core Data + CloudKit으로 문서화된 기능이며, iOS 27 베타 기준으로 SwiftData 수준의 API는 없습니다10. 핵심 기능이 공유 목록이나 공동 편집 문서인 앱은 동기화되는 저장소만 Core Data로 내려가거나, CloudKit 계층을 손으로 만들어 올리거나 둘 중 하나를 하게 됩니다.
저장소 수준의 일괄 갱신. Core Data의 NSBatchUpdateRequest는 객체를 적재하지 않고 저장소 안에서 해당하는 행을 바로 다시 씁니다11. SwiftData의 ModelContext에는 삭제 쪽 절반(delete(model:where:)이 predicate를 받습니다)은 있지만 일괄 갱신에 해당하는 것이 없어서, SwiftData에서 필드를 대량으로 다시 쓴다는 것은 영향을 받는 모델을 전부 실체화한다는 뜻이 됩니다.
iOS 17보다 낮은 배포 하한선. SwiftData는 iOS 17을 요구합니다. Core Data는 앱이 지원하는 한 얼마든지 거슬러 올라가고, 그 CloudKit 컨테이너는 iOS 13까지 돌아갑니다1012. OS 지원 범위가 긴 코드베이스에는 선택의 여지가 없습니다.
이 목록에서 빠진 항목도 짚어 둘 만합니다. “뷰 바깥에서 NSFetchedResultsController가 필요하다”라는 점은 iOS 27 베타가 ResultsObserver를 추가하기 전까지 Core Data의 강점이었습니다. 이것은 Swift Observation을 통해 앱의 어느 곳에서든 조회를 관찰합니다13. 격차 목록은 릴리스를 거듭할수록 줄어듭니다. 이렇게 걸면 됩니다. 새 앱은 SwiftData로 시작하고, 위에 적은 특정 작업에는 공존을 도입하며, 공유나 퍼블릭 데이터베이스나 배포 하한선이 강요할 때에만 온전한 Core Data 스택을 답으로 취급하십시오.
iOS 26 이상에서 출시하는 앱에 이 패턴이 뜻하는 것
가져갈 것은 세 가지입니다.
-
매크로는 쉬운 부분이고, 비용은 마이그레이션입니다.
@Model과@Attribute는 상당한 양의 Core Data 배관을 감춰 주는 두 줄짜리 선언입니다. 앱의 수명 내내 실제로 치르는 비용은 마이그레이션 규율 쪽입니다. v2를 염두에 두고 v1을 설계하십시오. -
출시되는 앱에 첫날부터의
VersionedSchema는 타협 대상이 아닙니다. 감싸는enum은 파일 하나면 됩니다. 나중에 추가할 때 치르는 소급 비용은 그보다 훨씬 큽니다. -
옵셔널 필드와 명시적인 관계는 값싼 보험입니다. 동기화 메타데이터를 위한 옵셔널 타임스탬프, 관계에 붙이는 명시적인
deleteRule과inverse:. 둘 다 아주 작은 선언이면서 v2의 유연성을 크게 사 줍니다.
Apple Ecosystem 클러스터 전체는 이렇습니다. Apple Intelligence를 위한 타입이 붙은 App Intents, 여러 LLM을 넘나드는 에이전트를 위한 MCP 서버, 그 둘 사이의 라우팅 문제, 온디바이스 LLM과 Tool 프로토콜을 위한 Foundation Models, iOS 잠금 화면 상태 기계를 위한 Live Activities, Apple Watch에서의 watchOS 런타임 계약, 프레임워크의 바탕을 다루는 SwiftUI 내부, visionOS 씬을 위한 RealityKit의 공간 심상 모델, 시각 계층을 위한 Liquid Glass 패턴, 기기를 넘나드는 도달을 위한 멀티 플랫폼 출시입니다. 허브는 Apple Ecosystem 시리즈에 있습니다. iOS와 AI 에이전트를 아우르는 더 넓은 맥락은 iOS 에이전트 개발 가이드를 참고하십시오.
자주 묻는 질문
@Model과 Core Data의 NSManagedObject는 무엇이 다릅니까?
@Model은 내부적으로 NSManagedObject 배관을 생성하는 Swift 매크로입니다. SwiftData는 백킹 스토어로 Core Data를 쓰기 때문에 런타임 모델은 같고, 차이는 표면에 있습니다. @Model은 .xcdatamodeld 파일, value transformer를 다루는 격식, NSManagedObjectContext 생명 주기 관리를 없애 줍니다. 같은 영속 저장소를 Swift다운 형태의 API로 쓰게 되는 셈입니다.
스키마를 바꿀 계획이 전혀 없어도 VersionedSchema가 필요합니까?
앱이 v2를 출시할 가능성이 있다면 필요합니다. 한 번 쓰고 마는 데모라면 필요 없습니다. v1부터 VersionedSchema를 두는 비용은 enum 선언 하나입니다. v2에서 소급해 추가하는 비용은 프레임워크가 기존 데이터를 알아보도록 v1 스키마의 형태를 정확히 맞추는 일인데, 불가능하지는 않지만 실수하기 쉽습니다. 출시되는 앱은 대부분 언젠가 스키마 변경이 필요해집니다. v1에서 그 몫을 미리 잡아 두십시오.
@Attribute(.unique)는 언제 써야 합니까?
그 필드가 행의 자연 키일 때입니다. 직접 생성한 UUID, 가져온 외부 ID, 직접 부여한 슬러그 같은 것입니다. SwiftData는 .unique를 upsert로 취급합니다. .unique 값이 이미 존재하는 모델을 삽입하면 새 행이 추가되는 대신 기존 행이 갱신됩니다. 바로 그 의미론이 upsert 방식의 동기화 경로(같은 UUID가 두 기기에서 들어오는 경우)를 안전하게 만들어 줍니다. 또한 그것이 title 같은 표시용 이름 필드에 .unique가 어울리지 않는 이유이기도 합니다. 두 사용자가 같은 제목을 입력하면 별개의 레코드 두 개가 생기는 대신 행이 조용히 병합되어 버리기 때문입니다.
기존 스키마에 논옵셔널 필드를 추가하려면 어떻게 해야 합니까?
MigrationStage.custom과 didMigrate 클로저를 써서 기존 행에 그 필드를 채우십시오. 아니면 더 쉬운 방법으로, 새 스키마 버전에서 그 필드를 옵셔널로 선언하고 접근 시점에 느긋하게 채우는 방법도 있습니다. 옵셔널 쪽이 더 값싼 마이그레이션이고, 논옵셔널 추가에는 명시적으로 값을 채우는 로직이 필요합니다.
PersistentIdentifier와 직접 만든 UUID는 어떻게 다릅니까?
PersistentIdentifier는 SwiftData의 프로세스 내부 행 ID이고, 자동으로 생성되며 실행 중인 프로세스의 수명 동안 유지됩니다. @Attribute(.unique)를 붙인 직접 만든 UUID는 프로세스와 기기를 넘나들며 안정적인 식별자입니다. 앱 안의 프로세스 내부 참조에는 PersistentIdentifier를 쓰십시오. 프로세스 경계를 넘는 모든 것(기기 간 동기화, 외부 연동, MCP 도구, 네트워크 호출)에는 UUID를 쓰십시오.
지금도 SwiftData 대신 Core Data를 골라야 하는 때는 언제입니까?
iOS 27 베타 기준으로 네 가지 경우입니다. 데이터베이스 쪽 집계(SwiftData에 없는 NSExpression 조회), CKShare나 퍼블릭 CloudKit 데이터베이스를 통한 iCloud 사용자 간 공유(SwiftData의 동기화는 프라이빗 데이터베이스를 다룹니다), 저장소 수준의 일괄 갱신(NSBatchUpdateRequest), 그리고 iOS 17보다 낮은 배포 타깃입니다91011. 집계의 경우라면 SwiftData를 버릴 필요가 없습니다. 같은 저장소 파일을 상대로 공존하는 Core Data 스택을 함께 돌리십시오.
참고 자료
-
저자의 Get Bananas. SwiftData에 iCloud Drive JSON 동기화와 MCP 서버를 결합한 SwiftUI 장보기 목록 앱입니다.
ShoppingItem모델은 초기 개발 주기를 거치며 진화했고,lastModified: Date?필드는 최초 스키마 이후에 추가되었습니다(2025-12-01 커밋268a00d, “Make lastModified optional to fix migration crash”). 논옵셔널로 두면 기존 행에 채워 넣을 값이 없을 때 마이그레이션이 깨졌기 때문입니다. ↩ -
Apple Developer, “SwiftData” 및 “Adding and editing persistent data in your app”.
@Model매크로,@Attribute제약의 표면, 그리고 Core DataNSManagedObjectModel과의 관계를 다룹니다. ↩↩ -
Apple Developer, “Preserving your app’s model data across launches” 및 “Adopting SwiftData for a Core Data app”. lightweight migration의 의미론과 프레임워크가 처리를 중단하게 되는 조건을 다룹니다. ↩
-
Apple Developer, “VersionedSchema” 및 “SchemaMigrationPlan”. 버전이 붙은 스키마 선언, 마이그레이션 스테이지 정의, 그리고 마이그레이션 플랜을 받는
ModelContainer생성자를 다룹니다. ↩ -
Apple Developer, “Defining data relationships with enumerations and model classes” 및 “Schema.Relationship”.
@Relationship매크로,deleteRule선택지(.cascade,.nullify,.deny,.noAction), 그리고 양방향 관계 유지에서inverse:파라미터가 하는 역할을 다룹니다. ↩ -
저자의 분석, Two Agent Ecosystems, One Shopping List(2026년 4월 29일) 및 Five Apple Platforms, Three Shared Files. 멀티 프로세스 워크플로 안에서 SwiftData를 보완하고 때로는 대체하는 Get Bananas와 Return의 프로세스 간·기기 간 동기화 패턴을 다룹니다. ↩
-
Apple Developer, “PersistentIdentifier”(
Sendable을 준수) 및 “ModelActor”. SwiftData 팀은 WWDC 2026 SwiftData Group Lab에서@Model객체는Sendable이 아니며 억지로 준수시켜서는 안 된다고 확인했습니다. 컨텍스트 안에 살아 있는 참조 그래프이기 때문입니다. 권장되는 경계 계약은Sendable인PersistentIdentifier와 뽑아낸 평범한 값을 넘기고 목적지 컨텍스트에서 다시 조회하는 것이며, 모델 그래프를 넘기면 받는 쪽에 부분적으로만 실체화된 객체가 남는다는 점도 함께 밝혔습니다. WWDC 2026 SwiftData Group Lab을 로컬에서 전사한 녹음에서 요약한 내용입니다. Apple은 이 랩들에 대한 공식 캡션을 공개하지 않습니다. ↩↩ -
Apple Developer, “Adopting SwiftData for a Core Data app”. 기본 구성에서는 “SwiftData가 기존 저장소를 app group 컨테이너로 복사한다”라고 밝히고 있으며, 커스텀 저장소 URL을 쓰면 위치 관리는 사용자의 몫으로 남습니다. App Group 구성원에 대한 CloudKit entitlement 요구 사항과, 위젯 및 익스텐션을 동기화 경로에서 빼기 위한 두 개의
ModelConfiguration분리(하나는 동기화, 하나는 로컬)는 WWDC 2026 SwiftData Group Lab에서 설명되었습니다. WWDC 2026 SwiftData Group Lab을 로컬에서 전사한 녹음에서 요약한 내용입니다. Apple은 이 랩들에 대한 공식 캡션을 공개하지 않습니다. ↩↩ -
Apple, WWDC 2023 세션 10189, “Migrate to SwiftData”. 공존이라는 틀(“완전히 분리된 두 개의 영속 스택, 곧 하나의 Core Data 스택과 하나의 SwiftData 스택이 같은 영속 저장소와 대화한다”)의 출처입니다. 그리고 Apple Developer, “NSExpression”. Core Data가 SQL로 밀어 넣는 집계 조회의 메커니즘이며, SwiftData에는 이에 해당하는 것이 없습니다. 이 격차는 WWDC 2026 SwiftData Group Lab의 SwiftData 엔지니어링 패널에서 확인되었습니다(로컬에서 전사한 녹음에서 요약). ↩↩↩
-
Apple Developer, “Syncing model data across a person’s devices”. “SwiftData는 CloudKit 동기화를 처리하기 위해 Core Data의
NSPersistentCloudKitContainer클래스를 사용한다”라고 밝히고 있습니다. 또한 “NSPersistentCloudKitContainer”(iOS 13.0+)는 그 개요에서 “선택한 영속 저장소를 CloudKit 프라이빗 데이터베이스로” 미러링한다고 설명합니다. 그리고 “Sharing Core Data objects between iCloud users”는CKShare기반 협업을 위해 문서화된 Core Data 경로입니다. SwiftData 문서는 iOS 27 베타 기준으로 공유나 퍼블릭 데이터베이스 API를 공개하지 않습니다. ↩↩↩↩ -
Apple Developer, “NSBatchUpdateRequest” 및 SwiftData의 predicate 기반 일괄 삭제인 “ModelContext.delete(model:where:includeSubclasses:)”. SwiftData의
ModelContext문서에는 일괄 갱신에 해당하는 것이 나와 있지 않습니다. ↩↩ -
Apple Developer 문서에 따른 플랫폼 지원 범위: SwiftData(iOS 17.0+)와 Core Data(iOS 3.0+). ↩
-
Apple Developer, “ResultsObserver”(iOS 27.0 베타). “모델 컨텍스트 안에 있는 영속 모델 컬렉션의 변경을 관찰하고 추적”하며
Observable을 준수해, 이전에는 Core Data의NSFetchedResultsController가 필요했던 뷰 바깥에서의 관찰 역할을 대신합니다. ↩