SwiftUI @Observable vs @State: 2026년에 무엇을 언제 사용할까
SwiftUI에서 @Observable과 @State의 차이를 마스터하고 iOS 앱에 적합한 상태 관리 도구를 선택해보세요.

상태 관리는 성능 좋은 SwiftUI 앱의 핵심 기반입니다. iOS 17부터 @Observable 매크로가 리액티브 모델 작성 방식을 혁신했고, @State는 뷰의 로컬 상태에 여전히 필수적인 도구로 남아 있습니다. 각 도구를 언제 사용할지 이해하면 불필요한 리렌더링을 방지하고 부드럽고 반응성이 뛰어난 앱을 만들 수 있습니다.
이 글에서는 @Observable과 @State의 내부 메커니즘, 근본적인 차이점, 그리고 상황에 맞는 도구를 선택하기 위한 명확한 지침을 살펴봅니다.
@State 기초
@State는 SwiftUI에서 가장 단순한 형태의 상태 관리입니다. 이 프로퍼티 래퍼는 자신을 선언한 뷰가 독점적으로 소유하는 값을 위한 영속적 저장소를 만들어 줍니다.
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는 최적의 사용 영역을 정의하는 여러 가지 두드러진 특성을 가지고 있습니다.
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 프로토콜과 달리, 이 매크로는 세분화된 관찰을 제공합니다. 뷰가 실제로 읽은 프로퍼티만 리렌더링을 발생시킵니다.
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와의 가장 큰 차이는 추적 세분도에 있습니다.
@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
두 도구 사이의 선택은 여러 요인에 달려 있습니다. 다음은 구조화된 비교입니다.
// 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은 비즈니스 데이터를 캡슐화합니다.
@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)을 통한 주입이 효과적인 결합 분리를 가능하게 합니다.
@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의 세분도가 있어도 특정 패턴은 성능을 떨어뜨릴 수 있습니다.
// ❌ 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")
}
}
}@Observable의 computed property는 접근할 때마다 다시 평가됩니다. 복잡한 계산이라면 결과를 stored property에 캐싱하는 것을 고려하십시오.
withObservationTracking을 이용한 일괄 업데이트
고급 시나리오에서는 withObservationTracking을 사용해 바인딩 없이 변경을 감지할 수 있습니다.
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로 마이그레이션하면 코드가 단순해집니다.
// ❌ 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를 사용합니다 - 자동으로 세분화된 관찰이 이루어집니다
- 더 가독성 있고 유지보수하기 쉬운 코드가 됩니다
실용적인 의사결정 규칙
적절한 도구를 선택하기 위한 의사결정 가이드입니다.
/*
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 성능: LazyVStack과 복잡한 리스트 최적화
LazyVStack과 SwiftUI 리스트를 위한 최적화 기법. 메모리 소비를 줄이고 스크롤 성능을 향상시키며 흔한 함정을 피합니다.

SwiftUI 커스텀 ViewModifier: 디자인 시스템을 위한 재사용 가능한 패턴
일관된 디자인 시스템을 위해 SwiftUI에서 커스텀 ViewModifier를 구축합니다. iOS 뷰를 효율적으로 스타일링하기 위한 패턴, 베스트 프랙티스, 실용적인 예시를 다룹니다.

SwiftUI: iOS를 위한 모던 인터페이스 구축 가이드
SwiftUI로 모던 UI를 구축하는 방법을 알아봅니다. 선언적 구문, 컴포넌트, 애니메이션, iOS 18 모범 사례를 종합적으로 다룹니다.