(유명한 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스러운 코드를 원했던 저는 너무 불편했습니다. 바로 해결하러 가보도록 하겠습니다.
먼저 SwiftData + MVVM 구현하기 위해서는 SwiftData
의 modelContext
가 가지는 역할을 알아야 합니다. 간단하게 modelContext
는 데이터모델의 CRUD(추가, 읽기, 업데이트, 삭제) 기능을 수행해주는 역할을 합니다.
@Environment (\.modelContext) var context
context.fetch()
context.delete()
context.insert()
context.save()
"엇? 그러면 이 modelContext
를 View 레이어에서 분리한다면 @Query
를 사용하지 않고도 ViewModel
에서의 데이터 CRUD 처리가 가능하지 않을까요?"
여기서 이 의문을 해결하기 위한 키포인트는 이 context 를 담고 있는 Container 입니다.
사실 @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
가 프로퍼티의 형태로 저 써주세요..!! 하는 느낌으로 존재하고 있는 것을 확인할 수 있었습니다.
이제 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
어노테이션입니다.
modelContainer
의 mainContext
는 @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 를 사용하는 시도 자체가 애플에서 원하는 방향성과 멀어졌다고 생각하기에 아쉽지만 넘어가도록 하겠습니다. (애플이 정말 원하는게 뭘까..)
이제 마지막으로 구현했던 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를 적용할 프로젝트가 많진 않을 것 같지만, 저랑 똑같은 문제를 겪은 분들이 이 글을 보고 도움을 받으시면 좋겠다는 생각이 들어 해결 프로세스를 공유하는 글을 써보았습니다.
여기까지 읽어주시느라 정말 수고 하셨습니다. 감사합니다!