[iOS] 내가 보려고 정리하는 SwiftData의 모든 것 (feat. CRUD)

2025. 4. 18. 14:57Apple Framework, Library

오늘 글은 WWDC23에서 처음 공개된 SwiftData에 대해 소개하고,
앱에 Apple의 SwiftData Framework를 사용해 로컬 데이터 저장공간에 CRUD 기능을 구현하는 방식을 설명하는 글을 준비했습니다!

*잠깐! CRUD 기능이란?
: 앱에서 데이터를 관리할 때 사용하는 기본 연산, Create (생성)-Read (읽기)-Update (수정)-Delete (삭제)의 약어입니다.
 앱에서 화면 (View)을 그리기 위해서는 데이터 (숫자, 문자, 문자열 등)가 필수적으로 필요한데, 이 데이터를 다루는 네 가지 핵심 방법이라고 생각하면 됩니다!

**잠깐! 로컬 데이터 저장이란? 
: 데이터를 사용자 기기 (iOS 애플리케이션이면, iPhone의 내부 저장소 = 메모리가 해당되겠죠?) 저장공간에 직접 저장하는 것을 의미합니다.
이와 대비되는 개념으로는 큰 컴퓨터에 저장하는 외부 서버를 이용하는 방식이 있습니다.

 

SwiftData가 뭐시기야?

SwiftData는 너무나 당연하게 Swift + Data의 합성어로,
WWDC23에 처음 소개된 Swift 기반으로 구현되어 있는 영구 데이터 (Persistent Data) 저장용 프레임워크입니다.

오잉? 2023년도에 처음 생겼다고?

그렇다면 이 전에 Apple 앱에서는 어떤 식으로 로컬 데이터를 관리했는지 물어본다면... Core Data라는 프레임워크가 있었습니다.

실제로 SwiftData는 Core Data 기반으로 만들어진 프레임워크기도 하구요.
지금도 로컬 데이터 저장하는 방식으로 Core Data를 사용할 수는 있지만, 복잡하고 어려운 점이 많기에 (하지만 SwiftData의 최소 호환버전은 iOS 17 이상부터인걸....) 이를 개선해 Apple이 새로운 데이터 저장 프레임워크를 만들었다!라고 이해하면 되겠습니다.

💡 SwiftData는 Swift 언어로 앱에서 데이터를 저장하고 관리하는 것을 아주 쉽게 만들어주는 도구입니다.


이렇게 설명하면 너무 간단하니 SwiftData의 특징에 대해 조금 더 구체적으로 알아보도록 하죠.
아래와 같은 세 가지 특징을 확인할 수 있습니다.

  • 선언형 (Declarative) : SwiftData는 Swift의 최신 문법 (Macro, Property Wrapper)을 활용해 코드가 "어떻게 (How)"가 "무엇을 (What)" 수행하는지를 명확하게 작성할 수 있습니다. -> 쉽게 말하면, 코드가 간결하고, 읽기가 쉽다는 의미죠!
  • 영속성 (persistence) : SQLite 기반의 로컬 저장소를 활용하여, 앱을 종료하거나 기기를 재시작해도 데이터가 유지되어 관리할 수 있다는 것을 의미합니다.
  • SwiftUI와의 호환성 : SwiftUI와의 통합을 바탕으로 설계되어있기 때문에, 데이터의 상태 변화가 UI에 실시간 반영되는 바인딩 (Binding)이나 쿼리 (Query)가 자동으로 혹은 매우 쉽게 연결할 수 있습니다. -> 이 부분은 아래 코드에서 더 자세하게 살펴보도록 하죠!

모델을 선언형 (Declarative) 방식으로 작성해 -> 자동으로 영속성 (Persistence)과 효율적인 Fetching (데이터 조회)이 가능하다.


이제 특징은 얼추 살펴봤고, SwiftData를 직접 써 볼 차례인데..

어디서부터 공부를 해야하나 살펴보면... SwiftData의 주요 기능은 아래 보이는 것처럼 아주 많습니다. (이걸 언제 다 공부하냐 🧐)

하지만 이번 글에서는 큰 틀 네 가지의 요소로 SwiftData의 기능을 쉽게 이해해보려고 합니다.
그것은 바로 Schema (스키마), Query (쿼리), ModelContainer (모델 컨테이너), Model Context (모델 컨텍스트)이죠.

 

 

냅다! 네 가지 요소 빠르게 Swift 코드로 살펴보기

일단 묻지도 따지지도 않고 바로 Swift 코드를 보면서 네 가지 요소를 큰 틀에서 설명해보고자 합니다.
바로 예시를 살펴보죠.

사용자로부터 텍스트필드로 입력받은 텍스트와 + 값을 저장한 시간을 함께 묶어서 데이터베이스에 저장하고 싶은 경우.

아래와 같이 String 타입의 failureText와 Date 타입의 savedDate 값을 Class로 정의한 후, (왜 클래스인지는 뒤에서 살펴볼게요 :))
상단에 @Model 키워드를 붙여주면 스키마 (Schema)라는 녀석이 손쉽게 만들어집니다.

💡 Schema (스키마)는 앱이 어떤 데이터 구조 (= 테이블)를 사용하는지를 나타내는 설계도를 의미합니다.
    ex) 회원가입 시에 사용되는 테이블에는 사용자의 ID, 비밀번호, 사용자 이름, 나이 값 등이 있다고 설계해두는 것.


참고로 @Model이란 키워드를 사용했지만, 해당 클래스 자체는 모델을 찍어 만들어내는 설계도에 불과하고요. 해당 클래스를 바탕으로 만들어낸 인스턴스가 실제 모델 (Model)에 해당한다고 볼 수 있습니다.

import Foundation
import SwiftData

@Model
class TrainingRecord {
    var failureText: String
    var savedDate: Date
}

자 그럼 위에서 정의한 스키마에 따라 만들어진 모델을 저장할 공간이 필요하겠죠. 
그곳이 바로 모델 컨테이너 (Model Container)입니다.

💡 ModelContainer (모델 컨테이너)는 앱 전체 데이터의 저장소를 의미합니다.

위에서 SwiftData는 앱 로컬 데이터 저장을 수행하는 프레임워크라고 설명을 했었죠.

SwiftUI에서는 @main 키워드가 붙어있는 앱의 시작점에서 modelContainer를 environment에 주입함으로써,
해당 앱 전체에서 사용하는 데이터 모델을 메모리에 로드하고 / 사용할 수 있도록 만드는 과정을 거칩니다.

@main
struct Challenge2App: App {
    var body: some Scene {
        WindowGroup {
            MainView()
        }
        .modelContainer(for: [TrainingRecord.self])
    }
}

모델 컨테이너 (ModelContainer)와 스키마 (Schema)로부터 만들어진 모델 (Model)을 이어주는 녀석으로 모델 컨텍스트 (Model Context)가 등장합니다.

💡 ModelContext (모델 컨텍스트)는 실제로 데이터를 삽입 (insert), 삭제 (delete), 저장 (save) 등 데이터베이스를 건드릴 때 사용하게 되는 작업공간을 의미합니다.

실제 데이터베이스에 새로운 추가 사항 혹은 수정 사항을 전달하고 싶을 때, 아래 코드처럼 modelContext를 사용하게 되죠.

struct TrainingListView: View {
    @Environment(\.modelContext) private var modelContext
    
    ...
    
    Button("삭제", role: .destructive) {
        withAnimation { modelContext.delete(record) }
    }

하지만 위에서 봤던 ModelContext를 사용하지 않고도, Model 데이터를 가져올 수 있는 방식이 하나 있습니다.
*내부적으로는 환경에 주입된 ModelContext를 사용한다고 하긴 하네요.. 하지만 저희들이 직접 접근하는 것은 아니니까요...!

💡 Query (쿼리)저장된 데이터 (Model)를 (필터링/정렬/조건 등을 활용해서) 가져올 수 있도록 만들어주는 기능입니다.

@Query를 사용하면 데이터 저장소와 뷰가 자동으로 동기화도 이루어집니다.
= 즉, 해당 모델의 데이터가 변경될 때마다 -> UI도 자동 실시간으로 렌더링 된다는 의미죠!

자세하게 필터링을 걸거나, 정렬, 조건을 이용해서 특정 모델 데이터만 받아오는 방식은 아래에서 더 자세하게 알아보도록 할게요.

struct TrainingListView: View {
    @Query private var records: [TrainingRecord]
    
    ...
    
    LazyVStack(spacing: 16) {
        ForEach(records) { record in
            TrainingListCardView(record: record)
            ...

 

 

지금까지 큰 틀 정리!

이해가 조금 되시는지요! 진짜요..?

아직은 자세한 내용을 설명하지 않았기에,
앞으로의 설명에서 계속 반복 사용하게 될 어려운 용어들을 익숙하게 익혀보고 - 전체 큰 흐름이 어떻게 이루어지는지만 알아두면 좋을 것 같습니다.

직관적으로 아래 이미지를 바탕으로 과정을 이해하고 설명을 이어가 보겠습니다.

  1. 스키마 (Schema)는 앱에서 사용할 데이터 구조에 대한 설계도를 그린다.
  2. 모델 컨테이너 (ModelContainer)가 그 설계도를 바탕으로 앱의 진입점에서 집을 짓는다.
  3. 모델 컨텍스트 (ModelContext)는 그 만들어진 집 안에서 물건 (Data Model)을 넣고, 꺼내고, 지우고 왔다 갔다 하는 역할을 수행한다.
  4. 쿼리 (Query)는 그 집 안을 계속 들여다보면서 필요한 물건 (Data)을 가져오고, 물건이 바뀌면 새롭게 UI를 그리도록 알려준다.
  5. 모델 컨테이너 (ModelContainer)가 지은 집에 담기는 물건은 앱의 로컬 저장소 (DataBase)가 해당된다.

 

 

그래서 SwiftData로 CRUD 어떻게 구현한다고?

지금까지 이해한 내용으로 여러분들은 이제 기본적인 CRUD를 SwiftUI에서 어떻게 구현하는지 이해할 수 있습니다.

위에서 반복되는 내용을 한 번 더 리마인드 해보자면,
스키마를 @Model을 붙여 Clsas로 정의할 거고요 -> App 진입점에 모델 컨테이너를 설정해 줄 거고요 -> 데이터를 추가 (Create)하거나 수정 (Update)하거나, 지울 때 (Delete)는 모델 컨텍스트를 활용해서 수행해 줄 겁니다. -> 데이터를 읽을 때는 쿼리를 사용하면 좋을 것 같네요.

순서대로 가봅시다.

먼저 @Model 키워드로 사용할 스키마를 선언해 줍니다.
간단하게 메모와 입력 시간을 함께 저장하는 Note라는 class를 @Model로 선언해줬습니다.
*id는 Note 데이터를 구분하기 위한 고유의 이름이라고 이해하면 될 것 같습니다. 고유한 값이므로 unique를 붙여줬고 UUID라는 것을 이용해서 선언했습니다. (unique에 대한 설명은 아래에서 이어집니다!)

import SwiftData

@Model
class Note {
    @Attribute(.unique) var id: UUID
    var content: String
    var createdAt: Date

    init(content: String) {
        self.id = UUID()
        self.content = content
        self.createdAt = Date()
    }
}

앱의 진입점 @main에서는 모델 컨테이너를 설정합니다. 위에서 정의했던 SwiftData의 스키마가 해당 컨테이너에 주입되어 사용됩니다.
이 간단한 코드 두 개로 SwiftData를 사용할 사전 준비는 끝!

@main
struct NotesApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: Note.self)   
    }
}

1. Create : 데이터 추가하기

modelContext.insert(*추가하고자 하는 데이터)의 코드로 데이터를 추가할 수 있습니다.

여기서 말하는 <추가하고자 하는 데이터>란 위에서 정의했던 스키마 구조, Note가 해당됩니다.
즉, 아래 코드에서는 버튼을 눌렀을 때 -> "아무 내용"이라는 내용의 content가 담긴 Note 모델이 만들어지고 -> 이 모델을 modelContext에 삽입 (insert) -> modelContext의 변경 내용을 저장 (save)하는 흐름으로 이루어지는 것이죠.

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        Button("Add Note") {
            let newNote = Note(content: "아무 내용")
            modelContext.insert(newNote)
            try? modelContext.save()
        }
    }
}


2. Read : 데이터 읽기

@Query 코드로 데이터를 읽을 수 있습니다.

아래 코드에서는 위에서 저장했던 Note를 notes라는 이름의 @Query 프로퍼티로 불러와,
List 뷰로 연결해서 Text로 값을 출력하고 있는 것을 확인할 수 있죠.

import SwiftUI
import SwiftData

struct ContentView: View {
    @Query private var notes: [Note]

    var body: some View {
        List(notes) { note in
            Text(note.title)
        }
    }
}


3. Update : 데이터 수정하기

이제 modelContext와 @Query를 함께 사용해서 데이터를 수정할 수도 있습니다.

@Query로 선언된 데이터 모델에 접근해서 -> 값을 수정해주고 -> modelContext에 변경 내용을 저장 (save)해주면 되죠.

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var notes: [Note]

    var body: some View {
        Button("Update First Note") {
            if let firstNote = notes.first {
                firstNote.title = "수정된 제목"
                try? modelContext.save()
            }
        }
    }
}


4. Delete : 데이터 삭제하기

modelContext.delete(*삭제하고자 하는 데이터)의 코드로 데이터를 삭제할 수 있습니다.

마찬가지로 데이터에 접근하고 -> modelContext를 통해 값을 삭제하고 -> 변경 사항을 modelContext에 저장해주면 되는 흐름입니다.

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var notes: [Note]

    var body: some View {
        Button("Update First Note") {
            if let firstNote = notes.first {
                modelContext.delete(firstNote)
                try? modelContext.save()
            }
        }
    }
}

CRUD는 이게 전부입니다!
그럼 이제 다시 처음으로 넘어가 스키마 (Schema)부터 각 부분별로 필요한 부분에 대해 차근차근 자세한 설명을 시작하고자 하는데요.

1️⃣ 스키마 (Schema) : @Attribute, @Relationship, @Transient, #Unique 키워드를 사용해 추가적인 속성 지정하기
2️⃣ 모델 컨테이너 (ModelContainer) : ModelConfiguration을 활용해서 컨테이너의 커스텀 속성 설정하기
3️⃣ 모델 컨텍스트 (ModelContext) : modelContext.save()에서 시작된 모델 컨텍스트 내부의 동작 원리 이해하기
4️⃣ 쿼리 (Query) : #Predicate, #Expression을 사용해서 데이터 조회 시 세부 조건 추가하기

설명을 보기 전에 미리 경고를 하자면, 현재까지 나와있는 대부분의 SwiftData 개념 내용을 포함하고자 해서 글이 아마 길어질 것 같아요.
하나씩 차근차근 공부하는 것도 물론 도움이 되겠지만!
지금부터는 위에서 설명했던 큰 맥락 아래 세부적으로 이런이런 속성을 사용할 수 있구나 정도로 가볍게 읽어보시는 것도 좋을 것 같습니다☺️

 

 

1️⃣ 스키마 (Schema) = 설계도

위에서 설명한 것처럼 SwiftData에서는 @Model 매크로를 사용해서 스키마를 만들 수 있습니다.

@Model
final class Trip {
    var name: String
    var destination: String
    var start_date: Date
    var end_date: Date
    
    var bucketList: [BucketListItem]? = []
    var livingAccommodation: LivingAccommodation?
    
    // ...
}
🧐 잠깐 @Model을 class로만 생성하는 이유를 생각해 볼까요?
: 관계형 데이터베이스와 객체 상태를 관리하는 것이 목적인 데이터 저장 프레임워크 SwiftData에서는, 객체가 메모리 주소를 공유하는 참조 타입 (Reference Type)이어야만 하기 때문입니다.
: DB와 모델을 연결하는 과정 or 데이터 상태 변화를 추적하는 기능 등은 값을 복사해서 사용하는 (Reference Type) Struct에서 구현하기는 불가능했을 거예요!

@Model의 실체는 자동으로 PersistentModel 프로토콜을 채택하고 있고,
이 PersistentModel은 클래스가 해당하는 AnyObject를 채택하고 있으니 -> @Model은 Class로만 생성할 수밖에 없이 제한이 걸려있는 거죠!

PersistentModel은 SwiftData가 정의해 둔 "데이터를 저장 가능한 객체가 지켜야 할 규약을 정의하고 있는 프로토콜"입니다.

스키마 (Schema)는 @Model 외에도, 다양한 Swift 매크로 문법을 사용해서 선언적으로 다양한 속성을 지정할 수 있습니다.
어떤 매크로가 있는지 하나씩 살펴볼게요!


@Attribute : 프로퍼티에 추가적인 규칙을 부여하기

@Attribute는 SwiftData Class 내부에 있는 속성값, 즉 프로퍼티에 적용할 커스텀 동작 (custom behavior)을 정의할 때 사용합니다.

아래 코드를 살펴보면,
name이라는 속성을 @Attribute(.unique)라는 매크로를 붙임으로써 -> 중복되면 안 되는 속성이라고 정의해 준 것을 확인할 수 있습니다.
즉, 같은 이름을 가진 Trip 모델을 두 개 이상 저장할 수 없게 만들 수 있는 것이죠.

밑에 있는 startDate와 endDate는 기존 언더바 기반 (start_date, end_date)으로 되어있던 데이터를. originalName이라는 파라미터를 사용해 / 데이터 손실 없이 새로운 이름 (lowerCamel 스타일로!)으로 사용할 수 있도록 만들고 있는 모습입니다.

@Model
final class Trip {
    @Attribute(.unique) var name: String
    var destination: String
    @Attribute(originalName: "start_date") var startDate: Date
    @Attribute(originalName: "end_date") var endDate: Date

지금 보여드린 예시 외에도 Attribute의 속성으로 지정할 수 있는 사항들은 다양합니다. 가볍게 훑어만 보시지요.

.unique 해당 속성에 대해 같은 값이 두 번 이상 저장되지 않도록, 고유성을 지정하고 싶을 때 사용
.externalStorage 대용량 데이터를 외부 저장 공간에 저장하고자 지정하고 싶을 때 사용 -> 외부 저장 시 Query에서 사용 불가
.allowsCloudEncryption 주로 externalStorage와 함께 사용되어, 외부 저장 속성에 저장 시 암호화를 허용하는 목적
.ephemeral 일시적인 데이터를 저장할 때 사용. 영구 저장소에 저장하지 않지만, 값의 변경은 추적함.
.preserveValueOnDeletion 모델을 삭제하더라도, 해당 속성의 값이 보존되어 삭제된 엔티티의 속성 값을 추적하거나 기록할 수 있음.

@Relationship : 모델과 모델 사이의 관계 만들기

@Relationship은 한 @Model이 또 다른 @Model 객체를 프로퍼티로 가질 때 사용할 수 있는 속성입니다,

아래 코드는 bucketList와 livingAccommodation 프로퍼티에 @Relationship 키워드를 통해 관계가 형성된 모습인데요.
.casecade 속성에 의해 Trip 모델에 삭제될 때, bucketList와 livingAccommodation에 있는 모든 BucketListItem과 LivingAccommodation 모델도 함께 삭제가 됩니다.

cascade를 제외하고도 @Relationship에서 설정할 수 있는 삭제 규칙 (deleteRule)은 아래와 같은 것들이 있죠.

.casecade 상위 Model이 삭제되면, 하위 Model도 함께 삭제되도록 하는 설정
.nullify 상위 Model이 삭제되면, 하위 Model에 대한 참조를 nil (무효)로 만드는 설정
.deny 하나 이상의 하위 Model이 남이있으면, 삭제가 불가능하도록 만드는 설정
.noAction 하위 Model의 Relationship과 상관없이 삭제를 할 수 있도록 만드는 설정
@Model
final class Trip {

    @Relationship(.cascade)
    var bucketList: [BucketListItem]? = []
    
    @Relationship(.cascade)
    var livingAccommodation: LivingAccommodation?
    
    // ...
}

위에서 살펴본 삭제 규칙으로 @Relationship을 활용하는 것 외에도,
파라미터로 역관계 (inverse:)를 설정하거나 / 기존 이름을 매핑하거나 (orignalName:) / 관계의 최소 (minimumModelCount:), 최대 (maximumModelCount:) 개수를 지정하는 등의 관계에 대한 속성을 지정할 수도 있습니다.

여기서 역관계 (Inverse Relationship)는 코드로도 살펴보도록 할게요!

역관계두 모델이 서로 양방향으로 연결해주도록 만드는 설정입니다.
SwiftData에서는 관계가 형성되어 있는 두 엔티티 중 한쪽에만 @Relationship(inverse:)를 붙여 역관계 = 즉, 양방향 관계를 만들어줄 수 있습니다.

아래 코드에서는 School은 여러 Student를 가질 수 있고, Student는 하나의 School에 반드시 속해야 하는 관계가 만들어져 있죠.
*학교는 여러 명의 학생을 보유하고 있고 / 학생은 하나의 학교에 재학 중인 관계를 코드로 작성한 것입니다

@Model
class School {
    @Relationship(inverse: \Student.school) var students: [Student]
}

@Model
class Student {
    var school: School
}

@Transient : 영구적으로 저장하지 않을 속성 지정하기

@Transient 매크로를 통해 설정한 값은 일시적으로 메모리에서만 저장됩니다.
어려운 말로, 해당 데이터의 영속화 (persistence)를 막을 수 있는 것이죠. -> SwiftData의 주요 특징 중 하나가 영속성이었던 것 기억하시죠?
*예를 들어, UI를 그리기 위한 임시값을 할당하는 경우는 메모리에 영구적으로 저장할 필요가 굳이 없으니! 해당 매크로를 사용할 수 있겠군요.

@Transient 사용 시 특성 두 가지만 보고 이 부분은 넘어가겠습니다.

  • @Transient로 선언한 프로퍼티는 항상 기본 값 (default value)이 할당되어 있어야 한다. -> 값이 영구 저장 되어있지 않으니, 불러올 수 있는 기본 값이 당연히 있어야겠죠.
  • 계산된 속성 (Computed Propeties)은 자동으로 @Transient 프로퍼티로 간주되기 때문에, 해당 매크로를 붙일 필요는 없다.
@Model
final class Trip {
    @Transient
    var tripViews: Int = 0
    
    // ...
}

#Unique : 고유성을 가진 모델 조합 만들기

위에서 설명한 @Attribute(.unique) 속성은 프로퍼티 하나에 대해서만 고유성을 지정할 수 있었다고 한다면,
#Unique를 사용하면 고유성 조합을 만들 수 있습니다.

아래 코드의 경우 단순히 Trip 이름이 같은 경우는 모델의 생성을 허용하더라도,
이름과 시작일, 종료일마저 모두 같은 경우에 대해서는 데이터 중복을 허용하지 않도록 만드는 코드입니다. -> 데이터 중복을 더 쉽게 방지할 수 있겠네요!

@Model
final class Trip {
    #Unique<Trip>([\.name, \.startDate, \.endDate])

    var name: String
    var destination: String
    var startDate: Date
    var endDate: Date
    
    var bucketList: [BucketListItem]? = []
    var livingAccommodation: LivingAccommodation?
    
    // ...
}

 

 

2️⃣ 모델 컨테이너 (ModelContainer) = 스키마 + 저장소

모델 컨테이너는 정확하게 말하면, 모델 타입에 영구적 (persistence)으로 유지되는 백엔드를 제공하는 녀석입니다.

@main
struct Challenge2App: App {
    var body: some Scene {
        WindowGroup {
            MainView()
        }
        .modelContainer(TrainingRecord.self)
    }
}

사실 이렇게 위에서 코드 한 줄로 간단하게 정의했었지만, 이 모델 컨테이너도 사실 커스텀 속성을 지정해서 만들어줄 수 있습니다.
이 부분에서는 모델 컨테이너를 커스텀할 수 있는 다른 방법을 알아보죠.

이때 등장하는 두 개념이 ModelConfiguration과 JSONConfiguration입니다.

  • ModelConfiguration : SwiftData가 데이터를 어디 (SQLite 기반 영속 저장소)에 어떻게 저장할지 정하는 클래스
  • JSONConfiguration : JSON 파일 형태로 데이터를 저장하는 방식 (iOS 18+, WWDC24에서 처음 소개되어 아직은 기능이 많이 없음)

사용은 아래 코드처럼 container를 지정해서 사용하기만 하면 되고요.
간단하게 어떤 커스텀 옵션을 ModelContainer에 지정할 수 있는지 살펴보도록 하겠습니다.

url 저장소의 경로 (SQLite 파일 경로가 해당되어 들어가게 됩니다!)
name 저장소의 식별 이름
allowsSave 연결되어 있는 영구 저장소가 쓰기 (save) 가능한지 여부를 지정할 수 있습니다.
isStoredInMemoryOnly 연결되어 있는 영구 저장소가 일시적인지 = 메모리에만 존재하는지 여부를 지정할 수 있습니다.
cloudKitDatabase CloudKit 사용여부 및 위치 = iCloud 동기화를 지정하는 속성입니다.
import SwiftUI
import SwiftData

@main
struct Challenge2App: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            TrainingRecord.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 {
            MainView()
        }
        .modelContainer(sharedModelContainer)
    }
}

 

 

3️⃣ 모델 컨텍스트 (ModelContext) = 작업공간

ModelContext는 모델에 생기는 모든 변화를 관찰하고 수행하는 녀석입니다.

즉, 업데이트를 트레킹하거나 (Tracking updates), 모델을 패치하거나 (Fetching models), 새로운 사항을 저장하거나 (Saving changes), 변화를 적용하지 않는 (Undoing changes) 등의 모델과 연관된 대부분의 작업을 제공하고 있죠.
CRUD를 구현하는 View에서 이래 코드와 같이 modelContext를 Environment로 선언해서 사용한다고 위에서 설명했습니다.

struct TrainingListView: View {
    @Environment(\.modelContext) private var modelContext

위의 코드를 주의깊게 보신 분은 아실지 모르겠지만,
modelContext를 활용해서 작업을 할 때 아래와 같은 저장 코드가 반복해서 사용되었는데 - 이 의문을 바탕으로 모델 컨텍스트 내부 동작에 집중해서 설명을 해보려고 합니다.

try? modelContext.save()

코드에서 @Model 클래스의 모델 인스턴스를 다룰 때는, 사실 해당 인스턴스가 ModelContext에 연결되고 있습니다.

즉, 다시 말해 ModelContext는 바인딩된 ModelContainer와 함께 작동한다는 것이죠.
엥? 이게 무슨 말이지?

이는 ModelContext의 핵심 컨셉을 이해하기 위한 빌드업인데요. 설명을 이어가보죠.

ModelContext의 핵심 컨셉은 아래와 같습니다.

  1. ModelContext는 인스턴스의 변경 사항을 자동으로 추적하고 유지하고 있다. -> 즉, 변경 사항이 발생했다는 것을 알고 있음
  2. 하지만, 이 추적이 영구 데이터 모델의 반영과 직접적으로 이어지는 것은 아니다. 이때까지는 Just 메모리에만 존재하고 있는 상황이다.
  3. 메모리에 담겨있는 ModelContext의 추적 상태가 영구 데이터 모델에 반영되기 위해서는 명시적으로 save()를 호출해야만 한다.


즉 그렇기에 앱에서 모델에 대한 변경사항이 발생하면, 해당 내용은 일차적으로 메모리에만 반영된다는 의미이고

이를 View + 영구 저장소와 연결을 해주기 위해 modelContext.save() 코드를 호출해줘야 했던 것이죠.

View에 발생한 변경 사항과 Memory에 담겨 있는 상황이 일치하지 않는 상황이 발생할 수도 있다는 것입니다!


여담이기는 하지만 그렇기 때문에 Apple이 제공해주는
세 손가락 제스처나, 흔들기 (shake) 등의 시스템 제스처 기능을 통해 이전 실행을 취소하는 기능 구현이 가능했던 것이라고도 소개합니다.

왼쪽이 영구 저장소에 올라가기 전 -> 오른쪽은 제스처를 이용해서 원래 데이터로 복구한 화면

그리고 이를 암묵적으로 사용하기 위해 modelContainer의 autoSavedEnabled 메서드를 통해 지정해주는 방법도 사실 제공해주고 있던 것이죠.

-> 이게 어떻게 작동 가능하냐고? ModelContext는 바인딩된 ModelContainer와 함께 작동한다고 했으니까!

@main
struct Challenge2App: App {
    var body: some Scene {
        WindowGroup {
            MainView()
        }
        .modelContainer(TrainingRecord.self, isAutosaveEnabled: false)
    }
}

 

 

4️⃣ 쿼리 (Query) = 조회

View에서 사용하던 정적 데이터를 제거하고, @Query를 사용해서 View에 동적으로 데이터를 가져올 수 있다.

동적 데이터 (Dynamic Data)란 앱 실행 중에 사용자 혹은 네트워크 통신 등을 통해, 새롭게 변경되고 저장되는 데이터를 의미합니다.
즉, SwiftData로 저장된 데이터를 @Query를 사용해 가져오는 것을 "동적 데이터 바인딩"이라고 부를 수 있는 것이죠.

*참고로 정적 데이터 (Static Data)는 코드 기반으로 고정되어 있는 기본 값 (예를 들어, 더미 데이터)을 의미합니다.

@Query private var trips: [Trip]

쿼리를 사용했을 때 영구 저장소에 저장된 모든 데이터를 가져올 수도 있지만, 조건이나 정렬 기능을 추가해서 가져오는 것도 가능합니다.
조건은 filter, 정렬은 sort 파라미터를 활용해서 지정할 수 있죠.
아래 코드에서는 개별적으로 작성하는 것만 보여줬지만, 함께 사용하는 것도 가능합니다!

조건을 지정할 때 #Predicate를 사용하면, 클로저 문법을 활용해서 작성할 수 있습니다. = SQL의 WHERE 조건과 유사하다고 이해할 것.

@Query(filter: #Predicate { $0.destination == "Seoul" }) 
private var seoulTrips: [Trip]

@Query(sort: \Trip.startDate) 
private var trips: [Trip]

값 계산이 필요할 때는 #Expression을 사용할 수 있습니다. = SQL의 EXPRESSION 연산과 유사하다고 이해할 것.

let unplannedItemsExpression = #Expression<[BucketListItem], Int> { items in
    items.filter {
        !$0.isInPlan
    }.count
}

let today = Date.now
let tripsWithUnplannedItems = #Predicate<Trip> { trip in
    (trip.startDate ..< trip.endDate).contains(today) &&
    unplannedItemsExpression.evaluate(trip.bucketList) > 0
}

지금까지 나온 내용이 SwiftData 개념의 전반적인 부분입니다.
추가적인 공부나 / 이해가 안 되는 부분이 있어 더 자세하게 공부가 하고 싶다면, 아래 첨부한 SwiftData 관련 WWDC 세션을 참고하길 권장드립니다 ^__^

길고 길었던 이번 글은 여기까지! 🫡

 

 

WWDC23 SwiftData 세션 정리

 

Meet SwiftData - WWDC23 - Videos - Apple Developer

SwiftData is a powerful and expressive persistence framework built for Swift. We'll show you how you can model your data directly from...

developer.apple.com

 

Model your schema with SwiftData - WWDC23 - Videos - Apple Developer

Learn how to use schema macros and migration plans with SwiftData to build more complex features for your app. We'll show you how to...

developer.apple.com

 

Dive deeper into SwiftData - WWDC23 - Videos - Apple Developer

Learn how you can harness the power of SwiftData in your app. Find out how ModelContext and ModelContainer work together to persist your...

developer.apple.com

 

Build an app with SwiftData - WWDC23 - Videos - Apple Developer

Discover how SwiftData can help you persist data in your app. Code along with us as we bring SwiftData to a multi-platform SwiftUI app...

developer.apple.com

 

Migrate to SwiftData - WWDC23 - Videos - Apple Developer

Discover how you can start using SwiftData in your apps. We'll show you how to use Xcode to generate model classes from your existing...

developer.apple.com


WWDC24 SwiftData 세션 정리

 

What’s new in SwiftData - WWDC24 - Videos - Apple Developer

SwiftData makes it easy to add persistence to your app with its expressive, declarative API. Learn about refinements to SwiftData,...

developer.apple.com

 

Create a custom data store with SwiftData - WWDC24 - Videos - Apple Developer

Combine the power of SwiftData's expressive, declarative modeling API with your own persistence backend. Learn how to build a custom data...

developer.apple.com

 

Track model changes with SwiftData history - WWDC24 - Videos - Apple Developer

Reveal the history of your model's changes with SwiftData! Use the history API to understand when data store changes occurred, and learn...

developer.apple.com