[Swift 입문] 17편 — 상태 관리 2부: @Observable과 외부 데이터

🤖 이 글은 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.countmodel.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)로 주입하면, ProductListViewCartView 모두 같은 인스턴스에 접근합니다. 상품을 담으면 두 화면이 동시에 업데이트됩니다.


언제 무엇을 쓸까?

상황 사용할 것
이 뷰에서만 쓰는 간단한 값 @State
자식 뷰에서 부모 값을 읽고 써야 함 @Binding ($값)
여러 뷰에서 공유할 복잡한 상태 @Observable + @State
깊이 있는 자식 뷰에서 공유 상태 접근 @Environment
시스템 정보(다크모드, 폰트 크기 등) @Environment(\.키)

핵심 요약

  • @Observable: 클래스에 붙이면 속성 변경을 SwiftUI가 자동 감지
  • @Observable 클래스를 뷰 자신이 소유할 때는 @State로 선언
  • 외부에서 주입받을 때는 일반 var 속성으로 선언
  • @Environment: 계층을 건너뛰어 데이터를 공유할 때 사용
  • .environment(값)으로 주입, @Environment(타입.self)로 꺼내 씀

다음 편에서는 리스트, 내비게이션, 시트를 조합해 실제 앱에 가까운 화면 구조를 만들어봅니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다