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

Day 5 · 2부 · @Environment와 마이그레이션

환경 주입과 커스텀 환경 값(@Entry), ObservableObject → @Observable 마이그레이션 순서와 표, 옮기는 도중의 함정을 다룬다.
진행률
0 / 0 완료
1부에서 @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)로 꺼낸다. 중간 뷰는 모델의 존재조차 모른다.

environment가 트리 아래로 자동 전달 App .environment(lib) RootView ListView RowView DetailBadge @Environment 주입 (한 번) 프로퍼티 없음 · 의존성 없음 읽기 → 의존성 Observable 객체는 "읽은 곳만" 갱신되므로 환경에 넣어도 비용이 퍼지지 않는다
WWDC23 발표 표현: "Observable types work fantastically here since the updates created by them are based upon access."
주입은 최상위에서 한 번, 소비는 필요한 곳에서만
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에 프로퍼티를 추가한다. 방법은 두 가지다.

iOS 17 SDK(Xcode 15) 방식 — EnvironmentKey + 확장
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) // 하위 트리만 덮어쓴다
    }
}
🆕 @Entry 매크로 Xcode 16+

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에도 같은 문법을 쓴다.

✅ @Entry — 같은 기능, 한 줄 (Xcode 16+ 필요)
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 프로퍼티에 새 인스턴스를 대입할 수 없다. 값을 넣는 건 .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 xvar 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: Mvar m: M 또는 @Bindable var m: M바인딩 필요 여부로 결정
@EnvironmentObject var m: M@Environment(M.self) var m—
.environmentObject(m).environment(m)—
BEFORE — 검색어 한 글자마다 BookCountLabel까지 갱신된다
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를 읽지 않는데도 타이핑마다 갱신된다
    }
}
AFTER — 읽은 books가 바뀔 때만 갱신된다
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만 추적
    }
}
ObservableObject @Observable searchText 변경 searchText 변경 SearchField 갱신 CountLabel 갱신 Header 갱신 SearchField 갱신 CountLabel 그대로 Header 그대로 객체를 관찰하는 모든 뷰 그 프로퍼티를 읽은 뷰만
ObservableObject는 "객체 단위" 알림, @Observable은 "프로퍼티 단위" 알림이다. 검색 화면이 버벅이던 흔한 원인이 여기서 사라진다.
📝 Apple 원문 — 한 번에 다 바꾸지 않아도 된다

"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)권") }
}
  1. 모델: @Observable을 붙이고 @Published를 지운다. : ObservableObject는 남겨 둔다.
  2. 뷰: 그 모델을 쓰는 곳을 하나씩 @State · @Environment(M.self) · @Bindable/래퍼 없음으로 바꾼다. 이 동안 옛 래퍼를 쓰는 뷰도 계속 컴파일된다.
  3. 마지막: 옛 래퍼가 하나도 안 남았을 때 : 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."

모델 밖 저장소를 Observation에 알리기 — 드물게만 필요
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)가 없다. 무슨 일이 생기고, 어떻게 막나?
타입 키로 찾는 객체가 환경에 없으면 SwiftUI가 예외를 던져 앱이 죽는다(Managing model data 문서). 주입이 보장되지 않는 뷰는 @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 }
    }
}
🔮 코드 예측: 위 버튼을 세 번 누르면 버튼 글자는 무엇이 되나?
"0" 그대로다. n은 3이 되지만 화면은 갱신되지 않는다. Apple 문서 · State: ObservableObject를 @State에 넣으면 "the view will only update when the reference to the object changes." 고치는 순서는 모델에 먼저 @Observable을 붙이고(@Published 삭제, : ObservableObject는 잠시 유지) 뷰를 바꾼 뒤, 옛 래퍼가 다 사라지면 채택을 지우는 것이다(흔한 실수 ③).
쉽게 말하면 옛날식 초인종(@Published)을 달아 놓고 새 집 인터폰(@State)에 연결하지 않은 셈이라, 벨을 눌러도 안에서는 모른다.
🔁 1부 복습: @Observable 모델에 var tags: [Tag]가 있고 Tag는 struct다. tags[0].title만 바꿨을 때, tags.count만 보여 주는 뷰도 다시 그려지나?
다시 그려진다. struct 요소는 Observable 인스턴스가 아니어서 추적 단위가 tags 배열 프로퍼티 하나다. 요소 하나를 고치면 배열 프로퍼티가 바뀐 것이 되고, 그 배열을 body에서 읽은 뷰는 모두 무효화된다. 요소 단위 갱신이 필요하면 Tag를 @Observable 클래스로 만든다.
쉽게 말하면 반 전체 명단(배열)을 한 장의 종이로 관리하면, 한 명 이름만 고쳐도 종이 전체를 새로 복사해 돌리는 것과 같다.

🛠 미니 과제 (선택 · 10분)

아래 ObservableObject 코드를 @Observable로 옮긴다.

시작 코드 — ObservableObject 버전
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() }

확인 방법: Preview를 Debug 모드(또는 시뮬레이터)로 실행한다. Stepper만 눌렀을 때 "CountText 갱신"이 찍히지 않으면 성공이다 (step을 읽지 않으니까). 원래 코드에서는 찍힌다.

정답 코드 펼치기
✅ @Observable 버전
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() }

출처 · 참고자료