원문: 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가 바뀌면 →DonutMenuinvalidate → 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)
}
}@Bindable은 Observable 타입에서 바인딩을 만들어 주기만 하는 가벼운 wrapper다. $donut.name 문법으로 Binding<String>을 얻는다. 소유·환경 주입과는 무관하다.
의사결정: 세 가지 질문
- 뷰가 직접 소유해야 하나? →
@State - 앱 전역에서 공유되나? →
@Environment - 바인딩만 필요한가? →
@Bindable - 위 셋 다 아닌가? → 그냥
letproperty로 받아서 쓴다. 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 = 0ObservationRegistrar가 각 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
공식 자료
- Apple Docs — Migrating from the Observable Object protocol to the Observable macro
- Apple Docs — Observable — 프레임워크 레퍼런스
- WWDC23 — Write Swift macros / Expand on Swift macros — 매크로 자체를 배우고 싶다면
심화
- 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
이 시리즈의 다음 편
- 5편 — A Day in the Life of a SwiftUI View (Chris Eidhof) — WWDC 세션 네 편을 하나의 멘탈 모델로 통합해 준 외부 정리. 뷰 트리·렌더 트리의 변화를 한 프레임씩 그림으로 따라간다