이재용의 iOS

StateObject vs ObservedObject

2022년 4월 29일 • ☕️ 6 min read

observing photo

SwiftUI에서 화면 내의 데이터를 변경하기 위해서 여러가지 프로퍼티 래퍼를 사용해야 한다. 데이터 타입이 구조체인 경우 @State, @Binding를 사용해야하고, ObservableObject 프로토콜을 준수하는 클래스인 경우 @ObservedObject@StateObject를 사용한다.

State와 Binding은 대부분 구분해서 사용 가능하다. 모두 iOS 13부터 사용 가능하고 Binding은 양방향 바인딩을 도와주는 프로퍼티 래퍼로, 해당 데이터를 바로 저장하지 않고 source of truth를 연결한다. 즉, 부모뷰의 State 프로퍼티를 하위뷰에서 연결해주고 싶을 때 binding을 사용한다.

하지만 ObservedObject와 StateObject는 어느 상황에 쓰는 지 확실하지 않다. 두 개의 차이점을 공부하여 어느 상황에서 어떤 걸 사용하는 게 좋을 지 판단할 수 있도록 하자.

ObservedObject

ObservedObject의 정의는 아래와 같다.

Observable Object를 구독하고 Observable Object가 변경될 때마다 뷰를 무효화시키는(새로 그려주는) 프로퍼티 래퍼

위 정의대로 ObservableObject 프로토콜을 준수하는 클래스의 내부에 Published로 선언된 값이 변경될 때 외부로 알려준다. (퍼블리싱)

class TimeCounter: ObservableObject {
    @Published var count: Int = 0
    
    func increase() {
        count += 1
    }
}

ObservableObject를 준수하는 timer 모델 내부의 count 값은 버튼을 누를 때마다 증가하게 되고, @Published가 변화했다는 신호를 쏴서 다른 부분에서 변화를 알게 한다.

이렇게 외부로 전달된 값은 TimerView의 화면을 새롭게 그린다.

struct TimerView: View {
    @ObservedObject var timer = TimeCounter()
    
    var body: some View {
        VStack(spacing: 10) {
            Text("타이머 : \(timer.count)")
            Button(action: timer.increase) {
                Text("+1")
            }
            
        }
    }
}

StateObject

StateObject의 정의는 아래와 같다.

Observable Object를 인스턴스화하는 프로퍼티 래퍼

ObservedObject와 동일하게 작동한다. ObservableObject를 준수하는 객체를 화면에 연결하여 데이터가 변경되었을 때 화면을 다시 그려준다.

위 코드 예제에서 ObservedObjectStateObject로 바꿔도 동작에 이상은 없다.

struct TimerView: View {
    @StateObject var timer = TimeCounter()
    
    var body: some View {
        VStack(spacing: 10) {
            Text("타이머 : \(timer.count)")
            Button(action: timer.increase) {
                Text("+1")
            }
            
        }
    }
}

그럼 뭐가 다르다는 것일까?

StateObject의 정의에 나와있는 객체를 인스턴스화한다는 점이다.

StateObject와 ObservableObject의 차이

차이점은 두가지 프로퍼티 래퍼가 속한 뷰가 하위뷰인 경우. 그래서 상위뷰의 State가 변하면서 화면을 새로 그려야하는 경우 알 수 있다.

TimerView가 ContentView에 속한다고 가정해보자.

struct ContentView: View {
    @State var randomNumber: Int = 0
    var body: some View {
        VStack(spacing: 60) {
            VStack(spacing: 15) {
                Text("랜덤 숫자: \(randomNumber)")
                Button("Randomize number") {
                    randomNumber = (0..<1000).randomElement()!
                }
                TimerView()
            }
        }
    }
}

ContentView에는 State 프로퍼티인 randomNumber가 있고 버튼을 누를 때마다 0부터 1000까지 랜덤한 수가 나온다. 이 버튼을 누를 때마다 숫자는 바뀔것이고 body 내 State 속성이 업데이트가 되어 바인딩된 뷰는 새로 그려질 것이다.

struct TimerView: View {
    @StateObject var timer = TimeCounter()

    // ...
}

위 상황에서 StateObject로 선언된 timer의 값은 어떻게 될까? TimerView의 화면은 어떻게 될까?

변화가 없는 것을 볼 수 있다. 부모뷰의 body가 업데이트되면 내부에 위치한 TimerView도 새로 업데이트되어 새로운 구조체를 생성하지만 모델을 참조하기 때문에 참조한 값을 그대로 가져온다.

StateObject로 선언되면 새로운 객체 인스턴스를 생성한다는 의미이다. View에 종속되지 않는 메모리에 객체를 저장하고 여기서 꺼내서 쓴다.

swift observedobject example gif

struct TimerView: View {
    @ObservedObject var timer = TimeCounter()

    // ...
}

그럼 ObservedObject로 선언하면 어떨까?

0으로 초기화되는 것을 볼 수 있다. TimerView는 재생성될 것이고 그에 따라 TimerView 내부에 위치한 timer도 재생성된다.

swift stateobject example gif

둘의 차이점을 한마디로 정리하면 아래와 같다.

  • ObservedObject는 뷰의 라이프사이클에 의존하고 있다.
  • StateObject는 뷰의 라이프사이클에 의존하고 있지 않다.

(미디엄 글에서 본 말인데 정말 이해하기 편해서 그대로 가져와 보았다.)

하위뷰에서 새로 모델을 생성하는 것을 지양하자?

부모뷰의 State가 변경되면 하위뷰도 새로 그려진다. 그럼 하위 뷰 내에 있는 프로퍼티들도 StateObject와 같이 다른 별도의 속성을 부여하지 않는다면 초기화가 될 것이다. 상위뷰에서 데이터를 초기화하고 이를 하위뷰에 주입하게 된다면 이와 같은 고민을 하지 않아도 된다.

StateObject는 iOS14부터 사용 가능한 프로퍼티 래퍼이다. 그럼 StateObject 없이 ObservedObject만을 이용해 타이머가 초기화되는 문제를 해결하려면 어떻게 해야할까?

답은 위에서 설명한 방법이다.

struct ContentView: View {
    @State var randomNumber: Int = 0
    @ObservedObject var timer = TimeCounter() // ✅ 초기화
    var body: some View {
        VStack(spacing: 60) {
            VStack(spacing: 15) {
                Text("랜덤 숫자: \(randomNumber)")
                Button("Randomize number") {
                    randomNumber = (0..<1000).randomElement()!
                }
                TimerView(timer: timer) // ✅ 주입
            }
        }
    }
}

struct TimerView: View {
    @ObservedObject var timer: TimeCounter // ✅
    
    var body: some View {
        VStack(spacing: 10) {
            Text("타이머 : \(timer.count)")
            Button(action: timer.increase) {
                Text("+1")
            }
        }
    }
}

부모뷰에서 timer를 생성하고 이를 하위뷰에 주입한다. class의 참조를 전달하는 방식이기 때문에 StateObject처럼 동작한다.

iOS13에서는 이런 방법을 사용해야하지만 StateObject가 나온 iOS14부터는 굳이 이 방법을 사용하지 않아도 된다.

필자는 상위뷰에서 모델을 선언하고 하위뷰로 전달하는 방식을 사용하자라고 이 문단을 주장하지만 사실 이 방법에도 단점이 있다. 상위 뷰에서 하위뷰로 ObservedObject를 주입하게 되면 상위뷰에서 가지고 있어야할 모델들이 많아진다. 결국 상위뷰는 여러 모델들에 대한 강한 결합이 되어 버린다. 항상 개발 상황에 따라서 유동적으로 쓰자.

동일한 객체를 사용하는 뷰에서 StateObject만을 계속 사용하지말자

StateObject 프로퍼티는 뷰의 라이프사이클에 의존하지 않기 때문에 사이드 이펙트가 생기지 않아서 좋다! 라고만은 할 수 없다. 모든 모델을 StateObject로 관리하게 되면 한 개의 객체를 여러 곳에서 관리하게 되어 또 다른 사이드 이펙트를 야기할 수 있다.

애플문서에서는 모델에 StateObject 속성을 적용하고 하위뷰에 데이터를 넘겨야 한다면 ObservedObject 속성을 이용하라고 한다. 또는, environmentObject를 이용하여 하위 뷰의 모든 곳에서 사용 가능하게 하라고 한다. 항상 애플문서를 최우선으로 생각해서 코드를 짜자.

결론

  • 한 화면 내 상위와 하위 뷰 간의 데이터 전달에서 어떤 것을 사용해야할까?
  • 화면에서 화면 사이로 데이터를 넘길 때 새로운 프로퍼티를 선언해야할까? 아니면 주입할까?
  • 서버에서 데이터가 주기적으로 넘어와 모델을 변경하는 상황에는?

StateObjectObservableObject 중 어떤 것을 사용해야 할지는 그 개발 상황에 따라 다를 것이다. 간단한 구조체 모델을 경우에는 StateBinding을 사용하면 되지만 class인 경우에는 조금 더 생각할거리가 많기 때문에 그때마다 상황을 보며 개발에 임하자.

참고한 블로그 글