@Observable 내부 구조: 매크로, 레지스트라, 그리고 ObservableObject가 잘못 설계한 것
iOS 17과 Swift 5.9에서 도입된 Observation 프레임워크는 Combine 기반의 ObservableObject 모델을 매크로 기반의 프로퍼티별 접근 추적 시스템으로 대체했습니다1. 호출 측에서 보면 변화가 작아 보입니다(: ObservableObject 더하기 모든 곳의 @Published 대신 @Observable 매크로 하나만 쓰면 됩니다). 하지만 런타임 동작은 성능, 정확성, 마이그레이션 경로에 영향을 미치는 방식으로 달라졌습니다. 한 문장으로 요약하면, 변경된 프로퍼티를 읽지 않은 뷰는 해당 프로퍼티가 변경되어도 더 이상 재평가되지 않습니다.
이 글에서는 Apple 문서와 SE-0395 제안서2를 참고하여 프레임워크 내부를 살펴봅니다. 초점은 “매크로가 실제로 무엇을 생성하며 그 이유는 무엇인가”입니다. 대부분의 팀은 문법 때문에 @Observable을 채택하고 업데이트 전파의 구조적 변화는 놓치는데, 바로 그 부분에 진정한 성능 향상(과 마이그레이션 함정)이 자리잡고 있기 때문입니다.
TL;DR
@Observable은 클래스를Observable마커 프로토콜을 따르는 타입으로 확장하는 Swift 매크로입니다._$observationRegistrar: ObservationRegistrar인스턴스가 저장 프로퍼티로 합성됩니다3.- 각 프로퍼티의 getter는
_$observationRegistrar.access(self, keyPath:)를 감싸고, 각 setter는_$observationRegistrar.withMutation(of:keyPath:_:)를 감쌉니다. 레지스트라는 어떤 스코프가 어떤 키 경로에 접근했는지 추적합니다. - 대체되는 어휘는 다음과 같습니다.
class Foo: ObservableObject는@Observable class Foo가 됩니다.@Published var name은var name이 됩니다.@StateObject var foo = Foo()는@State var foo = Foo()가 됩니다.@EnvironmentObject는@Environment(Foo.self)가 됩니다.@ObservedObject var foo는 그냥 프로퍼티를 사용하는 형태가 됩니다. @Bindable은 observable 인스턴스의 프로퍼티에 대한 바인딩을 생성하는 새로운 프로퍼티 래퍼입니다(바인딩 용도의 일부@ObservedObject사용 사례를 대체합니다).- 마이그레이션 함정: 참조 타입을 사용하는
@State는 뷰 아이덴티티 측면에서@StateObject와 미묘하게 다른 동작을 보입니다. 무작정 교체한 앱은 뷰 재구성 시 혼란스러운 초기화 동작을 만들 수 있습니다.
매크로 확장
컴파일러가 @Observable을 만나면 다음 세 가지를 추가하여 타입을 확장합니다3.
@Observable
class UserProfile {
var name: String = ""
var email: String = ""
var preferences: [String] = []
}
확장 결과는 (단순화하면) 다음과 같이 생성됩니다.
class UserProfile: Observable {
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
private var _name: String = ""
var name: String {
get {
access(keyPath: \.name)
return _name
}
set {
withMutation(keyPath: \.name) {
_name = newValue
}
}
}
// ... email과 preferences에도 동일한 패턴
func access<Member>(keyPath: KeyPath<UserProfile, Member>) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
func withMutation<Member, T>(
keyPath: KeyPath<UserProfile, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
세 가지 구조적 변화가 일어납니다.
레지스트라. private ObservationRegistrar 인스턴스가 추적 상태를 소유합니다. 레지스트라는 모델의 변경과 의존하는 스코프의 재평가를 잇는 다리 역할을 합니다. 매크로는 레지스트라 자체가 추적되지 않도록 @ObservationIgnored로 표시합니다.
프로퍼티 저장 재작성. 선언된 각 저장 프로퍼티는 private 백업 필드와, getter/setter가 레지스트라를 호출하는 계산 프로퍼티가 됩니다. 컴파일러가 생성한 접근자가 프로퍼티별 추적을 가능하게 만듭니다.
Observable 준수. 레지스트라의 API가 기대하는 마커 프로토콜입니다. 이 프로토콜에는 요구사항이 없습니다. 인터페이스 계약이 아니라 준수 검사일 뿐입니다.
레지스트라의 역할
ObservationRegistrar는 두 가지 일을 합니다3.
접근 추적. withObservationTracking { ... } onChange: { ... }(SwiftUI가 뷰 본문에 사용하는 기반 추적 API)가 클로저를 실행할 때, 레지스트라는 읽혀진 모든 (self, keyPath) 쌍을 기록합니다. 접근된 경로의 집합이 그 스코프의 “의존성 풋프린트”가 됩니다.
무효화 트리거. 프로퍼티가 변경되면, 레지스트라는 해당 keyPath에 접근한 모든 스코프를 찾아 그들의 onChange 클로저를 발동합니다. 그 keyPath에 접근하지 않은 스코프는 영향을 받지 않습니다.
ObservableObject와의 대비가 바로 이 구조적 변화입니다. ObservableObject의 objectWillChange publisher는 모든 @Published 변경 시마다 발동하며, 모든 구독자가 알림을 받습니다. SwiftUI의 뷰 본문 메커니즘은 publisher를 사용해 “무언가 변경되었으니 재평가해라”를 알아냅니다. 재평가는 전체 뷰에 대해 실행되고, 그다음 SwiftUI가 실제로 변경된 의존 뷰를 계산하여 그것만 업데이트하지만, 본문 재평가는 이미 일어난 뒤입니다. @Observable에서는 본문 재평가 자체가 게이트로 작동합니다. 본문이 변경된 프로퍼티를 읽지 않았다면 재실행되지 않습니다.
세 개의 프로퍼티를 가진 UserProfile과 name만 읽는 뷰가 있다면, 차이는 분명합니다. @ObservableObject 모델은 email과 preferences 변경 시에도 본문 재평가를 트리거하지만, @Observable 모델은 그렇지 않습니다. 모델과 뷰가 많은 복잡한 앱에서는 누적된 절감 효과가 상당합니다.
마이그레이션 매핑
마이그레이션 어휘를 나란히 정리하면 다음과 같습니다4.
| ObservableObject | @Observable |
|---|---|
class Foo: ObservableObject |
@Observable class Foo |
@Published var name: String |
var name: String |
@StateObject var foo = Foo() |
@State var foo = Foo() |
@ObservedObject var foo: Foo |
var foo: Foo(또는 바인딩이 필요하면 @Bindable var foo: Foo) |
@EnvironmentObject var foo: Foo |
@Environment(Foo.self) var foo |
.environmentObject(foo) |
.environment(foo) |
@Bindable 래퍼는 별도로 짚어볼 만합니다. @Observable 인스턴스의 프로퍼티에 대한 Binding을 만드는 새로운 방법입니다.
@Bindable var profile: UserProfile
TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)
@Bindable 없이는 $profile.name 문법이 동작하지 않습니다. @Observable 타입은 자동으로 projected value를 제공하지 않기 때문입니다. @Bindable을 사용하면 모든 프로퍼티에 바인딩 형태가 생깁니다. 자식 뷰가 부모의 observable 모델에 대한 양방향 바인딩이 필요할 때 @Bindable을 사용하고, 자식이 읽기만 한다면 일반 참조(var profile: UserProfile)를 사용하세요.
@State 대 @StateObject 함정
가장 많은 프로덕션 버그를 유발하는 마이그레이션 라인은 @StateObject var foo = Foo()가 @State var foo = Foo()로 바뀌는 것입니다. 변경은 컴파일됩니다. 동작은 미묘한 메커니즘을 통해 갈라집니다. 바로 기본값 표현식이 평가되는 방식입니다5.
@State와 @StateObject 둘 다 뷰의 아이덴티티가 안정적일 때 SwiftUI의 뷰 재구성 전반에 걸쳐 인스턴스를 보존합니다. 둘 다 아이덴티티 키 기반의 백업 저장소가 부모로부터 비롯된 재초기화를 버립니다. 차이는 초기화 표현식이 언제 실행되느냐에 있습니다.
@StateObject는 매개변수를 @autoclosure로 선언합니다. Foo() 초기화 표현식은 래핑되어 SwiftUI가 실제로 인스턴스를 구성해야 할 때만 평가됩니다. 뷰의 아이덴티티가 보존되어 기존 인스턴스가 재사용되는 부모 재구성에서는 표현식이 호출되지 않습니다. 비싼 초기화는 절대 발동하지 않습니다.
@State는 autoclosure로 래핑되지 않습니다. Foo() 초기화 표현식은 뷰의 init이 실행될 때마다 즉시 평가됩니다(이는 뷰의 아이덴티티가 보존되고 기존 인스턴스가 저장소에 보관되어 있을 때조차도 모든 부모 재구성마다 발생합니다). Foo() 할당이 일어나고, SwiftUI는 새 인스턴스를 버리고 저장된 것을 계속 사용합니다. init()이 가벼운 모델이라면 낭비된 할당은 보이지 않습니다. 비싼 init()을 가진 모델(네트워크 요청, 대용량 데이터 로드, init에서 시작되는 비동기 작업)이라면, 이 차이는 정상 동작하는 앱과 부모 재구성마다 자체 백엔드를 DDoS 공격하는 앱의 차이가 됩니다.
방어적 패턴은 다음과 같습니다. 모델 init()을 가볍게 유지하여 차이가 문제되지 않게 하거나, 비싼 모델을 앱 수준에서 한 번 초기화해서 .environment()로 전달하세요. 비싼 셋업 작업이 필요한 모델은 어떤 프로퍼티 래퍼가 보유하든 상관없이 init에서 그 작업을 수행해서는 안 됩니다. 지연 초기화 또는 명시적인 셋업 메서드가 @State와 @StateObject 두 경우 모두에 옳은 패턴입니다.
명시적 추적을 위한 withObservationTracking
SwiftUI 외부에서는 추적 프리미티브가 withObservationTracking { ... } onChange: { ... }입니다6.
import Observation
let profile = UserProfile()
withObservationTracking {
print("Name: \(profile.name)")
} onChange: {
print("Something we read changed")
}
profile.name = "Alice" // onChange 발동
profile.email = "..." // onChange 발동 안 함 (읽지 않았으므로)
클로저는 한 번 실행되며 모든 observable 접근을 기록합니다. 그 접근들의 출처 프로퍼티 중 하나라도 변경되면, onChange는 정확히 한 번 발동합니다(일회성 콜백입니다). 다시 추적하려면 클로저를 다시 설정해야 합니다. 이 패턴은 SwiftUI가 뷰 본문 의존성을 추적하는 데 내부적으로 사용하는 방식입니다. SwiftUI가 아닌 코드(NSWindowController, Cocoa 앱, 명령행 도구)에서는 withObservationTracking이 적합한 프리미티브입니다.
ObservableObject가 여전히 옳은 선택일 때
ObservableObject가 자리를 지키는 세 가지 경우가 있습니다.
iOS 16 이하를 타겟팅하는 앱. Observation 프레임워크는 iOS 17+입니다. 더 오래된 배포 타겟을 가진 앱은 ObservableObject가 필요합니다. 배포 타겟이 17+로 옮겨가면 마이그레이션이 안전해집니다.
값 그래프 외부로 알림을 발행해야 하는 모델. ObservableObject의 objectWillChange는 Combine publisher입니다. Combine 파이프라인을 통해 “어떤 변경이든” 구독하려는 코드(디바운싱, 스로틀링, 이벤트 스트림 변환)는 ObservableObject로는 그것을 무료로 얻지만 @Observable로는 동등한 것을 다시 만들어야 합니다. Observation 프레임워크는 임의의 publisher 구독보다 뷰 재평가 효율성을 우선합니다.
마이그레이션 비용이 이점보다 큰 기존 코드베이스. 성능 문제를 측정하지 않은 상태로 잘 동작하는 ObservableObject 코드베이스는 감사를 정당화할 만큼 마이그레이션에서 충분한 이득을 얻지 못합니다. 이미 파일을 만지고 있을 때나 프로파일링이 핫스폿을 식별할 때 마이그레이션하세요.
iOS 17+ 타겟의 새 코드라면 @Observable이 현대의 기본값이며 마이그레이션 경로는 명확합니다.
이 패턴이 iOS 26+ 앱에 의미하는 바
세 가지 시사점이 있습니다.
-
새 코드는
@Observable을 기본으로. 매크로는 간결하고, 프로퍼티별 추적이 일반적인 경우의 성능을 향상시키며, 마이그레이션 어휘는 명확합니다. iOS 17+ 코드베이스의 새 모델은@Observable이어야 합니다. -
@StateObject→@State마이그레이션을 뷰 아이덴티티 측면에서 감사하세요. 교체는 깨끗하게 컴파일되지만 조건부 구조를 가진 뷰에서 놀라운 재초기화를 만들 수 있습니다. 비싼init()작업을 하는 모델은 신중한 마이그레이션이 필요합니다. 그렇지 않은 모델은 안전합니다. -
@Bindable을 의도적으로 사용하세요. observable 모델로의 양방향 바인딩을 위한 새 패턴입니다. 부모의 모델을 변경해야 하는 자식 뷰에서 사용하고, 읽기 전용 뷰에는 일반 참조(var foo: Foo)를 유지하세요.
전체 Apple Ecosystem 클러스터: 타입드 App Intents, MCP 서버, 라우팅 질문, Foundation Models, 런타임 대 도구 LLM 구분, 세 가지 표면, 단일 진실 출처 패턴, 두 개의 MCP 서버, Apple 개발용 훅, Live Activities, watchOS 런타임, SwiftUI 내부, RealityKit의 공간 멘탈 모델, SwiftData 스키마 규율, Liquid Glass 패턴, 멀티플랫폼 출시, 플랫폼 매트릭스, Vision 프레임워크, Symbol Effects, Core ML 추론, Writing Tools API, Swift Testing, Privacy Manifest, 플랫폼으로서의 접근성, SF Pro 타이포그래피, visionOS 공간 패턴, Speech 프레임워크, SwiftData 마이그레이션, tvOS 포커스 엔진, 내가 쓰지 않을 것들. 허브는 Apple Ecosystem Series에 있습니다. AI 에이전트와 함께하는 더 폭넓은 iOS 컨텍스트는 iOS Agent Development guide를 참고하세요.
FAQ
Apple은 왜 ObservableObject를 대체했나요?
두 가지 이유 때문입니다. 첫째, 성능입니다. ObservableObject의 objectWillChange publisher는 모든 @Published 변경 시마다 발동하여, 뷰가 실제로 변경된 프로퍼티를 읽었는지 여부와 무관하게 모든 의존 뷰의 본문 재평가를 트리거합니다. @Observable의 프로퍼티별 추적은 뷰가 실제로 접근한 프로퍼티에 대해 본문 재평가를 게이트합니다. 둘째, 문법입니다. 프로퍼티마다 붙는 @Published 어노테이션과 @StateObject/@ObservedObject/@EnvironmentObject 사다리는 개념상 하나의 아이디어(“이것은 가변 공유 상태다”)에 비해 장황했습니다. @Observable + @State + @Environment가 더 짧습니다.
@Observable이 struct에서도 동작하나요?
아닙니다. @Observable은 참조 의미론을 요구하며, struct는 자격이 없습니다. 이 매크로는 뷰 간에 변경 가능한 상태를 보유하는 클래스를 위한 것입니다. 단일 뷰의 값 타입 상태에는 값 타입과 함께 @State를 직접 사용하세요.
같은 앱에서 @Observable과 ObservableObject를 함께 사용할 수 있나요?
네. 충돌 없이 공존합니다. 마이그레이션은 파일 단위로 진행할 수 있습니다. 경계는 타입별입니다. 클래스는 ObservableObject이거나 @Observable이지 둘 다일 수는 없지만, 같은 앱의 다른 클래스들은 다른 접근 방식을 사용할 수 있습니다.
Combine 파이프라인을 발동하는 @Published 프로퍼티는 어떻게 되나요?
@Observable은 개별 프로퍼티에 대한 Combine publisher 동등물을 제공하지 않습니다. @Published 프로퍼티에서 $foo.publisher 패턴을 사용하는 코드는 @Observable로는 그 구독을 다르게 다시 만들어야 합니다(예를 들어 프로퍼티를 값 타입 모델로 감싸 SwiftUI의 업데이트 사이클을 통해 관찰하거나, withObservationTracking을 반복적으로 사용). Combine 의존도가 높은 코드 경로에서는 마이그레이션이 실질적인 엔지니어링 작업이 됩니다.
@Observable은 SwiftData의 @Model과 어떻게 상호작용하나요?
@Model(SwiftData) 타입은 자동으로 @Observable입니다. 영속성 프레임워크가 코드 생성의 일부로 Observable 준수를 추가하기 때문에, SwiftData 모델은 일반 @Observable 타입과 동일한 프로퍼티별 추적에 참여합니다. @Model 타입의 프로퍼티를 관찰하는 뷰는 동일한 세분화된 재평가 동작을 얻습니다. 이 클러스터의 SwiftData 마이그레이션과 SwiftData 스키마 규율 글이 동일한 관찰 표면의 영속성 측면을 다룹니다.
@ObservationIgnored는 무엇을 위한 것인가요?
저장 프로퍼티를 관찰 추적에서 제외시킵니다. 매크로는 일반적으로 모든 저장 프로퍼티를 레지스트라를 거치도록 재작성하지만, @ObservationIgnored로 표시된 프로퍼티는 추적 없이 직접 저장을 유지합니다. 뷰 재평가를 트리거해서는 안 되는 프로퍼티에 사용하세요. 캐시, 파일 핸들, 메트릭 카운터, 레지스트라 자체 같은 것들입니다.
참고 자료
-
Apple Developer Documentation: Observation framework.
Observable프로토콜과@Observable매크로를 다루는 프레임워크 레퍼런스입니다. iOS 17+, macOS 14+, Swift 5.9+에서 사용 가능합니다. ↩ -
Swift Evolution: SE-0395 Observability. 설계 근거, 의미 요구사항, 레지스트라 프로토콜 계약을 담은 채택된 Swift 제안서입니다. ↩
-
Apple Developer Documentation:
ObservationRegistrar와Observable. 매크로가 준수를 생성하는 런타임 타입과, 합성된 접근자가 호출하는 레지스트라 API입니다. ↩↩↩ -
Apple Developer Documentation: Migrating from the Observable Object protocol to the Observable macro. 프로퍼티 래퍼 매핑 표와 SwiftUI 통합 변경사항을 다루는 Apple의 공식 마이그레이션 가이드입니다. ↩
-
Apple Developer Documentation:
State와StateObject. 두 프로퍼티 래퍼의 뷰 아이덴티티 및 재구성 라이프사이클 관련 문서화된 초기화 의미입니다. ↩ -
Apple Developer Documentation:
withObservationTracking(_:onChange:). SwiftUI의 자동 뷰 본문 추적 외부에서 사용되는 명시적 추적 프리미티브입니다. ↩