NotificationCenter를 쏘거나, Combine으로 sink해서 라벨에 다시 넣었다. 그러다 한 곳을 빠뜨리면 화면이 낡은 값을 보여 줬다. iOS 17의 @Observable은 이 연결을 없앤다. body가 읽은 프로퍼티가 곧 구독 목록이다. 1부는 이 추적 규칙과, 모델을 받는 프로퍼티에 붙일 래퍼 3개의 결정법을 다룬다.1부 목표 · 21분
@Observable모델에서 어떤 프로퍼티가 바뀔 때 어떤 뷰가 다시 그려지는지 코드를 보고 예측한다.- 배열 요소가
@Observable클래스일 때와 struct일 때 갱신 범위가 어떻게 다른지 설명하고,.task·버튼 액션 안에서 읽은 값이 의존성이 아님을 코드로 짚는다. - 모델을 받는 프로퍼티에
@State·@Environment·@Bindable· 래퍼 없음 중 무엇을 붙일지 3개 질문으로 고른다. - 프로토콜만 채택한 모델, 받은 모델을
@State로 감싼 자식 뷰를 코드에서 찾아 고친다.
1. @Observable — body가 읽은 것만 추적한다 🔥 핵심
@Observable은 Swift 5.9 매크로다. 클래스에 붙이면 컴파일러가 각 저장 프로퍼티의 get/set에 "읽혔다/바뀐다" 기록 코드를 넣어 준다. 프로퍼티마다 @Published를 붙일 필요가 없다. 가용성은 Apple 문서 · Observable() 기준 iOS 17.0+다.
UIKit에서 하던 일 vs SwiftUI에서 하는 일
import UIKit
import Combine
final class CartModel: ObservableObject {
@Published var count = 0
}
final class CartViewController: UIViewController {
private let model = CartModel()
private let label = UILabel()
private var bag = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(label)
// 구독을 직접 걸고, 값을 직접 라벨에 넣는다
model.$count
.sink { [weak self] in self?.label.text = "\($0)개" }
.store(in: &bag)
}
}import SwiftUI
@Observable
final class Cart {
var count = 0
var coupon: String?
}
struct CartBadge: View {
var cart: Cart // 래퍼 없음
var body: some View {
Text("\(cart.count)개") // count를 읽었다 → count만 추적
// coupon이 바뀌어도 이 뷰는 다시 그려지지 않는다
}
}"When body is executed, SwiftUI tracks all access to properties used from 'Observable' types." — WWDC23 · Discover Observation in SwiftUI
풀이: SwiftUI는 body를 실행하는 동안 Observable 객체의 어떤 프로퍼티를 읽었는지 기록한다. 그 기록이 다음 변경 감지의 기준이 된다.
추적 규칙 5줄
- body 실행 중에 읽은 것만 의존성이다.
.task·onAppear·버튼 액션 안에서 읽은 값은 의존성이 아니다 — 그 값이 바뀌어도 body는 다시 불리지 않는다. 문서: "SwiftUI updates a view only when an observable property changes and the view's body reads the property directly." (Migrating to the Observable macro) - 계산 프로퍼티: 그 안에서 읽은 저장 프로퍼티로 추적된다.
orderCount를 읽으면orders가 추적된다. - @Observable 클래스의 배열·옵셔널·중첩: 인스턴스마다 프로퍼티 단위로 추적된다. 자막: "Because SwiftUI tracks access to fields per instance, it means that you can use arrays, optionals, or … any type that contains your observable models." (WWDC23 · 10149) 도넛 하나의
name이 바뀌면 그name을 읽은 곳만 갱신된다. - struct 배열: struct 요소는 Observable 인스턴스가 아니다. 추적되는 건 모델의 배열 프로퍼티 하나뿐이라, 요소 하나만 고쳐도 배열 프로퍼티가 바뀐 것이 되고 그 배열을 읽은 모든 body가 다시 불린다. 요소 단위 갱신이 필요하면 요소를
@Observable클래스로 만든다. - 통과만 하는 뷰: 객체를 받아 자식에게 넘기기만 하면 의존성이 생기지 않는다 (Managing model data in your app).
import SwiftUI
@Observable final class Donut: Identifiable { // 요소가 클래스 → 요소 프로퍼티 단위
let id = UUID()
var name: String
init(name: String) { self.name = name }
}
struct Tag: Identifiable { // 요소가 struct → 배열 하나로 추적
let id = UUID()
var title: String
}
@Observable final class FoodTruckModel {
var donuts: [Donut] = [Donut(name: "Glazed")]
var tags: [Tag] = [Tag(title: "신메뉴")]
var orders: [String] = []
var orderCount: Int { orders.count } // 계산 → orders 추적
}
struct DonutMenu: View {
var model: FoodTruckModel
var body: some View {
List {
ForEach(model.donuts) { donut in
Text(donut.name) // 이 donut의 name만 추적
}
ForEach(model.tags) { tag in
Text(tag.title) // tags[0].title 수정 → tags 전체 변경
}
Text("주문 \(model.orderCount)건")
Button("주문") {
model.orders.append("#\(model.orders.count + 1)") // 액션 안 읽기: 의존성 아님
}
}
.task { print(model.tags.count) } // task 안 읽기: 의존성 아님
}
}UIKit에서는 뷰 컨트롤러를 잘게 쪼개면 보일러플레이트만 늘었다. SwiftUI에서는 프로퍼티를 읽는 지점을 작은 서브뷰로 내리면 갱신 범위가 그만큼 줄어든다. 쪼갠 경계가 곧 갱신 경계다. 성능 진단은 Day 9에서 다룬다.
2. 래퍼는 세 개 — @State · @Environment · @Bindable 🔥 핵심
Observation 이후 모델용 래퍼는 세 개로 줄었다. WWDC23 발표는 질문 세 개로 고르라고 정리한다. 세 질문 모두 "아니오"면 래퍼 없이 그냥 프로퍼티다.
"Does this model need to be state of the view itself? If so, use '@State'. Does this model need to be part of the global environment of the application? If so, use '@Environment'. Does this model just need bindings? If so, use the new '@Bindable'. And if none of these questions have the answer as yes, just use the model as a property of your view." — WWDC23 · Discover Observation in SwiftUI
풀이: 뷰가 만들고 소유하면 @State, 앱 전체가 나눠 쓰면 @Environment, $ 바인딩만 필요하면 @Bindable, 셋 다 아니면 평범한 var.
| UIKit에서 하던 방식 | SwiftUI + Observation |
|---|---|
VC가 let model = Model()로 강하게 소유 | @State private var model = Model() |
부모 VC가 init(model:)으로 주입 | var model: Model (래퍼 없음) |
| 싱글턴 / DI 컨테이너에서 꺼내 쓰기 | .environment(model) + @Environment(Model.self) |
UITextField 델리게이트에서 model.name = text | @Bindable var model 후 TextField("", text: $model.name) |
import SwiftUI
@Observable final class Book: Identifiable {
var title = "Sample Book Title"
var isAvailable = true
}
struct ShelfView: View {
@State private var book = Book() // ① 소유: 이 뷰가 만든다
var body: some View {
VStack {
BookRow(book: book) // 그냥 넘긴다
BookEditor(book: book)
}
}
}
struct BookRow: View {
var book: Book // ④ 래퍼 없음: 읽기 + 메서드 호출
var body: some View {
Button(book.isAvailable ? "대출" : "반납") {
book.isAvailable.toggle() // 참조 타입이라 바로 바꿔도 반영
}
}
}
struct BookEditor: View {
@Bindable var book: Book // ③ 바인딩이 필요할 때만
var body: some View {
TextField("제목", text: $book.title)
}
}BookRow는 래퍼 없이도 book.isAvailable.toggle()로 값을 바꾼다. @Bindable은 TextField·Toggle처럼 Binding을 요구하는 컨트롤에 넘길 때만 필요하다. 발표에서도 "All it does is allow bindings to be created from that type"이라고 한다.
@Bindable은 지역 변수에도 붙는다
List 행이나 환경에서 꺼낸 객체처럼 프로퍼티로 선언할 자리가 없을 때, body 안에서 @Bindable var를 만든다. Apple 문서 · Bindable의 예시 그대로다.
import SwiftUI
@Observable final class Book: Identifiable {
var title = "Untitled"
}
struct LibraryList: View {
@State private var books = [Book(), Book(), Book()]
var body: some View {
List(books) { book in
@Bindable var book = book // 행마다 바인딩 생성
TextField("제목", text: $book.title)
}
}
}
struct TitleEditView: View {
@Environment(Book.self) private var book // 환경에서 꺼낸 객체
var body: some View {
@Bindable var book = book // 여기서 바인딩으로 변환
TextField("제목", text: $book.title)
}
}#Preview에서 @Bindable 뷰를 볼 땐 BookEditor(book: Book())처럼 인스턴스를 바로 넘기면 된다. 참조 타입이라 Preview 안에서 타이핑한 값이 그대로 반영된다.
3. 1부 흔한 실수 — 추적과 래퍼
① 매크로 대신 프로토콜만 채택
import Observation
final class Counter: Observable { // 매크로가 아니다
var value = 0
}import Observation
@Observable final class Counter {
var value = 0
}Apple 문서가 못 박은 부분이다: "Don't apply the Observable protocol by itself to your data model type, since that alone doesn't add any observation functionality." (Managing model data) 1절에서 본 "읽혔다/바뀐다" 기록 코드는 매크로가 넣어 준다. 프로토콜만으로는 그 코드가 없다.
② 자식이 받은 모델을 @State로 다시 감싼다
import SwiftUI
@Observable final class Book { var title = "" }
struct BookDetail: View {
@State var book: Book // 소유하지 않는데 @State
var body: some View { Text(book.title) }
}
// @State 초기값은 뷰 identity가 살아 있는 동안 한 번만 반영된다 (Day 3·4)import SwiftUI
@Observable final class Book { var title = "" }
struct BookDetail: View {
var book: Book
var body: some View { Text(book.title) }
}2절 결정 트리의 첫 질문 "이 뷰가 만들고 소유하나?"에 "아니오"인데 @State를 붙인 경우다. 받은 모델은 맨 아래 칸 "래퍼 없음"(바인딩이 필요하면 @Bindable)이다.
✅ 복습 · 5분
@Observable 모델의 프로퍼티 20개 중 1개가 바뀌었다. 어떤 뷰가 다시 그려지나?
ObservableObject였다면 객체를 관찰하는 모든 뷰가 갱신됐다. 단, 바뀐 것이 struct 배열의 요소 하나라면 배열 프로퍼티가 바뀐 것이므로 그 배열을 읽은 뷰는 모두 다시 그려진다.TextField에 모델의 title을 연결하려 한다. 모델이 부모에게서 넘어왔다면 무엇을 붙이나? 버튼으로 toggle만 한다면?
@Bindable var book: Book 후 $book.title. toggle만 한다면 래퍼 없이 var book: Book으로 충분하다 — 참조 타입이라 바로 바꿔도 반영된다.@Bindable은 "리모컨"을 다른 기계(TextField)에 건네줘야 할 때만 필요하다.🔮 코드 예측: @Observable class M { var a = 0; var b = 0 }이고 struct V: View { var m: M; var body: some View { let _ = print("V"); Text("\(m.a)") } }일 때 버튼이 m.b += 1을 실행하면 콘솔에 "V"가 찍히나?
V.body는 m.a만 읽었으므로 b 변경은 V를 무효화하지 않는다. 버튼이 m.a += 1을 실행하면 "V"가 찍힌다.🔁 지난 Day 복습: 같은 자리의 뷰를 .id(user.id)로 감쌌다. user가 바뀌면 그 뷰의 @State는 어떻게 되나?
@State도 초기값으로 리셋된다. 다만 "받은 모델을 @State로 복사"한 코드를 억지로 고치는 데 .id()를 쓰기보다, 받은 모델은 래퍼 없이 그냥 프로퍼티로 받는 게 맞다 — 오늘 결정 트리의 맨 아래 칸이다(3절 흔한 실수 ②).2부는 모델을 트리 깊숙이 건네는 @Environment와 커스텀 환경 값(@Entry), 회사 코드의 ObservableObject를 표 한 장으로 옮기는 마이그레이션, 그리고 옮기는 도중 밟기 쉬운 함정 두 가지를 다룬다. 핵심은 순서다 — : ObservableObject 채택은 마지막에 지운다.