SwiftUI @Observable vs @State: 2026년에 무엇을 언제 사용할까

SwiftUI에서 @Observable과 @State의 차이를 마스터하고 iOS 앱에 적합한 상태 관리 도구를 선택해보세요.

iOS 개발자를 위한 SwiftUI에서의 @Observable과 @State 비교

상태 관리는 성능 좋은 SwiftUI 앱의 핵심 기반입니다. iOS 17부터 @Observable 매크로가 리액티브 모델 작성 방식을 혁신했고, @State는 뷰의 로컬 상태에 여전히 필수적인 도구로 남아 있습니다. 각 도구를 언제 사용할지 이해하면 불필요한 리렌더링을 방지하고 부드럽고 반응성이 뛰어난 앱을 만들 수 있습니다.

이 글이 다루는 내용

이 글에서는 @Observable@State의 내부 메커니즘, 근본적인 차이점, 그리고 상황에 맞는 도구를 선택하기 위한 명확한 지침을 살펴봅니다.

@State 기초

@State는 SwiftUI에서 가장 단순한 형태의 상태 관리입니다. 이 프로퍼티 래퍼는 자신을 선언한 뷰가 독점적으로 소유하는 값을 위한 영속적 저장소를 만들어 줍니다.

CounterView.swiftswift
struct CounterView: View {
    // @State creates storage managed by SwiftUI
    @State private var count = 0

    var body: some View {
        VStack(spacing: 20) {
            // The view updates when count changes
            Text("Counter: \(count)")
                .font(.largeTitle)

            HStack(spacing: 16) {
                Button("- 1") {
                    count -= 1
                }

                Button("+ 1") {
                    count += 1
                }
            }
            .buttonStyle(.borderedProminent)
        }
    }
}

count가 수정될 때마다 뷰가 다시 렌더링됩니다. SwiftUI는 이 값의 라이프사이클을 자동으로 관리하며 body 재구성 사이에서도 값을 유지합니다.

@State의 핵심 특성

@State는 최적의 사용 영역을 정의하는 여러 가지 두드러진 특성을 가지고 있습니다.

StateCharacteristics.swiftswift
struct FormView: View {
    // ✅ Simple local state - value types
    @State private var username = ""
    @State private var isEnabled = true
    @State private var selectedIndex = 0

    // ✅ Complex value types supported
    @State private var configuration = FormConfiguration()

    var body: some View {
        Form {
            TextField("Username", text: $username)

            Toggle("Enabled", isOn: $isEnabled)

            Picker("Option", selection: $selectedIndex) {
                Text("Option A").tag(0)
                Text("Option B").tag(1)
                Text("Option C").tag(2)
            }
        }
    }
}

// Structs work perfectly with @State
struct FormConfiguration: Equatable {
    var theme: Theme = .light
    var fontSize: CGFloat = 16
    var showNotifications: Bool = true
}

enum Theme {
    case light, dark, system
}

핵심은 @State가 값 타입(struct, enum, 원시 타입)과 함께 동작한다는 점입니다. 참조 타입(클래스)에는 다른 도구가 필요합니다.

@Observable 매크로 설명

iOS 17과 함께 도입된 @Observable은 어떤 클래스도 리액티브한 데이터 소스로 변환합니다. 기존 ObservableObject 프로토콜과 달리, 이 매크로는 세분화된 관찰을 제공합니다. 뷰가 실제로 읽은 프로퍼티만 리렌더링을 발생시킵니다.

UserModel.swiftswift
import Observation

// @Observable transforms the class into a reactive source
@Observable
class UserModel {
    var name: String = ""
    var email: String = ""
    var avatarURL: URL?
    var preferences = UserPreferences()

    // Computed properties work too
    var isValid: Bool {
        !name.isEmpty && email.contains("@")
    }
}

struct UserPreferences {
    var newsletter: Bool = false
    var notifications: Bool = true
    var theme: Theme = .system
}

마법은 컴파일 타임에 일어납니다. 매크로가 각 프로퍼티에 필요한 추적 코드를 자동으로 생성합니다.

실제 동작에서의 세분화된 관찰

기존 ObservableObject와의 가장 큰 차이는 추적 세분도에 있습니다.

GranularObservation.swiftswift
@Observable
class ProfileModel {
    var name: String = ""
    var bio: String = ""
    var followerCount: Int = 0
    var posts: [Post] = []
}

struct ProfileHeaderView: View {
    let model: ProfileModel

    var body: some View {
        VStack {
            // This view only re-renders if name or bio change
            Text(model.name)
                .font(.title)
            Text(model.bio)
                .foregroundStyle(.secondary)
        }
    }
}

struct FollowerCountView: View {
    let model: ProfileModel

    var body: some View {
        // This view only re-renders if followerCount changes
        HStack {
            Image(systemName: "person.2")
            Text("\(model.followerCount) followers")
        }
    }
}

struct ProfileScreen: View {
    @State private var model = ProfileModel()

    var body: some View {
        VStack {
            // Each subview tracks only its dependencies
            ProfileHeaderView(model: model)
            FollowerCountView(model: model)

            Button("Simulate new follower") {
                // Only re-renders FollowerCountView
                model.followerCount += 1
            }
        }
    }
}
자동 추적

SwiftUI는 각 뷰의 body를 분석하여 어떤 프로퍼티가 읽히는지 판단합니다. 그 프로퍼티들만 수정 시 리렌더링을 발생시킵니다.

직접 비교: @Observable vs @State

두 도구 사이의 선택은 여러 요인에 달려 있습니다. 다음은 구조화된 비교입니다.

ComparisonExample.swiftswift
// Scenario 1: Temporary UI state → @State
struct ToggleExample: View {
    @State private var isExpanded = false  // ✅ @State appropriate

    var body: some View {
        VStack {
            Button(isExpanded ? "Collapse" : "Expand") {
                withAnimation {
                    isExpanded.toggle()
                }
            }

            if isExpanded {
                Text("Detailed content...")
            }
        }
    }
}

// Scenario 2: Shared business data → @Observable
@Observable
class CartModel {  // ✅ @Observable appropriate
    var items: [CartItem] = []
    var promoCode: String?

    var total: Decimal {
        items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
    }

    var itemCount: Int {
        items.reduce(0) { $0 + $1.quantity }
    }

    func addItem(_ item: CartItem) {
        if let index = items.firstIndex(where: { $0.id == item.id }) {
            items[index].quantity += 1
        } else {
            items.append(item)
        }
    }

    func removeItem(_ item: CartItem) {
        items.removeAll { $0.id == item.id }
    }
}

struct CartItem: Identifiable, Equatable {
    let id: UUID
    let name: String
    let price: Decimal
    var quantity: Int
}

사용 사례 요약 표

| 기준 | @State | @Observable | |------|--------|-------------| | 데이터 타입 | 값 타입 (struct, enum) | 클래스 | | 범위 | 단일 뷰 내부 | 뷰 간 공유 가능 | | 복잡도 | 단순 상태 | 복잡한 비즈니스 로직 | | 라이프사이클 | SwiftUI가 관리 | 명시적으로 관리 | | 리렌더링 | 뷰 전체 | 프로퍼티 단위 세분화 |

iOS 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

고급 사용 패턴

@State와 @Observable 결합

실제 앱에서는 두 도구가 조화롭게 공존합니다. @State는 로컬 UI 상태를 처리하고, @Observable은 비즈니스 데이터를 캡슐화합니다.

CombinedPatterns.swiftswift
@Observable
class TodoListModel {
    var todos: [Todo] = []
    var filter: TodoFilter = .all

    var filteredTodos: [Todo] {
        switch filter {
        case .all:
            return todos
        case .active:
            return todos.filter { !$0.isCompleted }
        case .completed:
            return todos.filter { $0.isCompleted }
        }
    }

    func addTodo(title: String) {
        let todo = Todo(id: UUID(), title: title, isCompleted: false)
        todos.append(todo)
    }

    func toggleTodo(_ todo: Todo) {
        guard let index = todos.firstIndex(where: { $0.id == todo.id }) else { return }
        todos[index].isCompleted.toggle()
    }
}

struct Todo: Identifiable, Equatable {
    let id: UUID
    var title: String
    var isCompleted: Bool
}

enum TodoFilter: CaseIterable {
    case all, active, completed
}

struct TodoListView: View {
    // Business data via @Observable
    @State private var model = TodoListModel()

    // Local UI state via @State
    @State private var newTodoTitle = ""
    @State private var isAddingTodo = false
    @State private var selectedTodo: Todo?

    var body: some View {
        NavigationStack {
            VStack {
                // Filter with Picker
                Picker("Filter", selection: $model.filter) {
                    ForEach(TodoFilter.allCases, id: \.self) { filter in
                        Text(filter.label).tag(filter)
                    }
                }
                .pickerStyle(.segmented)
                .padding()

                // Todo list
                List(model.filteredTodos, selection: $selectedTodo) { todo in
                    TodoRowView(todo: todo) {
                        model.toggleTodo(todo)
                    }
                }
            }
            .navigationTitle("Tasks")
            .toolbar {
                Button {
                    isAddingTodo = true
                } label: {
                    Image(systemName: "plus")
                }
            }
            .sheet(isPresented: $isAddingTodo) {
                AddTodoSheet(model: model)
            }
        }
    }
}

struct TodoRowView: View {
    let todo: Todo
    let onToggle: () -> Void

    var body: some View {
        HStack {
            Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
                .foregroundStyle(todo.isCompleted ? .green : .secondary)
                .onTapGesture(perform: onToggle)

            Text(todo.title)
                .strikethrough(todo.isCompleted)
        }
    }
}

extension TodoFilter {
    var label: String {
        switch self {
        case .all: return "All"
        case .active: return "Active"
        case .completed: return "Completed"
        }
    }
}

의존성 주입과 함께 사용하는 @Observable

더 복잡한 앱에서는 SwiftUI 환경(environment)을 통한 주입이 효과적인 결합 분리를 가능하게 합니다.

DependencyInjection.swiftswift
@Observable
class AuthenticationService {
    var currentUser: User?
    var isAuthenticated: Bool { currentUser != nil }

    func login(email: String, password: String) async throws {
        // Authentication logic
        currentUser = User(id: UUID(), email: email, name: "User")
    }

    func logout() {
        currentUser = nil
    }
}

struct User: Identifiable, Equatable {
    let id: UUID
    let email: String
    let name: String
}

// Extension to create an environment key
extension EnvironmentValues {
    @Entry var authService: AuthenticationService = AuthenticationService()
}

// Configuration in the App
@main
struct MyApp: App {
    @State private var authService = AuthenticationService()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.authService, authService)
        }
    }
}

// Usage in views
struct ProfileView: View {
    @Environment(\.authService) private var authService

    var body: some View {
        if let user = authService.currentUser {
            VStack {
                Text("Hello, \(user.name)")
                Button("Sign Out") {
                    authService.logout()
                }
            }
        } else {
            Text("Not signed in")
        }
    }
}

성능과 최적화

불필요한 리렌더링 피하기

@Observable의 세분도가 있어도 특정 패턴은 성능을 떨어뜨릴 수 있습니다.

PerformanceOptimization.swiftswift
// ❌ Bad pattern: reading the entire object
struct BadPatternView: View {
    let model: ProfileModel

    var body: some View {
        // Reads model.name AND model.posts even if only name is displayed
        let _ = model.posts.count  // Creates unnecessary dependency
        Text(model.name)
    }
}

// ✅ Good pattern: targeted reading
struct GoodPatternView: View {
    let model: ProfileModel

    var body: some View {
        // Tracks only name
        Text(model.name)
    }
}

// ✅ Extract into subviews to isolate dependencies
struct OptimizedProfileView: View {
    let model: ProfileModel

    var body: some View {
        VStack {
            // Each subview has its own dependencies
            ProfileNameView(model: model)
            ProfilePostsView(model: model)
            ProfileStatsView(model: model)
        }
    }
}

struct ProfileNameView: View {
    let model: ProfileModel

    var body: some View {
        Text(model.name)
            .font(.title)
    }
}

struct ProfilePostsView: View {
    let model: ProfileModel

    var body: some View {
        ForEach(model.posts) { post in
            PostRow(post: post)
        }
    }
}

struct ProfileStatsView: View {
    let model: ProfileModel

    var body: some View {
        HStack {
            StatBadge(value: model.followerCount, label: "Followers")
            StatBadge(value: model.posts.count, label: "Posts")
        }
    }
}
비용이 큰 computed property

@Observable의 computed property는 접근할 때마다 다시 평가됩니다. 복잡한 계산이라면 결과를 stored property에 캐싱하는 것을 고려하십시오.

withObservationTracking을 이용한 일괄 업데이트

고급 시나리오에서는 withObservationTracking을 사용해 바인딩 없이 변경을 감지할 수 있습니다.

ObservationTracking.swiftswift
import Observation

@Observable
class DataSyncModel {
    var lastSyncDate: Date?
    var pendingChanges: Int = 0
    var isSyncing: Bool = false
}

class SyncCoordinator {
    let model: DataSyncModel

    init(model: DataSyncModel) {
        self.model = model
        startObserving()
    }

    private func startObserving() {
        // Observe changes without UI
        withObservationTracking {
            // Access that creates dependencies
            _ = model.pendingChanges
            _ = model.isSyncing
        } onChange: {
            // Called when an observed property changes
            Task { @MainActor in
                self.handleModelChange()
            }
        }
    }

    private func handleModelChange() {
        if model.pendingChanges > 0 && !model.isSyncing {
            // Trigger synchronization
            Task {
                await syncChanges()
            }
        }
        // Re-establish observation
        startObserving()
    }

    private func syncChanges() async {
        model.isSyncing = true
        // Sync logic...
        model.isSyncing = false
        model.pendingChanges = 0
        model.lastSyncDate = Date()
    }
}

ObservableObject에서의 마이그레이션

ObservableObject를 사용하는 기존 프로젝트는 @Observable로 마이그레이션하면 코드가 단순해집니다.

MigrationExample.swiftswift
// ❌ Old pattern with ObservableObject
class OldSettingsModel: ObservableObject {
    @Published var darkMode: Bool = false
    @Published var fontSize: CGFloat = 16
    @Published var notifications: Bool = true
}

struct OldSettingsView: View {
    @StateObject private var settings = OldSettingsModel()
    // or @ObservedObject if injected

    var body: some View {
        Form {
            Toggle("Dark Mode", isOn: $settings.darkMode)
            Slider(value: $settings.fontSize, in: 12...24)
            Toggle("Notifications", isOn: $settings.notifications)
        }
    }
}

// ✅ New pattern with @Observable
@Observable
class NewSettingsModel {
    var darkMode: Bool = false
    var fontSize: CGFloat = 16
    var notifications: Bool = true
}

struct NewSettingsView: View {
    @State private var settings = NewSettingsModel()

    var body: some View {
        Form {
            Toggle("Dark Mode", isOn: $settings.darkMode)
            Slider(value: $settings.fontSize, in: 12...24)
            Toggle("Notifications", isOn: $settings.notifications)
        }
    }
}

마이그레이션의 장점:

  • 각 프로퍼티에 @Published를 붙일 필요가 없습니다
  • 생성 시 @StateObject 대신 @State를 사용합니다
  • 자동으로 세분화된 관찰이 이루어집니다
  • 더 가독성 있고 유지보수하기 쉬운 코드가 됩니다

실용적인 의사결정 규칙

적절한 도구를 선택하기 위한 의사결정 가이드입니다.

DecisionGuide.swiftswift
/*
 RULE 1: Ephemeral UI state → @State
 - Animations, transitions
 - Local form states
 - Temporary selections
 - Section expand/collapse
*/
struct AnimatedCard: View {
    @State private var isFlipped = false  // ✅ Local UI state
    // ...
}

/*
 RULE 2: Shared data across views → @Observable
 - Business data models
 - Authentication state
 - Shopping cart
 - User preferences
*/
@Observable
class UserSession {  // ✅ Shared across app
    var user: User?
    var preferences: Preferences
    // ...
}

/*
 RULE 3: Simple struct with binding → @State
 - Local configuration
 - Isolated forms
*/
struct FormData {
    var name: String = ""
    var email: String = ""
}

struct FormView: View {
    @State private var formData = FormData()  // ✅ Struct with @State
    // ...
}

/*
 RULE 4: Complex business logic → @Observable
 - Validations
 - Network calls
 - Data transformations
*/
@Observable
class OrderProcessor {  // ✅ Complex logic
    var items: [OrderItem] = []
    var status: OrderStatus = .draft

    func validate() -> [ValidationError] { /* ... */ }
    func submit() async throws { /* ... */ }
}

결론

@Observable@State의 선택은 두 가지 근본적인 질문으로 압축됩니다. 데이터 타입(값 타입인지 참조 타입인지)과 상태의 범위(로컬인지 공유인지)입니다. @State는 단순한 로컬 UI 상태에 탁월하고, @Observable은 세분화된 관찰이 필요한 복잡한 데이터 모델에서 빛을 발합니다.

의사결정 체크리스트

  • ✅ 값 타입과 일시적인 UI 상태에는 @State를 사용합니다
  • ✅ 비즈니스 데이터를 가진 클래스에는 @Observable을 사용합니다
  • ✅ 상태가 여러 뷰에 걸쳐 있을 때는 @Observable을 우선 고려합니다
  • ✅ 서브뷰로 분리해 리렌더링을 최적화합니다
  • ✅ body에서 불필요한 프로퍼티 읽기를 피합니다
  • ObservableObject에서 점진적으로 마이그레이션합니다
  • ✅ 의존성 주입에는 환경(environment)을 사용합니다
  • ✅ 복잡한 케이스는 Instruments로 성능을 검증합니다

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#swiftui
#ios
#observable
#state-management
#swift

공유

관련 기사