Swift의 @Observable 매크로는 클래스의 저장 프로퍼티 변경을 자동으로 SwiftUI 뷰에 전달하는 매크로로, iOS 17부터 도입된 Observation 프레임워크의 핵심입니다. 기존 ObservableObject 프로토콜과 @Published 조합을 단 한 줄로 대체하며, 실제로 읽힌 프로퍼티만 추적하기 때문에 불필요한 뷰 재렌더링을 줄여 줍니다. iOS 17~26에서 모두 동작하고, Objective-C 시절부터 이어진 KVO 기반 옵저버 패턴을 Swift 답게 정리한 최종 형태로 봐도 좋습니다.
@Observable은 클래스에 붙이는 매크로로, ObservableObject 채택과 @Published 표기를 모두 생략할 수 있게 해 줍니다.
iOS 17 이상에서 사용 가능하며 swift-syntax 기반 매크로로 컴파일 타임에 ObservationRegistrar 코드를 합성합니다.
뷰가 실제로 접근한 프로퍼티만 추적하기 때문에 큰 모델에서 ObservableObject보다 재렌더링 횟수가 눈에 띄게 줄어듭니다.
양방향 바인딩이 필요한 경우 @State 대신 @Bindable을, 의존성 주입에는 @Environment를 사용합니다.
옵저베이션은 willSet 시점에 호출되며 withObservationTracking(_:onChange:)로 SwiftUI 밖에서도 직접 구독할 수 있습니다.
@Observable 매크로란 무엇인가
@Observable은 Swift 5.9에서 도입된 매크로 시스템 위에 만들어진 붙이기 매크로(attached macro)입니다. 클래스 선언 앞에 @Observable만 적어 두면 컴파일러가 다음 세 가지를 자동으로 합성합니다. 첫째, 내부적으로 ObservationRegistrar 인스턴스를 추가합니다. 둘째, 모든 저장 프로퍼티에 대해 접근자(getter/setter)에 등록 호출을 끼워 넣어 어떤 뷰가 어떤 키를 읽고 쓰는지 추적합니다. 셋째, 새 Observable 프로토콜을 채택시킵니다. Objective-C의 KVO가 런타임 키 코딩에 의존했던 것과 달리, 매크로는 컴파일 타임에 코드를 생성하므로 키 패스 오타가 곧 컴파일 오류가 됩니다. 저는 Objective-C 시절부터 NSNotification, KVO, RxSwift, Combine, ObservableObject를 거쳐 왔는데, 매크로 기반 관찰은 그동안의 "옵저버 패턴 보일러플레이트" 중 가장 자연스럽게 사라진 형태라고 생각합니다.
매크로의 정의는 SE-0395 Observability와 그 후속 제안에 문서화되어 있습니다. Xcode에서 @Observable이 붙은 타입에 우클릭 → "Expand Macro"를 누르면 합성된 코드를 직접 볼 수 있는데, 학습용으로 한 번쯤 펼쳐 보길 권합니다.
ObservableObject와 @Observable의 차이
두 방식은 SwiftUI 뷰에 변경을 알린다는 목적은 같지만, 추적 단위와 보일러플레이트 양에서 결이 다릅니다. 솔직히 한 번이라도 큰 뷰모델을 손본 적이 있다면 이 표만 봐도 왜 갈아타는 사람이 많은지 감이 옵니다.
항목
ObservableObject (구)
@Observable (신)
최소 OS
iOS 13+
iOS 17+
프로토콜 채택
필요 (ObservableObject)
매크로가 자동 처리
속성 표기
각 프로퍼티에 @Published
표기 불필요
변경 추적 단위
객체 전체 (어떤 프로퍼티가 바뀌든 모든 구독 뷰 갱신)
실제로 읽힌 프로퍼티 단위
SwiftUI 프로퍼티 래퍼
@StateObject, @ObservedObject, @EnvironmentObject
@State, @Bindable, @Environment
Combine 호환
objectWillChange 퍼블리셔 제공
기본 제공 안 함 (Observations AsyncSequence로 대체)
값 타입 지원
참조 타입 전용
참조 타입 전용 (구조체는 여전히 @State)
가장 큰 차이는 "추적 단위"입니다. ObservableObject는 objectWillChange가 한 번 발생하면 그 객체를 구독하는 모든 뷰가 다시 평가됩니다. 반면 @Observable은 SwiftUI 런타임이 뷰 본문을 실행하는 동안 어떤 키를 읽었는지를 기록해 두었다가, 그 키가 변경될 때만 재평가를 예약합니다. 모델이 커질수록 차이가 크게 벌어집니다.
기본 사용법과 SwiftUI 통합
가장 단순한 예부터 봅시다. 모델 한 개와 SwiftUI 뷰 한 개, 그게 전부입니다(정말로요).
import SwiftUI
import Observation
@Observable
final class Counter {
var value = 0
var lastChangedAt: Date = .now
func increment() {
value += 1
lastChangedAt = .now
}
}
struct CounterView: View {
@State private var counter = Counter()
var body: some View {
VStack(spacing: 16) {
Text("값: \(counter.value)")
.font(.largeTitle)
Button("증가", action: counter.increment)
}
.padding()
}
}
주목할 점이 두 가지 있습니다. 첫째, 모델 자체에는 @Published도, 프로토콜 채택도 없습니다. 둘째, SwiftUI 측에서는 @StateObject가 아니라 @State를 씁니다. iOS 17 이후 @State는 값 타입뿐 아니라 Observable 참조 타입의 라이프사이클도 책임집니다. lastChangedAt은 뷰 본문에서 읽지 않았기 때문에, 그 값을 갱신해도 본문은 재실행되지 않습니다 — Observation의 핵심 이점이 이 한 줄에 있습니다.
@Bindable과 @Environment 활용
자식 뷰가 모델의 프로퍼티를 양방향으로 바인딩해야 한다면 @Bindable을 사용합니다. @ObservedObject가 했던 역할의 후계자라고 생각하면 됩니다.
@Observable
final class Profile {
var name = ""
var notificationsEnabled = true
}
struct ProfileEditor: View {
@Bindable var profile: Profile
var body: some View {
Form {
TextField("이름", text: $profile.name)
Toggle("알림", isOn: $profile.notificationsEnabled)
}
}
}
struct RootView: View {
@State private var profile = Profile()
var body: some View {
ProfileEditor(profile: profile)
}
}
의존성 주입에는 @Environment를 그대로 활용할 수 있습니다. @EnvironmentObject는 더 이상 필요하지 않습니다.
@Observable
final class Session {
var currentUserId: UUID?
}
@main
struct MyApp: App {
@State private var session = Session()
var body: some Scene {
WindowGroup {
ContentView()
.environment(session)
}
}
}
struct SignOutButton: View {
@Environment(Session.self) private var session
var body: some View {
Button("로그아웃") {
session.currentUserId = nil
}
}
}
주의할 점: @Environment(Session.self)로 꺼낸 값은 기본적으로 읽기 전용입니다. 자식 뷰에서 $session.foo 형태의 바인딩을 만들고 싶다면, 같은 뷰에서 @Bindable var session = session 줄을 한 번 더 적어 주어야 합니다. 이 사소한 규칙이 마이그레이션 중 가장 자주 막히는 지점입니다.
성능: 왜 더 빠른가
"실제로 읽힌 프로퍼티만 추적한다"는 말이 막연하게 들릴 수 있어 구체적으로 풀어 봅니다. 다음과 같은 큰 뷰모델이 있다고 가정합니다.
@Observable
final class Dashboard {
var unreadCount = 0
var notifications: [Notification] = []
var profileImage: Data?
var weather: Weather?
// ... 더 많은 프로퍼티
}
이 모델을 BadgeView에서 dashboard.unreadCount만 읽도록 만들면, weather가 어떻게 바뀌든 BadgeView의 본문은 재실행되지 않습니다. ObservableObject 시절에는 같은 객체를 구독하는 모든 뷰가 일률적으로 무효화되었기 때문에, 같은 효과를 내려면 모델을 잘게 쪼개거나 별도의 @Published 캡슐을 만들어야 했습니다. 저는 동시성 모델과 함께 이 변화가 SwiftUI 앱의 평균 프레임 시간에 미치는 영향이 크다고 보고 있으며, 큰 모델일수록 Swift 6.2의 Approachable Concurrency와 함께 사용했을 때 메인 액터의 일감이 눈에 띄게 줄어듭니다.
withObservationTracking으로 직접 구독
Observation 프레임워크는 SwiftUI 전용이 아닙니다. UIKit, AppKit, 또는 그냥 일반 Swift 코드에서도 withObservationTracking(_:onChange:)로 변경을 한 번 구독할 수 있습니다.
import Observation
let counter = Counter()
func observeOnce() {
withObservationTracking {
print("현재 값:", counter.value)
} onChange: {
// willSet 시점에 호출 — 새 값은 아직 반영 전
Task { @MainActor in
observeOnce() // 다시 추적해야 다음 변경도 받음
}
}
}
observeOnce()
counter.increment()
두 가지를 기억하세요. 첫째, onChange는 변경이 커밋되기 직전(willSet 시점)에 한 번만 호출됩니다. 둘째, 한 번 호출되고 나면 추적이 끝나므로 계속 구독하려면 위 예시처럼 재호출해야 합니다. iOS 26 SDK부터는 같은 의미를 더 우아하게 표현하는 Observations AsyncSequence가 추가되어 있어, for await로 변경 스트림을 다룰 수 있습니다.
ObservableObject에서 마이그레이션
기존 코드를 점진적으로 옮기고 싶다면 다음 순서가 무난합니다. 한 번에 전체를 바꾸려 하지 말고 뷰모델 단위로 PR을 쪼개는 것이 안전합니다.
final class 앞에 @Observable을 추가하고 : ObservableObject 채택을 삭제합니다.
모든 @Published 표기를 제거합니다. 컴파일러가 "사용되지 않는 속성"이라고 경고하면 그대로 두면 됩니다.
UIKit 측에서 objectWillChange.sink를 사용하던 곳은 withObservationTracking 또는 iOS 26 이상에서는 for await change in model.observed 패턴으로 옮깁니다.
이 단계는 SwiftData 마이그레이션을 함께 진행할 때 특히 자연스럽습니다. SwiftData의 @Model 타입은 내부적으로 Observation을 사용하기 때문에 SwiftUI 측 코드가 두 종류를 따로 다룰 필요가 없어집니다.
자주 만나는 함정과 한계
매크로 기반이라 전반적으로 깔끔하지만, 실제 프로젝트에 옮기다 보면 부딪히는 모서리가 몇 군데 있습니다. 저도 첫 마이그레이션에서 1, 3번 함정에 둘 다 걸려 봤습니다.
1. 계산 프로퍼티는 추적되지 않습니다
저장 프로퍼티만 자동 등록됩니다. var fullName: String { firstName + lastName } 같은 계산 프로퍼티는 firstName, lastName을 본문에서 직접 읽지 않는 한 뷰가 자동으로 다시 그려지지 않을 수 있습니다. 계산 프로퍼티를 만들 때는 의존하는 저장 프로퍼티도 같은 흐름에서 읽히도록 두세요.
2. 컬렉션 안의 참조 타입은 별도 처리
var items: [Item]처럼 @Observable 객체의 배열을 가지고 있을 때, 배열 자체의 추가/삭제는 추적되지만 각 Item의 내부 변화는 해당 Item을 보고 있는 뷰가 추적합니다. ForEach(model.items) { item in ItemRow(item: item) }가 자연스럽게 작동하는 이유입니다.
3. Combine 퍼블리셔가 필요한 경우
@Observable은 objectWillChange를 제공하지 않습니다. 기존 Combine 파이프라인과 연결해야 한다면 withObservationTracking으로 PassthroughSubject를 끌어 주거나, iOS 26의 Observations AsyncSequence를 publisher로 변환하는 헬퍼를 만드세요.
마지막으로, SwiftUI 애니메이션과 결합할 때 withAnimation 블록 안에서 @Observable 프로퍼티를 변경하면 추적 가능한 모든 뷰가 같은 트랜잭션으로 보간됩니다. 이 부분은 기존 ObservableObject와 동일하게 동작합니다.
자주 묻는 질문
@Observable과 ObservableObject 중 무엇을 써야 하나요?
iOS 17 이상만 지원한다면 @Observable이 사실상 기본 선택입니다. iOS 16 이하를 지원해야 한다면 ObservableObject를 유지하거나, #if available로 분기하세요. 신규 코드에서 ObservableObject로 시작할 이유는 거의 없습니다.
@Observable 클래스에서 @Published를 함께 써도 되나요?
쓸 수는 있지만 의미가 없습니다. @Published는 ObservableObject 컨텍스트에서 동작하는 프로퍼티 래퍼이고, @Observable은 별도 메커니즘으로 추적합니다. 컴파일러가 경고를 띄울 수 있으며, 중복 등록만 발생하므로 지우는 편이 깔끔합니다.
@StateObject 대신 @State를 쓰는 이유는 무엇인가요?
iOS 17부터 @State는 Observable 클래스의 저장과 라이프사이클까지 책임집니다. 뷰가 다시 만들어져도 동일 인스턴스가 유지되며, 별도의 @StateObject가 필요하지 않습니다.
@Bindable은 어디에 써야 하나요?
자식 뷰에서 @Observable 객체의 프로퍼티를 양방향 바인딩($model.name)으로 다루어야 할 때 사용합니다. 부모가 @State로 보관 중인 객체를 자식이 받아 편집해야 하는 모든 폼 화면이 전형적인 사례입니다.
withObservationTracking은 왜 매번 다시 호출해야 하나요?
설계상 한 번의 추적은 한 번의 변경만 감지합니다. 이는 의도된 동작으로, SwiftUI 런타임도 매 뷰 본문 실행마다 새로 추적을 시작합니다. 외부에서 지속 구독이 필요하다면 onChange 안에서 자기 자신을 재호출하거나 iOS 26의 Observations AsyncSequence를 사용하세요.
기사 변경 이력 (3)
— SEO meta refreshed (title and description updated)
— SEO meta refreshed (title and description updated)
— SEO meta refreshed (title and description updated)
SwiftUI NavigationStack과 NavigationPath로 값 기반 라우팅부터 딥링크, 상태 복원, TabView 조합, @Observable Router 패턴, VoiceOver 접근성까지 iOS 26 최신 코드와 실전 예제로 한 번에 정리한 가이드입니다.