Apple🍎/SwiftData

SwiftData 사용할 때 변경 시점도 업데이트 하는 방법

생각 깎는 아이 2025. 3. 11. 22:21

상황설명 

- swiftData를 사용하여 데이터 모델링

@Model
public final class SceneCard {
    // MARK: - Properties
    @Attribute(.unique) public var id: String
    public var title: String
    public var order: Int
    public var summary: String
    public var subtitle: String
    public var done: Bool
    public var createdAt: Date
    public var modifiedAt: Date
 ...

-@Bindable을 사용해 모델의 프로퍼티를 직접 TextEditor에 바인딩함 

struct SceneCardView: View {
    @Bindable let scene: SceneCard
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Header area
            HStack {
                TextEditor(text: $scene.title)

위와 같이 구성하면 SwiftData의 모델은 context에서 관리되고 있기 때문에 @Bindable로 바인딩된 모델의 프로퍼티가 변경되었을 때 이를 context 내부에 체크에 두었다가 내부적으로 commit(or flush)가 나가는 시점에 자동으로 db에 업데이트가 됩니다.  

처음에는 SwiftData의 모델 업데이트 자동관리를 사용하면 편리하게 CRUD를 할 수 있겠지라고 생각하며 위와 같이 구조를 설계했습니다.  따로 업데이트 로직을 호출해 줄 필요도 없고 자동으로 관리를 해준다고 개꿀인데?

 

하지만 세상 만사 마음대로 되는 게 없더군요.

 

하고 싶은 것 : SwiftData 모델의 프로퍼티가 변경된 시점에 modifiedAt 프로퍼티도 업데이트를 해주고 싶음 

문제 사항 

1. SwiftData의 @Model 모델은 변경이 일어났을 때 이를 자동으로 감지했다가 지가 원하는 타이밍에 db에 커밋함, 근데 이 시점이 명확히 언제지 모르겠음, 그래서 찾아보니까 "뷰 생명주기, 런 루프에 따라 자동으로 업데이트됨 걱정 ㄴㄴ"라고 하는데 실제로 RUN을 해보면 저장이 될 때도 있고 안될 때도 있고 시점이 불명확함 

2. 그러면 자동으로 업데이트가 반영되는 시점에 원하는 행동을 같이 해주는 (willSet, didSet 같은) 것들을 적용하고 싶은데 SwiftData의 @Model 매크로가 저장 프로퍼티 접근을 관리하며 내부적으로 프로퍼티 접근을 재정의하여 모델의 프로퍼티에 대해서는 willSet, didSet 옵저버가 사용 불가함. 

3. 값 변경을 위한 별도의 연산프로퍼티를 만들고 이를 통해 변경을 하기에는 연산 프로퍼티는 내부적으로 저장하고 있는 값이 없기 때문에 @Bindable 을 이용한 UI에 바인딩이 불가함 

4. context의 willSet 노티를 notificationCenter를 통해 받아서 이를 감지해 modifiedAt을 업데이트하면 이 또한 변경 감지가 되어 순환 사이클이 형성됨 

5. @Transient 프로퍼티를 사용하고 didSet, willSet를 정의해 이를 통해 프로퍼티 값 변경 및 변경시점 감지와 저장을 한다. 

 

마지막 방법을 시도하려고 구조를 짜보니까 텍스트 필드 특성상 입력으로 인한 변경이 아주 많이 발생하는데, 그러면 텍스트 변경이 있을 때마다 프로퍼티 업데이트를 시도하고 또 이때마다 db에 자동으로 commit을 한다???? 일반 String을 쓴다면, 애플이 잘 만들어 놓았겠지라고 생각하면서 사용했겠지만, 향후에 리치 텍스트 제공을 위해 NSAttributedString을 사용할 것이고 해당 타입은 SwiftData가 지원하지 않는 타입이기 때문에 ValueTransformer를 사용해 저장을 할 때마다 Data로 컨버팅을 해줘야 하는데 매 입력마다 자동으로 저장을 해버리면 버벅거리는 게 눈에 선했기 때문에 다른 방법을 선택했습니다. 

선택한 방법 : 뷰 단에 상태 관리 + 명시적인 save 호출 + combine으로 조절 

일단 언제 되는지 제대로 설명도 안 해주는 자동 저장 옵션을 껐습니다. 젠장 이럴거면 그냥 coredata 쓸껄

self.container.mainContext.autosaveEnabled = false

 

컴포넌트 내부에서 현재 입력되는 텍스트를 관리하도록 @State 프로퍼티로 뷰에서 상태관리를 따로 해줍니다. 그리고 onChange 모디파이어를 사용해 해당 프로퍼티에 변경 시 전달받은 클로저를 통해 Combine으로 변경 사항을 발행하도록 합니다. 

struct SceneCardView: View {
    let scene: SceneCard
    let onPropertyChanged: (String, Any) -> Void
    
    // Local state for UI only
    @State private var titleText: String
    
    // Initialize with current values
    init(scene: SceneCard, onPropertyChanged: @escaping (String, Any) -> Void) {
        self.scene = scene
        self.onPropertyChanged = onPropertyChanged
        _titleText = State(initialValue: scene.title)
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Header area
            HStack {
                TextEditor(text: $titleText)
                	.onChange(of: titleText) { _, newValue in
                        onPropertyChanged("title", newValue)
                    }
                    ...

마지막으로 해당 컴포넌트를 사용하는 부모 뷰에서 발행된 변경사항을 구독한 다음 debouce를 통해 더 이상 변경이 일어나지 않은 시점에 명시적으로 save를 호출합니다. 이러면 각 입력마다 저장을 호출하지 않아 효율적인 저장이 가능합니다. 

struct ScriptModePanel: View {
    var draft: Draft
    let onAddScene: () -> Void
   
    // State for tracking scene changes
     @State private var changePublisher = PassthroughSubject<(SceneCard, String, Any), Never>()
     @State private var cancellables = Set<AnyCancellable>()

    var body: some View {
        List {
            ForEach(sortedScenes) { scene in
                SceneCardView(
                    scene: scene,
                    onPropertyChanged: {(property, value) in
                        changePublisher.send((scene, property, value))
                    }
                )
                
               ... 
               
        .onAppear {
            setupPublishers()
        }
        .onDisappear {
            cancellables.removeAll()
        }
    }
    
    private func setupPublishers() {
        cancellables.removeAll()
        
        changePublisher
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .sink { (scene, property, value) in
                do {
                    try scene.updateProperty(property, value: value)
                } catch {
                    print("Failed to update scene property: \(error)")
                }
            }
            .store(in: &cancellables)
    }
}

 

정리해 보니까 뭐 별거 없는 것 같아서 조금 허탈하기도 하지만 해결 과정에서 깨달은 게 있습니다. "새롭다고 다 좋은 건 아니다." 가뜩이나 iOS 커뮤니티도 그렇게 크지 않은데 신생아 수준의 프레임워크라 자료도 많이 없어서 관련된 내용은 거의 다 읽어본 것 같습니다. 조금 더 성숙해질 때까지는 CoreData를 애용할 것 같습니다. 

 

 

autosaveEnabled | Apple Developer Documentation

A Boolean value that indicates whether the context should automatically save any pending changes when certain events occur.

developer.apple.com

  • SwiftData의 @Model에 프로퍼티 감시자 사용법 
 

SwiftData Model didset | Apple Developer Forums

It's somewhat I finally done in my code. But, for this to work, it's mandatory to use @Transient macro to tell Xcode the property is not persistent. This approach involves to "double" some properties, as you've done in your example. So, for your solution t

developer.apple.com

  • SwiftData 모델에 modifiedAt 속성 업데이트 방법 
 

What is the best SwiftData approach to implement a modified date

When using Core Data I would override willSave on NSManagedObject to compute a lastModified value on a model object. This allowed one simple method to check changed values and set a date if necessa...

stackoverflow.com