SwiftUI

[SwiftUI] Observation으로 SwiftUI 데이터 모델을 간소화하자

Jade-Lee 2024. 4. 8. 19:46

 

안녕하세요!

오늘은 WWDC23에서 새롭게 발표된 Swift의 기능인 Observation에 대해 알아볼 거예요.

저는 평소에 WWDC를 챙겨보지 않았고 관심을 가지지 않았지만,

최근 SwiftUI를 공부하면서 새롭게 알게 된 부분이라 조금 더 자세히 알아보고자

늦었지만 제가 이해한 대로 정리해 보기로 했어요!

그리고 이번 글을 시작으로 앞으로는 WWDC에 더 많은 관심을 가지려고 합니다!

(지금까지 WWDC에 관심 갖지 않았던 저.. 반성합니다....😓)

 

 

Observation이란?

Swift Framework로,

macro 기능을 기반으로 프로퍼티 변경 사항을 추적하기 위한 새로운 Swift 기능이에요.

제가 아직은 macro 개념에 대해서는 자세히 알지 못하지만,

간단히 말해서 @Observable macro가 특정 타입을 관찰할 수 있는 확장된 타입으로 변환하도록 지시한다고 해요.

 

기존에 우리는 SwiftUI에서 모델의 프로퍼티 변경 사항을 추적해서 View를 업데이트하기 위해

모델에 ObservableObject 프로토콜을 채택하고 관찰할 프로퍼티 변수 앞에 @Published를 정의했어요.

그리고 해당 모델을 View에서 property wrapper를 통해 사용했어요.

// ObservableObject 프로토콜 채택
class CounterViewModel: ObservableObject { 
    // 관찰할 프로퍼티 변수 앞에 @Published 선언
    @Published var count: Int = 0 
    
    func incrementCounter() {
        self.count += 1
    }
}

struct CounterView: View {
    // @ObservedObject property wrapper 사용
    @ObservedObject var viewModel = CounterViewModel() 
    
    var body: some View {
        VStack {
            Text("CounterView number is: \(viewModel.count)")
            Button("CounterView Increase") {
                viewModel.incrementCounter()
            }
        }
    }
}

하지만 Observation을 사용하게 된다면 ObservableObject 채택 및 property wrapper를 사용하지 않고

@Observable 단 하나만을 추가하는 것만으로도 View가 모델의 변경 사항에 반응하도록 할 수 있답니다!

// ObservableObject 대신 @Observable 사용
@Observable class CounterViewModel {
    // @Published 필요 X
    var count: Int = 0 
    
    func incrementCounter() {
        self.count += 1
    }
}

struct CounterView: View {
    // property wrapper 사용 X
    var viewModel = CounterViewModel() 
    
    var body: some View {
        VStack {
            Text("CounterView number is: \(viewModel.count)")
            Button("CounterView Increase") {
                viewModel.incrementCounter()
            }
        }
    }
}

 

 

Observation의 View Update

Observation을 통해 뷰를 업데이트하는 과정은 다음과 같아요.

 

1. @Observable 매크로를 사용하면 Observation을 지원할 수 있도록 타입이 확장됨

2. View의 body가 실행되면 SwiftUI는 Observable 타입에서 사용되는 속성에 대한 모든 액세스를 추적함

3. 그다음 해당 추적 정보를 사용하여 특정 인스턴스의 해당 속성에 대한 변경 시기를 결정함

단, View의 body를 실행할 때 결정한 추적 속성의 일부가 아닌 경우 view를 업데이트하지 않음

 

여기서 'body를 실행할 때 결정한 추적 속성'이란 말이 무슨 의미일까요?

 

다음 예시를 살펴보겠습니다.

class CounterViewModel: ObservableObject {
    @Published var count: Int = 0
    @Published var randomNumber: Int = 0
    
    func incrementCounter() {
        self.count += 1
    }
    
    func random() {
        self.randomNumber = Int.random(in: 0...100)
    }
}

struct CounterView: View {
    @ObservedObject var viewModel = CounterViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("CounterView number is: \(viewModel.count)")
            Button("CounterView Increase") {
                viewModel.incrementCounter()
            }
            Button("Update Random Number") {
                viewModel.random()
            }
            
            Divider()
            
            SubView()
        }
    }
}

struct SubView: View {
    var date = Date()
    
    var body: some View {
        VStack {
            Text("\(date)")
        }
    }
}

CounterView에는 CounterViewModel의 count, randomNumber 프로퍼티 값을 변경하는 버튼들이 존재하고,

하위 뷰로 날짜를 표시해 주는 SubView가 있는 상태예요.

이 상황에서 "Update Random Number" 버튼을 클릭하게 된다면 View가 어떻게 동작할까요?

("Update Random Number"를 클릭할 때마다 date가 변경됨)

 

1. 현재 관찰 중인 CounterViewModel 내 프로퍼티 randomNumber의 값이 변경되어

CounterView의 body가 새롭게 업데이트됨.

2. CounterView의 body가 업데이트됨에 따라 하위뷰인 SubView 또한 새롭게 업데이트되면서

date가 현재 시간으로 변경됨.

 

그런데, CounterView에서 CounterViewModel의 randomNumber 프로퍼티를 body에서 사용하고 있지 않죠?

즉, 해당 프로퍼티를 View로써 사용하고 있지 않기 때문에 View가 업데이트될 내용이 없어요.

하지만, 그 값이 변경될 때마다 불필요하게 View를 새롭게 업데이트하고 있는 것이지요.

 

하지만 Observation에서는 View의 body를 실행할 때 결정한 추적 속성의 일부가 아닌 경우

view를 업데이트하지 않는다고 했죠?

다음 예시를 한번 살펴볼까요?

@Observable class CounterViewModel {
    var count: Int = 0
    var randomNumber: Int = 0
    
    func incrementCounter() {
        self.count += 1
    }
    
    func random() {
        self.randomNumber = Int.random(in: 0...100)
    }
}

struct CounterView: View {
    var viewModel = CounterViewModel()
    
    var body: some View {
        VStack(spacing: 20) {
            Text("CounterView number is: \(viewModel.count)")
            Button("CounterView Increase") {
                viewModel.incrementCounter()
            }
            Button("Update Random Number") {
                viewModel.random()
            }
            
            Divider()
            
            SubView()
        }
    }
}

struct SubView: View {
    var date = Date()
    
    var body: some View {
        VStack {
            Text("\(date)")
        }
    }
}

아까와 동일하게 작동하는 코드이지만 @Observable로 변경해 준 코드입니다.

이제 아까와 같이 "Update Random Number" 버튼을 클릭하게 된다면 그 결과는??

("Update Random Number"로는 date가 업데이트되지 않지만, "CounterView Increase" 클릭 시 date가 업데이트됨)

 

SwiftUI는 CounterView의 body를 실행할 때

해당 View에서는 randomNumber 프로퍼티에 접근하지 않는다는 것을 판단해요.

 그렇기 때문에 randomNumber 값이 변경돼도 View가 업데이트되지 않는 모습을 확인할 수 있어요.

 

이와 반대로 "CounterView Increase" 버튼을 클릭하면 추적 속성의 일부였던 count값이 변경되면서

View가 업데이트되는 모습을 확인할 수 있습니다.

Observation을 사용하게 된다면 불필요한 View Update를 줄일 수 있는 것이죠!!

 

 

SwiftUI에서 @Observable 사용 시 property wrapper 사용 규칙

기존 ObservableObject 사용 시 property wrapper로

@Published, @StateObject, @ObservedObject, @EnvironmentObject 등을 고려해야 했어요.

 

하지만 Observation에서는 @State, @Environment, @Bindable로 총 3가지만 고려하면 돼요.

@State와 @Environment는 기존에 있던 property wrapper이고 @Bindable만 새롭게 추가된 것이기 때문에

고려해야 할 property wrapper가 줄어면서 단순화된 것을 확인할 수 있죠.

 

WWDC23에서 @Observable에서의 property wrapper 사용 규칙을 다음과 같이 정의했어요.

 

각 단계를 살펴보면

View가 모델의 자체 상태를 저장해야 하는가? -> @State 사용

값을 전역적으로 액세스 가능하도록 전달해야 하는가? -> @Environment 사용

Binding만 필요한가? -> @Bindable

위 사항 모두 해당하지 않는가? -> propery wrapper 사용 X

 

하나씩 살펴볼까요?

 

@State

@State에 대해 먼저 알아볼게요.

@Observable에서 사용하는 @State는 기존 @StateObject 사용 시기와 동일하답니다.

그럼 기존에 @StateObject는 왜 사용했었나요??

그 이유는 View에서 단 하나의 모델 인스턴스를 만들어 관리해 주기 위해 사용했어요.

즉, View가 모델의 자체 상태를 저장함으로써 View 랜더링 시 인스턴스 초기화 이슈를 해결하기 위함이었어요.

다음 예시 코드로 한번 살펴볼까요?

struct CounterView: View {
    @State var count = 0
    
    var body: some View {
        VStack {
            Text("CounterView number is: \(count)")
            Button("CounterView Increase") {
                count += 1
            }
            
            Divider()
            
            SubView()
        }
    }
}

struct SubView: View {
    var viewModel = CounterViewModel()
    
    var body: some View {
        VStack {
            Text("SubView number is: \(viewModel.count)")
            Button("CounterView Increase") {
                viewModel.count += 1
            }
        }
    }
}

CounterView의 하위 뷰로 SubView가 존재하고 각 화면에 버튼들이 있습니다.

그리고 각각의 버튼은 각자의 화면에 표시할 count를 증가시켜 주죠.

SubView에서는 @Observation으로 정의한 모델을 사용하고 있습니다.

여기서 SubView의 버튼을 먼저 클릭하고 상위 뷰인 CounterView의 버튼을 클릭하게 된다면 어떻게 될까요?

 

상위 뷰의 버튼을 클릭하게 되면 count 프로퍼티가 변경이 되어 상위 뷰가 새롭게 그려지고,

SubView도 다시 그려지게 되면서 CounterViewModel이 초기화되는 것을 볼 수 있습니다.

이렇듯, 뷰 렌더링 시 인스턴스 초기화 이슈를 해결하기 위해 @State를 사용하면 됩니다.

struct CounterView: View {
    @State var count = 0
    
    var body: some View {
        VStack {
            Text("CounterView number is: \(count)")
            Button("CounterView Increase") {
                count += 1
            }
            
            Divider()
            
            SubView()
        }
    }
}

struct SubView: View {
    @State var viewModel = CounterViewModel()
    
    var body: some View {
        VStack {
            Text("SubView number is: \(viewModel.count)")
            Button("CounterView Increase") {
                viewModel.count += 1
            }
        }
    }
}

 

 

아까와 달리 인스턴스가 초기화되지 않고 그대로 유지되고 있습니다!!

 

간단히 말해서 @Observation에서 @State는 기존 @StateObject의 용도로 사용해 주시면 되겠습니다.

 

 

@Environment

@Environment도 간단합니다!

기존에 우리는 모델을 뷰에 전역적으로 전달하기 위해 @EnvironmentObject를 사용했었죠??

이를 @Observable에서는 @Environment로 사용하면 돼요!

@main
struct SwiftUIApp: App {
    @State var viewModel = CounterViewModel()
   
    var body: some Scene {
        WindowGroup {
            CounterView()
                .environment(viewModel)
        }
    }
}

struct CounterView: View {
    @Environment(CounterViewModel.self) var viewModel
    
    var body: some View {
        VStack {
            Text("CounterView number is: \(viewModel.count)")
            Button("CounterView Increase") {
                viewModel.count += 1
            }
        }
    }
}

main에서 정의한 CounterViewModel을 CounterView에 environment로 전달하고 사용하는 코드입니다!

간단하죠???

 

 

@Bindable

Observation이 나오면서 새롭게 탄생한 property wrapper입니다!!

@Observation 객체의 변경 가능한 프로퍼티에 대한 바인딩 생성을 지원하는 property wrapper라고 하네요.

(아직 @Bindable 사용 시기에 대해 완벽히 이해하진 못했지만...

바인딩만 필요한 경우에 사용하면 된다라고 이해하고 있답니다..🥲)

struct CounterView: View {
    var viewModel = CounterViewModel()
    
    var body: some View {
        VStack {
            Text("\(viewModel.text)")
            
            SubView(viewModel: viewModel)
        }
    }
}

struct SubView: View {
    @Bindable var viewModel: CounterViewModel
    
    var body: some View {
        VStack {
            TextField("Field", text: $viewModel.text)
        }
    }
}

하위 View인 SubView에서 바인딩을 필요로 하고 있기 때문에

하위 View에서는 CounterViewModel을 @Bindable로 받아서 사용하고 있는 모습입니다.

 

그런데...!!!

@State에서는 바인딩이 지원되지만, @Environment에서는 바인딩을 지원하지 않아요.

다음 예시 코드를 살펴볼까요?

struct CounterView: View {
    @Environment(CounterViewModel.self) var viewModel
    
    var body: some View {
        VStack {
            Text("\(viewModel.text)")
            TextField("Field", text: $viewModel.text)  // 바인딩 사용 불가 (컴파일 에러 발생)
        }
    }
}

그렇다면...

위 예시 코드와 같이 @Environment로 받은 모델을 바인딩으로 바로 사용해야 하는 경우는 어떻게 해야 할까요??

그건 바로 body 내에서 @Bindable을 새롭게 정의해 주면 된답니다!!!

struct CounterView: View {
    @Environment(CounterViewModel.self) var viewModel
    
    var body: some View {
        @Bindable var viewModel = viewModel  // body 내에서 @Bindable을 새롭게 정의
        VStack {
            Text("\(viewModel.text)")
            TextField("Field", text: $viewModel.text)
        }
    }
}

 

지금까지 @Observable에서 property wrapper 사용 시기에 대해 이야기를 해보았는데

간단하게 다음과 같이 요약하면 될 것 같아요.

 

@State -> 기존 @StateObject 용도처럼 사용

@Environment -> 기존 @EnvironmentObject 용도처럼 사용

@Bindable -> 바인딩이 필요한 경우

property wrapper 사용 X -> 위 사항 모두 해당하지 않을 경우

(기존 @ObservedObject처럼 사용하면 되지 않을까라는 생각입니다.

단, 바인딩 기능을 제외한.)

 

 

 

ObservableObject와 @Observable 코드 비교

 

이 글의 마지막으로,

기존 ObservableObject에서 @Observable로 변경 시 코드가 단순화되는 것을 확인해보려고 해요.

WWDC23에서 나온 예제 코드들을 통해 같이 확인해 볼게요.

 

이 글을 처음 시작할 때 보여드렸던 예시와 비슷합니다.

기존 SwiftUI에서는 왼쪽 코드처럼 모델에 ObservableObject 프로토콜을 채택하고,

추적할 프로퍼티 앞에 @Published를 작성했어요.

하지만 @Observable을 사용한다면 오른쪽 코드처럼 간편하게 @Observable만 작성해주기만 하면 된답니다!

 

View에서는 어떠한지 한번 살펴볼까요?

 

기존 SwiftUI에서는 왼쪽 코드처럼 고려해야 할 property wrapper의 수가 많았답니다.

그렇지만 @Observable을 사용하면 오른쪽 코드처럼 고려해야 할 property wrapper의 수가 줄어든 것을 확인할 수 있죠!

 

 

 

이렇게 WWDC23에서 새롭게 발표한 Observation에 대해 제가 이해한 내용들을 정리해 보았는데요,

WWDC 영상만을 보고 이해해보려 했는데, 쉽지 않네요...🥲

 

그리고 이 글 처음에 말씀드렸던 것처럼

제가 이해한 부분들만 간략히 정리한 거라 생략된 내용들이 존재합니다...!!

 

그래서 좀 더 자세한 내용을 원하시는 분들은 아래 WWDC23 영상을 시청해 보시는 것을 추천드립니다!!

WWDC23 - Discover Observation in SwiftUI

 

Discover Observation in SwiftUI - WWDC23 - Videos - Apple Developer

Simplify your SwiftUI data models with Observation. We'll share how the Observable macro can help you simplify models and improve your...

developer.apple.com

 

 

이건 추가적으로 참고한 자료입니다!

 

https://swiftwithmajid.com/2023/10/03/mastering-observable-framework-in-swift/

 

Mastering Observation framework in Swift

Apple introduced the new Observation framework powered by the macro feature of the Swift language. The new Observation framework, in combination with the Swift Concurrency features, allows us to replace the Combine framework that looks deprecated by Apple.

swiftwithmajid.com

 

 

 

 

피드백은 언제나 환영입니다! 😁