(좌측이 개선 이전, 우측이 개선 이후입니다. 개선 이후에는 해당 함수가 호출되지 않아 공백으로 남았습니다.)
문제:
당시 View에서 터치, 드래그, 또는 내부 이벤트가 발생할 때마다 updateUIViewController 함수가 매번 호출되어, UIViewControllerRepresentable에서 불필요한 업데이트가 발생했습니다.
이 문제는 주입된 ViewModel이 @ObservedObject
가 아닌 @StateObject
로 잘못된 property wrapper를 사용했기 때문이었습니다.
해결:
Property wrapper를 @ObservedObject
로 다시 변경함으로써, 불필요한 리렌더링을 방지하고, 이벤트 발생 시마다 updateUIViewController 함수가 호출되지 않도록 했습니다.
성능 개선:
이를 통해 의미 없이 소모되던 메인 스레드 점유 시간 104ms와 CPU 사용률 2.8%를 절감할 수 있었습니다.
이 수치는 특히 애니메이션이나 반복 작업이 많은 환경에서 성능 향상에 큰 영향을 미쳤습니다.
@StateObject
와 @ObservedObject
는 SwiftUI에서 ObservableObject
를 사용하는 방법입니다.
두 방법 모두 @Published
로 선언된 프로퍼티의 값이 변경되면 View를 랜더링한다는 공통점이 있습니다.
하지만@StateObject
는 View가 해당 객체를 소유할 때, @ObservedObject
는 외부에서 전달받을 때 사용한다는 차이점이 있습니다.
이 차이를 무시하면, View가 다시 그려질 때 @ObservedObject
가 다시 생성되면서 값이 초기화되는 대참사가 일어나곤 합니다.
그렇다면, 모든 경우에 @StateObject
를 사용하면? 당연히 안되겠죠?
오늘은 렌더링 관점에서 @StateObject
와 @ObservedObject
를 혼동해서는 안 되는 이유를 살펴보겠습니다.
테스트를 위해, 아무 역할도 하지 않는 ViewModel을 부모 View에 @StateObject
로 선언합니다.
그다음, 이를 @ObservedObject
로 자식 View에 전달해 의존성 주입을 합니다.
이후 부모 View에 있는 버튼을 눌러 number 값을 증가시키면 어떻게 될까요?
이 경우 부모 View만 업데이트되고, ViewModel이 역할을 하지 않기 때문에 자식 View에서는 렌더링이 발생하지 않습니다.
그렇다면, 만약 자식 View가 ViewModel을 @StateObject
로 참조하면 어떻게 될까요?
이때는 부모 View에서 버튼을 누르면 자식 View도 다시 렌더링됩니다.
문제는, ViewModel이 부모 View의 @State
값과 전혀 관련이 없는데도 자식 View가 다시 그려진다는 점입니다.
전혀 관계 없는 상태의 변화 때문에 불필요한 렌더링이 발생하는 것이죠.
import SwiftUI
fileprivate final class ViewModel: ObservableObject {}
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
@State private var number: Int = 0
var body: some View {
VStack {
ChildView(viewModel: viewModel)
Button("number = \(number)") {
number += 1
}
}
}
}
fileprivate struct ChildView: View {
@StateObject var viewModel: ViewModel
var body: some View {
let _ = Self._printChanges()
Text("넘버는 나랑 무관해서 렌더링이 일어나면 안 됨")
}
}
이번에는 ViewModel을 @State
로 선언하면 어떤 결과가 나올지 살펴보겠습니다.
우선, 부모 View에서 ViewModel의 property wrapper를 @State
로 두고, number 변수를 ViewModel 안에 넣어 @Published
로 선언해 보겠습니다.
이렇게 하면, 자식 View는 ViewModel을 @ObservedObject
로 참조하므로, number 값이 바뀔 때마다 자식 View가 다시 그려지게 됩니다.
하지만, 부모 View는 이 변화를 감지하지 못합니다.
그 이유는, 부모 View에서 ViewModel을 @State
로 선언했기 때문입니다.
@State
는 값 타입(Value Type)의 변화를 추적하여 View를 다시 그리지만, ViewModel은 참조 타입(Reference Type)이므로, 내부 값의 변화만으로는 부모 View에 변경 사항이 전달되지 않습니다.
결국 부모 View는 number 값의 변화를 알지 못해 업데이트가 발생하지 않게 되는 것이죠.
이로 인해 부모 View에서는 렌더링이 일어나지 않아 숫자가 변하지 않는 이상한 상황이 발생하게 됩니다.
import SwiftUI
fileprivate final class ViewModel: ObservableObject {
@Published private(set) var number: Int = 0
func upCount() {
number += 1
}
}
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
VStack {
ChildView(viewModel: viewModel)
Button("number = \(viewModel.number)") {
viewModel.upCount()
}
}
}
}
fileprivate struct ChildView: View {
@ObservedObject var viewModel: ViewModel
var body: some View {
let _ = Self._printChanges()
Text("ViewModel이 Publish 하니까 다시 그려 짐")
}
}
ViewModel은 해당 Life cycle을 관리할 View에서 @StateObject
로 선언하고, 자식 View에 주입할 때는 반드시 @ObservedObject
로 사용해야 합니다.
전역적으로 관리가 필요한 객체는 @ObservedObject
대신 @EnvironmentObject
로 처리해도 됩니다.
여기서 @EnvironmentObject
를 무조건 사용하는 것이 더 편리할 것 같다는 생각이 들 수 있습니다.
하지만, @EnvironmentObject
는 주입이 제대로 이루어지지 않을 경우 크래시가 발생할 수 있습니다.
특히 ViewModel을 다양한 View에서 사용하는 경우, 이 문제가 더 자주 발생할 수 있다는 점을 꼭 유념해야 합니다.
ps. ViewModel을 @State
로 두어 부모 View의 렌더링은 없애고 자식 View만 렌더링 되게 하면 좋지 않을까 라는 생각이 든다면 그냥 let으로 두면 되니 @State
로 두지 마세요...