1. 진실은 한 곳에만 있어야 한다
Apple이 데이터 흐름 도구를 소개하는 문장에 목적이 박혀 있다. Model data:
"These tools help you maintain a single source of truth for every piece of data in your app, in part by reducing the amount of glue logic you write."
"glue logic"이 UIKit에서 우리가 쓰던 코드다 — 모델이 바뀌면 라벨을 갱신하고, 라벨이 편집되면 모델에 되돌려 쓰는 그 왕복. 진실이 두 군데(모델과 뷰) 있으니 둘을 맞추는 코드가 필요했고, 그 코드가 빠지는 순간이 버그였다.
UIKit 습관으로 모델 값을 @State에 복사해 두는 것. 그 순간 진실이 둘이 되고, 우리가 없애려던 동기화 코드가 되돌아온다. @State는 파생 값이 아니라 원본을 담는 자리다.
말로는 추상적이니 실제로 어긋나는 코드를 보자. 이건 SwiftUI 입문자가 거의 반드시 한 번 쓰는 코드다.
struct ProfileEditor: View {
let user: User // 진실 ①: 넘겨받은 원본
@State private var name: String // 진실 ②: 복사본
init(user: User) {
self.user = user
_name = State(initialValue: user.name) // 이 시점의 스냅샷
}
var body: some View {
TextField("이름", text: $name)
// 문제 1: 여기서 편집해도 user.name 은 안 바뀐다
// 문제 2: 부모가 다른 user 를 넘겨줘도 name 은 그대로다
// → @State 기본값은 identity가 처음 만들어질 때만 쓰인다 (3챕터)
}
}@State의 초기값은 뷰 identity가 처음 생길 때 한 번만 쓰인다. 그래서 목록에서 A를 눌러 편집기를 열고, 닫고, B를 눌러 다시 열면 B의 편집기에 A의 이름이 남아 있는 버그가 난다. identity가 유지됐기 때문이다 — 3챕터에서 정확한 이유를 다룬다.
@Observable final class User { // 참조 타입 모델
var name: String
init(name: String) { self.name = name }
}
struct ProfileEditor: View {
@Bindable var user: User // 복사하지 않는다. 바인딩만 만든다
var body: some View {
TextField("이름", text: $user.name) // 편집이 곧바로 모델에 반영된다
}
}"취소하면 원래대로 돌아가야 하는" 편집 화면은 의도적으로 복사하는 게 정답이다. 이때는 복사본이 또 하나의 진실이 아니라 독립된 진실(초안)이라는 걸 코드로 분명히 하고, 저장 시점에만 원본에 반영한다.
@Observable final class User: Identifiable {
let id = UUID()
var name: String
init(name: String) { self.name = name }
}
struct ProfileEditor: View {
let user: User
@State private var draft: String // 초안 = 독립된 진실
@Environment(\.dismiss) private var dismiss
init(user: User) {
self.user = user
_draft = State(initialValue: user.name)
}
var body: some View {
VStack {
TextField("이름", text: $draft)
HStack {
Button("취소") { dismiss() } // 초안을 버린다
Button("저장") { user.name = draft // 이때만 원본에 반영
dismiss() }
}
}
}
}다만 이 편집기를 여러 user로 재사용한다면 초안이 남는 문제가 그대로다. identity를 갈아 주는 건 부모가 할 일이다 — 자기 body 안에 .id()를 붙여도 자기 @State는 초기화되지 않는다.
// 부모 뷰
ProfileEditor(user: selected)
.id(selected.id) // user가 바뀌면 편집기를 새로 만든다 → 초안도 새로 시작2. 도구 다섯 개와 결정 트리
Apple이 Model data에 적어둔 선택 지침이 거의 그대로 결정 트리다.
"Manage transient UI state locally within a view by wrapping value types as State properties."
"Share a reference to a source of truth, like local state, using the Binding property wrapper."
"Connect to and observe reference model data by applying the Observable macro to the model data type. Instantiate an observable model data type directly in a view with a State property. Share the observable model data with other views in the hierarchy without passing a reference using the Environment property wrapper."
트리의 다섯 갈래가 한 화면 안에서 어떻게 같이 쓰이는지 보는 게 제일 빠르다. 장바구니 화면 하나로 전부 나온다.
@Observable final class Cart { // 참조 타입 모델
var items: [Item] = []
var couponCode: String = ""
var total: Int { items.reduce(0) { $0 + $1.price } }
}
@main
struct ShopApp: App {
@State private var cart = Cart() // ① 소유: 진실은 여기 하나
var body: some Scene {
WindowGroup {
CartScreen()
.environment(cart) // ② 계층에 흘려보낸다
}
}
}
struct CartScreen: View {
@Environment(Cart.self) private var cart // ③ 중간 뷰 없이 꺼내 쓴다
@State private var showsCoupon = false // ④ 값 타입 로컬 UI 상태
var body: some View {
VStack {
Text("합계 \(cart.total)원") // total 을 읽었으니 여기가 의존한다
Button("쿠폰 입력") { showsCoupon = true }
}
.sheet(isPresented: $showsCoupon) { // ⑤ Binding 으로 통로를 넘긴다
CouponEditor(cart: cart) // 모델은 참조라 그냥 넘긴다
}
}
}
struct CouponEditor: View {
@Bindable var cart: Cart // ⑥ 모델 프로퍼티에 바인딩이 필요할 때
var body: some View {
TextField("쿠폰 코드", text: $cart.couponCode)
}
}· cart는 어디에도 복사되지 않는다. 진실은 ShopApp의 @State 하나뿐이다.
· showsCoupon만 @State다 — UI 상태는 모델에 넣지 않는다. 시트가 열려 있는지는 서버에 저장할 데이터가 아니다.
· CouponEditor에 cart를 그냥 넘겼다(@Binding이 아니다). 참조 타입이라 통로가 필요 없고, @Bindable은 TextField가 Binding을 요구해서 쓴 것이다.
Cart가 그 역할이다. 달라진 건 "뷰 하나에 ViewModel 하나"라는 1:1 대응이 사라진 것이다. 화면마다 ViewModel을 만들어 값을 릴레이하면, 2챕터 첫 절의 진실 복제를 계층마다 반복하게 된다. 모델은 도메인 단위로 두고 필요한 뷰가 직접 읽는 게 이 프레임워크의 결이다.
| 도구 | 쓰는 곳 | 진실을 소유? | UIKit 대응 |
|---|---|---|---|
@State | 이 뷰(+하위)에서만 쓰는 값 · 이 뷰가 소유하는 모델 | 소유 | VC의 프로퍼티 |
@Binding | 남의 진실을 읽고 쓸 권한만 받음 | 소유 안 함 | 양방향 델리게이트 왕복 |
@Observable + @State | 참조 타입 모델을 이 뷰가 소유 | 소유 | VC가 ViewModel 소유 |
@Bindable | @Observable 모델의 프로퍼티에 바인딩이 필요할 때 | 소유 안 함 | — |
@Environment | 중간 뷰를 거치지 않고 계층 아래로 흘려보내기 | 소유 안 함 | 싱글턴 · DI 컨테이너 |
3. @State — 소유. 그리고 기본값 함정
Apple State 문서의 규칙은 짧고 단호하다.
"Use state as the single source of truth for a given value type that you store in a view hierarchy."
"Declare state as private to prevent setting it in a memberwise initializer, which can conflict with the storage management that SwiftUI provides."
"Declare state as private in the highest view in the view hierarchy that needs access to the value. Then share the state with any subviews... either directly for read-only access, or as a binding for read-write access."
"Use state only for storage that's local to a view and its subviews."
"You can safely mutate state properties from any thread."
실무 규칙 세 개로 압축된다.
private를 붙인다. 취향이 아니라 규약이다 — 외부에서 초기화하면 SwiftUI의 저장소 관리와 충돌한다.- 필요한 가장 높은 뷰에 둔다. 너무 아래 두면 형제 뷰와 공유가 안 되고, 너무 위에 두면 불필요한 갱신 범위가 커진다.
- 읽기만 하는 자식에게는 그냥 값을 넘긴다. 쓰기가 필요할 때만
@Binding이다.
struct PlayerView: View {
@State private var isPlaying = false // 여기가 진실. private 필수
var body: some View {
VStack {
// 읽기만 → 값 그대로 넘긴다. Binding 불필요
TitleLabel(isPlaying: isPlaying)
// 쓰기도 해야 함 → $ 로 바인딩을 넘긴다
PlayButton(isPlaying: $isPlaying)
}
}
}함정 — @State 기본값은 매번 만들어진다
1챕터의 "init에서 일하지 마라"가 @State에서 구체적인 사고로 나타난다. Apple 문서가 이 함정을 직접 설명한다.
"A State property always instantiates its default value when SwiftUI instantiates the view. For this reason, avoid side effects and performance-intensive work when initializing the default value. For example, if a view updates frequently, allocating a new default object each time the view initializes can become expensive. Instead, you can defer the creation of the object using the task modifier, which is called only once when the view first appears."
중요한 건 기본값이 실제로 쓰이지 않아도 만들어진다는 점이다. SwiftUI가 저장된 값을 연결해 주기 전에 struct는 이미 초기화됐기 때문이다.
struct ContentView: View {
@State private var library = Library() // 무거우면 이게 매번 비용
var body: some View { LibraryView(library: library) }
}struct ContentView: View {
@State private var library: Library?
var body: some View {
LibraryView(library: library)
.task { library = Library() } // 최초 등장 시 한 번만
}
}Library()가 그냥 빈 배열 몇 개 만드는 정도면 신경 쓰지 않아도 된다. 이 패턴은 네트워크·파일 접근·큰 할당이 초기화에 얽혀 있을 때 쓰는 것이다. 무조건 옵셔널로 만들면 코드만 지저분해진다.
4. @Binding — 소유하지 않고 읽고 쓸 권한만
Apple Binding:
"A binding connects a property to a source of truth stored elsewhere, instead of storing data directly."
UIKit에서 자식 뷰가 부모 값을 바꾸려면 delegate 프로토콜을 만들고, 부모가 채택하고, 콜백에서 값을 대입하고, 다시 화면을 갱신했다. @Binding은 그 왕복 전체를 "이 값에 대한 읽기·쓰기 통로" 하나로 대체한다.
$는 "값"이 아니라 "그 값으로 가는 통로"를 넘긴다. 자식이 통로에 쓰면 부모의 진실이 바뀌고, 그 결과가 다시 흘러 내려온다.5. @Observable — "body가 읽은 것만" 의존성이 된다
값 타입 몇 개로 안 끝나는 화면에서는 클래스 모델이 필요하다. iOS 17부터의 답은 @Observable 매크로다.
@Observable class Book: Identifiable {
var title = "Sample Book Title"
var author = Author()
var isAvailable = true
}여기서 SwiftUI를 이해하는 데 가장 중요한 규칙이 나온다. Managing model data in your app:
"a view forms a dependency on an observable data model object... when the view's body property reads a property of the object. If body doesn't read any properties of an observable data model object, the view doesn't track any dependencies."
"When a tracked property changes, SwiftUI updates the view. If other properties change that body doesn't read, the view is unaffected and avoids unnecessary updates."
다시 말하면 의존성은 내가 선언하는 게 아니라, body가 무엇을 읽었는지로 자동 결정된다. 이게 성능과 직결된다.
Apple이 못 박은 부분: "Don't apply the Observable protocol by itself to your data model type, since that alone doesn't add any observation functionality. Instead, always use the Observable macro." 프로토콜을 직접 채택하면 관찰이 동작하지 않는다.
이 규칙을 성능으로 바꿔 쓰는 법 — 읽는 범위를 쪼갠다
"body가 읽은 것만 의존성"이라는 규칙은 곧 뷰를 쪼개면 갱신 범위가 줄어든다는 뜻이다. 5챕터의 성능 항목이 사실 여기서 결정된다.
struct BookRow: View {
var book: Book
var body: some View {
HStack {
Text(book.title) // title 읽음
AvailabilityChart(book.isAvailable) // isAvailable 읽음 + 무거움
}
// title 만 바뀌어도 이 body 전체가 다시 돌고, 차트도 다시 만들어진다
}
}struct BookRow: View {
var book: Book
var body: some View {
HStack {
TitleText(book: book) // 여기서는 아무 프로퍼티도 읽지 않는다
AvailabilityBadge(book: book) // → BookRow 는 의존성이 없다
}
}
}
private struct TitleText: View {
var book: Book
var body: some View { Text(book.title) } // title 에만 의존
}
private struct AvailabilityBadge: View {
var book: Book
var body: some View { AvailabilityChart(book.isAvailable) } // isAvailable 에만 의존
}UIKit에서는 뷰 컨트롤러를 잘게 쪼개면 보일러플레이트가 늘어 손해였다. SwiftUI에서는 뷰를 쪼개는 것이 그 자체로 성능 최적화다 — 쪼갠 경계가 곧 갱신 경계가 된다. "이 뷰 너무 작지 않나?" 싶은 정도가 대체로 맞다.
바인딩이 필요하면 @Bindable
@Observable 모델의 프로퍼티를 TextField에 연결하려면 바인딩이 필요하다. 그때만 @Bindable을 쓴다.
// 읽기 + 메서드 호출 → 래퍼 불필요
struct BookView: View {
var book: Book
var body: some View {
Button(book.isAvailable ? "Check out" : "Return") {
book.isAvailable.toggle() // 바인딩 없이 그냥 바꿔도 된다
}
}
}
// TextField 처럼 Binding을 요구할 때만
struct BookEditView: View {
@Bindable var book: Book
var body: some View {
TextField("Title", text: $book.title)
}
}@Observable 모델은 참조 타입이라 그냥 바꿔도 반영된다. @Bindable은 "값을 바꾸기 위해"가 아니라 Binding을 요구하는 컨트롤에 넘겨주기 위해 쓰는 것이다. 이 구분을 놓치면 @Bindable을 남발하게 된다.
6. @Environment — 중간 뷰를 건너뛰고 흘려보내기
모델을 5단계 아래 뷰에 넘기려고 중간 뷰 4개에 프로퍼티를 뚫는 건 고통이다. @Environment가 그걸 없앤다.
@main
struct BookReaderApp: App {
@State private var library = Library() // 진실은 여기 하나
var body: some Scene {
WindowGroup {
LibraryView().environment(library) // 계층에 흘려보낸다
}
}
}
struct DeepChildView: View {
@Environment(Library.self) private var library // 중간 뷰를 안 거친다
var body: some View { Text("\(library.availableBooksCount)권") }
}Apple 원문: "If a view attempts to retrieve an object using its type and that object isn't in the environment, SwiftUI throws an exception." 즉 주입을 잊으면 런타임에 죽는다. 프리뷰나 특정 진입 경로에서 주입이 보장되지 않으면 옵셔널로 받아라.
@Environment(Library.self) private var library: Library?"You can use this property wrapper to read — but not set — an environment value." 값을 넣는 건 .environment(_:) 모디파이어의 일이다.
7. ObservableObject에서 Observable로
회사 코드베이스에 ObservableObject가 이미 깔려 있다면 이 절이 실전이다. Apple 마이그레이션 가이드가 이점 세 개를 든다.
· "Tracking optionals and collections of objects, which isn't possible when using ObservableObject."
· "Using existing data flow primitives like State and Environment instead of object-based equivalents such as StateObject and EnvironmentObject."
· "Updating views based on changes to the observable properties that a view's body reads instead of any property changes that occur to an observable object, which can help improve your app's performance."
세 번째가 성능의 핵심이고, Apple이 대조를 명확히 써 놨다.
"when tracking as Observable, SwiftUI updates a view only when an observable property changes and the view's body reads the property directly. The view doesn't update when observable properties not read by body changes. In contrast, a view updates when any published property of an ObservableObject instance changes, even if the view doesn't read the property that changes."
즉 ObservableObject 시절에는 프로퍼티 20개 중 하나만 바뀌어도 그 객체를 관찰하는 모든 뷰가 갱신됐다. @Observable은 실제로 읽은 뷰만 갱신한다.
같은 코드를 양쪽으로 놓고 보면 바뀌는 지점이 명확하다.
final class Library: ObservableObject {
@Published var books: [Book] = []
@Published var searchText = ""
var cacheDir: URL? // 관찰 대상이 아님을 표시할 방법이 없다
}
@main
struct BookReaderApp: App {
@StateObject private var library = Library()
var body: some Scene {
WindowGroup {
LibraryView().environmentObject(library)
}
}
}
struct BookCountLabel: View {
@EnvironmentObject var library: Library
var body: some View {
Text("\(library.books.count)권")
// ⚠️ searchText 를 읽지 않는데도, 타이핑할 때마다 이 뷰가 갱신된다
}
}@Observable final class Library {
var books: [Book] = []
var searchText = ""
@ObservationIgnored var cacheDir: URL? // 추적에서 명시적으로 제외
}
@main
struct BookReaderApp: App {
@State private var library = Library() // StateObject → State
var body: some Scene {
WindowGroup {
LibraryView().environment(library) // environmentObject → environment
}
}
}
struct BookCountLabel: View {
@Environment(Library.self) private var library
var body: some View {
Text("\(library.books.count)권")
// ✅ books 만 읽었으니, searchText 타이핑에는 갱신되지 않는다
}
}검색창이 있는 목록 화면을 떠올려 보라. ObservableObject 시절에는 한 글자 입력할 때마다 그 객체를 보는 모든 뷰가 갱신됐다. 그래서 "검색만 하면 버벅인다"가 흔했고, 해결책으로 ViewModel을 잘게 나누거나 objectWillChange를 손으로 다뤘다. @Observable에서는 그 작업이 그냥 없어진다.
| 기존 (ObservableObject) | 변경 후 (Observable) | 비고 |
|---|---|---|
class M: ObservableObject | @Observable class M | 프로토콜 채택 제거 |
@Published var x | var x | 래퍼 불필요 |
| (추적 제외하고 싶은 프로퍼티) | @ObservationIgnored var x | 새로 생긴 도구 |
@StateObject private var m = M() | @State private var m = M() | 소유는 @State로 통일 |
@ObservedObject var m: M | var m: M | 래퍼 자체가 사라진다 |
@EnvironmentObject var m: M | @Environment(M.self) var m | 주입도 .environment(_:)로 |
.environmentObject(m) | .environment(m) | — |
바인딩: $m.x (ObservedObject) | @Bindable var m: M 후 $m.x | 바인딩만 필요할 때 |
"You don't need to make a wholesale replacement... Your app can mix data model types that use different observation systems." @State/@Environment는 ObservableObject 타입도 계속 지원하므로, 모델 하나씩 옮겨도 앱이 정상 동작한다.
"@State에 클래스를 쓰면 매 업데이트마다 인스턴스가 재생성되니 @StateObject를 써야 한다"는 글이 아직 많다. Observation 이전 시대 조언이다. Apple 현행 문서는 정반대를 명시한다: "You can also store observable objects that you create with the Observable macro in State." 그리고 "Each time SwiftUI re-creates BookView, it connects the book variable to the managed instance" — 저장소는 SwiftUI가 관리하므로 재생성되지 않는다.
@StateObject가 여전히 필요한 건 아직 ObservableObject인 타입뿐이다.
ObservableObject는 반 전체 단체 문자다. 누구 하나에게만 필요한 소식도 40명 전원에게 알림이 간다.
@Observable은 개인 알림 설정이다. "나는 급식 메뉴만 볼래"라고 한 사람에게는 급식 메뉴가 바뀔 때만 알림이 온다. 그런데 그 설정을 내가 적는 게 아니라, 내가 실제로 읽은 항목이 자동으로 구독 목록이 된다.
이 챕터 요약
- 진실은 한 곳에만. 모델 값을
@State로 복사하면 우리가 없애려던 동기화 버그가 돌아온다. @State는 소유(+private필수),@Binding은 통로. 읽기만 하면 그냥 값으로 넘긴다.@Observable의 의존성은body가 실제로 읽은 프로퍼티로 자동 결정된다. 지나가는 뷰는 갱신되지 않는다.@Bindable은 값 변경용이 아니라Binding을 요구하는 컨트롤용이다.@Environment는 타입 키로 읽을 때 없으면 크래시다. 보장 없으면 옵셔널.ObservableObject→@Observable은 모델 단위로 점진 이행이 가능하다.