SwiftUI - 마음으로 이해하는 SwiftData

mgdgc·2023년 10월 12일
3
post-thumbnail

SwiftData는 WWDC2023에서 공개된 새로운 데이터 관리 프레임워크이다.
Core Data와 비슷한 개념으로, Swift를 사용해 더 쉽게 데이터를 관리할 수 있고, 무엇보다 SwiftUI에 최적화되어있다.

SwiftData는 iOS 17 이상, macOS 14 이상, watchOS 10 이상에서 사용이 가능하다.

Core Data와의 차이

당장 SwiftData의 에러 메시지 또한 Core Data로 레이블되어 있는 것만 보아도, SwiftData는 사실 Core Data의 많은 부분을 공유하고 있다.
하지만 SwiftData는 Swift의 현대적인 concurrency, macros 등과 같은 기능들로 쉽고 빠르게, 적은 코드로 목적을 달성할 수 있다.

SwiftData를 품을 준비

Xcode 15 새 프로젝트 추가 화면
SwiftData를 사용하기 위해서는 Xcode 15 버전 이상을 사용해야 한다.
만약 Xcode 15 버전 이상을 사용중이라면, 새 프로젝트 생성 창의 Storage 선택 항목에 SwiftData를 발견할 수 있다.

SwiftData를 기본으로 생성하면, Xcode가 친절하게 예제용 Item 모델과 이를 테스트할 수 있는 View를 그려준다.
이 대로 생성해서 사용해도 좋지만, 나는 none으로 생성하여 처음부터 작성해보려 한다.

@Model

Core DataData Model 파일에 모델을 작성했던 것과는 달리, SwiftData는 Swift의 새 기능, macro를 활용해 모델을 정의한다.

SwiftData의 모델을 선언하려면 @Model 매크로를 사용하면 된다.

간단한 메모 앱을 만들기 위해 Memo 모델을 만들어 보자.

import Foundation
import SwiftData

@Model
class Memo {
    var content: String
    var timestamp: Date
    
    init(content: String, timestamp: Date) {
        self.content = content
        self.timestamp = timestamp
    }
}

Memo 모델은 메모를 작성할 수 있는 memo 프로퍼티와 작성 시간을 저장하는 timestamp 프로퍼티로 구성했다.
View Model을 만드는 것 처럼 클래스를 작성하면 굉장히 간단하게 스키마를 만들 수 있다.

@Attribute(속성)

@Attribute 매크로를 사용하여 모델의 프로퍼티에 속성을 부여하는 것도 가능하다.

macro Attribute(
    _ options: Schema.Attribute.Option...,
    originalName: String? = nil,
    hashModifier: String? = nil
)

@Attribute 매크로의 구조이다.
@Attribute 매크로의 옵션에 사용할 수 있는 것에는 다음과 같은 것들이 있다.

  • allowsCloudEncryption: 프로퍼티의 값을 암호화하여 저장
  • ephermeral: 이 프로퍼티의 변경을 추적하지만 보존하지는 않음
  • externalStorage: 프로퍼티의 값을 모델 스토리지에 인접한 바이너리 데이터로 저장
  • preserveValueOnDeletion: 컨텍스트가 모델을 삭제했을 때, 프로퍼티의 값은 히스토리에 보존
  • spotlight: Spotlight 검색 결과에 나타날 수 있도록 프로퍼티의 값을 인덱싱
  • unique: 같은 타입 내 모든 모델에 대하여 이 프로퍼티의 값이 유일하다는 것을 보증

예를 들어, 만약 위에서 작성한 Memo 모델이 완전히 유일한 값인 id를 갖게 한다고 하면, 다음과 같이 코드를 작성할 수 있다.

import Foundation
import SwiftData

@Model
class Memo {
	@Attirbute(.unique) var id = UUID()
    var content: String
    var timestamp: Date
    
    init(content: String, timestamp: Date) {
        self.content = content
        self.timestamp = timestamp
    }
}

@Relationship (관계)

Model들은 서로에 대해 Relationship을 설정할 수 있다.
Relationship@Relationship 매크로를 통해 설정할 수 있는데, 예시를 위해 Memo에 관계를 설정할 Tag를 만들어 둘의 관계를 설정해보도록 하겠다.

우선 Tag 모델을 하나 만들어준다.

import Foundation
import SwiftData

@Model
final class Tag {
    var tag: String
    
    init(tag: String) {
        self.tag = tag
    }
}

Tag 모델은 Memo에 태그를 붙일 수 있도록 tag: String 프로퍼티를 하나 추가해주었다.

TagMemoRelationship으로 설정하려면, MemoTag 프로퍼티를 Optional로 선언하고, @Relationship 매크로를 붙여주면 된다.

import Foundation
import SwiftData

@Model
final class Memo {
    @Attribute(.unique) var id = UUID()
    var content: String
    var timestamp: Date
    
    // ↓ 이 부분
    @Relationship var tag: Tag?
    
    init(content: String, timestamp: Date) {
        self.content = content
        self.timestamp = timestamp
    }
}

그런데 보통 메모 앱들은 한 메모에 대해 여러개의 태그를 갖는 경우가 많기 때문에, 우리도 한 Memo가 여러개의 Tag를 갖도록 하면 좋을 것 같다.

만약 1:n 관계를 설정하려면, 단순히 프로퍼티를 배열로 선언해주면 된다.

import Foundation
import SwiftData

@Model
final class Memo {
    @Attribute(.unique) var id = UUID()
    var content: String
    var timestamp: Date
    
    // ↓ 이 부분
    @Relationship var tags: [Tag]?
    
    init(content: String, timestamp: Date) {
        self.content = content
        self.timestamp = timestamp
    }
}

이렇게 관계를 설정해 주었을 때, Memo에 관계된 Tag는 다음과 같이 접근할 수 있다.

memo.tags // Optional<[Tag]>

Memo에서 Tag를 참조하는 경우도 있지만, Tag에서 Memo를 참조하는 경우도 있지 않을까?

다행히 SwiftData@Relationship 매크로는 이를 위해 inverse 옵션을 제공한다.

inverse 옵션을 설정하려면, 우선 inverse를 설정할 모델에 다음과 같이 inverse할 모델을 프로퍼티로 설정해준다.

우리의 경우엔 Tag 모델에 Memo를 프로퍼티로 설정해주면 된다.

import Foundation
import SwiftData

@Model
final class Tag {
    var tag: String
    
    var memo: Memo? // 이 부분
    
    init(tag: String) {
        self.tag = tag
    }
}

다음으로, @Relationship 매크로에 inverse 옵션을 해당 프로퍼티의 Keypath로 넣어주면 inverse 옵션 설정이 완료된다.

import Foundation
import SwiftData

@Model
final class Memo {
    @Attribute(.unique) var id = UUID()
    var content: String
    var timestamp: Date
    
    // ↓ 이 부분
    @Relationship(inverse: \Tag.memo) var tags: [Tag]?
    
    init(content: String, timestamp: Date) {
        self.content = content
        self.timestamp = timestamp
    }
}

ModelContainer

이제 만든 모델을 스키마로 사용하기 위해 ModelContainer로 설정해주어야 한다.

SwiftDataModelContainer를 쉽고 편하게 설정하기 위한 .modelContainer modifier를 제공하는데, 이를 사용해 ModelContainer를 설정할 수 있다.

ModelContainer는 일반적으로 앱의 엔트리포인트 App 구조체 안에 작성해준다.

import SwiftUI
import SwiftData

@main
struct SwiftDataTestApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

여기에 프로퍼티로 ModelContainer를 만들어준 뒤, ContentView.modelContainer modifier로 등록해주면 된다.

import SwiftUI
import SwiftData

@main
struct SwiftDataTestApp: App {
    var modelContainer: ModelContainer = {
        let schema = Schema([Memo.self, Tag.self])
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        
        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(modelContainer)
        }
    }
}

전체 코드는 이렇게 된다.
modelContainer 부분을 조금 더 들여다보자.

var modelContainer: ModelContainer = {
    let schema = Schema([Memo.self, Tag.self])
    let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
    
    do {
        return try ModelContainer(for: schema, configurations: [modelConfiguration])
    } catch {
        fatalError("Could not create ModelContainer: \(error)")
    }
}()

ModelContainer는 주어진 스키마에서 데이터베이스를 생성하고, 디스크의 읽기 및 쓰기와 iCloud 동기화를 담당한다.
ModelContainer를 생성하려면 우선 사용할 모델들을 스키마로 만들어준다.

let schema = Schema([Memo.self, Tag.self])

다음으로 ModelConfiguration을 생성해 모델 관리 규칙을 설정해준다.

let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)

ModelConfiguration의 옵션으로는 여러 가지가 있는데, 프리뷰 등에서 데이터를 메모리 상에서만 관리할지 여부를 결정하는 inStoredInMemoryOnly, CloudKit을 사용할 때 데이터베이스를 설정하는 cloudKitbataBase 등이 있다.

여담으로, cloudKitDatabase.none으로 설정하더라도 SwiftData 버그로 인해 프로젝트에 CloudKit을 활성화하면 자동으로 iCloud에 동기화를 시도한다.
How to stop SwiftData syncing with CloudKit

let config = ModelConfiguration(cloudKitDatabase: .none)

혹시 앱이 SwiftData를 사용하는데, iCloud Database에 맞게 모델을 최적화하지 않았다면, iCloud capability를 활성화하지 않는 것을 추천한다..

마지막으로 이렇게 만든 SchemaModelConfiguration을 사용해 ModelContainer를 만들어준다.

do {
    return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
    fatalError("Could not create ModelContainer: \(error)")
}

이렇게 만든 modelContainerContentView에 연결하면 View에서 EnvironmentModelContext를 받아와 사용할 수 있다.

var body: some Scene {
    WindowGroup {
        ContentView()
            .modelContainer(modelContainer) // <--- 이 부분
    }
}

View에서 사용하기

import SwiftUI

struct ContentView: View {
    @Environment(\.modelContext) var modelContext // <--- 이 부분
    
    var body: some View {
        Text("Hello, world!")
    }
}

#Preview {
    ContentView()
}

CRUD

데이터 읽기

SwiftDataSwiftUI에서 저장된 데이터를 쉽게 쿼리할 수 있도록 @Query 매크로를 제공한다.

@Query var memos: [Memo]

또한 PredicateSortDescriptor을 사용해 데이터를 필터링하거나 보여줄 순서를 변경할 수도 있다.

@Query(
	filter: #Predicate<Memo>({ $0.content.contains("swift") }), 
    sort: [SortDescriptor(\.timestamp, order: .forward)]
) 
var memos: [Memo]

만약 코드상으로 데이터를 읽어야 한다면, FetchDescriptor를 사용해 데이터를 읽어올 수도 있다.

let predicate = #Predicate<Memo> { $0.content.contains("swift") }
let sort = [SortDescriptor<Memo>(\.timestamp, order: .forward)]
let desciptor = FetchDescriptor<Memo>(predicate: predicate, sortBy: sort)
let memos = try modelContext.fetch(desciptor)

데이터 삽입

데이터의 삽입 또한 modelContext.insert(_ model:)을 사용해 쉽게 할 수 있다.
먼저 삽입할 데이터를 생성해주고, modelContextinsert() 해주면 끝이다.

let memo = Memo(content: "Memo Content", timestamp: Date())
modelContext.insert(memo)

관계 데이터 삽입

앞서 Memo 모델과 관계를 가지는 Tag 모델을 만들었는데, MemoTag 모델을 넣을 때에는 우선 Tag를 생성해주고, 이를 Memo에 넣어주면 된다.

var memo = Memo(content: "Memo Content", timestamp: Date())
memo.tags = [Tag(tag: "test")]

modelContext.insert(memo)

데이터 수정

데이터를 수정해야 할 때에는 별 다른 작업 없이 Model을 직접 수정해준다.

var memo = Memo(content: "Memo Content", timestamp: Date())
modelContext.insert(memo)

...

memo.content = "Edited Content" // 데이터 수정

클래스에 @Model 매크로를 사용하게 되면 해당 클래스는 자동으로 Observable 프로토콜을 따르게 된다.
그래서 모델 클래스는 변경 사항을 자동으로 추적하고, 이를 SwiftUI View에 자동으로 업데이트 해준다고 한다.

데이터 삭제

데이터를 삭제할 때에는 ModelContext에 삭제할 모델에 대하여 .delete(_ model:) 함수를 사용해주면 된다.

var memo = Memo(content: "Memo Content", timestamp: Date())
modelContext.insert(memo)

...

modelContext.delete(memo)

2개의 댓글

comment-user-thumbnail
2024년 2월 14일

많은 도움이 되었어요 감사합니다~ ^-^

1개의 답글