렌더링 개선으로 CPU 사용률 2.8% 절감한 이야기 (SwiftUI 렌더링 관점에서 본 @StateObject와 @ObservedObject를 혼동하면 안되는 이유)

0

SwiftUI 렌더링 개선

목록 보기
4/8

미리보는 결과


(좌측이 개선 이전, 우측이 개선 이후입니다. 개선 이후에는 해당 함수가 호출되지 않아 공백으로 남았습니다.)

문제:
당시 View에서 터치, 드래그, 또는 내부 이벤트가 발생할 때마다 updateUIViewController 함수가 매번 호출되어, UIViewControllerRepresentable에서 불필요한 업데이트가 발생했습니다.
이 문제는 주입된 ViewModel이 @ObservedObject가 아닌 @StateObject로 잘못된 property wrapper를 사용했기 때문이었습니다.

해결:
Property wrapper를 @ObservedObject로 다시 변경함으로써, 불필요한 리렌더링을 방지하고, 이벤트 발생 시마다 updateUIViewController 함수가 호출되지 않도록 했습니다.

성능 개선:
이를 통해 의미 없이 소모되던 메인 스레드 점유 시간 104msCPU 사용률 2.8%를 절감할 수 있었습니다.
이 수치는 특히 애니메이션이나 반복 작업이 많은 환경에서 성능 향상에 큰 영향을 미쳤습니다.


서론

@StateObject@ObservedObject는 SwiftUI에서 ObservableObject를 사용하는 방법입니다.
두 방법 모두 @Published로 선언된 프로퍼티의 값이 변경되면 View를 랜더링한다는 공통점이 있습니다.

하지만@StateObject는 View가 해당 객체를 소유할 때, @ObservedObject는 외부에서 전달받을 때 사용한다는 차이점이 있습니다.
이 차이를 무시하면, View가 다시 그려질 때 @ObservedObject가 다시 생성되면서 값이 초기화되는 대참사가 일어나곤 합니다.

그렇다면, 모든 경우에 @StateObject를 사용하면? 당연히 안되겠죠?
오늘은 렌더링 관점에서 @StateObject@ObservedObject를 혼동해서는 안 되는 이유를 살펴보겠습니다.


자식 View에서 @StateObject로 ViewModel을 받으면 안 되는 이유

테스트를 위해, 아무 역할도 하지 않는 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로 두면 어떤 일이 일어날까?

이번에는 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로 두지 마세요...

profile
https://github.com/sustainable-git

0개의 댓글

Powered by GraphCDN, the GraphQL CDN