제목 그대로 SwiftData를 사용하면 쉽게 데이터 모델링을 할 수 있습니다. SwiftData의 컨셉을 이해하고 사용하면 SwiftData를 이용해서 쉽게 앱 애플리케이션 내부에 데이터베이스를 구축할 수 있어요. 심지어 CRUD도 간단 그 자체 입니다(물론 deep dive 하면 뭐든 복잡해짐)
SwiftData를 시작하기에 앞서 필요한 내용이 이 글에 담겼으니 글을 다 읽었을 때쯤 SwiftData가 쉬워지고, 해보고 싶어졌으면 좋겠어요 🐮
순서는 이렇게 갑니답
- SwiftData란?
- SwiftData 구조 이해하기
- 모델 클래스
- Observable Macro
- Model Container
- Preview with Model Container
- Model Context
- Bindable
SwiftData란?
SwiftData란 Apple 에서 제공하는 프레임워크입니다. 그 동안 앱내에서 데이터 모델을 구축하기 위해서는 애플에서 제공하는 프레임워크인 Core Data나, 외부 라이브러리인 Realm을 사용해야 했어요! 애플에서 제공하는 SwiftData는 Core Data 보다 코드를 작성하기 쉽고 Swift 언어로 데이터 모델링을 할 수 있습니다. 그렇기 때문에 Cloud Kit, Widgets에서도 SwiftData를 사용할 수 있어요!
SwiftData 구조 이해하기
SwiftData의 구조를 표현하면 위와 같아요. Schema, Container, Context에 대해 각각 자세히 알아보기 전에 예시를 들어 이해해 볼게요!
한글이나 워드 혹시 써보셨나요? 아니면 맥에 기본 프로그램인 Pages를 한번 열어보세요!
가장먼저 템플릿을 선택할 수 있는 창이 나와요! 여기에 이력서, 전단지, 편지지 등의 글을 작성할 수 있는 형식을 제공합니다. 이력서를 누르면 프로필, 경력 사항들을 작성하도록 되어 있는걸 확인할 수 있는데요, 이것은 SwiftData에서 Schema에 해당한다고 할 수 있어요.
Schema는 이력서에서 이름과 경력이 들어가야 하는 것을 알려 주듯 어떤 데이터가 들어가야 하는지 그 구조를 정의하게 되요! User라는 템플릿은 id, 이름, 전화번호를 받는다 -> User라는 스키마는 id, name, phoneNumber를 받는다라고 생각하면 쉬워요!
그렇다면 Container는 어디에 해당할까요?
Container는 이 Pages 프로그램입니다. Pages를 통해 데이터 파일에 접근 할 수 있는데, Container 또한 데이터베이스를 구축하고 해당 데이터베이스에 접근할 수 있게 해줘요. 하지만 주의해서 생각해야 할 점이 있는데 Container 자체가 데이터를 읽거나 저장하거나 삭제할 수 없다는 것입니다. 그저 데이터가 저장된 곳에 접근할 수 있게 해주는 역활이에요! 데이터의 CRUD는 Context 객체를 통해 이루어집니다!
키보드를 눌러 텍스트를 작성하거나, 저장 버튼을 누르거나, 백스페이스를 눌러 텍스트를 삭제 하는 행위는 Context 객체가 담당합니다.
이제 각각 하나씩 살펴볼게요!
Model Class
모델 클래스란 데이터가 어떤 구조로 저장되는지, 데이터와 데이터간의 관계가 어떻게 되는지 나타냅니다.
사용자 정보를 나타내는 User 모델은 사용자의 취미를 나타내기 위해 Interest라는 모델과 연결되어 있습니다.
SwiftData에서는 이 모델 클래스를 ‘스키마'라고 표현해요!
왼쪽 코드는 Realm, 오른쪽 코드는 SwiftData에요 각각 Memo라는 모델 클래스를 정의하고 있어요! 차이가 그렇게 커보이지 않지만 SwiftData가 좀더 익숙한 형태로 모델 클래스를 작성할 수 있게 해줘요! 그 이유는 바로 class 키워드 앞에 붙어 있는 @Model 이라는 매크로 덕분입니다.
이 Model 매크로를 열어서 확인 해 볼 수 있습니답
Model 매크로를 사용한 Test라는 모델 클래스에서 Model 매크로를 확장 시켜보면 extenstion을 사용해서 PersistentModel 프로토콜을 채택하고 있는 걸 확인할 수 있어요
이 PersistentModel은 AnyObject, Observable, Hashable, Identifiable을 채택하고 있기 때문에 Model 매크로를 사용하게 되면 기본적으로 이 네개의 프로토콜을 채택하는 것과 같습니다. 여기서 Observable 에 대해 좀더 자세히 살펴 볼게요!
Observable Macro
SwiftUI의 ViewModel을 정의할 때 ObservableObject라는 프로토콜을 사용해 보셨을 거에요 이 ObservableObject를 따르는 클래스 내부에서 Published프로퍼티 래퍼를 사용해 정의한 프로퍼티는 View에서 그 변화를 추적해 body property를 업데이트하는데 사용할 수 있습니다. 위 ObservableObject를 따르는 Test 클래스를 Observable 매크로를 사용해 똑같은 역활을 하면서도 더 간단하게 만들어 줄 수 있어요
Observable 매크로를 붙인 클래스는 그 내부의 프로퍼티가 Published를 달고 있는것과 같아요. 다시 모델 클래스를 살펴 볼게요
@Model매크로를 붙인 클래스는 PersistentModel이라는 프로토콜을 따르게 되고 이 PersistentModel은 AnyObject, Observable, Hashable, Identifiable을 채택하고 있으니 결국 모델 클래스는 Observable 매크로를 붙인 클래스와 같습니다. 즉 내부의 프로퍼티들이 Published로 정의된 것과 같고 이는 View에서 이 값의 변화를 추적할 수 있다는 뜻입니다. 그렇기 때문에 SwiftData는 SwiftUI에서 아주 쉽게 그 값을 가져다 쓸 수 있고 변화를 추적할 수 있어요
Model Container
다시 위에서 본 구조를 가져와 보면 Container는 Context와 DB 사이에 위치한 것을 확인 할 수 있어요.
Container에 스키마를 전달해 주면 Container는 이를 토대로 “데이터베이스를 구축"하게 됩니다! 즉 Container는 데이터베이스가 아닙니다! 데이터베이스를 구축하고 데이터를 디스크로 부터 불러오거나 사용할 수 있게끔 해주는 거에요! 그렇기 때문에 migration이나 configuration은 Container를 통해 이루어집니다. 또한 직접적으로 데이터를 쓰거나 업데이트, 삭제, 변경을 하지 않아요. Context라는 객체가 CRUD를 수행하면 이를 데이터베이스에 전달하는 중간 매개체, 브로커 역활을 해요!
Model Container는 스키마 정보나, DB 구축 정보를 받아 DB를 구축하게 되고 이를 View에 주입해서 DB를 사용할 수 있게 해주어야 합니다. 이는 SwiftUI에서 제공하는 modifier를 통해 쉽게 구현할 수 있어요!
앱에서 필요로하는 모델클래스인 스키마 정보를 .modelContainer의 파라미터로 전달합니다. modelContainer modifier는 이를 토대로 View에 Model Container를 주입합니다.
이렇게 하면 View에서는 Model Container를 사용할 수 있게끔 셋팅이 된거에요!
.modelContainer(for: [User.self, Interest.self, Recipt.self])
만약 여러개의 스키마를 전달하고 싶다면 array를 사용해서 전달해 주시면 됩니다. 만약에 Model Container라는 객체를 직접 만들어서 전달하고 싶다면 어떻게 해야 할까요?
let container: ModelContainer = {
let schema = Schema([User.self])
let container = try! ModelContainer(for: schema, configurations: [])
return container
}()
// 생략
WindowGroup {
ContentView()
}
.modelContainer(container)
ModelContainer 객체를 만들어 주시면 됩니다. 여기에 필요한 Configuration이나 데이터베이스 구축에 필요한 정보를 작성해 주고 이 Model Container를 필요로 하는 View에서 modelContainer modifier를 통해 Model Container를 주입해 주면 됩니다. 만일 WindowGroup이 여러개이지만 하나의 Model Container를 사용하고 싶다면, 하나의 DB 를 사용하고 싶다면 이렇게 container 객체를 만들어 전달해 주면 되요! 만약에 각 WindowGroup에 스키마 정보를 직접 전달받아 modelContainer를 주입해 주었다면 각 WindowGroup이 각각 DB를 구축하고 있는 것과 같기 때문에 하나의 Model Container를 이용하고 싶다면 이렇게 객체를 생성하고 이 객체를 주입하는 방식을 추천 드려요
@main
struct learn_swiftdataApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: User.self)
}
}
앱 애플리케이션 내의 모든 곳에서 Model Container를 사용할 수 있도록 하기 위해 주로 WindowGoup에 주입하게 됩니다. 앱을 실행하면 앱은 @main에 의해 modelContainer를 주입하는 코드를 실행하게 되는데요, 그렇다면 main으로 접근하는 것이 아니라 작업 중인 파일을 캔버스에 보여주는 Preview는 어떨까요? Preview는 분명 modelContainer modifier 를 실행하지 않기 때문에 DB에 대한 정보가 없습니다.
이럴 경우 Model Container를 만들어 Preview로 주입해 주면 됩니다.
Preview에서 사용할 Model Container를 만들고 Mock 데이터를 미리 넣어 줄 수 있어요.
#Preview {
MainView(viewModel: MainViewModel())
.modelContainer(previewChatRoomContainer)
}
modelContainer modifier를 이용해서 Preview에서 주입해 주면 됩니다. 이 경우 main 진입 시점에 WindowGroup에 주입해 준 Model Container랑 preview에서 주입해준 Model Container는 각각 다른 객체이기 때문에 DB도 각각 구축하게 됩니다. 즉 Preview에서 보는 데이터와 앱을 실행했을 때 보는 데이터가 다르게 되는데요, 그렇기 때문에 프리뷰를 위한 Model Container에 테스트용 데이터를 마음껏 넣어 테스트 해도 실제 디바이스에서 실행되는 앱의 DB에 영향을 미치지 않습니다. (하지만 이 Preview를 위한 Model Container를 WindowGroup에 주입시키면 같은 Model Container와 DB를 공유하기 때문에 실제 앱에서도 프리뷰를 위한 테스트 데이터가 나타납니다)
preview에 주입해 준 ModelContainer를 살펴보면 Mock Data에서 chatRoom 이라는 모델 클래스의 인스턴스를 container에 저장할 때 mainContext의 insert를 사용하는 걸 볼 수 있어요. 앞서 말했다 싶이 container는 context와 DB 사이에서 브로커 역활을 하고, CRUD는 context 가 한다고 했었는데요
이제 Context를 알아볼 차례입니다.
Model Context
Model Context는 CRUD를 담당합니다. 변경사항을 저장하고 업데이트 하며 이 변경사항을 추적할 수 있어요!
오른 쪽 코드는 realm으로 저장된 Memo 객체들을 불러오는 코드, 왼쪽은 User 객체들을 불러오는 코드입니다. SwiftData는 Query property wrapper를 사용해서 프로퍼티를 정의해 주듯 사용하면 끝이에요! Core Data와 realm 보다 더 쉽게 저장되어 있는 데이터에 접근할 수 있어요.
이 모델 객체들은 @Model에 의해 값의 변화를 추적할 수 있기 때문에 View에서 바로 사용할 수 있고, 값을 업데이트 할 수도 있습니다!
Create
Environment의 modelcontext 키패스를 사용해서 context 인스턴스를 하나 정의해주세요. 그리고 이 context에 insert를 사용해서 모델 인스턴스를 넘겨 주면 DB에 이 모델이 저장됩니다. 정말 아주 아주 쉽게 데이터를 저장할 수 있어요
Read
앞에서도 살펴봤듯이 Query property wrapper를 사용해서 DB로 부터 데이터를 읽어 올 수 있어요. 이때 sorting이나 filtering을 해줄 수 있습니다
Delete
@Query(sort: \User.birth) var users: [User]
context.delete(users[index])
context 의 delete를 사용해서 삭제할 모델 인스턴스를 지정해 주면 삭제됩니다.
Update
user.name = "개명한 이름"
모델 인스턴스의 값에 직접적으로 변경해주면 바로 반영됩니다. 허헣
Bindable
SwiftUI에서 Bind라는 property wrapper 기억 나시나요? 값 타입의 프로퍼티를 상위뷰와 하위뷰에서 공유하여 read & write가 가능하고 그 변화를 View에 반영할 수 있게 해주었는데요, Binding은 값 타입이라면 Bindable은 클래스 타입입니다. 왜 Bindable 이 필요한지 유추되지 않나요??
SwiftData의 스키마는 Class로 구현이 되는데, 이는 Query를 통해 쉽게 View에서 사용할 수 있습니다. 그리고 이 data model은 Bindable을 통해 하위뷰에서 쉽게 접근하고 업데이트 할 수 있어요! 즉 하위 뷰는 Query가 아닌 Bindable을 통해 값을 읽고 변경할 수 있다는 뜻이에요!
@Query(sort: \User.birth) var users: [User]
// 생략
UpdateUserSheetView(user: user)
Query property wrapper로 선언한 프로퍼티를 하위 뷰의 user로 전달해 줍니다.
이때 UpdateUserSheetView를 보면 @Bindable로 정의된 user을 볼 수 있는데요, 상위에서 Query를 통해 전달된 데이터 모델을 Bindable로 받아서 읽고 업데이트 할 수 있어요
TextField("이름을 입력해 주세요.", text: $user.name)
TextField("나이를 입력해 주세요.", value: $user.age, formatter: NumberFormatter()).keyboardType(.numberPad)
DatePicker("생일을 선택해 주세요.", selection: $user.birth, displayedComponents: .date)
사용자가 입력한 값을 바로 바로 DB에 반영해 줄 수도 있어요!
이렇게 SwiftData에 대해 중요한 개념들을 살펴 보았는데요, 어떠신가요? 생각보다 가져다 쓰기 편하도록 정의해 놓은 애플의 세심함이 보이지 않나요? 비즈니스 로직에 집중할 수 있도록 필요한 기능을 미리 작성해 놓은 매크로와 Property wrapper을 보면 감탄이 나옵니다.
혹여나 더 궁금한거나 틀린것이 있다면 언제든 의견 부탁드립니다!