MVVM 패턴에서 SwiftData 구현하기

민규·2024년 10월 25일
2

SwiftUI

목록 보기
1/1
post-thumbnail

(유명한 SwiftUI + ViewModel 밈)

SwiftUI 의 View 에서 SwiftData 의 로직을 분리시켜보자!

배경

visionOS 앱을 개발하며 나름 세상에 공개된 지 얼마 되지 않은 따끈따끈한 프레임워크인 SwiftData를 적용해보기로 하였습니다. 하지만 제가 개발하고 있었던 visionOS 앱은 무려 SwiftUI + MVVM로 개발 중인 앱이었습니다.

위 썸네일처럼, 몇몇 SwiftUI 개발자분들께서는 SwiftUI + MVVM은 족쇄다! 지양해야 한다! 라고 하셨습니다.

하지만 저는 Combine 덕분에 SwiftUI + MVVM 코드에 대해 별 불편함 없이 만족하며 잘 살아왔습니다. (보드야 뭐 잘 달리기만 하면 그만이니까요ㅎ)

SwiftUI + MVVM이었던 이번 프로젝트에서도 저는 어느 때와 같이 View와 State 를 가진 프로퍼티가 완벽히 분리된 그런 MVVM스러운 코드를 짜고 싶었습니다. 하지만 결국 찾아오고야 말았습니다.

import SwiftUI
import SwiftData

/// Model
struct Data: Identifiable {
    var id: UUID
    var text: String
}

/// View
struct ExampleView: View {
    @Query(sort: \.text, order: .forward) var data: [Data]
    @Environment (\.modelContext) var context
    
    var body: some View {
        List(data) {
            Text($0.text)
        }
    }
}

바로 SwiftData@Query macro로 만든 프로퍼티를 View에서 분리하는 과정에서 평소처럼 easy하게 ViewModel로 보낼 수 없다는 것이였습니다.

그리곤 생각했죠. "아? 어!? 이건 아니지..." MVVM스러운 코드를 원했던 저는 너무 불편했습니다. 바로 해결하러 가보도록 하겠습니다.

해결

ModelContext

먼저 SwiftData + MVVM 구현하기 위해서는 SwiftDatamodelContext가 가지는 역할을 알아야 합니다. 간단하게 modelContext는 데이터모델의 CRUD(추가, 읽기, 업데이트, 삭제) 기능을 수행해주는 역할을 합니다.

@Environment (\.modelContext) var context
context.fetch()
context.delete()
context.insert()
context.save()

"엇? 그러면 이 modelContext를 View 레이어에서 분리한다면 @Query를 사용하지 않고도 ViewModel에서의 데이터 CRUD 처리가 가능하지 않을까요?"

여기서 이 의문을 해결하기 위한 키포인트는 이 context 를 담고 있는 Container 입니다.

ModelContainer

사실 @Environment 프로퍼티 래퍼를 통해 modelContext 를 가져오기 위해 먼저 ModelContainer 를 생성하고 이 ModelContainer 를 Scene 의 환경으로 설정하는 과정을 가져야 합니다.

/// @main App
WindowGroup {
	ContentView()
}
.modelContainer(ModelContainer)

해당 코드에 등장한 ModelContainer객체는 앱의 스키마와 모델 스토리지를 관리하는 객체로서 context 를 제공해주는 역할을 합니다.

이 말은 즉슨, 반대로 이 ModelContainer 를 통해 modelContext 에 접근하여 context 의 기능을 사용할 수 있다는 것 입니다.

/// ModelContainer 구현부
public class ModelContainer : Equatable, @unchecked Sendable {
	// 생략
    @MainActor public var mainContext: ModelContext { get }
}

실제로 ModelContainer의 구현부를 보면 get only 인 ModelContext 가 프로퍼티의 형태로 저 써주세요..!! 하는 느낌으로 존재하고 있는 것을 확인할 수 있었습니다.

ViewModel + SwiftData

이제 View 레이어와 이 SwiftData 로직을 분리하기 위한 코드를 구현해보도록 하겠습니다. 가장 먼저 해야할 부분은 ViewModel 에서 ModelContext 에 접근할 수 있도록 하는 객체를 만드는 것 입니다.

final class DataService {
    private let modelContainer: ModelContainer
    private let modelContext: ModelContext
    
    @MainActor static let shared = DataService()
    
    @MainActor private init() {
        self.modelContainer = try! ModelContainer(
            for: DataModel.self,
            configurations: ModelConfiguration(isStoredInMemoryOnly: false)
        )
        self.modelContext = modelContainer.mainContext
    }
}

DataService라는 이름의 해당 class 는 딱 하나의 ModelContainer 객체를 가질 수 있게 싱글톤으로 구현되어 있습니다. 또 유심히 봐야할 부분은 @MainActor 어노테이션입니다.

modelContainermainContext@MainActor 어노테이션을 통해 정의되어 있습니다. 그래서 mainContext 에 접근하는 요소에도 함께 @MainActor 를 작성해주셔야 합니다!

final class DataService {
    // 생략
    func fetchDatas() -> [DataModel] {
    	do {
            return try modelContext.fetch(FetchDescriptor<DataModel>())
        } catch {
            fatalError("error fetchDatas\n\(error.localizedDescription)")
        }
    }
    
    func addData(_ data: DataModel) {
        modelContext.insert(workspace)
        do {
        	try modelContext.save()
        } catch {
        	fatalError(error.localizedDescription)
        }
    }
}

그리고는 DataService내에서 modelContext에 접근해 원하시는 기능들을 구현해주시면 됩니다. (사실 애플에 따르면 modelContext.save()는 ModelContext에 autosave 기능이 존재하기 때문에 꼭 명시적으로 호출하지 않아도 된다고 합니다!)

이제 이 객체를 활용한 ViewModel을 구현해보도록 하겠습니다.

@Observable
final class ViewModel {
    var datas: [dataModel] = []
    
    private let dataService: DataService
    
    init(dataService: DataService) {
        self.dataService = dataService
    }
    
    func addData() {
        let data = dataModel(data: "my data")
        dataService.addData(data)
    }
}

해당 ViewModel에는 DataService의 객체를 통해 데이터를 삽입하는 기능이 구현되어 있습니다. 하지만 여기서 사소하지만 약간 아쉬운 문제가 발생하게 됩니다.

바로 @Query macro만의 내부 DB와 항상 동기화된 상태, 즉 Observable한 상태를 포기하게 된 것이고 SwiftData 의 장점을 활용하지 못하게 되었다는 것입니다...

(당연하지만 @Query macro의 또 다른 장점인 filter도 활용하지 못한다는 것을 글을 쓰면서 알게 되었습니다...)

@Observable
final class ViewModel {
	/// 생략 
    init(dataService: DataService) {
        self.dataService = dataService
        datas = dataSource.fetchDatas() // fetch
    }
    
    func addData() {
        let data = dataMoel(data: "my data")
        dataService.addData(data)
        datas = dataSource.fetchDatas() // fetch
    }
}

그래서 어쩔 수 없이 fetch() 를 직접 명시적으로 호출하는 방식으로 datas 의 업데이트를 구현하였습니다. (Update 혹은 Delete 로직이 들어간 함수가 있다면 동일하게 fetch 를 마무리로 종료시켜야 합니다.)

사실 MVVM 에서 SwiftData 를 사용하는 시도 자체가 애플에서 원하는 방향성과 멀어졌다고 생각하기에 아쉽지만 넘어가도록 하겠습니다. (애플이 정말 원하는게 뭘까..)

View

이제 마지막으로 구현했던 ViewModel 을 View 에 추가해주도록 하겠습니다.

/// 이전 코드
struct ExampleView: View {
    @Query(sort: \.text, order: .forward) var data: [Data]
    @Environment (\.modelContext) var context
    
    var body: some View {
        List(data) {
            Text($0.text)
        }
    }
}
/// SwiftData + MVVM 코드
struct ExampleView: View {
	var viewModel: ViewModel = ViewModel(dataSource: .shared)

	var body: some View {
		VStack {
    		List(viewModel.datas) {
        		Text("\($0.data)")
        	}
    	}
	}
}

View 와 State 가 분리된 그런 코드가 완성되었습니다!

결론

해당 문제를 해결하기 위해 애플에서 제공하는 코드 스니펫들을 여럿 읽어보았습니다. 하지만 MVVM과 결합하여 사용하는 코드 스니펫은 눈을 씻고 찾아봐도 없었습니다. (당연하지...)

사실 이번 MVVM + SwiftData는 큰 고민 없이 해결한 문제였긴 하지만, 애플이 원하는 SwiftUI + MVVM 의 방향성에 대해 약간의 의문을 가지게 되는 계기가 되었습니다.

저만의 뇌피셜이긴 하지만 애플이 @Observable macro를 만들어준 이유는 "야, 니들 ViewModel 더 편하게 만들어서 써~"인 줄 알았는데, 또 나름 비슷한 시기에 출시한 SwiftData에서는 "ViewModel 쓰지 마~ 그럼 더 편하게 해줄게"인 것 같습니다...

아직 SwiftData를 적용할 프로젝트가 많진 않을 것 같지만, 저랑 똑같은 문제를 겪은 분들이 이 글을 보고 도움을 받으시면 좋겠다는 생각이 들어 해결 프로세스를 공유하는 글을 써보았습니다.

여기까지 읽어주시느라 정말 수고 하셨습니다. 감사합니다!

profile
어? 아니! 이런!?
post-custom-banner

0개의 댓글