SwiftUI 상태 관리는 도구 이름보다 값을 누가 소유하고, 누가 읽고, 누가 바꿀 수 있어야 하는가를 먼저 정하면 단순해진다. 최신 Observation을 사용할 수 있는 앱과 기존 ObservableObject 코드를 유지해야 하는 앱도 구분해야 한다.
기존 글에서는 Hashable과 상태 도구를 한 목록에서 설명했다. 그러나 Hashable은 값의 동일성과 해시 컬렉션에 관한 프로토콜이다. 화면 상태의 소유·전달 문제와는 별개다.
먼저 상태의 소유자를 정한다
SwiftUI는 한 값에 하나의 source of truth를 두는 방향으로 설계되어 있다.
- View 내부의 일시적인 UI 값은
@State가 소유한다. - 하위 View가 상위 상태를 수정해야 하면
@Binding으로 통로를 건넨다. - 여러 프로퍼티와 동작을 가진 참조 모델은 Observation의
@Observable로 관찰할 수 있다. - View 계층의 여러 곳에서 같은 모델을 써야 할 때는 environment 전달을 검토한다.
읽기만 필요한 하위 View에는 일반 값으로 전달하면 된다. 하위 View마다 같은 값을 @State로 중복 저장하면 서로 다른 source of truth가 생기고, 모든 값을 environment에 넣으면 의존성이 화면 밖에 숨는다.
iOS 17 이상에서는 Observation을 먼저 검토한다
Apple은 iOS 17, iPadOS 17, macOS 14부터 SwiftUI에서 Observation을 지원한다. @Observable 모델을 View가 @State로 소유할 수 있고, 하위 View는 일반 프로퍼티로 읽거나 @Bindable로 바인딩을 만들 수 있다.
import Observation
import SwiftUI
@Observable
final class CounterModel {
var count = 0
}
struct CounterScreen: View {
@State private var model = CounterModel()
var body: some View {
CounterEditor(model: model)
}
}
struct CounterEditor: View {
@Bindable var model: CounterModel
var body: some View {
Stepper("Count: \(model.count)", value: $model.count)
}
}
CounterScreen이 모델 인스턴스를 소유한다. CounterEditor는 @Bindable을 사용해 model.count의 Binding을 만든다. 값을 표시하고 메서드만 호출한다면 @Bindable 없이 일반 프로퍼티로 받아도 된다.
@State와 @Binding은 소유 여부가 다르다
@State는 SwiftUI가 View의 identity에 맞춰 관리하는 저장 공간이다. 토글, 선택 항목, 입력 중인 문자열처럼 View 생명주기에 속한 일시적인 UI 상태에 어울린다.
struct FilterView: View {
@State private var showsCompleted = false
var body: some View {
Toggle("완료 항목 표시", isOn: $showsCompleted)
}
}
@Binding은 값을 저장하지 않는다. 다른 곳이 소유한 값을 읽고 수정할 수 있는 연결이다.
struct FilterToggle: View {
@Binding var isOn: Bool
var body: some View {
Toggle("완료 항목 표시", isOn: $isOn)
}
}
따라서 하위 View가 값을 사용한다는 이유만으로 @State를 하나 더 만들면 안 된다. 읽기만 하면 값, 수정도 해야 하면 Binding을 전달한다.
기존 ObservableObject 코드는 소유권으로 구분한다
배포 대상이나 기존 코드 때문에 Combine 기반 ObservableObject를 유지한다면 다음 기준을 사용할 수 있다.
| 도구 | 역할 | View의 소유 여부 |
|---|---|---|
ObservableObject |
변경 알림을 제공하는 참조 모델 | 모델 정의 |
@Published |
해당 프로퍼티의 변경을 알림 | 모델이 소유 |
@StateObject |
View가 객체를 생성하고 생명주기를 유지 | 소유 |
@ObservedObject |
외부에서 받은 객체를 관찰 | 비소유 |
@EnvironmentObject |
상위 계층이 주입한 객체를 환경에서 조회 | 비소유 |
import SwiftUI
final class LegacyCounter: ObservableObject {
@Published var count = 0
}
struct LegacyOwnerView: View {
@StateObject private var model = LegacyCounter()
var body: some View {
LegacyChildView(model: model)
}
}
struct LegacyChildView: View {
@ObservedObject var model: LegacyCounter
var body: some View {
Button("증가: \(model.count)") {
model.count += 1
}
}
}
@StateObject와 @ObservedObject의 차이는 클래스냐 구조체냐가 아니다. 이 View가 인스턴스의 생명주기를 책임지는가가 기준이다. @StateObject는 ObservableObject용이고, Observation의 @Observable 모델은 @State로 소유한다.
Environment는 깊은 공유가 필요할 때 사용한다
최신 Observation 모델은 .environment(model)로 주입하고 @Environment(Model.self)로 읽을 수 있다. 기존 ObservableObject 코드는 .environmentObject(model)과 @EnvironmentObject를 사용한다.
Environment는 앱 전체 전역 변수라는 뜻이 아니다. 주입한 View 하위 계층에서만 조회할 수 있는 의존성이다. 특정 하위 View 한두 곳만 쓴다면 명시적인 프로퍼티 전달이 Preview와 테스트에 더 이해하기 쉽다. 여러 단계에서 공통으로 쓰고 중간 View가 전달만 반복할 때 environment가 유용하다.
Hashable은 상태를 관찰하지 않는다
Hashable은 값을 Set의 원소나 Dictionary의 키로 사용할 수 있게 한다. 상태 변경을 View에 알리는 기능은 없다.
struct User: Identifiable, Hashable {
let id: UUID
let name: String
}
직접 hash(into:)와 ==를 구현한다면 동일성을 판단하는 프로퍼티 집합을 일치시켜야 한다. 해시 컬렉션에 저장한 동안 동일성에 관여하는 값을 바꾸는 설계도 피하는 편이 안전하다. Hashable을 상태 관리 도구와 함께 쓰는 경우가 있어도 역할은 분리해서 이해해야 한다.
선택 순서는 다섯 질문이면 충분하다
- 이 값의 source of truth는 어느 View 또는 모델인가?
- 단순한 UI 값인가, 여러 프로퍼티와 동작을 가진 참조 모델인가?
- 하위 View는 읽기만 하는가, 수정도 해야 하는가?
- 배포 대상에서 Observation을 사용할 수 있는가?
- Environment가 필요한 깊이인가, 명시적 전달이 더 쉬운가?
화면 상태와 외부 통신의 책임을 나누는 기준은 SwiftUI에서 View·Model·Service 나누기, View의 반환 타입은 some View와 Opaque Return Type, 작은 View를 분리하는 기준은 SwiftUI View 선언 방법에서 이어서 볼 수 있다.
핵심 정리
@State는 소유, @Binding은 수정 가능한 연결이다. 최신 앱은 @Observable 모델을 @State로 소유하고 필요할 때 @Bindable이나 environment로 연결한다. 기존 ObservableObject 코드에서는 @StateObject와 @ObservedObject를 생명주기 소유 여부로 구분한다. Hashable은 이 흐름과 별개의 동일성 프로토콜이다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| Swift extension: 계산 프로퍼티·프로토콜 준수·접근 제어 경계 (1) | 2024.10.24 |
|---|---|
| Swift 접근 제어: open·public·package·internal·fileprivate·private (0) | 2024.10.24 |
| SwiftUI에서 View·Model·Service 나누는 기준: ViewModel은 언제 필요할까 (0) | 2024.10.17 |
| Python 추상 클래스와 추상 메서드: ABC·abstractmethod의 실제 동작 (1) | 2024.09.08 |
| Python 상속과 Interface 설계: super·MRO·Protocol·Composition 선택 기준 (1) | 2024.09.08 |
댓글