SwiftData의 @Query

Horus-iOS·2023년 12월 29일
1
post-custom-banner

SwiftUI는 MVVM 아키텍처와 어울리지 않는다는 의견이 많이 보입니다. ViewModel 역할을 이미 View에서 하고 있다는 생각에서 출발하는 것으로 보이는데, 그렇다면 어떤 형태가 어울릴까 생각해보려고 아래 프로젝트를 만들어봤습니다.

https://github.com/panther222128/musicvideosearch

항상 참고해오던 아래 링크의 프로젝트를 기반으로 합니다.

https://github.com/kudoleh/iOS-Clean-Architecture-MVVM

프로젝트를 만들면서 SwiftData도 사용해보려고 시도하던 중 마주친 문제가 있었습니다. 로컬 디바이스 데이터베이스 Storage 객체를 별도로 만들고 관리하려 했는데, 아래에서 보이는 @Environment(\.modelContext) private var modelContext, @Query var scoredHechPlayers: [HECHPlayerEntity] 두 가지 프로퍼티를 다른 곳으로 옮길 수 없었습니다.

import Foundation
import SwiftUI

@main
struct QueryApp: App {
    private let modelContainer: ModelContainer = {
        do {
            return try ModelContainer(for: HECHPlayerEntity.self)
        } catch {
            fatalError("Model container generation failed.")
        }
    }()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(modelContainer)
        }
    }
}

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    private let hechPlayers: [HECHPlayer] = [.init(id: .init(), name: "김다인", number: 3, position: .setter),
                                             .init(id: .init(), name: "황연주", number: 4, position: .oppositeSpiker),
                                             .init(id: .init(), name: "김사랑", number: 6, position: .setter),
                                             .init(id: .init(), name: "나현수", number: 9, position: .middleBlocker),
                                             .init(id: .init(), name: "고민지", number: 10, position: .outsideHitter),
                                             .init(id: .init(), name: "김주향", number: 11, position: .outsideHitter),
                                             .init(id: .init(), name: "이다현", number: 12, position: .middleBlocker),
                                             .init(id: .init(), name: "정지윤", number: 13, position: .outsideHitter),
                                             .init(id: .init(), name: "양효진", number: 14, position: .middleBlocker),
                                             .init(id: .init(), name: "고예림", number: 17, position: .outsideHitter),
                                             .init(id: .init(), name: "한미르", number: 18, position: .outsideHitter),
                                             .init(id: .init(), name: "정시영", number: 21, position: .outsideHitter),
                                             .init(id: .init(), name: "위파위", number: 23, position: .outsideHitter),
                                             .init(id: .init(), name: "모마", number: 99, position: .oppositeSpiker)]
    @Query var scoredHechPlayers: [HECHPlayerEntity]
    
    var body: some View {
        HStack {
            VStack {
                ScrollView {
                    ForEach(hechPlayers) { player in
                        Button {
                            insert(hechPlayer: player)
                        } label: {
                            HStack {
                                Text(String(player.number))
                                    .font(.system(size: 12))
                                Text(player.name)
                                    .font(.system(size: 12))
                                Text(player.position.rawValue)
                                    .font(.system(size: 12))
                                    .padding()
                                Spacer()
                            }
                        }
                    }
                }
            }
            Spacer()
            VStack {
                ScrollView {
                    VStack {
                        ForEach(scoredHechPlayers) { scoredPlayer in
                            HStack {
                                Text(String(scoredPlayer.number))
                                    .font(.system(size: 12))
                                Text(scoredPlayer.name)
                                    .font(.system(size: 12))
                                Text(scoredPlayer.position.rawValue)
                                    .font(.system(size: 12))
                                    .padding()
                                Spacer()
                            }
                        }
                    }
                }
                Spacer()
                Button {
                    deleteLast()
                } label: {
                    Text("Delete last")
                }
            }
        }
        .padding()
    }
    
    func insert(hechPlayer: HECHPlayer) {
        modelContext.insert(HECHPlayerEntity(id: hechPlayer.id, name: hechPlayer.name, number: hechPlayer.number, position: hechPlayer.position))
    }
    
    func deleteLast() {
        if let last = scoredHechPlayers.last {
            modelContext.delete(last)
        }
    }
}

마침 설명을 위해 만든 작은 프로젝트에서 Model, Entity 객체도 아래에 남깁니다.

import Foundation

enum VolleyballPosition: String, Codable {
    case setter = "세터"
    case middleBlocker = "미들"
    case libero = "리베로"
    case oppositeSpiker = "아포짓"
    case outsideHitter = "아웃사이드"
}

struct HECHPlayer: Identifiable {
    let id: UUID
    let name: String
    let number: Int
    let position: VolleyballPosition
}
import Foundation
import SwiftData

@Model
final class HECHPlayerEntity: Identifiable {
    let id: UUID
    let name: String
    let number: Int
    let position: VolleyballPosition
    
    init(id: UUID, name: String, number: Int, position: VolleyballPosition) {
        self.id = id
        self.name = name
        self.number = number
        self.position = position
    }
}

아래 gif처럼 잘 동작합니다.

먼저 @Environment(\.modelContext) private var modelContext 속성을 이동해보면 아래 메시지의 오류가 발생합니다. (오류가 발생하는 예시는 이 글의 가장 마지막에 남기겠습니다.)

Accessing Environment<ModelContext>'s value outside of being installed on a View. This will always read the default value and will not update.

이 글은 @Query에 대한 내용이기 때문에 원인이 무엇인지 따로 적지 않겠습니다. View 타입 객체에 남기면서도 사용하려면 다른 객체의 메소드에서 파라미터로 받아 사용할 수 있습니다. 아래와 같습니다.

// Storage methods
func insert(musicVideo: MusicVideo, using modelContext: ModelContext) {
    modelContext.insert(MusicVideoEntity(artistName: musicVideo.artistName, trackName: musicVideo.trackName))
}
    
func delete(musicVideoEntity: MusicVideoEntity, using modelContext: ModelContext) {
    modelContext.delete(musicVideoEntity)
}
    
func deleteAll(using modelContext: ModelContext) {
    modelContext.container.deleteAllData()
}

이렇게 해도 동작은 잘 합니다. 그러나 @Query 프로퍼티 래퍼를 이동하면 View가 업데이트되지 않습니다.

@Query의 공식문서 설명은 아래와 같습니다.

A property wrapper that fetches a set of models and keeps those models in sync with the underlying data.

모델의 셋을 가져오고 기반 데이터를 동기화하는 프로퍼티 래퍼라고 합니다. 흥미로운 점은 마치 @State 프로퍼티 래퍼처럼 View를 업데이트하기도 한다는 점입니다. Storage 객체에 옮기면서 값을 주입하는 형태도 생각해봤습니다. SwiftData의 Read는 @Query 프로퍼티 래퍼 속성으로 읽을 수도 있지만, 아래 메소드처럼 읽을 수도 있습니다.

func read(using modelContext: ModelContext) throws -> [MusicVideoEntity] {
    do {
        return try modelContext.fetch(FetchDescriptor<MusicVideoEntity>())
    } catch {
        throw MusicVideoStorageError.readFail
    }
}

그렇다면 Storage 객체에 @Query 프로퍼티 래퍼 속성을 정의하고, 이 속성에 읽어온 값을 일치시키는 것이 가능할지도 모른다고 생각했습니다. 아래처럼 프로토콜에 속성을 정의하고, 이 프로토콜을 따르는 Storage 객체에서 @Query 프로퍼티 래퍼 속성을 정의해 값을 할당합니다.

import SwiftUI
import SwiftData

protocol MusicVideoStorage {
    var musicVideos: [MusicVideoEntity] { get }
    
    func read(using modelContext: ModelContext) -> [MusicVideoEntity]
    func insert(musicVideo: MusicVideo, using modelContext: ModelContext)
    func delete(musicVideoEntity: MusicVideoEntity, using modelContext: ModelContext)
    func deleteAll(using modelContext: ModelContext)
}

final class DefaultMusicVideoStorage: MusicVideoStorage {

    @Query var musicVideos: [MusicVideoEntity]
    
    func read(using modelContext: ModelContext) throws {
        do {
            self.musicVideos = try modelContext.fetch(FetchDescriptor<MusicVideoEntity>())
        } catch {
            throw MusicVideoStorageError.readFail
        }
    }

}

그러면 @Query 프로퍼티 래퍼 속성은 get-only 속성이기 때문에 할당할 수 없다는 오류가 발생합니다. 당연하다고 생각해볼 것이 로컬 데이터베이스에 접근하는데, CRUD의 안정성을 해칠 수 있기 때문에 이렇게 구현되어 있는 것이 아닐까 싶습니다.

속성을 Storage와 View 사이에서 옮겨가며 시도해보던 중 View 업데이트는 되지 않았지만 실제로 로컬 데이터베이스에 추가는 되고 있었다는 사실을 중간에 알았습니다. 즉 insert() 메소드를 사용하는 동안 View가 업데이트되지 않았는데, 다시 View에 옮겼을 때 데이터가 추가되어 있었습니다. @Query 프로퍼티 래퍼와 관계없이 추가, 삭제가 잘 된다는 점을 생각하고, 읽어오는 방법이 Query 프로퍼티 래퍼 속성만 있는 것은 아니기 때문에 읽어온 값을 @State 프로퍼티 래퍼 속성에 할당하는 방법이 있습니다. 아래와 같습니다.

@main
struct MusicVideoDataApp: App {
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: [MusicVideoEntity.self])
        }
        
    }
    
}

struct MusicVideo: Identifiable {
    let id: UUID
    let artistName, trackName: String
}

@Model
final class MusicVideoEntity: Identifiable {
    
    var id: UUID
    var artistName: String
    var trackName: String
    
    init(artistName: String, trackName: String) {
        self.id = .init()
        self.artistName = artistName
        self.trackName = trackName
    }
    
}

protocol MusicVideoStorage {
    func read(using modelContext: ModelContext) -> [MusicVideoEntity]
    func insert(musicVideo: MusicVideo, using modelContext: ModelContext)
    func delete(musicVideoEntity: MusicVideoEntity, using modelContext: ModelContext)
    func deleteAll(using modelContext: ModelContext)
}

final class DefaultMusicVideoStorage: MusicVideoStorage {
    
    init() {
        
    }
    
    func read(using modelContext: ModelContext) -> [MusicVideoEntity] {
        do {
            return try modelContext.fetch(FetchDescriptor<MusicVideoEntity>())
        } catch {
            fatalError("Context fetch failed")
        }
    }
    
    func insert(musicVideo: MusicVideo, using modelContext: ModelContext) {
        modelContext.insert(MusicVideoEntity(artistName: musicVideo.artistName, trackName: musicVideo.trackName))
    }
    
    func delete(musicVideoEntity: MusicVideoEntity, using modelContext: ModelContext) {
        modelContext.delete(musicVideoEntity)
    }
    
    func deleteAll(using modelContext: ModelContext) {
        modelContext.container.deleteAllData()
    }
    
}

struct MusicVideoView: View {
    
    @Environment(\.modelContext) private var modelContext
    private let storage: MusicVideoStorage
    private let musicVideo: MusicVideo = .init(id: .init(), artistName: "NewJeans", trackName: "Ditto")
    @State var musicVideos: [MusicVideoEntity] = []
    
    var body: some View {
        VStack {
            ScrollView {
                VStack {
                    ForEach(musicVideos) { musicVideo in
                        HStack {
                            Text(musicVideo.artistName)
                            Text(musicVideo.trackName)
                        }
                    }
                    
                }
                .onAppear {
                    read()
                }
            }
            HStack {
                Button {
                    self.insert(musicVideo: musicVideo)
                } label: {
                    Text("Insert")
                }
                Button {
                    self.delete(musicVideoEntity: musicVideos[0])
                } label: {
                    Text("Delete")
                }
            }
        }
    }
    
    init(storage: MusicVideoStorage) {
        self.storage = storage
    }
    
    func read() {
        musicVideos = storage.read(using: modelContext)
    }
    
    func insert(musicVideo: MusicVideo) {
        storage.insert(musicVideo: musicVideo, using: modelContext)
        musicVideos = storage.read(using: modelContext)
    }
    
    func delete(musicVideoEntity: MusicVideoEntity) {
        storage.delete(musicVideoEntity: musicVideoEntity, using: modelContext)
        musicVideos = storage.read(using: modelContext)
    }
    
}

struct ContentView: View {
    var body: some View {
        MusicVideoView(storage: DefaultMusicVideoStorage())
    }
}

주의: 위 프로젝트는 첫 번째 인덱스 아이템을 삭제하고 있으므로 count가 0일 때, 즉 비어있을 때 삭제를 시도하면 오류가 발생합니다.

데이터를 추가, 삭제하는 과정에서 View의 업데이트가 되려면 추가, 삭제 후 다시 읽어오는 과정이 필요합니다.

func read() {
    musicVideos = storage.read(using: modelContext)
}
    
func insert(musicVideo: MusicVideo) {
    storage.insert(musicVideo: musicVideo, using: modelContext)
    musicVideos = storage.read(using: modelContext)
}
    
func delete(musicVideoEntity: MusicVideoEntity) {
    storage.delete(musicVideoEntity: musicVideoEntity, using: modelContext)
    musicVideos = storage.read(using: modelContext)
}

그런데 아쉬운 부분이 생깁니다. @Query는 아래처럼 정렬을 돕기도 합니다.

@Query(sort: \.name) var musicVideos: [MusicVideoEntity]

그 외에 order, animation@Query가 제공하는 많은 기능을 사용할 수 없게 됩니다.

아키텍처를 시도하던 중 만나게 된 문제를 해결하려 했지만, 이 시도가 애플이 제공하려는 SwiftUI, SwiftData의 다양한 기능을 사용하지 못하도록 하는 것이 아닌가 하는 생각이 듭니다. 글의 시작 부분 남긴 프로젝트는 업데이트될 수 있으며, 다른 방식으로 해결하거나 새로운 문제를 만나게 되면 다시 글을 남겨보도록 하겠습니다.

import SwiftUI
import SwiftData

@main
struct MusicVideoDataApp: App {
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: [MusicVideoEntity.self])
        }
        
    }
    
}

struct MusicVideo: Identifiable {
    let id: UUID
    let artistName, trackName: String
}

@Model
final class MusicVideoEntity: Identifiable {
    
    var id: UUID
    var artistName: String
    var trackName: String
    
    init(artistName: String, trackName: String) {
        self.id = .init()
        self.artistName = artistName
        self.trackName = trackName
    }
    
}

extension MusicVideoEntity {
    func toDomain() -> MusicVideo {
        return .init(id: id, artistName: artistName, trackName: trackName)
    }
}

protocol MusicVideoStorage {
    func read() -> [MusicVideoEntity]
    func insert(musicVideo: MusicVideo)
    func delete(musicVideoEntity: MusicVideoEntity)
    func deleteAll()
}

final class DefaultMusicVideoStorage: MusicVideoStorage {
    @Environment(\.modelContext) private var modelContext
    init() {
        
    }
    
    func read() -> [MusicVideoEntity] {
        do {
            return try modelContext.fetch(FetchDescriptor<MusicVideoEntity>())
        } catch {
            fatalError("Context fetch failed")
        }
    }
    
    func insert(musicVideo: MusicVideo) {
        modelContext.insert(MusicVideoEntity(artistName: musicVideo.artistName, trackName: musicVideo.trackName))
    }
    
    func delete(musicVideoEntity: MusicVideoEntity) {
        modelContext.delete(musicVideoEntity)
    }
    
    func deleteAll() {
        modelContext.container.deleteAllData()
    }
    
}

struct MusicVideoView: View {
    
    
    private let storage: MusicVideoStorage
    private let musicVideo: MusicVideo = .init(id: .init(), artistName: "NewJeans", trackName: "Ditto")
    @State var musicVideos: [MusicVideoEntity] = []
    
    var body: some View {
        VStack {
            ScrollView {
                VStack {
                    ForEach(musicVideos) { musicVideo in
                        HStack {
                            Text(musicVideo.artistName)
                            Text(musicVideo.trackName)
                        }
                    }
                    
                }
                .onAppear {
                    read()
                }
            }
            HStack {
                Button {
                    self.insert(musicVideo: musicVideo)
                } label: {
                    Text("Insert")
                }
                Button {
                    self.delete(musicVideoEntity: musicVideos[0])
                } label: {
                    Text("Delete")
                }
            }
        }
    }
    
    init(storage: MusicVideoStorage) {
        self.storage = storage
    }
    
    func read() {
        musicVideos = storage.read()
    }
    
    func insert(musicVideo: MusicVideo) {
        storage.insert(musicVideo: musicVideo)
        musicVideos = storage.read()
    }
    
    func delete(musicVideoEntity: MusicVideoEntity) {
        storage.delete(musicVideoEntity: musicVideoEntity)
        musicVideos = storage.read()
    }
    
}
post-custom-banner

0개의 댓글