[SwiftUI] iOS 17+부터 새로워진 SwiftUI의 Observation 상태 관리 알아보기 : @Observable @Bindable

2025. 6. 29. 21:28UIKit, SwiftUI, H.I.G

안녕하세요 여러분 👋
오늘 글은 WWDC23에서 소개된, Swift 5.9 / iOS 17 이후부터 적용가능한 새로운 SwiftUI의 상태 관리 방식에 대해 준비했습니다.

대부분의 내용은 아래 WWDC23 "Discover Observation in SwiftUI"를 참고해 공부했고,
이 외에 내용은 새로운 상태 관리의 개념 Observation과 이것을 구현하게 도와주는 매크로 @Observable @Bindable의 문서를 참고했습니다.

달라진 개념은 무엇인지, 앞으로 SwiftUI 코드는 어떤 흐름으로 데이터 모델 구성을 가져가야할지 알아보도록 하죠🤔

 

SwiftUI의 Observation 알아보기 - WWDC23 - 비디오 - Apple Developer

Observation을 통해 SwiftUI 데이터 모델을 단순화하세요. Observable 매크로는 모델을 단순화해 앱의 성능을 향상합니다. Observation과 매크로의 기초를 익히고 ObservableObject에서 Observable로...

developer.apple.com

 

Observation 

핵심 내용은 Observation 입니다.

Observation을 해석하면 "관찰"이라는 것을 모두 알고 있을 겁니다.
여기서 의미하는 "관찰"이란 "SwiftUI에서 사용하는 데이터 모델 (상태 = State)을 관찰/추적하는 것"을 의미한다고 이해하면 됩니다.
조금 더 자세하게 설명하면, View는 데이터 모델을 Observation (관찰)하다가 -> 상태가 변하면 UI를 자동으로 다시 렌더링하는 개념인 것이죠.

Observation은 Swift 매크로 (Macro) @Observable@Bindable을 활용해 구현합니다.


예시 코드를 살펴보죠.
Book이라는 SwiftUI View에서 사용할 데이터 모델을 정의하고자 합니다.

단순하게 모델로 활용하고자 하는 클래스 (Class) 앞에 @Observable 키워드를 붙이는 것만으로 Observation을 구현할 수 있죠.

조금 더 정확하게 말하면, 
@Observable 매크로를 붙이면 단순히 관찰 기능이 추가되는 것을 넘어서
데이터 모델 타입 자체 (여기서는 Book이 해당)가 확장되어 내부적으로 Observation 프로토콜을 따르는 타입으로 Swift가 만듭니다.

import SwiftUI

@Observable final class Book: Identifiable {
    var title = "Sample Book Title"
    var author = Author()
    var isAvailable = true
}
🤔 [자세하게 알고 넘어가기] @Observable 매크로를 통해 Observation 프로토콜을 채택하는 타입
Observable 프로토콜을 채택하고 있는 타입은 다른 SwiftUI를 비롯한 외부 프레임워크가 보는 입장에서,
"아 이 타입은 SwiftUI의 Observation을 지원하는 타입이구나!" 라는 것을 자동으로 알도록 도와주는 느낌입니다.

만약 내부적으로 Observation을 지원하는 타입이라는 것이 체크된 경우에는
diffing (이전 상태와 현재 상태를 비교하여 무엇이 달라졌는지를 계산하는 과정), View invalidation (상태가 바뀌었을 때, SwiftUI가 이 뷰를 다시 그려야 한다고 표시하는 과정), Data Binding (상태와 UI의 양방향 연결)과 같은 기능들을 지원하도록 설정하게 되는 것이죠.

단 Observation 프로토콜은 일반적인 프로토콜처럼 "ClassName: Observation"과 같은 형식으로 채택하는 것이 아니라,
@Observation 매크로 선언만으로 채택할 수 있습니다.

View에서는 별도의 프로퍼티 래퍼없이 그냥 그 자체의 @Observable로 선언한 모델과 의존성을 형성하는 것만으로 관찰이 이루어집니다.
이는 일반 저장 프로퍼티를 넘어서 계산 프로퍼티 (Computed Property)에도 동일하게 적용되는 내용입니다.

이때 핵심적으로 알아야하는 사항은 "Observation을 통해 상태 관리를 수행하면, SwiftUI의 View는 실제로 body 안에서 읽은 프로퍼티만 추적하여 의존성을 맺는다는 점"인데요.
(When a tracked property changes, SwiftUI updates the view. / If other properties change that body doesn't read, the view unaffected and avoids unnecessary updates.)

이게 무슨말이여

조금 어렵죠?
아래 BookView 코드를 통해 이해를 해보겠습니다.

BookView의 body에서는 book.title을 읽고 있습니다. -> ✅ BookView는 book.title에 의존하고 있다고 기록!
그래서 나중에 book.title이 변경되는 경우에 -> ✅ SwiftUI는 BookView를 invalidate 하고 -> rerendering 하는 과정을 거침!
하지만, book.author나 book.isAvailable이 바뀌는 경우에는 -> ✅ body에서는 해당 프로퍼티에 의존하고 있지 않기 때문에, 아무런 업데이트가 수행되지 않습니다.

만약 SwiftUI가 Book이라는 객체만 보고있다 생각해 보죠.
book.author나 book.isAvailable 값이 바뀔 때도 BookView를 계속 invalidate와 rerendering의 과정을 거치도록 한다면, 그 자체가 불필요한 UI 연산이 수행되는 것이고. 성능의 하락으로 이어지게 될 것입니다.

즉, 정리하자면 Observation은 SwiftUI의 body에서 실제로 관찰하고 있는 속성의 값만 추적한다. 그로 인해 성능을 높여준다. 끝입니다!
“Observation tracks changes to any observable property that appears in the execution scope of a view’s body property.”

struct BookView: View {
    var book: Book
    
    var body: some View {
        Text(book.title)
    }
}

 

 

SwiftUI Property Wrappers : @State, @Environment, @Bindable 

iOS 17+ 이후의 프로퍼티 래퍼는 다음과 같은 기준으로 사용하면 됩니다.

조금 더 깊게 알아보겠습니다.
iOS 17+ 버전의 Observable 개념을 기반으로, SwiftUI의 프로퍼티 래퍼 (@State, @Environment, @를 각각 어떤 상황에서 활용하는지에 대해 알아보려고요.

일단, 각 프로퍼티 래퍼는 뷰 계층 구조에서 데이터의 소유 (Source of truth)를 어디에 두느냐에 따라 차이를 갖는다고 이해하고 들어갑시다.

1️⃣ @State : View가 직접 데이터를 소유하고 있는 상태

@State로 선언하는 경우 데이터 모델은 해당 View가 직접 관리합니다.
이를 "Source of truth, 데이터의 진짜 저장소"라고 부르는데요.

무슨 말인지..? 싶은 분들을 위해 역시 아래 코드 예시로 쉽게 설명해보겠습니다.

일단, book은 BookView의 생명주기 동안만 관리될 수 있습니다. -> ✅ @State로 인해 View가 데이터를 소유하고 있기 때문!
@State를 붙이면 해당 값은 heap에 별도로 보관되기 때문에, View가 다시 렌더링되더라도 데이터 모델 값은 변하지 않고 계속 유지될 수 있죠,
만약, 일반 변수로 선언하는 경우 View가 생성될 때마다 / 다음 body 호출 때는 원래 값으로 돌아가게 될 거예요.
즉, View에서 사용하는 @Observation의 데이터 상태를 유지시켜주기 위해 사용하는 프로퍼티 래퍼라고 이해하면 되겠습니다.

struct BookView: View {
    @State private var book = Book()
    
    var body: some View {
        Text(book.title)
    }
}

앗 참고로! 일반적으로 @Observable의 기본 데이터 소유 위치는 @State인 경우가 많습니다.

 

2️⃣ @Environment : 앱 전체에서 데이터를 공유하는 상태

Environment를 사용하면 원하는 데이터를 앱 전체에서 자유롭게 접근할 수 있습니다.
*아래에서 볼 Bindable을 통해 부모와 자식 간의 뷰 전달마다 전달해줄 수 있지만, 이 방식은 복잡한 파라미터를 만들게 된다는 단점이 있죠.

.environment(_:) 수정자를 사용해서 Environemt에 데이터 모델을 직접 저장하는 방법을 사용할 수 있습니다.

아래 보이는 것처럼 @main 접근 수준에서 .environemt(_:) 수정자를 사용해 전역으로 사용가능하도록 Observable 데이터 모델을 추가해주고, (= library의 source of truth는 App에서 @State로 소유하고 있음)
하위 뷰에서 사용 시에는 @Environment 프로퍼티 래퍼와 타입 기반 (Library.self)으로 접근해 사용할 수 있습니다.

@Observable class Library {
    var books: [Book] = [Book(), Book(), Book()]
    
    var availableBooksCount: Int {
        books.filter(\.isAvailable).count
    }
}

@main
struct BookReaderApp: App {
    @State private var library = Library()
    
    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}

struct LibraryView: View {
    @Environment(Library.self) private var library
    
    var body: some View {
        NavigationStack {
            List(library.books) { book in
                // ...
            }
            .navigationTitle("Books available: \(library.availableBooksCount)")
        }
    }
}

@Environment를 사용하는 경우 주의할 점이 있는데요.

기본적으로 Key Path를 사용 (@Environment(\.library) 방식이 해당)하는 것이 아니라,
타입 기반으로 접근 (@Environment(Library.self) 방식이 해당)할 때는 뷰 트리 내에 해당 타입이 반드시 존재함을 가정으로 동작합니다.
-> 즉, 해당 데이터 모델 값을 주입하지 않은 경우 App이 Crash 날 수 있다는 문제가 존재함⚠️

그래서 객체가 환경에 있는지 보장할 수 없는 경우, SwiftUI가 nil 예외 처리를 할 수 있도록 옵셔널로 환경 변수 처리를 하는 것을 권장합니다.

@Environment(Library.self) private var library: Library?

 

3️⃣ @Bindable : 부모와 자식 간 데이터를 양방향으로 관리할 수 있는 상태

@Observable로 선언된 데이터 모델 타입을 양방향으로 자식 뷰에서 바인딩할 때 사용하는 프로퍼티 래퍼는 @Bindable입니다.

@Binding과 유사하다고 생각하면 됩니다.
단, 단일 값을 기준으로 바인딩되는 것이 아니라 / Observable 타입 전체를 기준으로 바인딩된다는 점만 차이점으로 생각하면 되겠습니다.
애플은 @Bindable이 굉장히 가볍다는 것 (lightweight)을 특징으로 강조하네요! 

struct BookView: View {
    var book: Book
    @State private var isEditorPresented = false
    
    var body: some View {
        ...
        .sheet(isPresented: $isEditorPresented) {
            BookEditView(book: book)
        }
    }
}

struct BookEditView: View {
    @Bindable var book: Book
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack {
            TextField("Title", text: $book.title)
            Toggle("Book is available", isOn: $book.isAvailable)
            Button("Close") { dismiss() }
        }
    }
}

 

 

그럼 Observation 왜 필요한거지? 기존 방식으로도 충분한거 아닌가?

네 맞습니다.
굳이 새로나온 @Observable과 @Bindable 매크로를 사용하지 않아도,
충분히 기존 SwiftUI 프로퍼티 래퍼 (Property Wrapper)들을 활용하는 선에서 지금과 같은 "상태 추적에 따른 자동 뷰 업데이트"의 메커니즘을 구현할 수 있었습니다.

바로 ObservableObject와 @Published를 기반한 데이터 모델 타입,
그리고 View 사용 부분에서는 @StateObject나 @ObservedObject 프로퍼티 래퍼를 활용해 불러올 수 있었죠.

Observation은 해당 방식을 @Observable 매크로에 기반한 데이터 모델 타입과,
사용 부분에서는 프로퍼티 래퍼를 사용하지 않는 방식 / 또는 위에서 설명한 기본 SwiftUI 프로퍼티 래퍼로 기능을 대체합니다.

// 🤨 BEFORE : ObservableObject와 @Piblished 기반
class Library: ObservableObject {
    @Published var books: [Book] = [Book(), Book(), Book()]
}

// 😀 AFTER : Observation 기반
@Observable class Library {
    var books: [Book] = [Book(), Book(), Book()]
}
// 🤨 BEFORE : @StateObject 기반 
@main
struct BookReaderApp: App {
    @StateObject private var library = Library()

    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environmentObject(library)
        }
    }
}

// 😀 AFTER : Observable 기반
@main
struct BookReaderApp: App {
    @State private var library = Library()

    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}
// 🤨 BEFORE : @ObservedObject 기반 
struct BookView: View {
    @ObservedObject var book: Book
    @State private var isEditorPresented = false
    
    var body: some View {
        HStack {
            Text(book.title)
            Button("Edit") { isEditorPresented = true }
        }
        .sheet(isPresented: $isEditorPresented) {
            BookEditView(book: book)
        }
    }
}

// 😀 AFTER : Observable 기반
struct BookView: View {
    var book: Book
    @State private var isEditorPresented = false
    
    var body: some View {
        HStack {
            Text(book.title)
            Button("Edit") { isEditorPresented = true }
        }
        .sheet(isPresented: $isEditorPresented) {
            BookEditView(book: book)
        }
    }
}

위의 세 부분 코드 예시처럼 Observable을 채택한 구조는,
과거 SwiftUI에서 사용했던 복잡한 구조 (ObservableObject, @Published, @StateObject, @ObservedObject 등등 이름은 비슷한데.. 붙여야 할 것도 같고..@Published는 SwiftUI 내부 방식도 아니고 Combine 프레임워크에 기반하고 있던...)보다 단순하고 효율적이게 코드를 작성할 수 있습니다.

또한 위에서 말한 것처럼 body에서 실제로 관찰하고 있는 속성의 값만 추적함 (smart tracking)으로써 리렌더링의 효율성을 높여주기도 하구요.

무엇보다 기존 Combine에 기반하고 있는 ObservableObject는 데이터 모델의 구조가 항상 클래스 (Class) 여야만 했습니다.
최근 Swift의 지향점은 값 타입 (Value Type)의 구조체 (Struct)인 만큼,
Observation이라는 기능을 만들어, 구조체 (Struct)의 특징인 데이터 레이스로부터 안전하고 효율적인 메모리 관리 (Copy-On-Write)를 적용한 데이터 모델 구조를 만들 수 있게 한 것입니다.

✍🏻 결론
iOS 17+ 부터는 SwiftUI에서 @State, @Observable + @Bindable, @Environment의 조합만으로 상태를 관리하는 것이 가능하다. (다른 프로퍼티 래퍼는 앞으로 SwiftUI에서 권장되지 않는 편이다.)

-> 그렇다면 더더욱 ObservableObject + @Published 기반의 기존 MVVM (ViewModel을 갖는) 구조는 SwiftUI 환경에서 적합하지 않은 방향인 것이라고 느껴지네요!
-> 앞으로는 상태를 Observation에 기반한 변형된 ViewModel 또는 State, Action으로 역할을 분리한 Redux 기반의 구조를 연구해 봐야겠다고 느낍니다 ! (TCA가 정답이 아닐 수도 있겠어요.)

 

 

Observation을 채택한 SwiftUI style 아키텍처 생각해보기 (iOS 17+) 🤔

커밍쑨 ! 곧 업데이트 예정 !

 

 

Reference

 

Managing model data in your app | Apple Developer Documentation

Create connections between your app’s data model and views.

developer.apple.com

 

Migrating from the Observable Object protocol to the Observable macro | Apple Developer Documentation

Update your existing app to leverage the benefits of Observation in Swift.

developer.apple.com

 

Observation | Apple Developer Documentation

Make responsive apps that update the presentation when underlying data changes.

developer.apple.com

 

Bindable | Apple Developer Documentation

A property wrapper type that supports creating bindings to the mutable properties of observable objects.

developer.apple.com