📝 공식 문서
1. Defining data relationships with enumerations and model classes
2. Filtering and sorting persistent data
위 두가지 공식문서를 참고하여 모델 수정작업을 진행했다.
🧐 문제 상황
Preview 및 앱 빌드 시 에러?
우선 디버깅을 위해서 아래와 같이 간단하게 데이터를 주입해주고(?) 있었다.
@main
struct TasterApp: App {
var container: ModelContainer
init() {
#if DEBUG
container = SchemaV2.sample
#else
let schema = Schema(versionedSchema: SchemaV2.self)
let migrationPlan = MigrationPlan.self
do {
container = try ModelContainer(
for: schema,
migrationPlan: migrationPlan
)
} catch {
fatalError(error.localizedDescription)
}
#endif
}
var body: some Scene {
WindowGroup {
Content()
.modelContainer(container)
}
}
}
실기기는 빌드가 잘 되긴 했지만 Preview에서는 크래시가 많이 났으며, 아래와 같이 Array<String>타입을 저장할 수 없다는 CoreData 오류가 발생했다.
fault: Could not materialize Objective-C class named "Array" from declared attribute value type "Array<String>" of attribute named _
CoreData: fault: Could not materialize Objective-C class named "Array" from declared attribute value type "Array<String>" of attribute named _
아마 기본타입인 String의 경우 Array로 저장하는 것을 지원하지 않는다는 오류가 뜨지만 우선 저장이 되긴 하는 것 같다...! 👀
데이터 구조의 문제
아래와 같이 TastingNote 라는 프로토콜을 가지고 있고, 각 테이스팅 노트가 이 프로토콜을 가지고 있어서 뷰에서 어떠한 타입이 와도 각 항목이 표시되도록 하는 것이 목표였다.
protocol TastingNote: PersistentModel {
var title: String { get set }
var category: String { get set }
var createdAt: Date { get set }
var imageData: Data? { get set }
var alcohol: Double { get set }
var look: String { get set }
var smells: [String] { get set }
var tastes: [Taste] { get set }
var think: String { get set }
var rating: Double { get set }
var isFavorite: Bool { get set }
}
@Model final class WineTastingNote: TastingNote {
var title: String
var category: String
var grape: String
var createdAt: Date
@Attribute(.externalStorage) var imageData: Data?
var alcohol: Double
var look: String
var smells: [String]
var tastes: [Taste]
var think: String
var rating: Double
var isFavorite: Bool
... 초기화
}
@Model final class CoffeeTastingNote: TastingNote {
var title: String
var category: String
var createdAt: Date
@Attribute(.externalStorage) var imageData: Data?
var alcohol: Double
var look: String
var smells: [String]
var tastes: [Taste]
var think: String
var rating: Double
var isFavorite: Bool
... 초기화
}
@Model final class WhiskeyTastingNote: TastingNote {
var title: String
var category: String
var createdAt: Date
@Attribute(.externalStorage) var imageData: Data?
var alcohol: Double
var look: String
var smells: [String]
var tastes: [Taste]
var finish: Finish
var think: String
var rating: Double
var isFavorite: Bool
... 초기화
}
@Model final class CocktailTastingNote: TastingNote {
var title: String
var category: String
var createdAt: Date
@Attribute(.externalStorage) var imageData: Data?
var alcohol: Double
var look: String
var smells: [String]
var tastes: [Taste]
var think: String
var rating: Double
var isFavorite: Bool
var ingredients: [Ingredient]
var isContainsIce: Bool
... 초기화
}
사실 CoffeeTastingNote의 경우 look을 쓰지 않고 있었고, CocktailTastingNote의 경우도 look과 smells를 쓰지 않고 있었다. 또한 각 노트별로 추가로 입력되는, 예를들면 Whiskey의 경우 finish항목이 추가되었고, Cocktail의 경우 ingredients가 추가되면서 결국 뷰에서 아래와 같이 상세 타입으로 캐스팅 해준다는 것이 프로토콜을 도입한 이유가 사라진 것 같았다.
그래서 조금더 이 프로토콜을 세분화 하여 각 뷰에서 어떠한 요소가 필요한지, 어떠한 요소를 나타낼건지도 확실하게 명시해주면 좋겠다는 생각을 했다.
💡 해결 과정
프로토콜 네이밍?
사실 프로토콜을 세분화하면서 네이밍에 관한 고민이 생겼었다. 👀 이렇게 형용사로 작성하는 것이 제일 좋겠다는 판단을 내렸다. 아래와 같이 프로토콜을 세분화 했다.
protocol Notable {
var title: String { get set }
var category: String { get set }
var createdAt: Date { get set }
var imageData: Data? { get set }
var think: String { get set }
var rating: Double { get set }
var isFavorite: Bool { get set }
}
protocol Lookable {
var look: Look { get set }
}
protocol Smellable {
var smells: [Smell] { get set }
}
protocol Tastable {
var tastes: [Taste] { get set }
}
protocol Alcoholable {
var alcoholByVolume: Double { get set }
}
protocol Grapable {
var grapeVariety: String { get set }
}
protocol Finishable {
var finish: Finish { get set }
}
protocol Ingredientable {
var ingredients: [Ingredient] { get set }
var isContainsIce: Bool { get set }
}
결국 아래와 같은 구조가 되었다. migration을 위한 convenience init()도 생성해 주었다.
extension SchemaV2 {
@Model final class WineTastingNote: Notable, Alcoholable, Lookable, Smellable, Tastable, Grapable {
var title: String
var category: Category
var createdAt: Date
@Attribute(.externalStorage) var imageData: Data?
var think: String
var rating: Double
var isFavorite: Bool
var alcoholByVolume: Double
var look: Look
var smells: [Smell]
var tastes: [Taste]
var grape: Grape
init() { ... }
convenience init() { ... }
convenience init(from note: WineNote) { ... }
}
@Model final class CoffeeTastingNote: Notable, Smellable, Tastable {
... 생략
convenience init(from note: CoffeeNote) { ... }
}
@Model final class WhiskeyTastingNote: Notable, Alcoholable, Lookable, Smellable, Tastable, Finishable {
... 생략
convenience init(from note: WhiskeyNote) { ... }
}
@Model final class CocktailTastingNote: Notable, Alcoholable, Tastable, Ingredientable {
... 생략
convenience init(from note: CocktailNote) { ... }
}
struct Look: Hashable, Codable {
... 생략
init(for noteCategory: NoteCategory, from rawValue: String) { ... }
}
struct Taste: Hashable, Codable { ... }
struct Grape: Hashable, Codable { ... }
struct Finish: Hashable, Codable { ... }
struct Ingredient: Hashable, Codable { ... }
}
여기서 고민인 점은 Look, Taste, Grape, Finish, Ingredient의 경우 변경이 자주 일어날 것 같은데 똑같이 @model class로 작성해 주어야 할지, 아래와 같이 작은 단위들은 struct로 작성해줘야 할지 고민이 들었다.
아래는 위에 나와있는 1번 데이터 구조인데, 아래의 예제를 보고 확신이 들었다! SwiftData를 활용한 이상 class로 데이터 구조를 만들어 놓는 것이 일관성이 있다고 생각했다. 또한 자주 바뀌는 내용이라 class로 작성해도 충분하다는 생각이 들었고, Taste나 Grape, Finish등 쿼리를 사용하여 다이렉트로 가져와서 검색에 활용하기 좋을 것 같다는 생각이 들어 class로 변경작업을 진행했다.
import Foundation
import SwiftData
@Model
final class Animal {
var name: String
var diet: Diet
var category: AnimalCategory?
init(name: String, diet: Diet) {
self.name = name
self.diet = diet
}
}
extension Animal {
enum Diet: String, CaseIterable, Codable {
case herbivorous = "Herbivore"
case carnivorous = "Carnivore"
case omnivorous = "Omnivore"
}
}
@Model
final class AnimalCategory {
@Attribute(.unique) var name: String
// `.cascade` tells SwiftData to delete all animals contained in the
// category when deleting it.
@Relationship(deleteRule: .cascade, inverse: \Animal.category)
var animals = [Animal]()
init(name: String) {
self.name = name
}
}
하지만 아래와 같이... 하려 했으나 Type 'any SchemaV2.Notable' cannot conform to 'PersistentModel' 라는 오류가 발생하면서 관계형 데이터베이스를 작성하기 위해서는 구체타입이 필요하다는 것을 알게 되었다...
@Model final class Look {
var tint: Tint
var note: String
var notable: (any Notable)?
}
따라서 우선 모델을 아래와 같이 만들어서 연결성을 만들어 줬는데... 좋은 방법은 아닌 듯 하다. 프로토콜로 엮을 수 없는게 좀 아쉽다! 저장과 필터링이 되는 모델을 구분하는게 좋을까? 고민이 된다 🧐
@Model final class Taste {
var label: String
var value: Double
var wineTastingNote: WineTastingNote?
var coffeeTastingNote: CoffeeTastingNote?
var whiskeyTastingNote: WhiskeyTastingNote?
var cocktailTastingNote: CocktailTastingNote?
init(label: String, value: Double) {
self.label = label
self.value = value
}
}
데이터를 수정하고 각 데이터 타입이 일을 하게 되니 아래와 같이 Migration이 명확해 졌다.
actor MigrationPlan: SchemaMigrationPlan {
typealias Taste = SchemaV2.Taste
typealias Finish = SchemaV2.Finish
typealias Ingredient = SchemaV2.Ingredient
static var schemas: [any VersionedSchema.Type] {
[SchemaV1.self, SchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
private static var wineTastingNotes: [WineTastingNote] = []
private static var coffeeTastingNotes: [CoffeeTastingNote] = []
private static var whiskeyTastingNotes: [WhiskeyTastingNote] = []
private static var cocktailTastingNotes: [CocktailTastingNote] = []
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: SchemaV1.self,
toVersion: SchemaV2.self,
willMigrate: { context in
let wineNotes = try context.fetch(FetchDescriptor<WineNote>())
let coffeeNotes = try context.fetch(FetchDescriptor<CoffeeNote>())
let whiskeyNotes = try context.fetch(FetchDescriptor<WhiskeyNote>())
let cocktailNotes = try context.fetch(FetchDescriptor<CocktailNote>())
wineTastingNotes = wineNotes.map { .init(from: $0) }
coffeeTastingNotes = coffeeNotes.map { .init(from: $0) }
whiskeyTastingNotes = whiskeyNotes.map { .init(from: $0) }
cocktailTastingNotes = cocktailNotes.map { .init(from: $0) }
}, didMigrate: { context in
wineTastingNotes.forEach { context.insert($0) }
coffeeTastingNotes.forEach { context.insert($0) }
whiskeyTastingNotes.forEach { context.insert($0) }
cocktailTastingNotes.forEach { context.insert($0) }
try context.save()
}
)
}
'→ Taster' 카테고리의 다른 글
[Project-Taster] SwiftData 마이그레이션 (0) | 2024.10.20 |
---|---|
[Project-Taster] 기본 컴포넌트 적용 및 아키텍처 변경 (0) | 2024.10.13 |
[Project-Taster] 앱을 고도화 시켜보자!! (1) | 2024.10.06 |
[Project-Taster] 버전 업 - 폰트 적용 및 중복 이미지 코드화 (0) | 2024.08.13 |
[Project-Taster] 버전 업 - 시작 (0) | 2024.08.12 |