State와 Binding
Chris Eidhof와 Florian Kugler의 『Thinking in SwiftUI』 iOS 17 개정판, 원문 22–55쪽의 한국어 번역입니다.
State와 Binding
원문 22쪽과 도표

이전 장에서는 view tree를 설계도로 구성하는 방법과 이를 지속적으로 유지되는 render tree로 변환하는 방법을 살펴봤습니다. 동적인 애플리케이션을 만들려면 현재 state에 따라 서로 다른 view tree를 구성하고, SwiftUI가 그에 맞게 render tree를 업데이트하도록 합니다. 이것이 SwiftUI의 가장 큰 장점 중 하나입니다. SwiftUI는 state를 자동으로 관찰하고 View를 항상 model과 동기화합니다.
일반적으로 View 업데이트 주기는 다음과 같이 정리할 수 있습니다.
- view tree를 구성합니다.
- 현재 view tree와 일치하도록 render tree의 node를 생성하거나 제거하거나 업데이트합니다.
- 어떤 이벤트가 state 변경을 일으킵니다.
- 이 과정을 반복합니다.
원칙적으로는 view tree를 언제 다시 만들어야 하는지, state 변경이 어떤 부분에 영향을 주는지, 현재 View의 state와 일치시키기 위해 화면에서 무엇을 업데이트해야 하는지 걱정할 필요가 없습니다. SwiftUI가 이 모든 일을 맡기 때문입니다. 대신 저희가 할 일은 특정 state에서 화면에 무엇이 나타나야 하는지 설명하는 것입니다.
다만 view tree의 어떤 부분을 무슨 이유로 다시 렌더링하는지 생각해야 할 때도 있다는 점은 덧붙여야겠습니다. 성능 문제가 발생한다면 View 업데이트의 범위가 지나치게 넓은 것이 원인에 포함될 가능성이 큽니다. 이 장의 마지막에서 좀 더 자세히 다루겠습니다.
SwiftUI에는 state가 값인지 object인지, View 내부에서만 사용하는지 외부에서 전달받아야 하는지에 따라 여러 state wrapper type이 있습니다. 하지만 SwiftUI가 이 모두를 @State, @StateObject, @ObservedObject와 같은 property wrapper로 제공하므로, 대개 이러한 wrapper type을 직접 다룰 필요는 없습니다.
iOS 17부터 SwiftUI가 object와 상호작용하는 방식은 완전히 달라졌습니다. SwiftUI는 observation에 더 이상 Combine framework를 사용하지 않고 macro에 기반한 방식을 사용합니다. 이에 따라 @StateObject와 @ObservedObject property wrapper도 필요 없어졌습니다. iOS 17 이전에는 @State property wrapper를 대개 값에만 사용했지만, 이제는 값과 object에 모두 사용합니다.
@State는 SwiftUI의 모든 버전에서 사용하므로, 먼저 이 property wrapper를 자세히 살펴보겠습니다. 그런 다음 object observation에 관해 iOS 17 이전과 이후를 구분해 설명하겠습니다.
원문 23쪽과 도표

State
@State property wrapper는 SwiftUI 애플리케이션에 state를 도입하는 가장 쉬운 방법입니다. View 내부에서만 사용하는 state 값을 위한 것입니다. 예를 들어 다음은 간단한 counter View입니다.
struct Counter: View {
@State private var value = 0
var body: some View {
Button("Increment: \(value)") {
value += 1
}
}
}counter가 처음 렌더링될 때 state property는 초기값 0을 갖습니다. body property를 실행하는 동안 SwiftUI는 이 state property에 접근한다는 것을 감지하고, value state property와 render tree에 있는 counter View의 node 사이에 dependency를 추가합니다. 따라서 버튼을 탭하는 등의 이유로 value가 변경될 때마다 SwiftUI는 counter View의 body를 다시 실행합니다.
버튼의 label에
value를 포함하지 않으면, SwiftUI는 state property가 변경되어도 counter의body를 다시 렌더링할 필요가 없다는 것을 알아냅니다.
counter View를 초기화할 때마다 value property에 0을 대입하는데, 어떻게 이 값이 바뀔 수 있는지 궁금할 수 있습니다. 이 동작을 좀 더 분명히 이해하고 마법처럼 보이는 부분을 줄이기 위해, @State property wrapper 없이 같은 코드를 작성해 보겠습니다.
struct Counter: View {
private var _value = State(initialValue: 0)
private var value: Int {
get { _value.wrappedValue }
nonmutating set { _value.wrappedValue = newValue }
}
var body: some View {
Button("Increment: \(value)") {
value += 1
}
}
}원문 24쪽과 도표

이제 @State를 사용하는 대신 State 값을 직접 만들고 _value property에 대입합니다. State(initialValue:) initializer를 보면 0은 state property의 초기값일 뿐이라는 사실이 분명해집니다. 이 값은 render tree에 counter View의 node를 처음 만들 때 사용합니다. node가 이미 존재하면 state property의 초기값은 무시하며, SwiftUI가 다시 렌더링하는 동안에도 현재 값을 유지합니다.
_value property에 더해, state를 편하게 사용할 수 있도록 computed property인 value도 추가했습니다. 값을 읽거나 쓸 때마다 _value.wrappedValue라고 적는 대신 value를 사용하면, computed property가 이를 _value.wrappedValue에 그대로 전달합니다. View의 body에서 state property의 wrappedValue를 사용할 때 실제로 다루는 것은 render tree에 계속 유지되는 state 값에 대한 reference입니다.
@State property wrapper는 이 모든 작업을 대신합니다. 실제 State 값을 저장하는 밑줄 접두사의 property를 만들고, getter와 setter를 wrappedValue로 전달하는 computed property도 만듭니다.
위 View를 처음 렌더링하는 경우와 버튼을 탭하여 두 번째로 렌더링하는 경우를 차례로 살펴보겠습니다. counter View가 처음 화면에 나타날 때는 다음과 같은 일이 일어납니다.
이 장에 나오는 state 도표를 더 쉽게 읽을 수 있도록, 이전 단계의 도표와 달라진 부분을 원문에 표시된 색으로 강조했습니다.
1단계. Counter struct를 처음 구성할 때는 아직 render tree에 대응하는 node가 없습니다. 아래 도표에서 View struct는 왼쪽에 있습니다. View struct의 위쪽 부분은 state property를 나타내며, 이 property에는 다시 두 개의 내부 값이 있습니다. initialValue는 property 초기화 시 대입한 값이고, wrappedValue는 View의 body에서 사용하는 값입니다. wrappedValue는 이 state property의 실제 값을 가리키는 pointer로 생각할 수 있으며, 지금은 아직 아무것도 가리키지 않습니다. View struct의 아래쪽 부분은 View의 body를 나타냅니다. 아직 실행하지 않았기 때문에 비어 있습니다.
원문 25쪽과 도표

2단계. SwiftUI가 오른쪽 render tree에 counter View의 node를 만들 때, View의 state property를 위한 메모리를 할당합니다. 시스템은 render node의 value property에 해당하는 메모리를 state property의 initialValue인 0으로 초기화합니다. 이제 state property의 wrapped value는 render node의 메모리를 가리킵니다.
3단계. View의 body를 실행하고 Button View를 만듭니다. 이제 state property의 wrapped value가 render node의 메모리를 가리키므로, 버튼의 제목을 구성할 때는 render node에 저장된 값을 사용합니다.
4단계. counter View의 body를 설계도로 사용하여 render node에 버튼 View를 만듭니다.



원문 26쪽과 도표

Counter의 body에서 버튼의 label에 value를 사용했으므로, render tree의 메모리와 counter View의 body 사이에 dependency가 추가됩니다.
이제 버튼을 탭했을 때의 단계를 살펴보겠습니다.
1단계. 실제로는 _value.wrappedValue인 value property가 render tree의 메모리를 가리키는 pointer이므로, 해당 메모리의 값을 증가시킵니다.


원문 27쪽과 도표

2단계. counter의 body가 이 state 메모리에 의존하므로 body를 다시 실행하고 새로운 버튼 View를 구성합니다. 버튼 제목을 위해 value property에 접근하면, state property의 초기값은 여전히 0이지만 이제 1이 반환됩니다.
3단계. 새로 구성한 counter View의 view tree를 설계도로 사용하여, render tree에 있는 버튼의 제목을 새 값으로 바꿉니다.
이 절의 시작에서 @State는 View 내부에서만 사용하는 state를 위한 것이라고 설명했습니다. 이 때문에 모든 state property에 private keyword를 붙였습니다. 필수는 아니지만 저희는 좋은 습관이라고 생각합니다. @State property wrapper가 어떻게 동작하는지 살펴봤으니, 이제 그 이유를 더 잘 설명할 수 있습니다. 앞선 코드에서 State의 initializer는 해당 state의 초기값만 받는다는 것을 봤습니다. View의 initializer를 통해 이를 외부에 노출하면 어떤 일이 일어나는지 살펴보겠습니다.


원문 28쪽과 도표

struct Counter: View {
@State private var value: Int
init(value: Int = 0) {
_value = State(initialValue: value)
}
var body: some View {
Button("Increment: \(value)") {
value += 1
}
}
}이제 외부에서 값을 전달할 수 있습니다. 하지만 View의 initializer에서는 state의 현재 값에 접근할 수 없으므로, 이렇게 바뀌는 것은 state property의 초기값뿐입니다. render tree에 이 View의 node를 만들고 나면 다른 초기값을 전달해도 아무 효과가 없거나, 적어도 기대한 효과는 나타나지 않습니다. 실제 값이 아니라 초기값만 바뀌기 때문입니다. 이 변경은 node를 render tree에서 제거한 뒤 다시 삽입해야만 영향을 줍니다.
예를 들어 counter View를 값 0으로 처음 렌더링한 다음, 위 예제처럼 외부에서 값 5를 전달하여 업데이트하면 다음과 같은 일이 일어납니다.
1단계. 처음 렌더링된 counter View에서 시작합니다. 초기값은 0이며, wrapped value는 render node의 state 메모리를 가리킵니다.
2단계. 이제 외부에서 counter View에 값 5를 전달하면 새로운 counter View가 구성됩니다. 하지만 달라진 것은 초기값뿐입니다. wrapped value는 여전히 값이 0인 state 메모리를 가리키므로, 버튼의 label에도 0이 표시됩니다.

원문 29쪽과 도표

외부에서 값을 전달받는 initializer를 두고 싶다면, 적어도 다음처럼 initializer의 label을 바꾸는 편이 더 명확합니다.
init(initialValue: Int = 0) {
_value = State(initialValue: initialValue)
}parameter의 이름을 initialValue로 지정하면 동작을 좀 더 명확하게 전달할 수 있습니다. 하지만 저희 경험상 외부에서 state property를 초기화하는 것은 그리 유용하지 않았습니다. 따라서 모든 @State property를 private으로 선언하기를 권장합니다.
다음처럼 initializer를 작성하면 안 되는지 궁금할 수 있습니다. compiler는 이 코드를 문제없이 받아들입니다.
struct Counter: View {
@State private var value = 0
init(value: Int = 0) {
self.value = value
}
...
}앞서 살펴봤듯이 self.value = value 문장은 실제로 self._value.wrappedValue = value로 변환됩니다. compiler의 관점에서는 문제가 없는 문장이지만, 여전히 기대한 대로 동작하지는 않습니다. 다만 왜 동작하지 않는지 살펴보면 유용한 교훈을 얻을 수 있습니다.
이전 장에서는 SwiftUI의 structural identity 개념을 이야기했습니다. 간단히 말해 View는 view tree에서 차지하는 위치에 따라 identity를 갖습니다. 위 initializer의 대입이 동작하지 않는 이유는 두 가지입니다. View의 state는 identity에 연결되어 있으며, initializer를 실행하는 시점에는 View에 아직 identity가 없습니다.

원문 30쪽과 도표

이 시점에 SwiftUI가 View의 identity를 알 수 없는 이유를 더 잘 이해하기 위해 간단한 사고 실험을 해보겠습니다. 다음 코드를 살펴보겠습니다.
struct ContentView: View {
let counter = Counter()
VStack {
counter
counter
}
}역주: 원문 예제에는
VStack을 감싸는body선언이 없습니다. 코드는 원문 그대로 보존했습니다.
이 예제에서 Counter의 initializer는 struct를 만들고 지역 변수 counter에 대입할 때 한 번 실행됩니다. 하지만 이 시점에는 View를 view hierarchy에 배치하지 않았습니다. 따라서 initializer를 실행할 때는 View가 아직 identity를 가질 수 없고, View의 state에도 접근할 수 없습니다. 나중에 counter의 body를 실행할 때는 View가 identity를 갖게 됩니다. 그때는 state property가 render tree의 올바른 state 값을 참조합니다.
@State property wrapper는 원래 거의 value type에만 사용했지만, 다음 절에서 보듯이 iOS 17에서 Observable macro가 도입되면서 달라졌습니다. 하지만 이전 플랫폼에서는 이 규칙을 어기는 이유를 정확히 아는 경우가 아니라면 여전히 @State를 value type에만 사용해야 합니다. 이전 버전의 SwiftUI에서 object를 관찰하는 방법은 이 장의 뒷부분에서 이야기하겠습니다.
Observable macro
Observable macro는 object의 state를 관찰하는 SwiftUI의 방식입니다. Observation framework에 속하며, 그동안 사용해 온 Combine framework 기반의 기존 object observation model 전체를 대체하기 위해 WWDC23에서 도입되었습니다.
Observable macro는 두 가지 작업을 수행합니다.
Observablemarker protocol conformance를 추가합니다. 이는 compile time에 class를 표시하는 빈 protocol이며 runtime representation은 없습니다.- object의 property를 바꾸어 읽기와 쓰기 접근을 모두 추적하도록 합니다.
Observable object의 observation은 object를 어디에 저장했든, 특별한 property wrapper 없이도 property에 접근하기만 하면 자동으로 이루어집니다. 따라서 Observable object와 @State property wrapper를 함께 사용하는 것은 object의 lifetime을 관리하는 방식을 바꾸기 위해서뿐입니다.
원문 31쪽과 도표

- observable object의 lifetime을 View의 render node lifetime에 연결하려면, 즉 View 내부에서만 사용하는 object라면
@Stateproperty wrapper로 property를 선언합니다. - View의 render node와 독립적인 lifetime을 가진 object, 즉 외부에서 전달받는 object를 사용하려면 일반 property에 저장합니다.
SwiftUI가 과거에 object observation을 처리하던 방식과 달리, iOS 17에서는 object의 lifetime과 observation 개념이 분리되었습니다. 이전에는 View가 업데이트되는 동안 SwiftUI가 state 값의 lifetime을 관리하게 하려면 @State를 사용했고, 같은 lifetime 관리에 object observation까지 더하려면 @StateObject를 사용했습니다. 이제 observation이 property wrapper에 포함되지 않으므로, SwiftUI가 state object의 lifetime을 관리해야 한다고 표시하는 데는 @State만 남았습니다.
앞선 counter 예제를 단순한 값 대신 model object를 사용하여 작성하면 다음과 같습니다.
@Observable final class Model {
var value = 0
}
struct Counter: View {
@State private var model = Model()
var body: some View {
Button("Increment: \(model.value)") {
model.value += 1
}
}
}ownership의 관점에서 @State를 Observable object와 함께 사용하는 것은 앞 절에서 value type과 함께 사용한 @State 예제와 같은 방식으로 동작합니다. SwiftUI는 render tree의 node에 object를 위한 메모리를 할당하고, node가 존재하는 동안 object를 유지합니다. @State property wrapper의 wrapped value는 이 object를 가리킵니다.
먼저 property wrapper 없이 @Observable object를 사용하는 예제를 살펴보겠습니다.
@Observable final class Model {
var value = 0
static let shared = Model()
}원문 32쪽과 도표

struct Counter: View {
var model: Model
var body: some View {
Button("Increment: \(model.value)") {
model.value += 1
}
}
}
struct ContentView: View {
var body: some View {
Counter(model: Model.shared)
}
}이제 counter View의 model property를 더 이상 @State로 선언하지 않으므로, 위 ContentView처럼 외부에서 전달할 수 있습니다. counter를 처음 만들 때는 다음과 같은 일이 일어납니다.
1단계. model property가 counter View에 전달한 model instance를 가리킵니다.
2단계. counter View에는 일반 property만 있으므로, render node는 연결된 state 없이 만들어집니다.

원문 33쪽과 도표

3단계. counter View의 body를 실행하고 버튼 View를 구성합니다. body에서 버튼의 제목은 counter View struct의 model property에 저장된 외부 model object를 사용합니다. 그런 다음 counter View의 새 view tree를 반영하도록 render node를 업데이트합니다.
@State가 없으면 SwiftUI는 이 object의 lifetime에 관해 아무 책임도 지지 않습니다. observation은 View의 body를 실행하면서 object의 property에 접근할 때 자동으로 이루어집니다. 따라서 counter View의 render node가 직접 유지해야 할 state는 없습니다.
물론 이 예제에서는 model을 외부에서 전달하는 대신 counter View 안에 다음과 같이 작성해도 됩니다.
struct Counter: View {
var model = Model.shared
...
}어차피 언제나 같은 Model.shared instance를 전달하고 있으므로, 이 코드는 정확히 같은 일을 합니다. 하지만 다른 state에 따라 View에 서로 다른 model object를 전달하고 싶은 상황도 생각해 볼 수 있습니다.


원문 34쪽과 도표

macro에 기반한 새로운 object observation model은 문법을 더 편하게 만들 뿐 아니라, View와 observable object 사이에 dependency를 만드는 방식도 바꿉니다. 이전 property wrapper를 사용할 때 SwiftUI는 View에 선언된 모든 @StateObject 또는 @ObservedObject의 objectWillChange publisher를 조건 없이 구독했습니다. 새로운 Observable macro에서는 object가 어디에서 왔든, View의 body에서 접근한 Observable object의 각 property와 해당 View 사이에 dependency가 생깁니다.
이 새로운 model은 훨씬 단순합니다. 예를 들어 View의 body에서 observable인 전역 singleton에 접근하면, @ObservedObject를 사용해 이 singleton을 View에 전달하지 않아도 접근한 property와 View 사이에 dependency가 자동으로 생깁니다. 마찬가지로 observable object는 optional, array 또는 다른 collection 안에 포함되어 있어도 됩니다. View의 body에서 dependency를 property 단위로 추적하므로, observation과 View 업데이트는 계속 예상대로 동작합니다.
새로운 model은 더 효율적이기도 합니다. View의 body에서 object의 property를 하나만 사용한다면 다른 property가 변경되어도 이 View를 다시 그리지 않습니다. 마찬가지로 model object를 사용하지 않는다면, 예를 들어 코드의 특정 분기에서만 사용한다면, 해당 model은 아예 관찰하지 않습니다. 이렇게 불필요한 View 업데이트를 줄여 성능을 개선할 수 있습니다. 이전에는 더 세밀하게 View를 업데이트하려면 model object를 직접 나눠야 했습니다.
State와 Observable
object observation과 관련하여 흔히 보게 되는 실수는 주로 두 가지입니다. 외부에서 전달받는 object에 @State를 사용하는 것과 View 내부에서만 사용하는 object에 @State를 사용하지 않는 것입니다. 다르게 표현하면, 하나는 object의 lifetime을 View 외부에서 관리하는데도 @State를 사용하는 문제이고, 다른 하나는 object의 lifetime을 View 외부에서 관리하지 않는데도 @State를 사용하지 않는 문제입니다.
외부에서 전달받는 object에 @State property를 사용하는 첫 번째 문제의 예제입니다.
struct Counter: View {
@State var model: Model
init(model: Model) {
self.model = model
}
var body: some View {
Button("\(model.value)") { model.value += 1}
}
}원문 35쪽과 도표

@State 절에서 설명했듯이, 여기서 문제는 initializer가 실행되는 시점에는 View에 아직 identity가 없다는 점입니다. 따라서 이 initializer는 @State property의 초기값만 바꿀 뿐, View가 이미 화면에 있을 때 사용 중인 state에는 영향을 주지 않습니다.
외부에서 값만 전달받고 initializer 안에서 view model object 등을 구성하려고 해도 마찬가지입니다.
struct Counter: View {
@State var model: CounterViewModel
init(value: Int) {
self.model = CounterViewModel(value: value)
}
var body: some View {
Button("\(model.value)") { model.value += 1}
}
}이 경우에도 counter View가 이미 화면에 있다면 initializer에 다른 값을 전달해도 버튼의 제목은 바뀌지 않습니다. @State property의 초기값만 새로운 CounterViewModel instance로 변경됩니다. 이 instance는 다음번에 View를 render tree에 삽입할 때만 사용합니다.
반대의 예로, 이 View에서 @State attribute를 제거하면 어떤 일이 일어나는지 살펴보겠습니다.
struct Counter: View {
var model: CounterViewModel
init(value: Int) {
self.model = CounterViewModel(value: value)
}
...
}이제 counter View는 model object의 lifetime을 직접 유지하지 않습니다. 새 값을 전달하면 새로운 view model이 구성되고, View의 body는 이 새 object를 사용해 렌더링합니다. 하지만 또 다른 문제가 생겼습니다. 이 코드는 view hierarchy의 상위 부분에서 일어난 변경으로 counter View를 다시 만들지 않는 동안에는 동작합니다. 다시 만들게 되면 View의 initializer에서 counter view model을 다시 구성하므로 state를 잃게 됩니다.
원문 36쪽과 도표

이 문제는 외부에서 view model을 전달하거나, 전역 model object에서 가져오는 등의 방식으로 해결할 수 있습니다. 어떤 방식이든 View의 model property를 @State로 선언하지 않았다면, view model의 lifetime은 View 외부 어딘가에서 관리해야 합니다.
Observable macro의 동작
새로운 observation model의 동작은 처음에는 마법처럼 보입니다. View의 body에서 접근하는 object property의 observation이 자동으로 이루어집니다. 이를 위해 SwiftUI는 새로운 Swift macro를 사용하여 동작에 필요한 코드를 모두 숨깁니다. 하지만 Observable macro는 model object의 코드를 깔끔하게 유지하기 위한 편의 기능일 뿐이며, macro 없이도 같은 기능을 구현할 수 있습니다. macro가 만들어 내는 코드를 단계별로 살펴보겠습니다.
@Observable final class Model {
var value = 0 {
get {
access(keyPath: \.value)
return _value
}
set {
withMutation(keyPath: \.value) {
_value = newValue
}
}
}
@ObservationIgnored private var _value = 0
...
}역주: 원문은 getter와 setter가 있는 property를
var value = 0으로 표기합니다. 이 표기는 컴파일 가능한 computed property 선언이 아니지만, 코드는 원문 그대로 보존했습니다.
value property는 stored property에서 computed property로 바뀌었고, private stored property인 _value가 추가되었습니다. 이 private property는 computed property의 실제 저장소로 사용합니다. computed property인 value의 getter는 key path와 함께 access를 호출하여 이 property에 대한 접근을 기록한 다음 _value를 반환합니다. setter는 withMutation closure 안에서 private property인 _value를 변경하며, 여기에서도 해당 property의 key path를 지정합니다.
생성된 두 method, access와 withMutation은 다음과 같습니다.
원문 37쪽과 도표

@Observable final class Model {
...
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
internal nonisolated func access<Member>(keyPath: KeyPath<Model, Member>) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<Member, T>(
keyPath: KeyPath<Model, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}두 method 모두 object의 observation registrar에 있는 대응하는 method로 호출을 전달합니다. registrar는 특정 property에 관심 있는 observer를 관리하고, 해당 property가 변경되면 이 observer에게 알리는 역할을 맡습니다.
그렇다면 object의 property와 SwiftUI View 사이의 연결은 어떻게 만들어질까요? 두 개의 closure를 받는 전역 함수 withObservationTracking(_ apply:onChange:)가 있습니다. 첫 번째 closure인 apply는 즉시 실행되며, observation 시스템은 apply 실행 중 어떤 property에 접근했는지 추적합니다. 두 번째 closure인 onChange는 apply closure 안에서 접근한 observable property 중 하나가 변경되면 호출되는 observer입니다.
withObservationTracking은 observation closure를 전역 변수에 저장한 다음 apply를 실행합니다. apply가 끝나면 접근했던 object들이 해당 property와 observer closure 사이에 dependency를 추가합니다. SwiftUI는 View의 body를 실행할 때 다음과 비슷한 일을 한다고 생각할 수 있습니다.
withObservationTracking {
view.body
} onChange: {
view.needsUpdate()
}이렇게 하면 View의 body에서 직접 또는 간접적으로 접근하는 모든 observable property가 object의 observation registrar를 거치게 됩니다. 그리고 바로 그 property와 현재 실행 중인 View의 body 사이에 dependency가 만들어집니다.
원문 38쪽과 도표

ObservableObject protocol
iOS 17 이전에는 object의 state 변경을 관찰하려면 ObservableObject protocol과 함께 @StateObject 또는 @ObservedObject property wrapper를 사용해야 했습니다. 이전 플랫폼을 지원해야 한다면 이 방식은 여전히 꼭 이해해야 합니다. 따라서 이 절에서는 @StateObject와 @ObservedObject가 어떻게 동작하는지, 각각 언제 사용해야 하는지를 자세히 설명하겠습니다.
StateObject
@StateObject property wrapper는 @State와 매우 비슷하게 동작합니다. 초기값으로 object를 지정하면, render tree의 node를 만들 때 그 object를 시작값으로 사용합니다. 그 이후에는 SwiftUI가 다시 렌더링하는 동안에도 render tree의 node lifetime에 걸쳐 이 object를 유지합니다.
@StateObject는 @State의 기능에 더해 ObservableObject protocol을 통해 object의 변경을 관찰합니다. 이 protocol은 SwiftUI가 model layer에 제공하는 몇 안 되는 interface 중 하나입니다. 앞의 counter View를 단순한 값 대신 model object를 사용하도록 바꾸면 다음과 같습니다.
final class Model: ObservableObject {
@Published var value = 0
}
struct Counter: View {
@StateObject private var model = Model()
var body: some View {
Button("Increment: \(model.value)") {
model.value += 1
}
}
}이 동작에 관여하는 여러 요소를 살펴보겠습니다. 먼저 @StateObject property wrapper의 내부를 들여다보겠습니다. property wrapper 문법 없이 같은 코드를 작성하면 다음과 같습니다.
struct Counter: View {
private var _model = StateObject(wrappedValue: Model())
private var model: Model { _model.wrappedValue }
var body: some View {
Button("Increment: \(model.value)") { model.value += 1 }
}
}원문 39쪽과 도표

@State와 마찬가지로 @StateObject property wrapper는 밑줄 접두사가 붙은 property를 만듭니다. 이 예제에서는 _model이며, 여기에는 StateObject 값이 들어갑니다. 또한 getter를 StateObject의 wrappedValue에 전달하는 computed property를 합성합니다.
StateObject의 wrappedValue는 ObservableObject protocol을 준수해야 합니다. 이 protocol에는 conforming type에 대한 요구사항이 하나뿐입니다. failure type이 Never인 objectWillChange Combine publisher를 구현해야 합니다. Never는 failure가 발생할 수 없다는 뜻입니다. 일반적으로 이 publisher의 output type은 Void이므로 이벤트에는 추가 정보가 없습니다. 하지만 이것은 공식적인 요구사항은 아닙니다. 유일한 요구사항은 변경이 일어나기 전에 매번 objectWillChange publisher가 이벤트를 보내야 한다는 것입니다.
SwiftUI의 초기 beta에서는 이 요구사항의 이름이
objectWillChange가 아니라objectDidChange였습니다. 하지만objectWillChange를 사용하면 업데이트를 더 잘 묶어 처리할 수 있습니다.
위 예제에서는 ObservableObject protocol이 기본으로 제공하는 objectWillChange publisher 구현을 사용했습니다. 그리고 value property가 변경되기 전에 이벤트를 보내도록 @Published property wrapper를 사용했습니다. @Published를 사용하는 대신 다음처럼 작성할 수도 있습니다.
final class Model: ObservableObject {
var value = 0 {
willSet { objectWillChange.send() }
}
}@Published를 사용하는 것은 위 코드의 syntactic sugar입니다. 다만 이 property wrapper는 objectWillChange publisher의 기본 구현을 사용할 때만 쓸 수 있습니다. 어떤 이유로 publisher를 직접 구현한다면 @Published는 동작하지 않습니다.
위 View를 구성할 때는 다음 단계가 진행됩니다.
원문 40쪽과 도표

1단계. counter View가 만들어지지만, 초기값을 autoclosure로 저장하므로 state object의 값은 아직 존재하지 않습니다.
2단계. render tree에 counter node를 만들고, autoclosure가 반환한 값으로 state object의 메모리를 초기화합니다. StateObject struct의 wrapped value는 render tree에 저장된 model object를 가리킵니다.
3단계. counter View의 body를 실행하고 버튼 View를 만듭니다. 버튼의 label에는 render tree에 있는 model object의 현재 값을 사용합니다.



원문 41쪽과 도표

4단계. 버튼 View를 설계도로 사용하여 render tree의 node를 업데이트합니다.
@State와 마찬가지로 @StateObject도 View 내부에서만 사용하는 state에 써야 합니다. 외부에서 object를 전달하거나 View의 initializer에서 object를 만들어 state object property에 대입하려고 해서는 안 됩니다. 이는 @State를 이야기할 때 설명한 것과 같은 이유로 동작하지 않습니다. View의 initializer가 실행될 때는 View에 아직 identity가 없습니다. 일반적인 기준으로, 앞의 counter 예제처럼 property를 선언한 줄에서 초기값을 대입할 수 있을 때만 @StateObject를 사용해야 합니다.
struct Counter: View {
@StateObject private var model = Model()
...
}위의 단순한 model에서는 @StateObject를 사용한 구현이 Observable과 @State를 사용한 구현과 거의 같습니다. 다만 observable 구현은 property 단위로 변경을 추적하지만 @StateObject는 object 단위로 변경을 추적합니다. 또한 @StateObject initializer는 autoclosure를 받는 반면, @State property는 View를 초기화할 때마다 초기값을 평가합니다.
Observed Object
@ObservedObject property wrapper는 @StateObject보다 훨씬 단순합니다. 초기값이라는 개념이 없으며, 다시 렌더링하는 동안 관찰하는 object를 유지하지도 않습니다. object의 objectWillChange publisher를 구독하고, 이 publisher가 이벤트를 보내면 View를 다시 렌더링할 뿐입니다. 따라서 iOS 17 이전 플랫폼을 지원하면서 외부의 object를 View에 명시적으로 전달하려는 경우, @ObservedObject가 유일하게 올바른 도구입니다. 이는 일반 property에 Observable object를 두는 것에 해당합니다.

원문 42쪽과 도표

counter 예제를 @ObservedObject를 사용하도록 다시 작성하면 다음과 같습니다.
final class Model: ObservableObject {
@Published var value = 0
static let shared = Model()
}
struct Counter: View {
@ObservedObject var model: Model
var body: some View {
Button("Increment: \(model.value)") {
model.value += 1
}
}
}
struct ContentView: View {
var body: some View {
Counter(model: Model.shared)
}
}counter View의 @StateObject property를 @ObservedObject property로 바꿨습니다. 이제 ContentView에서 하듯이 외부에서 model object를 전달할 수 있습니다. counter를 처음 만들 때는 다음과 같은 일이 일어납니다.
1단계. observed object property의 wrapped value가 counter View에 전달한 model instance를 가리킵니다.

원문 43쪽과 도표

2단계. render node를 만듭니다. @StateObject와 달리 이 node는 counter View에 전달한 것과 같은 외부 model object에 대한 reference만 가집니다.
3단계. counter View의 body를 실행하고 버튼 View를 구성합니다. body에서 버튼 제목은 observed object가 가진 외부 model object에 대한 reference를 사용합니다. 그런 다음 counter View의 새 view tree를 반영하도록 render node를 업데이트합니다.
물론 이 예제에서는 counter View에 다음과 같이 작성해도 됩니다.
struct Counter: View {
@ObservedObject var model = Model.shared
...
}어차피 외부에서 언제나 같은 Model.shared instance를 전달하므로, 이 코드도 정확히 같은 일을 합니다. 하지만 이 예제를 확장하여 다른 state에 따라 counter에 서로 다른 model object를 전달할 수도 있습니다.


원문 44쪽과 도표

다음은 여러 방에 있는 사람 수를 세는 간단한 counter 예제입니다.
final class CounterModel: ObservableObject {
@Published var value = 0
}
final class Model {
static let shared = Model()
var counters: [String: CounterModel] = [:]
func counterModel(for room: String) -> CounterModel {
if let m = counters[room] { return m }
let m = CounterModel()
counters[room] = m
return m
}
}
struct ContentView: View {
@State private var selectedRoom = "Hallway"
var body: some View {
VStack {
Picker("Room", selection: $selectedRoom) {
ForEach(["Hallway", "Living Room", "Kitchen"], id: \.self) { value in
Text(value).tag(value)
}
}
.pickerStyle(.segmented)
Counter(model: Model.shared.counterModel(for: selectedRoom))
}
}
}picker로 방을 전환하고, 공유 model에서 올바른 CounterModel instance를 가져와 counter View에 전달할 수 있습니다. 공유 model은 여러 CounterModel instance를 관리하며 View가 업데이트되는 동안에도 유지합니다. @ObservedObject가 하는 일은 변경 알림을 받기 위해 현재 instance를 구독하고, 이전 instance가 있다면 구독을 해제하는 것뿐입니다. 방을 전환해도 render tree에 있는 counter View의 node는 다시 만들어지지 않는다는 점에 주목해 주세요. 단지 다른 object를 관찰할 뿐입니다.
원문 45쪽과 도표

예를 들어 counter가 현재 “Living Room” object를 관찰하고 있다고 가정해 보겠습니다. Counter를 렌더링하면 view tree와 render tree 모두 이 object를 가리키는 pointer를 포함합니다. object를 관찰할 책임은 render tree에 있으며, view tree는 여전히 설계도일 뿐이라는 점에 주목해 주세요.
사용자가 “Kitchen”을 선택하면 counter View의 observed object는 이제 다른 model object에 대한 reference를 갖습니다.
따라서 render tree도 그에 맞게 업데이트해야 합니다. 먼저 render tree가 관찰하는 model에 대한 reference를 “Living Room” model에서 현재 model인 “Kitchen” model로 바꿉니다.


원문 46쪽과 도표

마지막으로 counter View의 body를 다시 실행하여 새로운 view tree를 구성하고, 그에 맞게 render tree를 업데이트합니다.
흥미롭게도 SwiftUI가 처음 등장했을 때는 @StateObject가 없었고 @ObservedObject만 사용할 수 있었습니다. 당시에도 같은 프로그램을 작성할 수 있었지만, @StateObject가 추가되면서 @ObservedObject의 일부 사용 사례를 훨씬 적은 코드로 대체할 수 있게 되었습니다.
Binding
앱의 state를 일관되게 유지하려면 각각의 state에 single source of truth가 있어야 합니다. 그렇지 않으면 같은 것을 나타내야 하는 두 값이 서로 달라질 수 있습니다.


원문 47쪽과 도표

component를 작성하다 보면 어떤 값에 대한 읽기와 쓰기 접근은 필요하지만, 그 값의 source of truth가 실제로 무엇인지는 알 필요가 없는 경우가 많습니다. Binding은 바로 이런 목적으로 사용하며, SwiftUI의 여러 내장 component에서도 볼 수 있습니다. 예를 들어 Toggle은 Bool에 대한 Binding을, TextField는 String에 대한 Binding을, Stepper는 Strideable 값에 대한 Binding을 받습니다.
앞에서 만든 @State 기반 counter View를 integer에 대한 Binding을 받는 component로 바꿔보겠습니다.
struct Counter: View {
@Binding var value: Int
var body: some View {
Button("Increment: \(value)") { value += 1 }
}
}value property의 @State attribute를 @Binding으로 바꾸고, 이전에 state property에 사용했던 private keyword를 제거하기만 하면 됩니다. 애초에 Binding으로 바꾸는 목적은 외부에서 전달할 수 있게 하는 것입니다.
Binding이 실제로 하는 일을 살펴보기 위해, 한 걸음 물러나 @Binding 없이 외부 state를 어떻게 구현할지 생각해 보겠습니다. counter View는 현재 값을 읽어야 하고, 현재 값을 바꿀 수 있어야 합니다. value를 일반 property로 두고, 값을 변경할 수 있도록 또 다른 property인 setValue를 추가하면 됩니다.
struct Counter: View {
var value: Int
var setValue: (Int) -> ()
var body: some View {
Button("Increment: \(value)") { setValue(value + 1) }
}
}이 버전의 Counter View는 다음과 같이 사용합니다.
struct ContentView: View {
@State private var value = 0
var body: some View {
Counter(value: value, setValue: { value = $0 })
}
}value와 setValue라는 한 쌍의 property를 작성하는 대신, Binding type으로 두 property를 합칠 수 있습니다.
원문 48쪽과 도표

struct Counter: View {
var value: Binding<Int>
var body: some View {
Button("Increment: \(value.wrappedValue)") {
value.wrappedValue += 1
}
}
}Binding은 wrapped value의 getter와 setter를 감싸는 wrapper로 동작합니다. 코드에서 wrappedValue를 사용하지 않으려면, 다시 밑줄 접두사의 Binding property와 밑줄 없는 computed property를 사용하는 패턴을 적용할 수 있습니다. computed property는 getter와 setter를 Binding의 wrapped value로 전달합니다.
struct Counter: View {
var _value: Binding<Int>
var value: Int {
get { _value.wrappedValue }
set { _value.wrappedValue = newValue }
}
init(value: Binding<Int>) {
self._value = value
}
var body: some View {
Button("Increment: \(value)") { value += 1 }
}
}역주: 위 예제의 setter도 원문 그대로 보존했습니다. 버튼의 closure에서
value를 변경하려면 이 setter에nonmutating이 필요합니다.
initializer의 유일한 역할은 parameter에 밑줄 접두사가 있는 _value label을 노출하지 않고 더 보기 좋은 API를 제공하는 것입니다. 그러면 Counter를 다음과 같이 사용합니다.
struct ContentView: View {
@State private var value = 0
var body: some View {
Counter(value: Binding(get: { value }, set: { value = $0 }))
}
}여기서는 Binding이 getter와 setter를 감싸는 wrapper라는 본질을 보여주기 위해, Binding(get:set:) initializer로 Binding을 직접 구성합니다.
원문 49쪽과 도표

이 코드를 모두 직접 작성해 봤으니, 이제 다시 단순하게 바꿀 수 있습니다. counter View의 _value와 value property에 관한 코드는 @Binding property wrapper가 생성하는 코드와 정확히 같습니다. ContentView에서는 Binding을 직접 구성하는 대신, Binding을 만들어 주는 달러 기호 문법인 $value를 사용할 수 있습니다.
struct Counter: View {
@Binding var value: Int
var body: some View {
Button("Increment: \(value)") { value += 1 }
}
}
struct ContentView: View {
@State private var value = 0
var body: some View {
Counter(value: $value)
}
}$value 문법의 원리를 이해하기 위해, @State와 $value 없이 ContentView를 다시 한 번 작성해 보겠습니다.
struct ContentView: View {
private var _value = State(initialValue: 0)
private var value: Int {
get { _value.wrappedValue }
set { _value.wrappedValue = newValue }
}
var body: some View {
Counter(value: _value.projectedValue)
}
}여기서 $value는 _value.projectedValue의 축약형이라는 것을 알 수 있습니다. 달러 기호 문법은 SwiftUI 전용이 아닙니다. Swift의 property wrapper 기능이며, property wrapper의 projectedValue에 접근하기 위한 syntactic sugar입니다.
달러 기호 문법은 @State property뿐 아니라 @StateObject property나 @ObservedObject property에도 사용할 수 있습니다. 예를 들어 ContentView의 값을 model object에 저장할 수 있습니다.
final class Model: ObservableObject {
@Published var value = 0
}원문 50쪽과 도표

struct ContentView: View {
@StateObject var model = Model()
var body: some View {
Counter(value: $model.value)
}
}computed property를 만들고 여기에 Binding을 만들 수도 있습니다. 예를 들어 다음 computed property는 value를 바탕으로 값을 0부터 10까지의 범위로 제한합니다.
final class Model: ObservableObject {
@Published var value = 0
var clampedValue: Int {
get { min(max(0, value), 10) }
set { value = newValue }
}
}
struct ContentView: View {
@StateObject var model = Model()
var body: some View {
Counter(value: $model.clampedValue)
}
}$ 뒤에서 참조하는 property에 getter와 setter가 있다면 그 property에 대한 Binding을 만들 수 있습니다.
Observable과 Bindable
SwiftUI의 macro 기반 object observation model에는 Binding과 관련한 어려움이 있습니다. 앞서 봤듯이 Binding을 구성하는 $ 문법은 property wrapper의 projectedValue에 의존합니다. @Observable macro를 사용할 때는 property wrapper가 더 이상 필요하지 않은 경우가 많으므로, 예를 들어 @ObservedObject property wrapper를 사용할 때와 같은 방식으로 Binding을 구성할 수 없습니다.
이 문제를 해결하기 위해 Bindable wrapper가 도입되었습니다. property wrapper로 사용하거나 코드 안에서 직접 사용할 수 있습니다. 예를 들어 다음은 property wrapper로 사용하는 경우입니다.
struct Counter: View {
@Bindable var model: Model
var body: some View {
Stepper("\(model.value)", value: $model.value)
}
}
struct ContentView: View {
var model = Model.shared
var body: some View {
Counter(model: model)
}
}원문 51쪽과 도표

Counter의 memberwise initializer는 일반 Observable object를 받고, 내부에서 Bindable(wrappedValue:) initializer를 사용하여 이 object를 Bindable 값으로 감쌉니다. label이 없어 더 간결한 Bindable(_:) initializer를 사용하면 코드 안에서 bindable 값을 직접 만들 수도 있습니다. 두 initializer는 같은 일을 합니다.
struct ContentView: View {
var model = Model.shared
var body: some View {
Stepper("\(model.value)", value: Bindable(model).value)
}
}@StateObject, @ObservedObject와 마찬가지로 @Bindable의 projected value를 통해 dynamic member lookup 문법으로 property에 접근할 수 있습니다. 예를 들어 앞의 Counter View에서 Bindable의 syntactic sugar를 모두 풀어 쓰면 다음과 같습니다.
struct Counter: View {
var model: Model { _model.wrappedValue }
var _model: Bindable<Model>
init(model: Model) {
_model = Bindable(wrappedValue: model)
}
var body: some View {
Stepper("\(model.value)", value: _model.projectedValue[dynamicMember: \.value])
}
}원문 52쪽과 도표

counter View의 body에서 model의 value property에 접근할 때 dynamic member lookup 문법을 볼 수 있습니다. model.value는 _model.projectedValue[dynamicMember: \.value]로 변환됩니다.
역주: 원문은 위 문장에서
model.value라고 표기하지만, 직전 예제에서projectedValue를 사용하는 대상은$model.value입니다.
View 업데이트와 성능
SwiftUI의 주요 장점 중 하나는 UI를 state와 자동으로 동기화한다는 것입니다. state가 업데이트될 때마다 view tree 전체를 다시 렌더링하지 않도록, SwiftUI는 특정 state 값 또는 object와 이에 의존하는 View 사이에 dependency를 만듭니다.
View의 body가 실행되면 SwiftUI는 실행 중 어떤 state property에 접근했는지 파악하고, 둘 사이의 dependency를 기록합니다. 특정 state 값이 변경되면 SwiftUI는 그 값에 의존하는 View를 알고 있으므로 해당 View의 body만 다시 실행합니다. 그런 다음 state 변경 전후의 View 값을 비교하여 View의 subtree에서 어떤 body를 다시 실행해야 하는지 알아냅니다.
이처럼 SwiftUI는 state가 변경될 때 view tree에서 꼭 필요한 부분만 다시 렌더링하도록 많은 노력을 기울입니다. 하지만 저희 코드가 이러한 노력을 방해하지 않도록 작성하는 것도 중요합니다.
예를 들어 이전의 @StateObject 또는 @ObservedObject property wrapper를 사용하면서 모든 state를 하나의 큰 observable object에 넣으면, 어떤 변경이든 발생할 때마다 SwiftUI는 그 object를 관찰하는 모든 View를 다시 렌더링해야 합니다. 해당 변경이 object를 관찰하는 View 중 일부에만 영향을 주는 경우에도 마찬가지입니다. 성능이 문제가 된다면 state를 더 작은 단위로 나눠 View 업데이트를 더 세밀하게 수행하는 것이 도움이 될 수 있습니다. @Observed macro는 observation을 object 단위에서 object의 개별 property로 옮기므로 이 문제를 상당 부분 줄여 줍니다.
역주: 위 문단의
@Observed는 원문 표기입니다. 이 장에서 설명한 property 단위 observation macro의 이름은@Observable입니다.
또한 subview에는 실제로 필요한 데이터만 전달하는 것이 중요합니다. 예를 들어 property가 많이 들어 있는 큰 struct가 있고, 이 struct의 값 하나만 필요한 View에 struct 전체를 전달한다고 해보겠습니다. 그러면 struct의 어떤 값이 바뀌든 그 View를 매번 다시 렌더링합니다. View의 property를 필요한 것에 맞게 구체적으로 지정하면 이를 피할 수 있고, 추가로 preview에 표시하기도 쉬워집니다. 이를 위해 큰 View를 서로 다른 substate에 의존하는 여러 subview로 나누는 것이 합리적일 수 있습니다.
성능 문제가 발생했을 때 어떤 View의 body가 실행되는지 진단하는 방법은 여러 가지입니다. 첫 번째 방법은 View의 body에 print 문장을 삽입하는 것입니다.
원문 53쪽과 도표

var body: some View {
let _ = print("Executing <MyView> body")
...
}view builder는 View type의 expression만 받으므로 let _ = ...라는 이름 없는 변수 대입이 필요합니다. print 함수의 반환 type은 Void이고 view builder는 이를 처리할 수 없기 때문에, 단순히 print("...")를 호출할 수는 없습니다. let _ = ... 문법으로 이 문제를 우회합니다.
View를 다시 렌더링하는 상황을 더 시각적으로 확인하는 방법으로, Peter Steinberger가 소개한 무작위 배경색을 사용하는 기법이 있습니다.
View의 body를 다시 실행한 이유를 알아보려면 View의 body에서 다음과 같이 Self._printChanges() API를 사용할 수 있습니다.
var body: some View {
let _ = Self._printChanges()
...
}여기서도 함수의 반환 type이 Void이므로 이름 없는 변수 대입으로 우회해야 합니다. 이 print 문장은 다시 렌더링한 이유를 console에 기록합니다.
- state 변경으로 View를 다시 렌더링했다면, state property의 이름을 밑줄 접두사가 붙은 형태로 기록합니다.
- View 값 자체가 변경되었다면, 즉 View의 property 중 하나의 값이 변경되었다면
"@self"를 기록합니다. - View의 identity가 변경되었다면
"@identity"를 기록합니다. 실제로 이는 대개 View를 render tree에 새로 삽입했다는 뜻입니다.
마지막으로 Instruments에는 SwiftUI profiling template도 있습니다. 이를 사용하면 어떤 View의 body가 예상보다 자주 실행되는지 진단할 수 있습니다.
용도에 따른 property wrapper
SwiftUI 코드에서 이 장에서 다룬 여러 property wrapper를 필요 이상으로 사용하는 경우를 자주 봅니다. 첫 번째 규칙은 가능하면 View에서 property wrapper가 없는 일반 property를 사용하는 것입니다. 단순히 View에 값을 전달하려는 것이고 View가 해당 값에 쓰기 접근을 할 필요가 없다면, 일반 property만 있으면 됩니다.
원문 54쪽과 도표

View가 object가 아닌 값에 읽기와 쓰기 접근을 해야 하고, 그 값을 외부에서 전달받을 수 없는 View 내부의 private state로 소유해야 한다면 @State를 사용합니다. 반대로 View가 값에 읽기와 쓰기 접근은 해야 하지만 값을 소유해서는 안 되고, 실제로 어디에 저장되는지에도 관심이 없다면 @Binding을 사용합니다.
View에 object 형태의 state가 필요하고, 그 object를 외부에서 전달받을 수 없는 View 내부의 private state로 소유해야 한다면 iOS 17 이전에는 @StateObject를, iOS 17부터는 @Observable object와 함께 @State를 사용합니다. 하지만 외부에서 object를 전달받아야 한다면 iOS 17 이전에는 @ObservedObject를, iOS 17부터는 일반 property를 사용합니다.
유용한 기준은 property를 선언한 줄에서 직접 초기화할 수 있을 때만 @State와 @StateObject를 사용하는 것입니다. 그렇게 할 수 없다면 아마 @Binding, @ObservedObject 또는 @Observable object를 저장하는 일반 property를 사용해야 합니다.
원문 55쪽과 도표
