안녕하세요, iOS 개발하는 루피입니다!
오늘은 공식문서를 바탕으로 StateObject에 대해 정리해 보는 시간을 가져보려 합니다.
바로 시작합니다.
StateObject란?
StateObject는 SwiftUI에서 참조 타입 객체를 관리하기 위한 프로퍼티 래퍼로, iOS 14부터 도입되었습니다.
(iOS 17 이후부터는 참조 타입도 @Observable 매크로와 함께 @State로 관리하는 것을 Apple이 권장하고 있습니다.)
ObservableObject 프로토콜을 준수하는 참조 타입 객체의 생명주기를 관리하고, 뷰가 업데이트될 때도 객체의 상태를 안정적으로 유지하는 역할을 합니다.
@MainActor @frozen @propertyWrapper @preconcurrency
struct StateObject<ObjectType> where ObjectType : ObservableObject
Overview
뷰 계층에 저장하는 참조 타입의 단일 진실 소스(SSOT)로 상태 객체를 사용하세요.
App, Scene 또는 View에서 @StateObject 속성을 프로퍼티 선언에 적용하고, ObservableObject 프로토콜을 준수하는 초기값을 제공하여 상태 객체를 생성합니다. @StateObject는 SwiftUI가 제공하는 저장소 관리와 충돌할 수 있는 멤버별 초기화에서 설정되는 것을 방지하기 위해 private으로 선언하세요.
class DataModel: ObservableObject {
@Published var name = "Some Name"
@Published var isEnabled = false
}
struct MyView: View {
@StateObject private var model = DataModel() // 상태 객체 생성
var body: some View {
Text(model.name) // 데이터 모델이 변경되면 업데이트됨
MySubView()
.environmentObject(model)
}
}
SwiftUI는 상태 객체를 선언하는 컨테이너(상태 객체가 선언된 View)의 생명 주기 동안 모델 객체의 새 인스턴스를 한 번만 생성합니다.
예를 들어, 뷰의 입력이 변경되어도 SwiftUI는 새 인스턴스를 생성하지 않지만, 뷰의 아이덴티티가 변경되면, 새 인스턴스를 생성합니다.
관찰 가능한 객체의 게시된 속성이 변경되면, SwiftUI는 위 예제의 Text 뷰처럼 해당 속성에 의존하는 모든 뷰를 업데이트합니다.
ObservableObject 사용할 경우, Published된 속성이 변경될 때마다, 해당 객체를 구독하는 모든 뷰가 업데이트되었습니다. 이 뷰가 실제로 해당 속성을 사용하지 않더라도 말이죠. 하지만, @Observable을 사용할 경우 뷰의 body에서 실제로 읽는 속성만 추적합니다.
구조체, 문자열 또는 정수와 같은 값 타입을 저장해야 하는 경우 @State 프로퍼티 래퍼를 사용하세요. 또한 @Observable 매크로로 표시된 참조 타입을 저장해야 하는 경우에도 @State를 사용하세요.
→ Apple은 iOS 17부터 @Observable 매크로 사용을 권장하고 있습니다. 이는 코드를 더 간결하게 만들고 성능을 향상 시키는 데 도움이 됩니다.
하위 뷰와 상태 객체 공유하기
하위 뷰에 상태 객체를 공유하는 방법으로 두 가지 접근법이 있습니다.
1. 직접 전달하기 (ObservedObject 사용)
부모 뷰에서 자식 뷰로 객체를 직접 전달하는 방법입니다. 이 방식은 "여기 내 데이터야, 사용해봐"라고 명시적으로 전달하는 것과 같습니다.
// 부모 뷰
struct ParentView: View {
@StateObject private var model = DataModel() // 부모 뷰에서 데이터 생성
var body: some View {
// 자식 뷰에 데이터 직접 전달
ChildView(model: model)
}
}
// 자식 뷰
struct ChildView: View {
@ObservedObject var model: DataModel // 전달받은 데이터 사용
var body: some View {
Text(model.name)
}
}
이 방식의 장점은 데이터 흐름이 명확하고 추적하기 쉽다는 것입니다. 또한 특정 뷰에만 데이터를 제한적으로 공유할 수 있어 데이터 범위를 제어하기 좋습니다.
단점은 여러 단계의 뷰 계층을 통과해야 할 경우 각 중간 뷰에서도 객체를 전달해야 하는 번거로움이 있습니다.
2. Environment를 통해 전달하기 (EnvironmentObject 사용)
이 방법은 여러 단계의 자식 뷰에 데이터를 전달할 때 유용합니다. 환경에 데이터를 추가하면 뷰 계층 내의 모든 뷰가 접근할 수 있습니다.
// 부모 뷰
struct ParentView: View {
@StateObject private var model = DataModel() // 부모 뷰에서 데이터 생성
var body: some View {
// 환경에 데이터 추가
MySubView()
.environmentObject(model)
}
}
// 자식 뷰
struct MySubView: View {
@EnvironmentObject var model: DataModel // 환경에서 데이터 가져오기
var body: some View {
Toggle("Enabled", isOn: $model.isEnabled)
}
}
이 방식의 장점은 여러 뷰에서 동일한 상태를 쉽게 공유할 수 있고, 상위 뷰에서 하위 뷰로 명시적으로 데이터를 전달할 필요가 없다는 것입니다.
단점은 데이터 흐름을 추적하기 어려울 수 있으며, 환경 객체가 제공되지 않으면 런타임 오류가 발생할 수 있습니다.
외부 데이터를 사용하여 상태 객체 초기화하기
상태 객체의 초기 상태가 컨테이너 외부에서 오는 데이터에 의존하는 경우, 컨테이너의 초기화자 내에서 객체의 초기화자를 명시적으로 호출할 수 있습니다.
왜 외부 데이터로 상태 객체를 초기화해야 할까?
보통은 다음과 같이 간단하게 초기화할 수 있습니다.
@StateObject private var model = DataModel() // 기본 초기화
하지만 실제 개발에서는 외부 데이터로 모델을 초기화해야 하는 여러 상황이 있습니다.
- 사용자 ID로 프로필 데이터 모델 초기화하는 경우
- 상품 ID로 상품 상세 정보 모델 초기화하는 경우
- 설정값으로 설정 모델 초기화하는 경우
이러한 상황에서는 외부 데이터로부터 상태 객체를 초기화해야 합니다.
struct MyInitializableView: View {
@StateObject private var model: DataModel
init(name: String) {
// SwiftUI는 다음 초기화가 뷰의 수명 동안 클로저를 한 번만 사용하도록 보장하므로
// 나중에 뷰의 name 입력이 변경되어도 영향이 없습니다.
_model = StateObject(wrappedValue: DataModel(name: name))
}
var body: some View {
VStack {
Text("Name: \\\\(model.name)")
}
}
}
여기서 중요한 점들
- _model에서 언더스코어(_)는 프로퍼티 래퍼 자체에 접근한다는 의미입니다.
- StateObject(wrappedValue:)는 StateObject를 초기화하는 방법입니다.
- 이 초기화는 뷰의 생명주기 동안 딱 한 번만 실행됩니다.
이 작업을 할 때는 주의해야 합니다. SwiftUI는 주어진 뷰에서 초기화자를 처음 호출할 때만 상태 객체를 초기화합니다. 이는 뷰의 입력이 변경되더라도 객체가 안정적인 저장소를 제공하도록 보장합니다. 그러나 상태 객체를 명시적으로 초기화하면 예상치 못한 동작이 발생할 수 있습니다.
예를 들어, 다음과 같은 시나리오를 생각해보세요.
struct ParentView: View {
@State private var userName = "Kim"
var body: some View {
VStack {
MyInitializableView(name: userName)
Button("이름 변경") {
userName = "Park" // 이름 변경
}
}
}
}
여기서 버튼을 눌러 userName을 "Kim"에서 "Park"으로 변경하면:
- ParentView는 다시 렌더링 됩니다
- MyInitializableView도 새 userName 값으로 다시 생성됩니다
- MyInitializableView의 init(name:)이 "Park"를 인자로 다시 호출됩니다
- 하지만 SwiftUI는 @StateObject의 초기화를 무시합니다 (이미 한 번 초기화했으므로)
- 결과적으로 model.name은 여전히 "Kim"입니다
이것이 "나중에 뷰의 name 입력이 변경되어도 영향이 없습니다"라는 의미입니다.
명시적 상태 객체 초기화는 객체가 의존하는 외부 데이터가 객체의 주어진 인스턴스에 대해 변경되지 않을 때 잘 작동합니다.
예를 들어, 서로 다른 상수 이름으로 두 개의 뷰를 만들 수 있습니다.
var body: some View {
VStack {
MyInitializableView(name: "Ravi")
MyInitializableView(name: "Maria")
}
}
뷰 아이덴티티 변경으로 강제 재초기화하기
뷰 입력이 변경될 때 SwiftUI가 상태 객체를 재초기화하도록 하려면 뷰의 아이덴티티가 동시에 변경되도록 해야 합니다. 이를 위한 한 가지 방법은 id(_:) 수정자를 사용하여 뷰의 아이덴티티를 변경되는 값에 바인딩하는 것입니다.
예를 들어, MyInitializableView 인스턴스의 아이덴티티가 name 입력이 변경될 때 변경되도록 할 수 있습니다:
MyInitializableView(name: name)
.id(name) // 뷰의 아이덴티티를 name 속성에 바인딩합니다.
이렇게 하면 name이 변경될 때마다 뷰가 완전히 새로 생성되고, @StateObject도 새로 초기화됩니다.
뷰가 ForEach 내부에 나타나면 해당 데이터 요소의 식별자를 사용하는 id(_:) 수정자를 암시적으로 받습니다.
둘 이상의 값 변경에 따라 상태를 재초기화해야 하는 경우, Hasher를 사용하여 값들을 단일 식별자로 결합할 수 있습니다. 예를 들어, MyInitializableView의 데이터 모델을 name 또는 isEnabled 값이 변경될 때 업데이트하려면 두 변수를 단일 해시로 결합할 수 있습니다.
var hash: Int {
var hasher = Hasher()
hasher.combine(name)
hasher.combine(isEnabled)
return hasher.finalize()
}
그런 다음 결합된 해시를 식별자로 뷰에 적용합니다.
MyInitializableView(name: name, isEnabled: isEnabled)
.id(hash)
하지만 이 방법을 사용할 때는 주의해야 할 점이 있습니다.
- 성능 비용: 입력이 변경될 때마다 상태 객체를 재초기화하면 성능에 영향을 줄 수 있습니다.
- 애니메이션 손실: 뷰의 아이덴티티가 변경되면 SwiftUI는 뷰 내부의 변경 사항을 자동으로 애니메이션 하지 않습니다.
- 상태 재설정: 아이덴티티를 변경하면 State, FocusState, GestureState 등으로 관리하는 값을 포함하여 뷰가 보유한 모든 상태가 재설정됩니다.
iOS 17과 @Observable 매크로
iOS 17부터 Apple은 @Observable 매크로와 함께 @State를 사용하여 참조 타입을 관리하는 것을 권장하고 있습니다. 검색 결과에 따르면, 이 새로운 접근 방식은 다음과 같은 이점을 제공합니다.
- 코드가 더 간결해집니다 - @Published 속성 래퍼가 필요 없습니다.
- 성능이 향상됩니다 - 뷰가 실제로 사용하는 속성만 추적합니다.
- 옵셔널 값과 컬렉션 객체도 추적할 수 있습니다.
예를 들어, iOS 17 이전에는 다음과 같이 작성했습니다.
class CounterViewModel: ObservableObject {
@Published var count = 0
}
struct CounterView: View {
@StateObject private var viewModel = CounterViewModel()
var body: some View {
// ...
}
}
iOS 17에서는 다음과 같이 작성할 수 있습니다.
@Observable
class CounterViewModel {
var count = 0
}
struct CounterView: View {
@State private var viewModel = CounterViewModel()
var body: some View {
// ...
}
}
이 새로운 접근 방식은 코드를 더 단순하게 만들고, 성능을 향상하며, 데이터 흐름을 더 명확하게 표현할 수 있게 해 줍니다.
결론
@StateObject는 SwiftUI에서 참조 타입 객체의 상태를 안정적으로 관리하기 위한 중요한 도구입니다. 뷰가 재생성되더라도 객체의 상태를 유지하고, 객체의 생명주기를 뷰의 생명주기와 일치시켜 데이터의 일관성을 보장합니다.
하지만 iOS 17부터는 @Observable 매크로와 @State를 사용하는 새로운 접근 방식이 권장됩니다. 이 새로운 방식은 코드를 더 간결하게 만들고 성능을 향상합니다.
참고
StateObject | Apple Developer Documentation
A property wrapper type that instantiates an observable object.
developer.apple.com
오늘도 화이팅입니다!
'iOS > SwiftUI' 카테고리의 다른 글
| [SwiftUI] some View (0) | 2025.05.15 |
|---|---|
| [SwiftUI] View & Modifiers (0) | 2025.05.13 |
| [SwiftUI] Bindable (1) | 2025.04.21 |
| [SwiftUI] 상태 관리 - PropertyWrapper (2) (0) | 2025.04.17 |
| [SwiftUI] 상태 관리 - PropertyWrapper (1) (0) | 2025.04.17 |