LODY/정리

애플 플랫폼 백과사전 / Discover Observation in SwiftUI (WWDC23 10149)

Discover Observation in SwiftUI (WWDC23 10149)

원문: Discover Observation in SwiftUI — WWDC23 #10149 발표자: Philippe (Swift Observation 팀) 공개: 2023년 6월

Swift 5.9에서 @Observable 매크로가 Observation 프레임워크의 일부로 도입됐다. 이 세션은 "왜 바꿔야 했는가""새 매크로가 내부에서 무엇을 해 주는가" 를 다룬다.

핵심 메시지 한 줄.

body 실행 중 실제로 읽힌 property만 관찰한다. 나머지는 바뀌어도 뷰를 invalidate하지 않는다.


Part 1: @Observable이 바꾼 것

예전 패턴: ObservableObject + @Published

class FoodTruckModel: ObservableObject {
    @Published var orders: [Order] = []
    @Published var donuts = Donut.all
}

@Published하나라도 바뀌면 objectWillChange가 발행되고, 이 모델을 @ObservedObject@StateObject로 쓰는 모든 뷰가 전부 invalidate된다. 뷰가 model.donuts만 읽고 model.orders는 한 번도 쓰지 않아도, orders가 바뀌면 함께 재평가된다.

새 패턴: @Observable

@Observable class FoodTruckModel {
    var orders: [Order] = []
    var donuts = Donut.all
}
  • ObservableObject 준수 제거
  • @Published 제거
  • 클래스 앞에 @Observable 매크로만 붙임

매크로가 컴파일 시점에 이 클래스를 관찰 가능한 형태로 확장한다. 그 결과 SwiftUI는 이 타입의 property 접근을 property 단위로 추적할 수 있다.

Granular tracking의 실제 동작

struct DonutMenu: View {
    let model: FoodTruckModel
 
    var body: some View {
        List {
            Section("Donuts") {
                ForEach(model.donuts) { donut in
                    Text(donut.name)
                }
                Button("Add new donut") {
                    model.addDonut()
                }
            }
        }
    }
}

body 실행 시 SwiftUI는 model.donuts에 접근하는 것을 감지한다.

  • model.donuts가 바뀌면 → DonutMenu invalidate → body 재실행
  • model.orders가 바뀌면 → 이 뷰의 tracked set에 없으므로 invalidate되지 않음

이 차이가 앱 전체의 성능을 바꾼다. 큰 모델을 공유해도 각 뷰는 자기가 실제로 쓰는 필드만 추적한다.

computed property도 추적된다

@Observable class FoodTruckModel {
    var orders: [Order] = []
    var donuts = Donut.all
    var orderCount: Int { orders.count }
}
 
struct DonutMenu: View {
    let model: FoodTruckModel
 
    var body: some View {
        List {
            Section("Donuts") {
                ForEach(model.donuts) { donut in
                    Text(donut.name)
                }
            }
            Section("Orders") {
                LabeledContent("Count", value: "\(model.orderCount)")
            }
        }
    }
}

orderCount는 computed지만 내부에서 orders에 접근한다. SwiftUI는 접근 체인을 따라가며 실제 stored property인 orders를 tracked set에 포함시킨다. 결과적으로 orders가 바뀌면 DonutMenu가 invalidate된다 — 원하던 동작.

용어 설명

  • macro (Swift 5.9): 컴파일 타임에 코드를 변환·확장하는 메타프로그래밍 기능. @Observable은 여기 속한다
  • tracked set: SwiftUI가 body 실행 중 접근한 property의 집합. 다음 번에 어느 property가 바뀌면 이 뷰를 invalidate할지의 기준
  • granular tracking: property 단위의 세밀한 변경 추적. ObservableObject의 object 단위 invalidation과 대비

Part 2: Property Wrapper 세 개로 끝난다

@State: 뷰 내부 스토리지

struct DonutListView: View {
    var donutList: DonutList
    @State private var donutToAdd: Donut?
 
    var body: some View {
        List(donutList.donuts) { DonutView(donut: $0) }
        Button("Add Donut") { donutToAdd = Donut() }
            .sheet(item: $donutToAdd) {
                TextField("Name", text: $donutToAdd.name)
                Button("Save") {
                    donutList.donuts.append(donutToAdd)
                    donutToAdd = nil
                }
                Button("Cancel") { donutToAdd = nil }
            }
    }
}

donutToAdd는 뷰 자신의 상태. @Observable 객체를 뷰가 직접 소유해야 할 때도 @State를 쓴다.

@Environment: 전역 주입

@Observable class Account {
    var userName: String?
}
 
struct FoodTruckMenuView: View {
    @Environment(Account.self) var account
 
    var body: some View {
        if let name = account.userName {
            HStack {
                Text(name)
                Button("Log out") { account.logOut() }
            }
        } else {
            Button("Login") { account.showLogin() }
        }
    }
}

@EnvironmentObject가 사라지고 @Environment(Account.self) 로 통일됐다. 주입부도 .environment(account)로 깔끔해진다. 여전히 granular tracking이 적용돼서 account.userName만 추적된다.

@Bindable: 새로 등장한 세 번째

@Observable class Donut {
    var name: String = ""
}
 
struct DonutView: View {
    @Bindable var donut: Donut
 
    var body: some View {
        TextField("Name", text: $donut.name)
    }
}

@BindableObservable 타입에서 바인딩을 만들어 주기만 하는 가벼운 wrapper다. $donut.name 문법으로 Binding<String>을 얻는다. 소유·환경 주입과는 무관하다.

의사결정: 세 가지 질문

  1. 뷰가 직접 소유해야 하나?@State
  2. 앱 전역에서 공유되나?@Environment
  3. 바인딩만 필요한가?@Bindable
  4. 위 셋 다 아닌가? → 그냥 let property로 받아서 쓴다. property wrapper 불필요

@ObservedObject·@StateObject·@EnvironmentObject@Observable 시대에는 쓸 일이 없다. 기존 코드를 마이그레이션할 때만 등장.


Part 3: 배열·optional에서도 똑같이 동작한다

@Observable은 컬렉션이나 중첩 타입에서도 자연스럽게 작동한다.

@Observable class Donut {
    var name: String = ""
}
 
struct DonutList: View {
    var donuts: [Donut]
 
    var body: some View {
        List(donuts) { donut in
            HStack {
                Text(donut.name)
                Spacer()
                Button("Randomize") {
                    donut.name = randomName()
                }
            }
        }
    }
}

Donut 인스턴스의 name에 개별적으로 접근하고, 그 인스턴스의 name이 바뀔 때만 해당 행을 invalidate한다. 수백 개짜리 리스트라도 관련된 행 하나만 리렌더.

옵셔널도 똑같다. model.selected?.name을 body에서 읽으면 selected의 identity와 name 둘 다 tracked set에 들어간다.


Part 4: 고급: computed property가 stored에 의존하지 않을 때

대부분의 경우 computed property는 내부에서 stored property를 읽는다 → 자동 추적. 드물게 외부 (non-observable) 저장소를 감싸는 경우만 수동 작업이 필요하다.

@Observable class Donut {
    var name: String {
        get {
            access(keyPath: \.name)
            return someNonObservableLocation.name
        }
        set {
            withMutation(keyPath: \.name) {
                someNonObservableLocation.name = newValue
            }
        }
    }
}

access(keyPath:)로 "이 property가 읽혔다"고 신고, withMutation(keyPath:)로 "이 property가 바뀌었다"고 신고. 이 두 훅이 평소에는 매크로가 자동으로 삽입해 주는 부분이다.

실무에서는 거의 쓸 일 없다. 대부분 stored property 위에 자연스럽게 얹힌다.


Part 5: ObservableObject → Observable 마이그레이션

모델 쪽

// Before
class FoodTruckModel: ObservableObject {
    @Published var donuts: [Donut] = []
    @Published var orders: [Order] = []
}
 
// After
@Observable class FoodTruckModel {
    var donuts: [Donut] = []
    var orders: [Order] = []
}

뷰 쪽

// Before
struct MenuView: View {
    @ObservedObject var model: FoodTruckModel
    @EnvironmentObject var account: Account
 
    var body: some View {
        TextField("Note", text: $model.note)
    }
}
 
// After
struct MenuView: View {
    var model: FoodTruckModel  // property wrapper 없이 그냥 property
    @Environment(Account.self) var account
 
    var body: some View {
        @Bindable var model = model  // bindings이 필요한 자리에서만
        TextField("Note", text: $model.note)
    }
}
  • @ObservedObject var model그냥 var model. 단, 바인딩($)이 필요한 자리에서는 함수 본문 안에 @Bindable var model = model로 지역 래핑
  • @StateObject var model@State private var model
  • @EnvironmentObject var x@Environment(X.self) var x

마이그레이션이 주는 이득

  • 코드가 줄어든다 (@Published 제거)
  • 뷰 재평가가 실제 의존성만으로 줄어든다 → 성능 향상
  • 뷰가 모델을 "그냥 let으로" 받아도 observation이 작동한다 (property wrapper 없이)

내부 메커니즘: 매크로가 만드는 코드

@Observable class Foo { var bar: Int = 0 }를 매크로가 전개하면 대략 다음 형태가 된다.

@_Observable
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
 
internal nonisolated func access<Member>(keyPath: KeyPath<Foo, Member>) {
    _$observationRegistrar.access(self, keyPath: keyPath)
}
 
internal nonisolated func withMutation<Member, MutationResult>(
    keyPath: KeyPath<Foo, Member>,
    _ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
    try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
 
var bar: Int {
    get { access(keyPath: \.bar); return _bar }
    set { withMutation(keyPath: \.bar) { _bar = newValue } }
}
 
private var _bar: Int = 0

ObservationRegistrar가 각 keyPath에 대해 "누가 읽었는지"를 기록하고, mutation 시점에 해당 구독자에게 알린다. SwiftUI는 body 실행을 observation 컨텍스트로 감싸서 접근 로그를 받아 간다.

이 동일한 메커니즘이 SwiftUI 바깥에서도 쓸 수 있다. withObservationTracking(_:onChange:) API로 직접 접근·변경을 관찰할 수 있다.


한 장짜리 비교표

항목ObservableObject + @Published@Observable
invalidation 범위object 전체property 단위
뷰에서의 property wrapper@ObservedObject/@StateObject/@EnvironmentObject@State/@Environment/@Bindable (또는 없음)
Combine Publisher 필요네 (objectWillChange)아니오
macro 사용아니오Swift 5.9 매크로
성능큰 모델 공유 시 overinvalidate필요한 뷰만 재평가
binding 만드는 법$model.x (property wrapper 기반)@Bindable$model.x

관련 읽을거리

한국어·영문 정리

  • Steven Curtis — WWDC 2023: Discover Observation in SwiftUI (Medium) — 세션 요약 + 코드 stevenpcurtis.medium.com
  • wwdcnotes — Discover Observation in SwiftUI. wwdcnotes.com
  • Simform Engineering — Observation Framework for SwiftUI (Medium) medium.com

공식 자료

심화

  • InfoQ — SwiftUI 5 Leaves Combine Behind, Extends Animations, and More — Combine과의 관계 분석. infoq.com
  • Paul Bancarel — Swift concurrency: async/await — All you need to know from WWDC 21 to 23 — concurrency와의 조합. medium.com

이 시리즈의 다음 편