CoreData 의 @Environment(.managedObjectContext) 나 @EnvironmentObject 등을 다루다보면 프리뷰에서 Preview Crashed 오류를 겪는 경우가 종종 발생한다.
코어 데이터 예제를 다뤄보면서 개인적으로 헷갈렸던 부분은 @Environment(.managedObjectContext) 의 사용이었다.
분명 이놈은 앱 수준에서 한 번만 주입하면 원하는 상황에 꺼내다 쓸 수 있는 걸로 아는데, 왜 자꾸 프리뷰에서 오류가 나는 것일까?
일단 이 래퍼속성은 앱 수준에서 한 번만 주입해주면 뷰 어느 곳에서든 꺼내쓸 수 있는 것이 확실하다고 단언할 수 있다. 프리뷰에서 오류가 발생하는 것과 별개로 Xcode 에서 빌드를 해보면 정석대로 @Environment 를 사용한 사례들은 코드가 정상 동작한다.
코어 데이터의 예제로 살펴보자.
빠르게 코어 데이터의 구조에 대해 살펴보고 넘어가는 것이 코드 이해에 더욱 도움이 된다.
CoreData 는 로컬 데이터베이스에 사용자의 데이터를 저장한다.
데이터의 상호작용은 <사용자, context, DB> 사이에서 이루어진다.
사용자가 데이터를 쏘면 context 가 그걸 받아서 DB에 저장하는 형식인 셈이다.
여기서 사용자와 context 사이에서 데이터가 다뤄질 때는 @Environment 라는 꼬리표가 달라붙어있다. 이는 데이터가 SwiftUI 에서 환경 값으로 사용되는 것을 의미한다. 환경 값은 뷰 전역에서 마음대로 꺼내쓸 수 있는 값으로 public 과 유사하게 받아들이면 이해가 쉽다.
이러한 @Environment 꼬리표를 달고 있는 녀석을 managedObjectContext 라고 한다. 이 녀석은 NSmanagedObjectContext 의 인스턴스로, NSmanagedObjectContext 과의 상호작용을 위해 존재하는 놈이다.
머리가 빠개질 것 같으니 4줄 요약을 해보면 다음과 같다.
여기까지 이해가 되었다면, 본체인 NSmanagedObjectContext 는 뭐하는 녀석일까?
이놈은 CoreData DB와 상호작용하는 녀석이다.
작업 처리된 데이터의 결과를 DB에 넘기는 역할을 담당하는 것이다.
이제 위에서 이해한 코어 데이터의 구조를 토대로 코드를 작성한다.
먼저, 아래와 같이 CoreDataMananger 라는 이름으로 Context 를 싱글톤으로 부를 수 있게 만들어주었다. 여기에 본체인 NSmanagedObjectContext 를 두고 다른 뷰에서는 분신들 (인스턴스) 을 소환하면 된다.
import Foundation
import CoreData
import SwiftUI
class CoreDataManager {
static let inst = CoreDataManager()
let container: NSPersistentContainer
let context: NSManagedObjectContext
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "MemoEntity")
// 메모리에 데이터 올려두기
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores { (description, error) in
if let error = error {
print("error loading core data: \(error)")
}
}
context = container.viewContext
}
func save() {
do {
try context.save()
} catch let error {
print("error saving core data: \(error.localizedDescription)")
}
}
}
다음으로 사용할 엔티티의 뷰 모델을 작성해주었다.
뷰 모델은 분신들의 모형이다.
뷰 전역에서 받아내는 데이터는 @Environment 상태인데, 이 데이터들은 NSManagedObjectContext 타입으로 변환되어야 한다.
해당 작업을 여기서 수행한다.
class MemoViewModel: ObservableObject {
@Published var memos: [MemoEntity] = []
var context: NSManagedObjectContext
init(context: NSManagedObjectContext) {
self.context = context
fetchMemo()
}
func fetchMemo() {
let request: NSFetchRequest<MemoEntity> = MemoEntity.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \MemoEntity.timeStamp, ascending: true)]
do {
memos = try context.fetch(request)
} catch {
print("failed to fetch memos: \(error.localizedDescription)")
}
}
func addMemo(title: String) {
let newMemo = MemoEntity(context: context)
newMemo.title = title
newMemo.timeStamp = Date()
save()
fetchMemo()
}
func deleteMemo(memo: MemoEntity) {
context.delete(memo)
save()
fetchMemo()
}
func save() {
do {
try context.save()
} catch {
print("failed to save context: \(error.localizedDescription)")
}
}
}
이제 CoreData 의 context 를 뷰 전역에서 부를 수 있도록 앱 수준에서 environment 로 주입해준다.
environment 를 주입해주는 상태를 보면 managedObjectContext 인 것을 확인할 수 있다. 이제부터 뷰 전역에서 부르는 context 는 모두 managedObjectContext 인 셈이다.
import SwiftUI
import CoreData
@main
struct CoreData_tutorialApp: App {
let cm = CoreDataManager.inst
var body: some Scene {
WindowGroup {
ContentView(context: cm.container.viewContext)
.environment(\.managedObjectContext, CoreDataManager.preview.container.viewContext)
}
}
}
위 절차를 요약해보면 다음과 같이 데이터가 넘어간다고 할 수 있다.
위 예제에서는 앱 수준에서 ManagedObjectContext 를 넘겼다.
하지만 NSManagedObjectContext 를 넘겨도 상관은 없다.
그러면 앱 수준에서 둘 중 어느 걸 넘겨주는게 더 좋을까?
먼저, NSManagedObjectContext 를 넘겨주는 것에 알아보자.
이는 본체가 전역을 돌아다니는 것과 같다.
ManagedObjectContext 를 보내면 경우는 다음과 같다.
코어 데이터의 마지막 부분을 살펴보자.
ContentView(context: cm.container.viewContext)
ContentView 에 매개변수로 context 를 넘기는 모습을 확인할 수 있다.
CoreData 를 사용하기 위해 NSManagedObjectContext 를 초기화하는 행위는 NSManagedObjectContext 의 인스턴스인 ManagedObjectContext 만드는 것과 같은 행위이다.
쉽게 말해, 본체를 초기화하는 행위는 분신을 생성하는 것과 같다.
이러한 관점에서 볼 때, 위 코드는 한 가지 의문점이 생긴다.
왜 ContentView 에서 인스턴스를 만들어서 쓰지 않고 윗단에서 받는 것인가?
어차피 둘 다 같은 행위라면 인스턴스를 만들어서 써도 되는데 말이다.
이는 ContentView 가 화면을 지우고 다시 그리는 행위를 반복하기 때문이다.
아래 코드를 확인해보면 ContentView 는 StateObject 를 채택하고 있다.
이는 Foreach 로 만든 카드들을 삭제하거나 추가하는 행위가 가능하기 때문에 이에 따라 뷰를 다시 그려야하기 때문이다.
따라서 이러한 동작을 하는 뷰에서 인스턴스를 생성하는 행위는 굉장히 비효율적이라고 할 수 있다. 왜냐하면 뷰를 지웠다가 다시 생성하는 과정에서 또 다시 인스턴스를 만들어야 하기 때문이다.
뷰가 지워지면 인스턴스도 지워진다.
그리고 뷰를 다시 그릴 때, 인스턴스도 다시 생성해야 한다.
그러므로 이러한 행동보다는 윗단에서 context 를 한 번만 받아오는 것이 더 효율적이다.
import SwiftUI
import CoreData
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@StateObject var vm: MemoViewModel
init(context: NSManagedObjectContext) {
_vm = StateObject(wrappedValue: MemoViewModel(context: context))
}
var body: some View {
ContentViewHeader()
ScrollView {
ForEach(Array(vm.memos.enumerated()), id: \.element.timeStamp) { index, memo in
SwipeCard(data: memo, idx: index, vm: vm)
}
}
}
}
#Preview {
ContentView(context: CoreDataManager.preview.container.viewContext)
.environment(\.managedObjectContext, CoreDataManager.preview.container.viewContext)
}
이제 본론인 Preview crashed 오류이다.
위 예제 코드에서 프리뷰 부분만을 살펴보자.
#Preview {
ContentView(context: CoreDataManager.preview.container.viewContext)
.environment(\.managedObjectContext, CoreDataManager.preview.container.viewContext)
}
위와 같이 작성하면 오류가 나지 않는다.
하지만 environment 를 넘기는 코드를 빼버리면 프리뷰에서 오류가 발생한다.