FE 개발자를 위한 안드로이드 후려치기 #3 자바스크립트 인터페이스로 새로운 웹뷰 액티비티 띄우기
SwiftData를 사용해보자! 1탄
2024-04-07
Explanation
내친김에 바로 작성하는 다음 포스팅!
이번 포스팅은 바로 간단하게 바로 사용해보는 SwiftData 1탄 입니다!
SwiftData는 Swift 코드로 직접 데이터를 모델링하고 다양한 모델 작업을 수행할 수 있는 프레임워크랍니다.
어제 글에서 SwiftUI가 최신? 프레임워크라 아직 구현되지 않은 많은 것들이 있다고 했었는데,
SwiftData는 레알 최신 기술이랍니다.
WWDC23에서 공개되었으니 아직 1년도 되지 않았네요?!
iOS 17.0+, macOS 14.0+ 에서 사용할 수 있기 때문에, 아마도 iOS나 macOS 애플리케이션을 개발하는 현업?에서는 아직 많이 사용하지는 못하려나 싶네요?. 잘 모르지만 아마 장기적으로 SQLite나 CoreData를 대체할 수 있는 프레임워크로 개발되지 않을까 싶어요.
(사실 저는, SQLite도 CoreData도 사용해 본 적이 없어서 잘 모릅니다..)
하지만 저는 가볍게 진행하는 개인 프로젝트이기 때문에 고민없이 바로 SwiftData를 사용하였답니다.
(SwiftData가 상대적으로 쉬워 보여서 선택한 건 안비밀)
그렇게 오늘은 짧게나마, 가볍게 사용해본 SwiftData에 대해서 적어보려 합니다!
앞서 이야기 했듯, 아직 공부를 한지 몇개월 되지 않아서 글에 잘못된 부분이 있을 수 있습니다!!
그리고 작성된 모든 코드는 https://github.com/falsy/blog-post-example/tree/master/macOS-project/SwiftDataEx에서 확인하실 수 있습니다.
저는 예시로 간단하게 Post와 Comment라는 두개의 모델을 만들었어요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import SwiftUI import SwiftData @Model class Post { @Attribute(.unique) var id: UUID var title: String var content: String @Relationship(deleteRule: .cascade, inverse: \Comment.post) var comments: [Comment] = [Comment]() var createdAt: Date @Transient var isShowComment: Bool = false init(id: UUID = UUID(), title: String, content: String) { self.id = id self.title = title self.content = content self.createdAt = Date.now } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import SwiftUI import SwiftData @Model class Comment { @Attribute(.unique) var id: UUID @Relationship var post: Post? var content: String var createdAt: Date init(id: UUID = UUID(), content: String) { self.id = id self.content = content self.createdAt = Date.now } } |
모델에 사용된 매크로들은 천천히 하나씩 이야기하기로 하고 일단 바로 ModelContainer를 추가해서 사용해볼게요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import SwiftUI import SwiftData @main struct SwiftDataExApp: App { var exModelContainer: ModelContainer = { let schema = Schema([Post.self]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) do { return try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { fatalError("Could not create ModelContainer: \(error)") } }() var body: some Scene { WindowGroup { ContentView() } .modelContainer(exModelContainer) } } |
여기서 보면 Post와 Comment 라는 두개의 모델을 추가했는데, ModelContainer에는 Post만 등록하였죠?(Comment 모델도 같이 등록한다고 해서 딱히 오류가 나거나 하는 건 아니에요.) SwiftData는 어느 정도 이 모델간의 관계를 자동으로 인식하는 거 같아요. 그래서 Post만 등록해도 Comment도 자동으로 인식을 한답니다.
‘isStoredInMemoryOnly’ 속성은 이름 그대로 실제 스토리지에 저장할지 아니면 인메모리 상에서만 저장할 지 설정하는 거에요. 일단 개발 단계에서는 인메모리만 사용하는 편이 좋은 거 같아요.
확실하지는 않은데, 제가 짧게 찾아본 바로는 SwiftData에 코드로 생성한 모델과 데이터를 전체를 깔끔하게 싹 지워주는 방법을 아직 딱하고 제공하고 있는 거 같진 않았어요..
그래서 초기화가 필요하면 별도에 초기화할 수 있는 반복문 코드를 만들어서 지우는 거 같아요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
import SwiftUI import SwiftData struct BasicSwiftData: View { @Environment(\.modelContext) private var modelContext @Query private var posts: [Post] var body: some View { VStack { Text("기본") ForEach(posts) { post in VStack { HStack { Text("\(post.title) / \(post.content) / \(post.createdAt) /") Button("글 삭제") { do { modelContext.delete(post) try modelContext.save() } catch { print("error") } } Button("댓글 추가") { post.comments.append(Comment(content: "Comment-\(post.comments.count)")) } } if post.comments.count > 0 { Divider() ForEach(post.comments) {comment in HStack { Text("\(comment.content) / \(comment.createdAt)") Button("댓글 삭제") { do { modelContext.delete(comment) try modelContext.save() } catch { print("error") } } } } } } } Divider() Button("글 추가") { do { modelContext.insert(Post(title: "Post-\(posts.count)", content: "Content-\(posts.count)")) try modelContext.save() } catch { print("error") } } } } } |
짜잔, 엄청 간단하죠?!
아까 ContentView에서 등록한 ModelContainer를 이렇게 ‘@Environment(\.modelContext) private var modelContext’를 사용해서 insert, delete, save 기능을 수행할 수 있답니다.
그리고 모델을 읽는 방법은 ‘@Query private var posts: [Post]’ 이렇게 ‘Query’ 매크로를 사용해서 뚝딱하고 불러올 수 있답니다.
Query에 다양한 기능들은 아래에서 조금 더 알아볼게요.
일단 모델에서 사용한 매크로를 중심으로 조금씩 이야기 해볼게요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import SwiftUI import SwiftData @Model class Post { @Attribute(.unique) var id: UUID var title: String var content: String @Relationship(deleteRule: .cascade, inverse: \Comment.post) var comments: [Comment] = [Comment]() var createdAt: Date @Transient var isShowComment: Bool = false init(id: UUID = UUID(), title: String, content: String) { self.id = id self.title = title self.content = content self.createdAt = Date.now } } |
‘@Attribute’ 이름 그대로 속성 매크로네요. ‘.unique’ 값을 주면 저 프로퍼티의 값이 저 모델에 고유함음 보장해줘요. 음.. 그러니까, 지금 위 코드는 id라는 프로퍼티에 UUID()를 생성해서 id 값이 어느정도 유니크함이 보장되지만, 만약에.
1 2 3 4 5 6 7 |
@Model class Post { @Attribute(.unique) var id: UUID @Attribute(.unique) var title: String ... |
이렇게 title 프로퍼티가 .unique 로 설정되어 있고 동일한 title 값의 Post가 추가(insert) 된다면 새로운 Post가 생기지 않고, 기존의 동일한 title 값이 있는 Post 데이터가 변경이 된답니다. DBMS를 사용해보셨다면 UPSERT를 생각하시면 이해가 편할 거 같아요.
만약 위 코드처럼 id 프로퍼티와 title 프로퍼티 두개가 .unique로 등록되어 있다면, 새로 추가하려고 하는 모델이 두 프로퍼티 중에 하나의 값만 같아도 모두 업데이트를 수행합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
import SwiftUI import SwiftData struct UpsertSwiftData: View { @Environment(\.modelContext) private var modelContext @Query private var posts: [Post] let uuid: UUID = UUID() @State var updateCnt: Int = 0 var body: some View { VStack { ForEach(posts) { post in VStack { HStack { Text("\(post.title) / \(post.content) / \(post.createdAt) /") } if post.comments.count > 0 { Divider() ForEach(post.comments) {comment in HStack { Text("\(comment.content) / \(comment.createdAt)") } } } } } Divider() Button("UPSERT 글 추가") { do { modelContext.insert(Post(id: uuid, title: "Post-\(updateCnt)", content: "Content-\(updateCnt)")) updateCnt = updateCnt + 1 try modelContext.save() } catch { print("error") } } } } } |
위 코드는 간단한 예시인데요, 위 코드를 실행해서 ‘UPSERT 글 추가’ 버튼을 누르면 ‘insert’를 수행하지만, .unique한 id 값을 동일하게 입력하기 때문에 글이 추가되지 않고 계속 업데이트 되는 것을 확인하실 수 있습니다.
그리고 @Attribute 매크로에는 아래와 같은 옵션들이 있답니다.
(저는 .unique 말고는 아직 사용해보지 못했네요.)
allowsCloudEncryption: 속성 값을 암호화된 형식으로 저장합니다.
externalStorage: 모델 저장소에 인접한 이진 데이터로 속성 값을 저장합니다.
preserveValueOnDeletion: 컨텍스트가 소유 모델을 삭제할 때 영구 기록에 속성 값을 유지합니다.
spotlight: Spotlight 검색 결과에 나타날 수 있도록 속성 값을 인덱싱합니다.
unique: 동일한 유형의 모든 모델에서 속성 값이 고유한지 확인합니다.
참고: https://developer.apple.com/documentation/swiftdata/schema/attribute/option
다음으로 ‘@Relationship’ 역시 이름 그대로 모델간의 관계를 정의해주는 매크로랍니다.
1 2 |
@Relationship(deleteRule: .cascade, inverse: \Comment.post) var comments: [Comment] = [Comment]() |
위에 사용된 ‘deleteRule’는 삭제규칙? 인데요. ‘.cascade’는 관련된 모델을 모두 삭제하는 규칙이에요.
그러니까 위 코드에서는 Post가 삭제되면 해당 Post의 comments 프로퍼티에 해당하는 Comment 모델들도 삭제 되겠지요??
하지만.. 정확하게 왠지 모르겠는데, ‘.cascade’는 아직 동작하지 않는 거 같아요…
모든 옵션은 아래와 같습니다.
cascade: 관련 모델을 삭제하는 규칙입니다.
deny: 다른 모델에 대한 참조가 하나 이상 포함되어 있기 때문에 모델 삭제를 방지하는 규칙입니다.
noAction: 관련 모델을 변경하지 않는 규칙입니다.
nullify: 삭제된 모델에 대한 관련 모델의 참조를 무효화하는 규칙입니다.
참고: https://developer.apple.com/documentation/swiftdata/schema/relationship/deleterule-swift.enum
다음으로 ‘inverse: \Comment.post’는 Post에서 Comment로 접근하는게 아닌 Comment에서 Post로 접근할 수 있도록 그 관계를 설정해주는 거에요.
마지막으로 ‘@Transient’ 해당 프로퍼티가 스토리지에는 저장되지 않게 해주는 매크로랍니다.
간단하게 예를 들면 아래와 같아요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import SwiftUI import SwiftData struct TransientSwiftData: View { @Query private var posts: [Post] @State var isUpdate: Bool = false var body: some View { VStack { Text("Transient 속성에 따른 뷰 설정") ForEach(posts) { post in VStack { HStack { Text("\(post.title) / \(post.content) / \(post.createdAt) /") } if post.isShowComment { Divider() ForEach(post.comments) {comment in HStack { Text("\(comment.content) / \(comment.createdAt)") } } } Button("댓글 보기 / 숨기기") { post.isShowComment.toggle() isUpdate.toggle() } } } } .id(isUpdate) } } |
Post 모델에 ‘@Transient’ 를 사용했던 ‘isShowComment’ 프로퍼티의 값에 따라 댓글을 보여줄지 숨길지 설정할 수 있는 예시랍니다. ‘댓글 보기 / 숨기기’ 버튼을 누르면 ‘isShowComment’ 프로퍼티의 값이 toggle 되면서 값이 업데이트 되는데요. 이건 ‘@Transient’ 매크로로 정의되어 있기 때문에 앱을 종료했다 다시 실행해도 초기값으로 설정한 false가 유지된답니다.
위 예시는 업데이트가 바로 뷰에 적용되지가 않아서, @State 값에 업데이트 상태값을 추가하고 뷰에 ‘.id()’ 메서드를 체이닝해서 변화에 따라 뷰를 다시 그리도록 해주었습니다!
앞서 잠깐 이야기한대로 삭제 규칙에 ‘cascade’가 동작하지 않더라고요?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
import SwiftUI import SwiftData struct CascadeSwiftData: View { @Environment(\.modelContext) private var modelContext @Query private var posts: [Post] @Query private var comments: [Comment] var body: some View { VStack { Text("Cascade 동작 테스트") ForEach(posts) { post in VStack { HStack { Text("\(post.title) / \(post.content) / \(post.createdAt) /") Button("글 삭제") { do { modelContext.delete(post) try modelContext.save() } catch { print("error") } } Button("댓글 추가") { post.comments.append(Comment(content: "Comment-\(post.comments.count)")) } } if post.comments.count > 0 { Divider() ForEach(post.comments) {comment in HStack { Text("\(comment.content) / \(comment.createdAt)") Button("댓글 삭제") { do { modelContext.delete(comment) try modelContext.save() } catch { print("error") } } } } } } } Divider() Button("글 추가") { do { modelContext.insert(Post(title: "Post-\(posts.count)", content: "Content-\(posts.count)")) try modelContext.save() } catch { print("error") } } Divider() Text("모든 댓글") ForEach(comments) { comment in Text("\(comment.content) / \("") / \(comment.createdAt)") } } } } |
위 코드에서 Post를 추가하고 해당 Comment를 추가한 후 Post를 삭제하면 여전히 연관된 Comment가 남아있는 것을 확인 할 수 있습니다. 그래서 저는 ‘.cascade’을 선언과 별개로 삭제는 자식도 함께 삭제하도록 코드를 구성했어요.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
Button("글 삭제") { do { if post.comments.count > 0 { for comment in post.comments { modelContext.delete(comment) } } modelContext.delete(post) try modelContext.save() } catch { print("error") } } |
아직 정확하게 왜 .cascade가 동작하지 않는지는 잘 모르겠어요.
마지막으로 Query의 filter와 sort에 대해 적어볼게요.
우선 filter!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import SwiftUI import SwiftData struct FilterSwiftData: View { @Query(filter: #Predicate<Post> { $0.comments.count > 0 }) private var posts: [Post] var body: some View { VStack { Text("댓글이 있는 글만 출력") ForEach(posts) { post in VStack { HStack { Text("\(post.title) / \(post.content) / \(post.createdAt) /") } Divider() ForEach(post.comments) {comment in HStack { Text("\(comment.content) / \(comment.createdAt)") } } } } } } } |
짜잔, 이렇게 예시처럼 filter 속성을 사용해서 댓글이 있는 글만 출력할 수도 있답니다.
다음으로 sort!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import SwiftUI import SwiftData struct SortSwiftData: View { @Query(sort: \Post.createdAt, order: .reverse) private var posts: [Post] var body: some View { VStack { Text("내림차순 정렬") ForEach(posts) { post in VStack { HStack { Text("\(post.title) / \(post.content) / \(post.createdAt) /") Button("댓글 추가") { post.comments.append(Comment(content: "Comment-\(post.comments.count)")) } } if post.comments.count > 0 { Divider() ForEach(post.comments.sorted { $0.createdAt > $1.createdAt }) {comment in HStack { Text("\(comment.content) / \(comment.createdAt)") } } } } } } } } |
짜잔, 이렇게 예시처럼 sort 속성을 사용해서 Post의 createdAt 프로퍼티의 역순(내림차순, 최신이 위로) 정렬되도록 할 수 있답니다. 그리고 comment는 sorted를 사용해서 동일하게 역순으로 사용해서 정렬해 주었답니다.
아직 더 많은 SwiftData의 기능들이 있겠지만, 아직 직접 사용해본 기능이 많지 않아서 오늘의 포스팅은 여기까지로하고 다음에 2탄에 더 많은 정보로 포스팅을 해보도록 하겠습니다!