@Observable은 body가 읽은 프로퍼티만 추적하고, 모델을 받는 래퍼는 @State · @Environment · @Bindable 셋으로 줄어든다는 것을 봤다. 2부는 그중 @Environment로 객체와 값을 트리 깊숙이 건네는 법, UIKit 시절부터 쌓인 ObservableObject 코드를 옮기는 순서, 옮기는 도중 조용히 터지는 함정을 다룬다. 주입은 한 번, 갱신은 읽은 곳만.2부 목표 · 25분
- 앱 모델을
.environment(_:)로 한 번 주입하고, 주입이 보장되지 않는 뷰는 옵셔널로 받아 크래시를 막는다. - 커스텀 환경 값을
EnvironmentKey방식과@Entry방식으로 각각 만든다. ObservableObject코드를 표 한 장을 보고@Observable로 옮기고,: ObservableObject는 뷰를 다 바꾼 뒤 마지막에 지운다.- 마이그레이션 도중의 함정 두 가지(
ObservableObject를@State에 넣기, 무거운@State초기값)를 코드에서 찾아 고친다.
1. @Environment — 중간 뷰를 건너뛰는 주입
모델을 다섯 단계 아래 뷰에 넘기려고 중간 뷰 네 개에 프로퍼티를 뚫을 필요는 없다. 상위에서 .environment(model)로 넣고, 필요한 뷰가 @Environment(Model.self)로 꺼낸다. 중간 뷰는 모델의 존재조차 모른다.
import SwiftUI
@Observable final class Library {
var books: [String] = ["A", "B"]
var availableCount: Int { books.count }
}
@main
struct BookReaderApp: App {
@State private var library = Library() // 진실은 여기 하나
var body: some Scene {
WindowGroup {
RootView().environment(library) // 타입 자체가 키
}
}
}
struct RootView: View {
var body: some View { DetailBadge() } // library를 모른다
}
struct DetailBadge: View {
@Environment(Library.self) private var library
var body: some View {
Text("\(library.availableCount)권")
}
}Apple 원문: "If a view attempts to retrieve an object using its type and that object isn't in the environment, SwiftUI throws exception." (Managing model data in your app) 풀이: 타입 키로 꺼냈는데 상위에서 넣지 않았으면 앱이 죽는다. Preview나 특정 진입 경로에서 주입이 보장되지 않으면 옵셔널로 받는다 — 없으면 nil이 온다.
import SwiftUI
@Observable final class Library { var books: [String] = [] }
struct SafeBadge: View {
@Environment(Library.self) private var library: Library?
var body: some View {
Text(library.map { "\($0.books.count)권" } ?? "라이브러리 없음")
}
}커스텀 환경 값 — 값 타입·기본값이 필요할 때
테마 색, 기능 플래그, 날짜 포맷처럼 객체가 아니라 값이거나 기본값이 필요하면 EnvironmentValues에 프로퍼티를 추가한다. 방법은 두 가지다.
import SwiftUI
private struct AccentThemeKey: EnvironmentKey {
static let defaultValue: Color = .blue // 넣지 않으면 이 값
}
extension EnvironmentValues {
var accentTheme: Color {
get { self[AccentThemeKey.self] }
set { self[AccentThemeKey.self] = newValue }
}
}
struct PriceTag: View {
@Environment(\.accentTheme) private var accent // 키패스로 읽는다
var body: some View {
Text("₩12,000").foregroundStyle(accent)
}
}
struct CheckoutView: View {
var body: some View {
PriceTag().environment(\.accentTheme, .orange) // 하위 트리만 덮어쓴다
}
}WWDC24에서 나온 @Entry는 위의 키 타입 + get/set 확장을 한 줄로 줄인다. 발표 원문: "Instead of having to write out a full conformance to EnvironmentKey, and an extension on environment values, you can now just write a simple property with the new Entry macro." (WWDC24 · What's new in SwiftUI)
매크로는 iOS 18 SDK(Xcode 16)에 들어 있지만, Apple 문서 · Entry()의 가용성은 iOS 13.0+다. 즉 Xcode 16 이상으로 빌드하면 배포 타깃 iOS 17 앱에서도 쓸 수 있다. Transaction·FocusedValues·ContainerValues에도 같은 문법을 쓴다.
import SwiftUI
extension EnvironmentValues {
@Entry var accentTheme: Color = .blue
}
struct PriceTag: View {
@Environment(\.accentTheme) private var accent
var body: some View { Text("₩12,000").foregroundStyle(accent) }
}| 상황 | .environment(obj) + @Environment(T.self) | 커스텀 EnvironmentValues 키 |
|---|---|---|
| @Observable 앱 모델 공유 | 가장 간단 | 가능 (기본값 인스턴스 필요) |
| 값 타입(Color, Bool, enum) | 불가 — Observable 객체만 | 적합 |
| 안 넣었을 때 | 크래시 (옵셔널로 받으면 nil) | defaultValue 사용 |
@Environment 프로퍼티에 새 인스턴스를 대입할 수 없다. 값을 넣는 건 .environment(_:) 모디파이어의 일이다. 단, 꺼낸 Observable 객체의 프로퍼티는 참조 타입이라 바꿀 수 있고, 바인딩이 필요하면 1부 2절처럼 지역 @Bindable을 만든다.
2. ObservableObject → @Observable 마이그레이션
회사 코드에 ObservableObject가 깔려 있다면 이 절이 실전이다. WWDC23 발표의 요약: "Changing from ObservableObject to the new '@Observable' macro was mostly just deleting annotations." 대부분 지우는 작업이다.
| 기존 (ObservableObject) | 변경 후 (@Observable) | 비고 |
|---|---|---|
class M: ObservableObject | @Observable class M | 매크로 추가. 프로토콜 채택 제거는 맨 마지막(아래 순서) |
@Published var x | var x | 래퍼 삭제 |
@Published 없는 var x — 원래 알림 안 함 (opt-in) | @ObservationIgnored var x | @Observable은 전부 추적(opt-out)이라 빼려면 명시 |
@StateObject private var m = M() | @State private var m = M() | 소유는 @State로 통일 |
@ObservedObject var m: M | var m: M 또는 @Bindable var m: M | 바인딩 필요 여부로 결정 |
@EnvironmentObject var m: M | @Environment(M.self) var m | — |
.environmentObject(m) | .environment(m) | — |
import SwiftUI
final class Library: ObservableObject {
@Published var books: [String] = []
@Published var searchText = ""
}
struct LibraryScreen: View {
@StateObject private var library = Library()
var body: some View {
VStack {
TextField("검색", text: $library.searchText)
BookCountLabel()
}
.environmentObject(library)
}
}
struct BookCountLabel: View {
@EnvironmentObject var library: Library
var body: some View {
Text("\(library.books.count)권")
// searchText를 읽지 않는데도 타이핑마다 갱신된다
}
}import SwiftUI
@Observable final class Library {
var books: [String] = []
var searchText = ""
@ObservationIgnored var cacheDir: URL? // UI와 무관 → 추적 제외
}
struct LibraryScreen: View {
@State private var library = Library() // StateObject → State
var body: some View {
@Bindable var library = library // $searchText용
VStack {
TextField("검색", text: $library.searchText)
BookCountLabel()
}
.environment(library) // environmentObject → environment
}
}
struct BookCountLabel: View {
@Environment(Library.self) private var library
var body: some View {
Text("\(library.books.count)권") // books만 추적
}
}"You don't need to make a wholesale replacement of the ObservableObject protocol throughout your app. Instead, you can make changes incrementally." (Migrating to the Observable macro)
풀이: 모델 하나씩 옮겨도 된다. 문서는 "Start by changing one data model type"이라며, 한 앱 안에 두 방식의 모델이 섞여 있어도 된다고 한다.
한 모델을 옮기는 순서 — 채택 제거는 마지막
함정이 하나 있다. @StateObject·@EnvironmentObject·@ObservedObject·.environmentObject(_:)는 타입이 ObservableObject를 채택해야 컴파일된다. 모델에서 : ObservableObject부터 지우면 뷰가 아직 옛 래퍼를 쓰는 곳마다 빌드가 깨진다.
import SwiftUI
@Observable final class Library { // : ObservableObject를 먼저 지웠다
var books: [String] = []
}
struct LibraryScreen: View {
@StateObject private var library = Library()
// error: generic struct 'StateObject' requires that 'Library' conform to 'ObservableObject'
var body: some View { Text("\(library.books.count)권") }
}- 모델:
@Observable을 붙이고@Published를 지운다.: ObservableObject는 남겨 둔다. - 뷰: 그 모델을 쓰는 곳을 하나씩
@State·@Environment(M.self)·@Bindable/래퍼 없음으로 바꾼다. 이 동안 옛 래퍼를 쓰는 뷰도 계속 컴파일된다. - 마지막: 옛 래퍼가 하나도 안 남았을 때
: ObservableObject를 지운다.
import SwiftUI
@Observable final class Library: ObservableObject { // 1단계: 채택은 아직 남긴다
var books: [String] = [] // @Published는 지웠다
}
struct OldScreen: View {
@StateObject private var library = Library() // 아직 안 바꾼 뷰 — 컴파일된다
var body: some View {
NewBadge().environmentObject(library)
}
}
struct NewBadge: View {
@EnvironmentObject var library: Library // 이것도 컴파일된다
var body: some View { Text("\(library.books.count)권") }
}
struct NewScreen: View {
@State private var library = Library() // 2단계: 바꾼 뷰
var body: some View { Text("\(library.books.count)권") }
}중간 상태에서 화면 갱신은 @Published가 아니라 1부에서 본 추적 — body가 읽은 프로퍼티 — 으로 일어난다. 모든 뷰가 OldScreen·NewBadge 같은 옛 래퍼를 벗으면 3단계로 채택을 지운다.
@ObservationIgnored — 추적하지 않을 프로퍼티
캐시, 포맷터, 로거처럼 UI와 상관없는 저장 프로퍼티에 붙인다. Apple 문서 · ObservationIgnored(): "To prevent observation of an accessible property, attach the ObservationIgnored macro to the property." 이 프로퍼티는 바뀌어도 어떤 뷰도 갱신하지 않는다.
계산 프로퍼티가 모델 밖(예: UserDefaults)에 값을 저장하면 Observation이 변경을 모른다. 이때만 매크로가 만들어 준 access(keyPath:)와 withMutation(keyPath:_:)를 직접 호출한다. WWDC23 발표: "all that needs to be done is tell Observation when the property is accessed and when the property changes."
import Foundation
import Observation
@Observable final class Settings {
@ObservationIgnored private let defaults = UserDefaults.standard
var nickname: String {
get {
access(keyPath: \.nickname) // "읽혔다" 기록
return defaults.string(forKey: "nickname") ?? ""
}
set {
withMutation(keyPath: \.nickname) { // "바뀐다" 알림
defaults.set(newValue, forKey: "nickname")
}
}
}
}3. 흔한 실수 — 옮긴 뒤
①(프로토콜만 채택)과 ②(받은 모델을 @State로 감싸기)는 추적·래퍼 문제라 1부 3절에서 봤다. 여기서는 마이그레이션 도중에 생기는 두 가지를 본다.
③ ObservableObject를 @State에 넣는다
Apple 문서 · State: "It's possible to store an object that conforms to the ObservableObject protocol in a State property. However the view will only update when the reference to the object changes." 풀이: @Observable이 아직 안 붙은 ObservableObject 타입을 @State에 넣으면 @Published 변경에 반응하지 않는다. 마이그레이션 중 뷰만 먼저 @State로 바꾸면 이 버그가 난다.
순서를 지킨다: 1단계 모델에 @Observable을 붙이고 @Published를 지운다(: ObservableObject는 유지) → 2단계 뷰를 @State·@Environment·@Bindable로 바꾼다 → 3단계 옛 래퍼가 다 사라지면 채택을 지운다. 2절 "한 모델을 옮기는 순서"와 같다.
④ @State 초기값에서 무거운 일을 한다
@State private var catalog = Catalog()는 뷰 struct가 만들어질 때마다 Catalog()를 실행한다(저장되는 건 첫 인스턴스뿐). 원리와 고치는 법은 Day 3 1부 함정 ②와 같다 — 옵셔널로 두고 .task { if catalog == nil { catalog = Catalog() } }로 한 번만 만든다. .task는 뷰가 나타날 때마다 다시 돌기 때문에 nil 검사가 꼭 필요하다.
개수·상태를 보여 주는 뱃지는 .accessibilityLabel("책 \(count)권")을 붙인다. Observation이 count를 추적하므로 VoiceOver가 읽는 문구도 값과 함께 자동으로 갱신된다.
✅ 복습 · 5분
@Environment(Library.self)로 꺼내는 뷰가 있는데, 어떤 진입 경로에서는 상위에 .environment(library)가 없다. 무슨 일이 생기고, 어떻게 막나?
@Environment(Library.self) private var library: Library?처럼 옵셔널로 받으면 없을 때 nil이 온다. Preview에서도 같은 크래시가 나므로 Preview에 .environment(Library())를 붙이는 습관을 들인다.앱 전체 강조색(Color)을 환경으로 내려보내려 한다. .environment(obj) + @Environment(T.self) 방식을 못 쓰는 이유와 대신 쓸 방법은?
.environment(_:)에 타입을 키로 넣는 방식은 Observable 객체용이다. Color 같은 값 타입은 EnvironmentValues에 프로퍼티를 추가한다 — EnvironmentKey + get/set 확장, 또는 Xcode 16 이상이면 @Entry var accentTheme: Color = .blue 한 줄. 넣지 않은 곳에서는 기본값이 쓰여 크래시도 없다.import SwiftUI
final class Tally: ObservableObject {
@Published var n = 0
}
struct TallyView: View {
@State private var tally = Tally() // 뷰만 먼저 @State로 바꿨다
var body: some View {
Button("\(tally.n)") { tally.n += 1 }
}
}🔮 코드 예측: 위 버튼을 세 번 누르면 버튼 글자는 무엇이 되나?
n은 3이 되지만 화면은 갱신되지 않는다. Apple 문서 · State: ObservableObject를 @State에 넣으면 "the view will only update when the reference to the object changes." 고치는 순서는 모델에 먼저 @Observable을 붙이고(@Published 삭제, : ObservableObject는 잠시 유지) 뷰를 바꾼 뒤, 옛 래퍼가 다 사라지면 채택을 지우는 것이다(흔한 실수 ③).🔁 1부 복습: @Observable 모델에 var tags: [Tag]가 있고 Tag는 struct다. tags[0].title만 바꿨을 때, tags.count만 보여 주는 뷰도 다시 그려지나?
tags 배열 프로퍼티 하나다. 요소 하나를 고치면 배열 프로퍼티가 바뀐 것이 되고, 그 배열을 body에서 읽은 뷰는 모두 무효화된다. 요소 단위 갱신이 필요하면 Tag를 @Observable 클래스로 만든다.🛠 미니 과제 (선택 · 10분)
아래 ObservableObject 코드를 @Observable로 옮긴다.
import SwiftUI
final class Counter: ObservableObject {
@Published var count = 0
@Published var step = 1
}
struct CounterScreen: View {
@StateObject private var counter = Counter()
var body: some View {
VStack(spacing: 16) {
CountText(counter: counter)
Stepper("간격 \(counter.step)", value: $counter.step, in: 1...10)
Button("더하기") { counter.count += counter.step }
}
.padding()
}
}
struct CountText: View {
@ObservedObject var counter: Counter
var body: some View {
let _ = print("CountText 갱신")
Text("\(counter.count)").font(.largeTitle)
}
}
#Preview { CounterScreen() }- 요구 1:
Counter를@Observable로 바꾸고@Published를 지운다. - 요구 2:
CounterScreen은@State로 소유하고,Stepper에 넘길 바인딩을 지역@Bindable로 만든다. - 요구 3:
CountText에서 래퍼를 없앤다.
확인 방법: Preview를 Debug 모드(또는 시뮬레이터)로 실행한다. Stepper만 눌렀을 때 "CountText 갱신"이 찍히지 않으면 성공이다 (step을 읽지 않으니까). 원래 코드에서는 찍힌다.
정답 코드 펼치기
import SwiftUI
@Observable final class Counter {
var count = 0
var step = 1
}
struct CounterScreen: View {
@State private var counter = Counter()
var body: some View {
@Bindable var counter = counter
VStack(spacing: 16) {
CountText(counter: counter)
Stepper("간격 \(counter.step)", value: $counter.step, in: 1...10)
Button("더하기") { counter.count += counter.step }
}
.padding()
}
}
struct CountText: View {
var counter: Counter
var body: some View {
let _ = print("CountText 갱신")
Text("\(counter.count)").font(.largeTitle) // count만 추적
}
}
#Preview { CounterScreen() }