iOS 27의 SwiftData: Observation과 History
SwiftData는 데이터를 관찰하는 두 가지 방법과 함께 iOS 17에서 출시되었습니다. SwiftUI 뷰 안에서 쓰는 @Query, 그리고 그 외 모든 경우에 ModelContext에 직접 알림 배선을 거는 방식입니다. 둘 중 어느 쪽도 동기화 앱에서 가장 중요한 경우, 즉 다른 기기가 스토어를 변경한 시점을 아는 일은 다루지 못했습니다. iOS 27은 이 두 공백을 하나의 릴리스에서 메웁니다. ResultsObserver는 변경 추적을 뷰 바깥에서 보유할 수 있는 일급 객체로 만들고, HistoryObserver는 SwiftData의 영구 히스토리를 관찰하다가 새 트랜잭션이 들어오면 observable 카운터를 증가시켜, 동기화 코드가 가장 최근의 변경만 가져올 수 있게 합니다. iOS 27은 observation을 SwiftUI의 부수 효과가 아니라 기본 요소(primitive)로 추가합니다.1
이 관점은 이 클러스터의 나머지 글들과 맞물립니다. SwiftData는 시작하기엔 저렴했지만 조율하기엔 비쌌습니다. 뷰 계층 바깥에서 fetch를 관찰하려면 @Query가 하는 일을 손으로 다시 만들어야 했습니다. 스토어를 외부 서버와 동기화하거나 앱 익스텐션의 쓰기에 반응하려면, 영구 히스토리를 직접 훑으며 트랜잭션을 일일이 대조해야 했습니다. iOS 27은 이 두 작업 모두에 Observable을 준수하는 이름 있는 타입을 부여하므로, 이미 뷰를 구동하는 그 동일한 SwiftUI 갱신 메커니즘이 동기화 계층까지 구동합니다.
TL;DR / 핵심 요약
ResultsObserver는 model context 안에서 영구 모델 컬렉션의 변경을 관찰하고 추적하며, 기저 데이터가 변할 때 실시간 업데이트를 제공합니다. 이것은Observable이므로 SwiftUI 뷰가 자동으로 업데이트되고,@Query가 닿을 수 없는 뷰 바깥에서도 동작합니다.2HistoryObserver는 SwiftData의 영구 히스토리를 관찰하며, 새 트랜잭션이 도착할 때 증가하는 단 하나의 observable 프로퍼티eventCounter를 노출합니다. 모델 타입과 트랜잭션 author로 필터링한 다음ModelContext.fetchHistory를 호출해 변경을 읽습니다. 외부 서버와의 동기화나 앱 익스텐션 쓰기에 반응하기 위한 구조화된 해답입니다.3@Attribute(.codable)는 프로퍼티의 codable 표현을 사용해 프로퍼티를 저장하므로,ValueTransformer없이도Codable값 타입을 선언적으로 영속화하는 방법을 제공합니다.4- 셋 모두 27.0 베타에서 iOS, iPadOS, macOS, Mac Catalyst, tvOS, visionOS, watchOS에 걸쳐 출시됩니다.234
뷰 바깥에서 Fetch 관찰하기: ResultsObserver
@Query는 훌륭하면서도 제약이 있습니다. SwiftUI 뷰 안에 존재하고, 자신의 predicate가 바뀌면 다시 실행되며, 뷰에 배열을 건넵니다. 제약은 위치입니다. 뷰 모델, 동기화 코디네이터, export 작업, 백그라운드 조정자에는 변하는 데이터가 있지만 기댈 @Query가 없습니다. iOS 27 이전에는 이런 호출자가 ModelContext 저장 알림을 구독하고 손으로 다시 fetch했는데, 이는 @Query가 내부적으로 이미 하는 일을 그대로 손으로 재구성하는 것입니다.
ResultsObserver는 iOS 27이 내놓은 이름 있는 해답입니다. 선언이 그 정체를 말해 줍니다:2
final class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable
이 클래스는 지정된 fetch 기준에 부합하는 모델의 변경을 자동으로 감시하고 fetch된 결과 컬렉션을 유지하므로, 단지 뷰만이 아니라 어떤 소비자든 영구 데이터와 동기화 상태로 유지하는 도구가 됩니다.2 두 가지 방식으로 구성합니다. 완전한 FetchDescriptor를 쓰거나, 개별 필터 predicate와 sort descriptor를 쓰는 것입니다.2 두 가지 SectionName 경로는 그룹화된 리스트에서 중요합니다. 섹션화가 필요 없을 때는 SectionName 타입 매개변수로 Never를 전달합니다.2
기존의 수동 방식 대비 이득은 Observable 준수입니다. ResultsObserver는 Observable이므로, @Observable 모델 객체가 뷰 무효화를 구동하는 것과 동일한 방식으로(자세한 내용은 @Observable 내부 동작 참조) 결과가 변할 때 SwiftUI 뷰가 자동으로 업데이트됩니다.2 ResultsObserver를 보유한 동기화 코디네이터는 NotificationCenter 코드를 단 한 줄도 쓰지 않고 변경 알림을 받고, 옵저버의 결과를 읽는 어떤 뷰든 공짜로 다시 렌더링됩니다.
세션 274에서 Apple은 @Query가 감당할 수 없는 경우를 위해 ResultsObserver를 소개합니다. 상태 객체나 SwiftUI를 전혀 건드리지 않는 게임을 포함해, 앱 어디에서든 Swift Observation을 통해 스토어를 fetch하고 관찰하는 것입니다.5
ResultsObserver는 query 스타일의 observation을 SwiftUI 뷰 바깥의 코드로 가져옵니다.
import SwiftData
import Observation
@Observable
final class ShoppingListModel {
let observer: ResultsObserver<ShoppingItem, Never>
init(context: ModelContext) {
let descriptor = FetchDescriptor<ShoppingItem>(
sortBy: [SortDescriptor(\.sortOrder)]
)
// No sectioning, so SectionName is Never.
observer = ResultsObserver(context: context, fetchDescriptor: descriptor)
}
}
Apple이 공개한 레퍼런스는 클래스 선언과 구성 표면(FetchDescriptor 또는 필터 predicate와 sort descriptor, 섹션화하지 않을 때 섹션 이름으로 Never)을 확인해 주지만, 작성 시점 기준으로 정확한 이니셜라이저 시그니처는 생략하고 있습니다. 따라서 이 예제들의 호출 형태는 예시로 간주하고 매개변수 레이블은 SDK와 대조해 확인하세요.
발상의 전환은, fetch가 뷰의 body에 갇힌 프로퍼티 래퍼가 아니라 직접 소유하고 여기저기로 전달하는 객체가 된다는 점입니다. @Query는 “이 뷰가 무엇을 보여 주는가?”에 답합니다. ResultsObserver는 “내가 어디에서 보유하고 있든, 이 fetch의 현재 상태는 무엇인가?”에 답합니다. 두 번째 질문이야말로 뷰가 아닌 호출자가 실제로 던지는 질문입니다.
History 변경에 반응하기: HistoryObserver
@Query와 ResultsObserver가 둘 다 열어 두는 공백은, 인프로세스 fetch 바깥에서 비롯되는 변경입니다. 스토어가 저장될 때마다 SwiftData는 무엇이 변했는지, 변경이 어디서 왔는지, 그리고 그것을 식별하는 토큰을 기술하는 히스토리 트랜잭션을 기록합니다. 스토어를 외부 서버와 동기화하거나 앱 익스텐션의 쓰기에 반응하려면 그 영구 히스토리를 직접 훑어야 했습니다. iOS 27은 이 작업에 이름 있는 옵저버를 부여합니다.3
HistoryObserver가 구조화된 해답입니다:3
final class HistoryObserver
이 옵저버는 SwiftData의 영구 히스토리를 관찰하며 새 트랜잭션이 추가될 때 코드가 반응할 수 있게 합니다.3 특정 종류의 변경만 필요하다면 모델 타입과 트랜잭션 author로 필터링해, 모든 스토어 변형이 아니라 관심 있는 쓰기에만 반응합니다.3
전체 표면은 하나의 observable 프로퍼티 eventCounter입니다. 영구 히스토리에 새 트랜잭션이 들어오면 카운터가 증가합니다. 그것을 관찰하다가 증가할 때마다 ModelContext.fetchHistory API를 호출해 가장 최근의 변경만 읽습니다.3 히스토리 토큰은 트랜잭션 자체에 들어 있으므로 fetchHistory는 스토어를 다시 스캔하지 않고 새로운 것만 반환합니다. 덕분에 히스토리가 커져도 동기화 핸들러가 빠르게 유지됩니다.
import SwiftData
import Observation
// Illustrative call shapes; confirm parameter labels against the SDK.
let historyObserver = HistoryObserver(container: modelContainer, authors: "App")
// Observe eventCounter; on each increment, fetch and process the new history.
let token = withContinuousObservation(of: historyObserver.eventCounter) {
Task { await processChanges() }
}
트랜잭션 author로 필터링하는 것이 서버 동기화를 올바르게 만드는 디테일입니다. Apple의 예제에서 옵저버는 author로 "App"을 전달해 앱이 만든 변경에만 반응하고, 서버로부터 온 변경을 다시 서버로 재생하지 않습니다.3 증가할 때마다 processChanges 단계가 ModelContext.fetchHistory를 호출해 새 트랜잭션을 읽고 업로드합니다. 이 클러스터의 앱들이 손수 짠 히스토리 배선으로 풀었던 멀티프로세스 및 외부 동기화 패턴(자세한 내용은 SwiftData 스키마 규율 참조)은 이제 기댈 프레임워크 이음새를 얻습니다.
History와 짝을 이루는 가벼운 읽기
HistoryObserver가 동기화 핸들러를 깨우고 나면, 다음 질문은 얼마나 많은 작업을 할 것인가입니다. 순진한 핸들러는 증가할 때마다 영향받은 모델을 다시 fetch하는데, 이는 필요 없을 수도 있는 전체 모델 객체를 채워 넣습니다. SwiftData는 아무것도 실체화하지 않고 더 좁은 질문에 답하는 두 가지 읽기를 제공하며, 둘 다 history observation과 자연스럽게 짝을 이룹니다.
ModelContext.fetchCount(_:)는 일치하는 객체를 로드하지 않고, fetch descriptor에 부합하는 모델의 개수를 순수한 Int로 반환합니다:6
func fetchCount<T>(_ descriptor: FetchDescriptor<T>) throws -> Int where T : PersistentModel
ModelContext.fetchIdentifiers(_:)는 그 뒤의 모델을 로드하지 않고 일치 항목을 [PersistentIdentifier]로 반환합니다. batchSize 오버로드는 큰 결과 집합에서 그 식별자들을 청크 단위로 스트리밍합니다:6
func fetchIdentifiers<T>(_ descriptor: FetchDescriptor<T>) throws -> [PersistentIdentifier] where T : PersistentModel
SwiftData Group Lab은 이것들을 history observation과 직접 연결하는 패턴을 제안했습니다. 히스토리 변경이 도착하면, 다시 로드할지 결정하기 전에 영향받은 식별자를 fetch해 뷰가 실제로 표시하는 것과 비교함으로써 필요 없는 객체를 채워 넣는 일을 피합니다.6 사용자가 보고 있지 않은 행의 변경은 픽셀을 하나도 움직이지 않으며, 식별자 비교는 전체 fetch가 아니라 키 조회 비용으로 그 사실을 알려 줍니다. fetchCount는 애초에 일치하는 것이 있는지라는 한층 더 저렴한 질문에 답하는데, 이는 배지나 빈 상태를 뒤집어야 할지 결정하기에 충분합니다.
값 타입을 깔끔하게 영속화하기: @Attribute(.codable)
세 번째 추가는 작고 실용적입니다. SwiftData는 Swift의 원시 타입과 @Model 관계를 네이티브로 저장하지만, 타입이 커스텀 Codable 값(몇 개의 필드를 담은 구조체, 연관 값을 가진 열거형)인 프로퍼티는 ValueTransformer와 .transformable(by:) 속성이 필요했습니다. 이는 매크로 표면을 통해 다시 새어 나오는 Core Data의 격식입니다.
iOS 27은 codable 저장 옵션을 추가합니다:4
static var codable: Schema.Attribute.Option { get }
이 옵션은 프로퍼티의 codable 표현을 사용해 프로퍼티를 저장하므로, Codable 값 타입은 등록할 transformer 없이 자신의 Encodable/Decodable 준수를 통해 영속화됩니다.4 다른 모든 속성 옵션과 동일한 방식으로 적용합니다:
import SwiftData
struct Coordinate: Codable {
var latitude: Double
var longitude: Double
}
@Model
final class Place {
var name: String
// Persisted via Coordinate's own Codable conformance.
@Attribute(.codable) var location: Coordinate
}
실용적인 규칙은, 프로퍼티가 자체 @Model 테이블을 가질 만하지 않은 자족적인 Codable 값일 때 .codable에 손을 뻗으라는 것입니다. 좌표 쌍, 작은 설정 구조체, 페이로드를 가진 열거형. 이것들은 엔티티가 아니라 데이터이며, .codable은 transformer나 인위적인 관계를 강요하는 대신 이들이 이미 정의한 표현을 통해 인라인으로 저장합니다.
Apple은 트레이드오프에 대해 명확합니다. codable 속성의 내용은 SwiftData에 불투명하므로, 결과를 필터링하는 predicate나 sort descriptor에서 그것을 사용할 수 없습니다. 또한 codable 타입의 형태가 바뀌는 변경(프로퍼티 추가나 제거)은 마이그레이션을 유발하지 않으므로, 그 Codable 구현은 앞뒤로 호환성을 유지해야 합니다.5 Apple은 .codable을 직접 소유하지 않는 타입을 위한 탈출구로 규정합니다. 자신이 정의하는 타입이라면 SwiftData 모델이나 지원되는 값 타입으로 모델링하는 편이 정렬, 필터링, 인덱싱을 테이블 위에 유지합니다.5
각각을 언제 선택할 것인가
세 가지 추가는 서로 다른 세 가지 질문에 답하며, 그 질문이 어느 것을 쓸지 알려 줍니다.
- 뷰가 아닌 호출자가 라이브 fetch를 필요로 할 때
ResultsObserver에 손을 뻗으세요. 뷰 모델, 코디네이터, export 작업 등 변하는 데이터를 가졌지만 SwiftUIbody가 아닌 모든 것입니다. 뷰 안에서는 여전히@Query가 더 가벼운 도구이며, 소비자가 뷰가 아닌 순간 옵저버가 제 자리를 얻습니다.2 - 스토어를 앱 바깥의 무언가와 동기화할 때
HistoryObserver에 손을 뻗으세요. 외부 서버, 또는 같은 스토어에 쓰는 앱 익스텐션.eventCounter를 관찰하고, 모델 타입과 트랜잭션 author로 필터링하며, 증가할 때마다ModelContext.fetchHistory를 호출해 새 트랜잭션만 읽습니다.3 - 프로퍼티가 엔티티가 아니라
Codable값일 때@Attribute(.codable)에 손을 뻗으세요. 소유자와 함께 다니는 작은 구조체와 열거형입니다. 타입이 자체 정체성, 관계, 쿼리를 필요로 한다면@Model을 원하는 것이고, 그저 인라인 데이터라면.codable이 transformer를 건너뜁니다.4
두 옵저버는 조합됩니다. ResultsObserver는 인프로세스 fetch를 라이브 상태로 유지하고, HistoryObserver는 원격 푸시가 변경된 내용에 대한 행동을 정당화할 때를 알려 줍니다. 진짜 멀티 기기 동기화를 하는 앱은 둘 다 사용하며, 그 과정에서 값 타입 컬럼을 정직하게 유지하기 위해 .codable을 씁니다.
FAQ
ResultsObserver는 @Query와 어떻게 다른가요?
@Query는 뷰 안에 존재하며 그 뷰에 배열을 공급하는 SwiftUI 프로퍼티 래퍼입니다. ResultsObserver는 뷰 계층 바깥을 포함해 어디서든 생성하고 보유하는 독립 클래스로, model context 안에서 영구 모델 컬렉션의 변경을 관찰하고 추적합니다.2 옵저버는 Observable이므로 그것을 읽는 SwiftUI 뷰도 여전히 자동으로 업데이트됩니다. 따라서 인뷰 경우와 @Query가 닿을 수 없는 뷰 모델 또는 코디네이터 경우를 모두 다룹니다.2
HistoryObserver는 실제로 무엇을 관찰하나요?
SwiftData의 영구 히스토리, 즉 스토어가 저장될 때마다 SwiftData가 기록하는 트랜잭션의 기록을 관찰합니다.3 새 트랜잭션이 사용 가능해질 때 증가하는 단 하나의 observable 프로퍼티 eventCounter를 노출하며, 모델 타입과 트랜잭션 author로 필터링해 관심 있는 변경만 카운터를 움직이게 할 수 있습니다.3 증가할 때마다 코드가 ModelContext.fetchHistory API를 호출해 새 트랜잭션을 읽으므로, 외부 서버와의 동기화나 앱 익스텐션 쓰기에 반응하는 구조화된 핸들러가 됩니다.3
ResultsObserver와 HistoryObserver를 함께 쓸 수 있나요?
네, 그리고 동기화 앱은 보통 그래야 합니다. ResultsObserver는 로컬 context가 변할 때 인프로세스 fetch를 최신으로 유지하고, HistoryObserver는 모델 타입과 트랜잭션 author로 필터링해 영구 히스토리에 기록된 변경을 드러냅니다.23 둘 다 SwiftUI 뷰나 다른 옵저버에서 반응할 수 있는 observable 객체이므로, 별도의 알림 처리 없이 동일한 반응형 흐름에 맞물립니다.23
관계 대신 @Attribute(.codable)은 언제 써야 하나요?
프로퍼티가 독립적인 정체성이 없는 자족적 Codable 값 타입일 때 .codable을 쓰세요. 이 옵션이 프로퍼티를 자신의 codable 표현을 통해 저장하기 때문입니다.4 값이 자체 라이프사이클, 정체성, 쿼리를 가진 실제 엔티티라면 @Model 관계를 쓰세요. 경계선은 그것이 소유자에게 속하는 데이터인지, 아니면 다른 행들이 참조하는 엔티티인지입니다.
Apple Ecosystem 클러스터 전체: observation이 그 위에 얹히는 마이그레이션 비용은 SwiftData 스키마 규율, VersionedSchema와 MigrationPlan 기계 장치는 SwiftData 마이그레이션 가이드, 이 클래스들이 꽂히는 observation 모델은 @Observable 내부 동작, 그 아래 깔린 프레임워크 기반은 SwiftUI 내부 구조. 허브는 Apple Ecosystem 시리즈에 있습니다. 더 넓은 iOS-와-AI-에이전트 맥락은 iOS 에이전트 개발 가이드를 참조하세요.
References
-
Apple Developer Documentation: SwiftData. The framework reference covering
@Model,ModelContext,ModelContainer, queries, and the iOS 27 observation additions. ↩ -
Apple Developer Documentation:
ResultsObserver(iOS 27.0 beta). “Observes and tracks changes to a collection of persistent models in a model context.” Declared asfinal class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable; configurable with aFetchDescriptoror with filter predicates and sort descriptors;Observable, so SwiftUI views update automatically; passNeverasSectionNamewhen no sectioning is needed. ↩↩↩↩↩↩↩↩↩↩↩↩ -
Apple, WWDC26 session 274, What’s new in SwiftData, and Apple Developer Documentation:
HistoryObserver(iOS 27.0 beta). Declared asfinal class HistoryObserver. Per session 274, it observes SwiftData’s persistent history and “has a single observable property” (eventCounter); “when new transactions are available in the persistent history, the eventCounter increments,” and “your code can observe the eventCounter and when it increments, use ModelContext.fetchHistory API to fetch the latest changes.” It “lets you filter by model type and transaction author”; the session’s example passes"App"as the author so app-originated changes are not replayed back to an external server. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
Apple Developer Documentation:
codable(iOS 27.0 beta). “Uses the property’s codable representation to store the property.” Declared asstatic var codable: Schema.Attribute.Option { get }. ↩↩↩↩↩↩ -
Apple, WWDC26 session 274, What’s new in SwiftData. Apple introduces
ResultsObserver, which “fetches data from your SwiftData store and then observes your store for changes” but “works anywhere in your app (independent of SwiftUI views) using Swift Observation,” and names a state object or a game written in SceneKit as cases@Querycannot reach. ↩↩↩ -
Apple Developer Documentation:
fetchCount(_:)andfetchIdentifiers(_:)onModelContext.fetchCountis declaredfunc fetchCount<T>(_ descriptor: FetchDescriptor<T>) throws -> Int where T : PersistentModeland “returns the number of models that match the criteria of the specified fetch descriptor.”fetchIdentifiersis declaredfunc fetchIdentifiers<T>(_ descriptor: FetchDescriptor<T>) throws -> [PersistentIdentifier] where T : PersistentModeland returns “an array of persistent identifiers, where each identifier represents a single model that satisfies the criteria”; afetchIdentifiers(_:batchSize:)overload returns the identifiers in batches as aFetchResultsCollection<PersistentIdentifier>. The fetch-affected-identifiers-then-compare-against-the-view pattern source: Paraphrased from a locally transcribed recording of the WWDC 2026 SwiftData Group Lab; Apple publishes no official captions for the labs. ↩↩↩