← SwiftUI 14일 마스터 5 / 14 · 1부

Day 5 · 1부 · @Observable 추적과 래퍼 3개

@Observable의 프로퍼티 단위 추적 규칙, 모델을 받는 프로퍼티에 붙일 래퍼를 세 질문으로 고르는 법, 추적·래퍼에서 흔한 실수 두 가지를 다룬다.
진행률
0 / 0 완료
UIKit에서 모델이 바뀌면 화면에 알리는 일은 사람 몫이었다. KVO를 걸거나, 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에서 하는 일

UIKit — 바뀔 때마다 라벨에 다시 넣는 코드를 사람이 쓴다
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)
    }
}
SwiftUI + @Observable — 읽기만 하면 구독이 생긴다
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이 바뀌어도 이 뷰는 다시 그려지지 않는다
    }
}
📝 Apple 원문

"When body is executed, SwiftUI tracks all access to properties used from 'Observable' types." — WWDC23 · Discover Observation in SwiftUI

풀이: SwiftUI는 body를 실행하는 동안 Observable 객체의 어떤 프로퍼티를 읽었는지 기록한다. 그 기록이 다음 변경 감지의 기준이 된다.

@Observable FoodTruckModel donuts orders orderCount (계산: orders 읽음) DonutMenu.body 읽은 것: donuts OrderBadge.body 읽은 것: orderCount → orders donuts 변경 → 갱신 orders 변경 → 갱신 orders가 바뀌어도 DonutMenu는 다시 그려지지 않는다 — 읽지 않았기 때문
의존성은 선언하지 않는다. body가 실행 중에 무엇을 읽었는지가 곧 의존성이다. 계산 프로퍼티는 그 안에서 읽은 저장 프로퍼티로 추적된다.

추적 규칙 5줄

규칙 5줄을 한 화면에 — 클래스 배열 · struct 배열 · 계산 프로퍼티 · body 밖 읽기
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과 반대인 직관

UIKit에서는 뷰 컨트롤러를 잘게 쪼개면 보일러플레이트만 늘었다. SwiftUI에서는 프로퍼티를 읽는 지점을 작은 서브뷰로 내리면 갱신 범위가 그만큼 줄어든다. 쪼갠 경계가 곧 갱신 경계다. 성능 진단은 Day 9에서 다룬다.

2. 래퍼는 세 개 — @State · @Environment · @Bindable 🔥 핵심

Observation 이후 모델용 래퍼는 세 개로 줄었다. WWDC23 발표는 질문 세 개로 고르라고 정리한다. 세 질문 모두 "아니오"면 래퍼 없이 그냥 프로퍼티다.

📝 Apple 원문

"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.

뷰에서 Observable 모델을 쓴다 ① 이 뷰가 만들고 소유하나? ② 트리 전체가 나눠 쓰나? ③ $바인딩이 필요한가? @State private var @Environment(T.self) @Bindable var var model: T (래퍼 없음) 예 예 예 아니오 아니오 아니오
위에서부터 차례로 묻는다. 가장 흔한 답은 맨 아래 "래퍼 없음"이다 — 읽기와 메서드 호출에는 래퍼가 필요 없다.
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)
    }
}
🔑 @Bindable은 "바꾸기 위해" 쓰는 게 아니다

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의 예시 그대로다.

body 안 지역 @Bindable — List 행과 환경 객체
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 팁

#Preview에서 @Bindable 뷰를 볼 땐 BookEditor(book: Book())처럼 인스턴스를 바로 넘기면 된다. 참조 타입이라 Preview 안에서 타이핑한 값이 그대로 반영된다.

3. 1부 흔한 실수 — 추적과 래퍼

① 매크로 대신 프로토콜만 채택

❌ Observable 프로토콜만 — 컴파일은 되지만 아무것도 추적되지 않는다
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개가 바뀌었다. 어떤 뷰가 다시 그려지나?
그 프로퍼티를 body 실행 중에 읽은 뷰만 다시 그려진다. 객체를 받아 넘기기만 한 중간 뷰, 다른 프로퍼티만 읽은 뷰는 그대로다. 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"가 찍힌다.
쉽게 말하면 V는 a만 보고 있었으니, b가 바뀐 건 V에게 알릴 이유가 없다.
🔁 지난 Day 복습: 같은 자리의 뷰를 .id(user.id)로 감쌌다. user가 바뀌면 그 뷰의 @State는 어떻게 되나?
id가 바뀌면 SwiftUI는 다른 뷰로 보고 기존 뷰를 없앤 뒤 새로 만든다. identity에 매달려 있던 @State도 초기값으로 리셋된다. 다만 "받은 모델을 @State로 복사"한 코드를 억지로 고치는 데 .id()를 쓰기보다, 받은 모델은 래퍼 없이 그냥 프로퍼티로 받는 게 맞다 — 오늘 결정 트리의 맨 아래 칸이다(3절 흔한 실수 ②).
쉽게 말하면 이름표를 바꿔 달면 새 학생으로 취급해서 사물함도 새로 준다.
➡️ 2부 예고

2부는 모델을 트리 깊숙이 건네는 @Environment와 커스텀 환경 값(@Entry), 회사 코드의 ObservableObject를 표 한 장으로 옮기는 마이그레이션, 그리고 옮기는 도중 밟기 쉬운 함정 두 가지를 다룬다. 핵심은 순서다 — : ObservableObject 채택은 마지막에 지운다.

출처 · 참고자료