🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [16편] 상태 관리 1부
@State만으로는 부족한 순간
앞 편에서 배운 @State는 뷰 하나 안에서만 사용하는 상태를 다룹니다. 하지만 실제 앱에서는 이런 상황이 자주 생깁니다.
- 여러 화면에서 같은 데이터를 공유해야 할 때
- 네트워크에서 가져온 데이터를 뷰에 표시할 때
- 타이머나 알림 같은 외부 이벤트를 뷰에 반영할 때
이런 경우를 위해 SwiftUI는 외부 객체의 상태를 관찰(Observe)하는 메커니즘을 제공합니다.
@Observable — Swift 5.9의 새 방식
Swift 5.9(2023)에서 @Observable 매크로가 도입되었습니다. 클래스에 붙이면 그 클래스의 속성 변경을 SwiftUI가 자동으로 감지합니다.
import Observation
@Observable
class CounterModel {
var count = 0
var name = "카운터"
func increment() {
count += 1
}
func reset() {
count = 0
}
}
@Observable을 붙이면 끝입니다. 이 클래스의 인스턴스를 뷰에서 사용하면 속성이 바뀔 때 자동으로 뷰가 업데이트됩니다.
struct CounterView: View {
var model: CounterModel // @Observable 객체를 그냥 받으면 됨
var body: some View {
VStack(spacing: 16) {
Text(model.name)
.font(.headline)
Text("\(model.count)")
.font(.system(size: 60, weight: .bold))
HStack(spacing: 20) {
Button("초기화") { model.reset() }
.buttonStyle(.bordered)
Button("증가") { model.increment() }
.buttonStyle(.borderedProminent)
}
}
.padding()
}
}
model.count나 model.name이 바뀌면 이 뷰가 자동으로 다시 렌더링됩니다. 별도의 프로퍼티 래퍼 없이 var model: CounterModel이라고만 써도 됩니다.
@Observable의 동작 원리
@Observable 매크로는 컴파일 시점에 코드를 자동으로 생성합니다. 각 속성에 getter/setter를 추가해 SwiftUI가 어떤 속성이 읽혔는지 추적하고, 그 속성이 바뀔 때만 해당 뷰를 업데이트합니다.
// @Observable이 없는 경우 직접 써야 했던 코드 (참고용)
// Swift 5.9 이전의 ObservableObject 방식
class OldCounterModel: ObservableObject {
@Published var count = 0 // 각 속성마다 @Published 필요
@Published var name = "카운터"
}
// 뷰에서도 다르게 써야 했음
struct OldView: View {
@StateObject private var model = OldCounterModel()
// 또는 @ObservedObject
}
@Observable은 이 모든 반복 작업을 없애줍니다.
@State와 함께 — 뷰에서 모델 인스턴스 생성
뷰 자체가 모델의 수명을 관리해야 할 때(뷰가 사라지면 모델도 사라지는 경우)는 @State로 선언합니다.
struct RootView: View {
@State private var counter = CounterModel() // 이 뷰가 소유
var body: some View {
CounterView(model: counter)
}
}
반면 모델이 외부에서 주입될 때는 일반 속성으로 받습니다.
struct CounterView: View {
var model: CounterModel // 외부에서 받음 — 이 뷰는 수명을 관리하지 않음
...
}
@Environment — 앱 전체에 데이터 공유
부모→자식으로 계속 내려보내는 대신, 환경(Environment)에 데이터를 주입하면 어느 뎁스의 자식 뷰든 꺼내 쓸 수 있습니다.
커스텀 환경 값 만들기
// 1. @Observable 모델
@Observable
class AppSettings {
var isDarkMode = false
var fontSize: Double = 16
var username = "Guest"
}
// 2. 최상위 뷰에서 environment로 주입
struct AppRootView: View {
@State private var settings = AppSettings()
var body: some View {
ContentView()
.environment(settings) // 하위 모든 뷰에서 사용 가능
}
}
// 3. 깊이 있는 자식 뷰에서 꺼내 쓰기
struct DeepChildView: View {
@Environment(AppSettings.self) private var settings
var body: some View {
Text("안녕하세요, \(settings.username)!")
.font(.system(size: settings.fontSize))
.preferredColorScheme(settings.isDarkMode ? .dark : .light)
}
}
Props drilling(부모→자식→자식→자식으로 계속 내려보내기) 없이 데이터를 공유할 수 있습니다.
SwiftUI 내장 환경 값
SwiftUI는 시스템 정보도 @Environment로 접근할 수 있게 해줍니다.
struct AdaptiveView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.locale) var locale
@Environment(\.sizeCategory) var sizeCategory
var body: some View {
Text("안녕하세요")
.foregroundColor(colorScheme == .dark ? .white : .black)
}
}
실전 예제 — 장바구니 앱
// 상품 모델
struct Product: Identifiable {
let id = UUID()
let name: String
let price: Int
let emoji: String
}
// 장바구니 상태 (앱 전체 공유)
@Observable
class CartStore {
var items: [Product] = []
var total: Int {
items.reduce(0) { $0 + $1.price }
}
func add(_ product: Product) {
items.append(product)
}
func remove(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
}
}
// 상품 목록 화면
struct ProductListView: View {
@Environment(CartStore.self) private var cart
let products = [
Product(name: "아메리카노", price: 4500, emoji: "☕️"),
Product(name: "카페라테", price: 5500, emoji: "🥛"),
Product(name: "케이크", price: 7000, emoji: "🎂")
]
var body: some View {
NavigationStack {
List(products) { product in
HStack {
Text(product.emoji).font(.title2)
VStack(alignment: .leading) {
Text(product.name).font(.headline)
Text("\(product.price)원").font(.caption).foregroundColor(.secondary)
}
Spacer()
Button("담기") { cart.add(product) }
.buttonStyle(.bordered)
}
}
.navigationTitle("메뉴")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink("장바구니 (\(cart.items.count))") {
CartView()
}
}
}
}
}
}
// 장바구니 화면
struct CartView: View {
@Environment(CartStore.self) private var cart
var body: some View {
List {
ForEach(cart.items) { item in
HStack {
Text(item.emoji)
Text(item.name)
Spacer()
Text("\(item.price)원")
}
}
.onDelete(perform: cart.remove)
}
.navigationTitle("장바구니")
.safeAreaInset(edge: .bottom) {
Text("합계: \(cart.total)원")
.font(.headline)
.padding()
.frame(maxWidth: .infinity)
.background(.thinMaterial)
}
}
}
// 루트 진입점
struct ShopApp: View {
@State private var cart = CartStore()
var body: some View {
ProductListView()
.environment(cart)
}
}
CartStore를 최상위에서 @State로 만들고 .environment(cart)로 주입하면, ProductListView와 CartView 모두 같은 인스턴스에 접근합니다. 상품을 담으면 두 화면이 동시에 업데이트됩니다.
언제 무엇을 쓸까?
| 상황 | 사용할 것 |
|---|---|
| 이 뷰에서만 쓰는 간단한 값 | @State |
| 자식 뷰에서 부모 값을 읽고 써야 함 | @Binding ($값) |
| 여러 뷰에서 공유할 복잡한 상태 | @Observable + @State |
| 깊이 있는 자식 뷰에서 공유 상태 접근 | @Environment |
| 시스템 정보(다크모드, 폰트 크기 등) | @Environment(\.키) |
핵심 요약
- @Observable: 클래스에 붙이면 속성 변경을 SwiftUI가 자동 감지
- @Observable 클래스를 뷰 자신이 소유할 때는 @State로 선언
- 외부에서 주입받을 때는 일반 var 속성으로 선언
- @Environment: 계층을 건너뛰어 데이터를 공유할 때 사용
- .environment(값)으로 주입,
@Environment(타입.self)로 꺼내 씀
다음 편에서는 리스트, 내비게이션, 시트를 조합해 실제 앱에 가까운 화면 구조를 만들어봅니다.
🤖 Generated with Claude Code