SwiftData의 진짜 비용은 스키마 규율입니다
Get Bananas의 ShoppingItem은 SwiftData 스키마 규율이 왜 중요한지 보여주는 대표적인 예시입니다. 원래 스키마에는 lastModified 타임스탬프가 없었고, 나중에 이를 추가하려니 특정한 마이그레이션 형태가 필요했습니다. 기존 데이터가 이미 디스크에 저장되어 있었기 때문입니다. 그리고 이 필드는 처음에 비선택적으로 추가했을 때 발생한 마이그레이션 크래시를 고치기 위해 일부러 선택적으로 만든 것입니다.1
SwiftData의 API는 매크로 두 개입니다. 클래스에 @Model을 붙이면 영속 타입이 됩니다. 프로퍼티에 @Attribute(.unique)를 붙이면 고유성 제약 조건이 부여됩니다. 이 프레임워크는 Core Data의 스택 관리, 값 변환기(value-transformer) 처리, 그리고 NSManagedObjectContext 보일러플레이트를 숨겨 줍니다. 하지만 이 프레임워크가 숨기지 않는 것이 바로 스키마 마이그레이션입니다. 단지 마이그레이션을 명령형(imperative)이 아니라 선언형(declarative)으로 만들 뿐입니다. 마이그레이션에 주의를 기울이지 않는 비용은 일상적인 업데이트에서 사용자의 데이터를 날려 버리는 버그입니다.
핵심 주장은 이렇습니다. SwiftData는 시작은 저렴하지만 허술하게 마이그레이션하면 비싸집니다. 규율이란 명명, 선택성(optionality), 그리고 VersionedSchema를 첫날부터 갖추는 것입니다. 이것들이 필요했음을 깨닫는 그날이 아니라 말입니다.
TL;DR
@Model매크로는 클래스를 영속 SwiftData 타입으로 바꿉니다. 프레임워크는 컴파일 타임에 프로퍼티 선언으로부터 스키마를 생성합니다.- 새로운 선택적 프로퍼티를 추가하는 것은 아무 작업도 필요 없는 마이그레이션입니다. SwiftData의 경량(lightweight) 마이그레이션이 이를 처리합니다. 기존 스키마에 비선택적 프로퍼티를 추가하려면
VersionedSchema와 함께, 기존 행에 대해 새 필드를 어떻게 채울지 프레임워크에 알려 주는MigrationPlan이 필요합니다. VersionedSchema를 첫날부터 건너뛴 비용은, 사소하지 않은 v2 스키마 변경이라면 무엇이든 사용자의 데이터베이스를 날려 버릴 위험을 안는다는 것입니다. 경량 경로는 보수적이어서 마이그레이션을 추론할 수 없으면 작업을 중단하기 때문입니다.@Attribute(.unique)는 자연 키(natural key, 직접 생성한UUID, 가져온 외부 ID)에 알맞은 도구입니다.@Relationship은 부모/자식 참조에 알맞은 도구입니다. 둘 다 내부적으로 적절한 Core Data 배관(plumbing)을 생성하는 매크로입니다.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가 사라져도 살아남는 안정적인 식별자가 필요한 경우). 그리고 upsert 동작은 동일한 UUID가 두 개의 동기화 경로에서 도착할 때 정확히 원하는 결과입니다.
@Model 클래스는 값 타입이 아니라 참조 타입입니다. ShoppingItem 인스턴스의 프로퍼티를 변경하면 SwiftData의 변경 추적이 작동합니다. 프레임워크는 변경을 등록하고 다음 컨텍스트 저장 시 이를 영속화합니다. @Query를 통한 SwiftUI 통합은 일치하는 술어(predicate)를 관찰하는 모든 뷰를 다시 렌더링합니다. 이 패턴은 @Observable(What SwiftUI Is Made Of에서 다룹니다)과 유사하며, 그 위에 영속성이 한 겹 더해진 것입니다.
선택적 필드는 저렴한 마이그레이션입니다
ShoppingItem의 lastModified: Date? 필드는 선택적이며, 그 선택성이 핵심을 담당합니다. 이 필드는 기기 간 동기화와 충돌 해결을 지원하기 위해 v1 출시 후에 추가되었습니다. 사용자 기기의 기존 행에는 lastModified 값이 없었습니다. 기본값이 없는 선택적 필드를 쓰면, SwiftData의 경량 마이그레이션이 마이그레이션 코드를 전혀 작성하지 않고도 이 추가를 처리합니다. 기존 행은 nil을 받고, 새 행은 init이 설정하는 값을 받습니다.3
경량 마이그레이션 경로는 프레임워크의 정중한 경로입니다. SwiftData는 새 스키마와 영속 저장소를 검사하여 가장 작은 호환 가능한 변경을 추론한 뒤 이를 적용합니다. 마이그레이션은 자동입니다. 사용자는 아무것도 보지 못하고, 앱은 기존 데이터 위에서 정상적으로 실행됩니다. 경량 경로가 깔끔하게 처리하는 경우는 다음과 같습니다.
- 선택적 프로퍼티 추가
- 프로퍼티 제거(데이터는 버려지고, 기존 읽기는 더 이상 그 컬럼을 볼 수 없습니다)
- 프레임워크가 힌트로 매칭할 수 있는 속성 이름 변경(
@Attribute(originalName: ...)사용) - 프레임워크가 매칭할 수 있는
@Model클래스 이름 변경(@Model.originalName또는 힌트 사용)
경량 경로가 작업을 중단하는 경우는 다음과 같습니다.
- 기본값 없이 기존 스키마에 비선택적 프로퍼티 추가(기존 행에는 채울 값이 없습니다)
- 프로퍼티 타입 변경(예:
Int→String) - 하나의 모델을 둘로 분할하거나 둘을 하나로 병합
- 마이그레이션에 커스텀 로직이 필요한 모든 경우
경량 경로가 작업을 중단할 때 안전한 동작은 마이그레이션을 실패시키는 것입니다. 안전하지 않은 동작은 데이터베이스를 버리고 처음부터 다시 시작하는 것일 텐데, 프레임워크는 보수적이어서 이를 조용히 하기를 거부합니다. 사용자는 실행 시 마이그레이션 오류와 함께 앱이 크래시되는 것을 보고, 개발자는 스키마 불일치를 가리키는 스택 트레이스를 보게 됩니다. 아무도 데이터를 잃지 않지만, 모두가 신뢰를 잃습니다.
VersionedSchema를 첫날부터 건너뛴 비용은 v2 → v3 경계에서 드러납니다. 경량 경로가 처리하는 범위를 넘어서는 스키마 변경을 동반하는 세 번째 기능을 추가할 때 말입니다.
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를 추가합니다.
규율이란 버전이 하나뿐일지라도 v1에 VersionedSchema를 출시하는 것입니다. 그렇게 하는 비용은 파일 하나와 enum 선언 하나가 늘어나는 것입니다. 그렇게 하지 않는 비용은, v2의 첫 번째 사소하지 않은 스키마 변경 시에 v1을 VersionedSchema로 소급해서 감싸야 한다는 것입니다. 이것은 가능하지만, 프레임워크가 기존 데이터를 SchemaV1으로 식별할 수 있도록 정확한 v1 형태에 맞추는 데 주의가 필요합니다. v2를 작업하는 미래의 당신은 그 세금을 치르게 됩니다. 현재의 당신은 한 번 치르고 잊어버릴 수 있습니다.
어려운 경우를 위한 커스텀 MigrationStage
경량 마이그레이션은 대부분의 추가형 변경을 처리합니다. 타입 변경, 분할, 병합, 그리고 조건부 채우기에는 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 코드(페치 디스크립터, 모델 컨텍스트 저장 등, 실행 중인 앱에서 쓰는 것과 동일한 API)이며, 마이그레이션 중에만 존재하는 일시적(transient) 컨텍스트를 상대로 동작합니다.
프로덕션에서 살아남는 패턴은 willMigrate를 비워 두고 모든 채우기 로직을 didMigrate에 넣는 것입니다. willMigrate 안에서 v1 데이터를 읽는 것은 허용되지만, 프레임워크의 관점에서는 아직 v2 스키마가 존재하지 않으므로, 어떤 계산이든 didMigrate 클로저가 읽을 수 있는 일시적 저장소에 단계적으로 저장(stage)해 두어야 합니다. 더 간단한 규칙은 이렇습니다. 구조적 마이그레이션은 프레임워크의 일이고, 기존 행에 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이 아닌 채로 남겨졌습니다. 적합성(conformance)을 강제한다고 데이터 경합(data race)이 사라지지는 않습니다. 그저 숨겨질 뿐입니다.7
작동하는 패턴은 정체성과 일반 값(plain values)을 보낸 뒤, 반대편에서 다시 페치하는 것입니다. 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
피해야 할 실패 양상은 모델 그래프 자체를 전달하는 것입니다. 그래프의 일부가 경계를 넘으면, 수신자는 반대편에서 부분적으로만 수화(hydrate)되는 모델을 받게 됩니다. 원본 컨텍스트에서 한 번도 폴트인(faulted in)되지 않은 관계와 지연 로딩 프로퍼티가 잘못된 컨텍스트를 상대로 해소되거나(아니면 아예 해소되지 않거나), 그 뒤에 따라오는 버그는 조용한 종류의 것입니다. 식별자에 추출한 값을 더한 것이 안전한 계약이고, 그래프는 그렇지 않습니다. ModelActor는 컨텍스트를 소유하고 인스턴스가 아니라 값을 제공함으로써 이 규율을 캡슐화합니다.7
CloudKit 동기화와 앱 그룹 entitlement 함정
SwiftData 저장소를 widget이나 extension이 읽을 수 있도록 app group 컨테이너로 옮기는 것은 CloudKit 동기화와 상호 작용하는데, 이것이 앱 출시 이후에 발목을 잡습니다. 두 가지 사실이 나머지를 이끌어 냅니다.
첫째, 저장소 위치입니다. 기본 ModelConfiguration을 쓰면, 앱이 그룹 없음에서 app group으로 진화할 때 SwiftData가 기존 저장소를 app group 컨테이너로 복사해 줍니다. Apple의 표현으로는 SwiftData가 “기존 저장소를 app group 컨테이너로 복사한다”입니다.8 커스텀 저장소 URL을 쓰면 위치를 직접 소유합니다. 파일을 새 컨테이너로 복사하고 구성이 그것을 가리키도록 직접 설정해야 합니다. 기본 경로가 편리한 이유는 바로 프레임워크가 복사를 해 주기 때문이며, 커스텀 경로는 그 편리함을 제어와 맞바꿉니다.
둘째, entitlement입니다. CloudKit 동기화 저장소를 읽는 모든 app group 구성원은 동일한 CloudKit entitlement를 지녀야 합니다. 그 각각의 프로세스가 자기 몫으로 그 컨테이너를 동기화하기 때문입니다. 그 요구 사항이 바로 함정입니다. widget이나 extension에는 장황한 동기화를 구동할 런타임 예산이나 포그라운드 윈도우가 없는데, 거기에 CloudKit entitlement를 쥐여 주면 그것을 시도하도록 강제하게 됩니다. 해결책은 두 개의 ModelConfiguration 인스턴스로 나누는 것입니다. 하나는 동기화 저장소(CloudKit entitlement, 메인 앱이 소유)이고, 다른 하나는 widget과 extension이 동기화 없이 읽는 app group 컨테이너 내의 로컬 저장소입니다. 동기화는 포그라운드 앱이 잘 해낼 수 있는 곳에 두고, 읽기용으로 공유되는 데이터는 동기화 경로 밖에 두십시오.8
내가 다르게 만들었을 것들
클러스터의 앱들이 실제로 출시했거나, 출시했더라면 좋았을 세 가지 패턴입니다.
v1부터 VersionedSchema를 출시하라. 출시되는 모든 @Model 클래스는 첫날부터 VersionedSchema 안에 있어야 합니다. 비용은 스키마 버전당 감싸는 enum 하나입니다. 이득은 v2의 첫 번째 사소하지 않은 변경이 이틀짜리 소급 리팩터링이 아니라 MigrationPlan.schemas에 한 줄을 추가하는 일이 된다는 것입니다.
모든 타임스탬프를 선택적으로 만들어라. 기기 간 동기화나 충돌 해결을 위해 존재하는 lastModified, createdAt, updatedAt 같은 필드는, v1 제품이 그것들을 필요로 하지 않는다면 v1에서 선택적이어야 합니다. 선택성은 (실제로 필요해지는) v2로의 마이그레이션을 저렴하게 유지합니다. didMigrate 중에 기존 행에 그것들을 채우는 것은 루프 하나입니다. v1부터 비선택적으로 만드는 것은 사용자 데이터에 대한 백필을 깨뜨릴 수 있는 제약입니다.
PersistentIdentifier가 아니라 UUID를 자연 키로 사용하라. SwiftData의 PersistentIdentifier는 프로세스 내부의 것입니다. 기기 간 동기화, MCP 통합(Two Agent Ecosystems, One Shopping List에서 다룹니다), 그리고 프로세스 밖의 모든 참조에는 안정적인 식별자가 필요합니다. @Attribute(.unique)가 붙은 UUID가 올바른 형태입니다. 프로세스 내부의 PersistentIdentifier는 프로세스 경계를 넘는 모든 것에 대해 잘못된 형태입니다.
@Model이 잘못된 답일 때
SwiftData가 알맞은 도구가 아닌 세 가지 경우입니다.
단일 레코드 키/값 상태. 앱 설정, 사용자가 선택한 언어, 마지막 동기화 타임스탬프 등입니다. UserDefaults나 NSUbiquitousKeyValueStore(Five Apple Platforms, Three Shared Files에서 다룹니다)를 사용하십시오. 단일 행에 대한 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)를 사용하십시오. 그렇지 않다면 파일 URL을 가리키는 메타데이터를 SwiftData에 두고 파일시스템을 직접 사용하십시오.
이 패턴이 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 Series에 있습니다. AI 에이전트와 함께하는 더 넓은 iOS 맥락은 iOS Agent Development guide를 참고하십시오.
FAQ
@Model과 Core Data의 NSManagedObject의 차이는 무엇인가요?
@Model은 내부적으로 NSManagedObject 배관을 생성하는 Swift 매크로입니다. SwiftData는 Core Data를 기반 저장소로 사용하므로 런타임 모델은 동일합니다. 차이는 표면에 있습니다. @Model은 .xcdatamodeld 파일, 값 변환기 격식, 그리고 NSManagedObjectContext 생명 주기 관리를 없앱니다. Swift 형태의 API로 동일한 영속 저장소를 얻습니다.
스키마를 절대 바꾸지 않을 계획이라면 VersionedSchema가 필요한가요?
앱이 v2를 출시할 가능성이 있다면 그렇습니다. 일회성 데모라면 아닙니다. v1부터의 VersionedSchema 비용은 enum 선언 하나가 더 늘어나는 것입니다. v2에서 그것을 소급해 추가하는 비용은 프레임워크가 기존 데이터를 인식하도록 정확한 v1 스키마 형태에 맞추는 것인데, 이는 가능하지만 오류가 나기 쉽습니다. 출시되는 대부분의 앱은 결국 스키마 변경이 필요해집니다. v1에서 그것을 예산에 넣으십시오.
@Attribute(.unique)는 언제 사용해야 하나요?
필드가 행의 자연 키일 때입니다. 직접 생성한 UUID, 가져온 외부 ID, 직접 부여한 슬러그 등입니다. SwiftData는 .unique를 upsert로 취급합니다. .unique 값이 이미 존재하는 모델을 삽입하면 새 행이 추가되는 대신 기존 행이 갱신됩니다. 그 의미론이 upsert 방식의 동기화 경로(두 기기에서 오는 동일한 UUID)를 안전하게 만들어 줍니다. 또한 바로 그 점이 title 같은 표시 이름 필드에 .unique가 잘못된 도구인 이유이기도 합니다. 두 사용자가 같은 제목을 입력하면 두 개의 별개 레코드를 만드는 대신 그들의 행을 조용히 병합하게 되기 때문입니다.
기존 스키마에 추가된 비선택적 필드는 어떻게 다루나요?
기존 행에 필드를 채우는 didMigrate 클로저와 함께 MigrationStage.custom을 사용하십시오. 또는 더 쉽게는, 새 스키마 버전에서 필드를 선택적으로 선언하고 접근 시 지연 채우기를 하십시오. 선택성이 더 저렴한 마이그레이션입니다. 비선택적 추가에는 명시적인 채우기 로직이 필요합니다.
PersistentIdentifier와 내 자신의 UUID의 차이는 무엇인가요?
PersistentIdentifier는 SwiftData의 프로세스 내부 행 ID입니다. 자동으로 생성되며 실행 중인 프로세스의 수명 동안 살아남습니다. @Attribute(.unique)가 붙은 당신 자신의 UUID는 안정적인 프로세스 간, 기기 간 식별자입니다. 앱 내부의 프로세스 내 참조에는 PersistentIdentifier를 사용하십시오. 프로세스 경계를 넘는 모든 것(기기 간 동기화, 외부 통합, MCP 도구, 네트워크 호출)에는 UUID를 사용하십시오.
References
-
Author’s Get Bananas, a SwiftUI shopping list app that pairs SwiftData with iCloud Drive JSON sync and an MCP server. The
ShoppingItemmodel evolved across the early development cycle; thelastModified: Date?field was added after the initial schema (commit268a00don 2025-12-01, “Make lastModified optional to fix migration crash”) because making it non-optional broke migration when existing rows had no value to populate it. ↩ -
Apple Developer, “SwiftData” and “Adding and editing persistent data in your app”. The
@Modelmacro, the@Attributeconstraint surface, and the relationship to Core Data’sNSManagedObjectModel. ↩↩ -
Apple Developer, “Preserving your app’s model data across launches” and “Adopting SwiftData for a Core Data app”. Lightweight migration semantics and what triggers the framework to bail. ↩
-
Apple Developer, “VersionedSchema” and “SchemaMigrationPlan”. Versioned schema declarations, migration stage definitions, and the
ModelContainerconstructor that takes a migration plan. ↩ -
Apple Developer, “Defining data relationships with enumerations and model classes” and “Schema.Relationship”. The
@Relationshipmacro,deleteRuleoptions (.cascade,.nullify,.deny,.noAction), and the role of theinverse:parameter in bidirectional relationship maintenance. ↩ -
Author’s analysis in Two Agent Ecosystems, One Shopping List, April 29, 2026, and Five Apple Platforms, Three Shared Files. The Get Bananas + Return cross-process and cross-device sync patterns that complement (and sometimes replace) SwiftData inside a multi-process workflow. ↩
-
Apple Developer, “PersistentIdentifier” (conforms to
Sendable) and “ModelActor”. The SwiftData team confirmed during the WWDC 2026 SwiftData Group Lab that@Modelobjects are notSendableand should not be forced to conform, because they are a reference graph living inside a context; the recommended boundary contract is to pass theSendablePersistentIdentifierplus extracted plain values and re-fetch on the destination context, and that passing the model graph leaves the receiver with a partially hydrated object. Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. ↩↩ -
Apple Developer, “Adopting SwiftData for a Core Data app”, which states that with the default configuration “SwiftData copies the existing store to the app group container,” while a custom store URL leaves the location for you to manage. The CloudKit-entitlement requirement for app-group members and the two-
ModelConfigurationsplit (one synced, one local) for keeping widgets and extensions out of the sync path were described during the WWDC 2026 SwiftData Group Lab. Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. ↩↩