๐Ÿ‘๏ธ @Observable ์ƒํƒœ๊ด€๋ฆฌ

iOS 17์˜ ์ƒˆ๋กœ์šด Observation ํ”„๋ ˆ์ž„์›Œํฌ. ObservableObject๋ฅผ ์™„์ „ํžˆ ๋Œ€์ฒดํ•˜๋Š” ํ˜„๋Œ€์  ์ƒํƒœ๊ด€๋ฆฌ์ž…๋‹ˆ๋‹ค.

โœจ @Observable์ด๋ž€?

iOS 17+์—์„œ ๋„์ž…๋œ Swift ๋งคํฌ๋กœ ๊ธฐ๋ฐ˜ ์ƒํƒœ๊ด€๋ฆฌ์ž…๋‹ˆ๋‹ค. @Published, @StateObject, @ObservedObject๋ฅผ ๋Œ€์ฒดํ•˜๋ฉฐ, ๋” ๊ฐ„๋‹จํ•˜๊ณ  ์„ฑ๋Šฅ์ด ๋›ฐ์–ด๋‚ฉ๋‹ˆ๋‹ค.

๐Ÿ†š Before & After ๋น„๊ต

Before (ObservableObject).swift
// iOS 16 ์ดํ•˜: ObservableObject
class CounterViewModel: ObservableObject {
    @Published var count = 0
    @Published var message = ""

    func increment() {
        count += 1
        message = "Count: \(count)"
    }
}

struct ContentView: View {
    @StateObject private var viewModel = CounterViewModel()

    var body: some View {
        Text("\(viewModel.count)")
    }
}
After (@Observable).swift
// iOS 17+: @Observable
import Observation

@Observable
class CounterViewModel {
    var count = 0
    var message = ""

    func increment() {
        count += 1
        message = "Count: \(count)"
    }
}

struct ContentView: View {
    var viewModel = CounterViewModel()

    var body: some View {
        Text("\(viewModel.count)")
    }
}

๐ŸŽฏ ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

TodoViewModel.swift
import Observation
import Foundation

@Observable
class TodoViewModel {
    var todos: [Todo] = []
    var isLoading = false
    var errorMessage: String?

    func addTodo(_ title: String) {
        let todo = Todo(title: title)
        todos.append(todo)
    }

    func loadTodos() async {
        isLoading = true
        defer { isLoading = false }

        do {
            // API ํ˜ธ์ถœ
            try await Task.sleep(for: .seconds(1))
            todos = [Todo(title: "์ƒ˜ํ”Œ ํ•  ์ผ")]
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

struct Todo: Identifiable {
    let id = UUID()
    var title: String
}

๐Ÿ“ฑ SwiftUI ํ†ตํ•ฉ

ContentView.swift
import SwiftUI

struct ContentView: View {
    var viewModel = TodoViewModel()

    var body: some View {
        NavigationStack {
            List(viewModel.todos) { todo in
                Text(todo.title)
            }
            .overlay {
                if viewModel.isLoading {
                    ProgressView("๋กœ๋”ฉ ์ค‘...")
                }
            }
            .navigationTitle("ํ•  ์ผ")
            .toolbar {
                Button("์ถ”๊ฐ€") {
                    viewModel.addTodo("์ƒˆ ํ•  ์ผ")
                }
            }
            .task {
                await viewModel.loadTodos()
            }
        }
    }
}

๐Ÿ”„ @State vs ์ผ๋ฐ˜ ํ”„๋กœํผํ‹ฐ

@Observable์„ ์‚ฌ์šฉํ•˜๋ฉด @State๊ฐ€ ํ•„์š” ์—†์Šต๋‹ˆ๋‹ค. ์ผ๋ฐ˜ ํ”„๋กœํผํ‹ฐ๋กœ ์„ ์–ธํ•ด๋„ ์ž๋™์œผ๋กœ ๊ด€์ฐฐ๋ฉ๋‹ˆ๋‹ค.

StateComparison.swift
struct ContentView: View {
    // โŒ ๋ถˆํ•„์š” (iOS 17+)
    // @State private var viewModel = TodoViewModel()

    // โœ… ์ด๋ ‡๊ฒŒ๋งŒ ํ•ด๋„ ์ž๋™์œผ๋กœ ์—…๋ฐ์ดํŠธ
    var viewModel = TodoViewModel()

    var body: some View {
        Text("\(viewModel.count)")
    }
}

๐ŸŽฏ @ObservationIgnored

ํŠน์ • ํ”„๋กœํผํ‹ฐ๋ฅผ ๊ด€์ฐฐ ๋Œ€์ƒ์—์„œ ์ œ์™ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

IgnoredProperty.swift
import Observation

@Observable
class ViewModel {
    var count = 0  // ๊ด€์ฐฐ๋จ (UI ์—…๋ฐ์ดํŠธ)

    @ObservationIgnored
    var internalCache: [String: Any] = [:]  // ๊ด€์ฐฐ ์•ˆ ๋จ (์„ฑ๋Šฅ ์ตœ์ ํ™”)

    func increment() {
        count += 1
        internalCache["lastIncrement"] = Date()  // UI ์—…๋ฐ์ดํŠธ ์•ˆ ํ•จ
    }
}

๐Ÿ”— Environment์—์„œ ์‚ฌ์šฉ

EnvironmentUsage.swift
import SwiftUI

@Observable
class AppState {
    var isLoggedIn = false
    var username = ""
}

@main
struct MyApp: App {
    @State private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
        }
    }
}

struct ContentView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        if appState.isLoggedIn {
            Text("์•ˆ๋…•ํ•˜์„ธ์š”, \(appState.username)")
        } else {
            LoginView()
        }
    }
}

๐Ÿงช Computed Property

์—ฐ์‚ฐ ํ”„๋กœํผํ‹ฐ๋„ ์ž๋™์œผ๋กœ ๊ด€์ฐฐ๋ฉ๋‹ˆ๋‹ค.

ComputedProperty.swift
@Observable
class CartViewModel {
    var items: [CartItem] = []

    // ์—ฐ์‚ฐ ํ”„๋กœํผํ‹ฐ๋„ ์ž๋™์œผ๋กœ ๊ด€์ฐฐ๋จ
    var totalPrice: Double {
        items.reduce(0) { $0 + $1.price }
    }

    var itemCount: Int {
        items.count
    }

    func addItem(_ item: CartItem) {
        items.append(item)
        // totalPrice์™€ itemCount๊ฐ€ ์ž๋™์œผ๋กœ ์—…๋ฐ์ดํŠธ๋จ
    }
}

struct CartView: View {
    var viewModel = CartViewModel()

    var body: some View {
        VStack {
            Text("์ด \(viewModel.itemCount)๊ฐœ")
            Text("ํ•ฉ๊ณ„: \(viewModel.totalPrice)์›")
        }
    }
}

โšก ์„ฑ๋Šฅ ์ตœ์ ํ™”

@Observable์€ ์‹ค์ œ๋กœ ์‚ฌ์šฉํ•˜๋Š” ํ”„๋กœํผํ‹ฐ๋งŒ ๊ด€์ฐฐํ•ฉ๋‹ˆ๋‹ค.

Performance.swift
@Observable
class ViewModel {
    var name = ""
    var age = 0
    var email = ""
}

struct NameView: View {
    var viewModel: ViewModel

    var body: some View {
        Text(viewModel.name)  // name๋งŒ ๊ด€์ฐฐ, age/email ๋ณ€๊ฒฝ ์‹œ ์—…๋ฐ์ดํŠธ ์•ˆ ํ•จ
    }
}

// ObservableObject๋Š” @Published๊ฐ€ ๋ถ™์€ ๋ชจ๋“  ํ”„๋กœํผํ‹ฐ ๋ณ€๊ฒฝ ์‹œ ์—…๋ฐ์ดํŠธ
// @Observable์€ ์‹ค์ œ๋กœ body์—์„œ ์‚ฌ์šฉํ•˜๋Š” ํ”„๋กœํผํ‹ฐ๋งŒ ๊ด€์ฐฐ (๋” ํšจ์œจ์ !)

๐Ÿ”„ withObservationTracking

SwiftUI ๋ฐ–์—์„œ๋„ ๋ณ€๊ฒฝ ๊ฐ์ง€๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

ManualTracking.swift
import Observation

@Observable
class Counter {
    var value = 0
}

let counter = Counter()

// ๋ณ€๊ฒฝ ๊ฐ์ง€
withObservationTracking {
    print("ํ˜„์žฌ ๊ฐ’: \(counter.value)")
} onChange: {
    print("๊ฐ’์ด ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!")
}

counter.value = 10  // onChange ์‹คํ–‰๋จ

๐Ÿ“Š Migration ๊ฐ€์ด๋“œ

Migration.swift
// BEFORE (iOS 13-16)
class OldViewModel: ObservableObject {
    @Published var name = ""
    @Published var age = 0
}

struct OldView: View {
    @StateObject var vm = OldViewModel()
    // ๋˜๋Š” @ObservedObject var vm: OldViewModel

    var body: some View {
        Text(vm.name)
    }
}

// AFTER (iOS 17+)
@Observable
class NewViewModel {
    var name = ""
    var age = 0
}

struct NewView: View {
    var vm = NewViewModel()

    var body: some View {
        Text(vm.name)
    }
}

// ๋ณ€๊ฒฝ ์‚ฌํ•ญ ์š”์•ฝ:
// 1. ObservableObject โ†’ @Observable
// 2. @Published ์‚ญ์ œ
// 3. @StateObject/@ObservedObject โ†’ ์ผ๋ฐ˜ ํ”„๋กœํผํ‹ฐ

๐Ÿ’ก @Observable์˜ ์žฅ์ 
โœ… ์ฝ”๋“œ๊ฐ€ 80% ๊ฐ„๊ฒฐํ•ด์ง
โœ… ์‚ฌ์šฉํ•˜๋Š” ํ”„๋กœํผํ‹ฐ๋งŒ ๊ด€์ฐฐ (์„ฑ๋Šฅ ํ–ฅ์ƒ)
โœ… @Published, @StateObject ๋ถˆํ•„์š”
โœ… ํƒ€์ž… ์•ˆ์ „์„ฑ ํ–ฅ์ƒ
โœ… ์ปดํŒŒ์ผ ํƒ€์ž„์— ์—๋Ÿฌ ๊ฒ€์ถœ

๐Ÿ“ฆ ํ•™์Šต ์ž๋ฃŒ

๐Ÿ’ป
GitHub ํ”„๋กœ์ ํŠธ
๐ŸŽ
Apple ๊ณต์‹ ๋ฌธ์„œ
๐ŸŽฅ
WWDC23 ์„ธ์…˜