Aggregate Model (2)
중첩된 Observable 객체의 한계와 Observation 프레임워크의 해결
과거 ObservableObject 기반의 중첩 구조에서 발생하던 UI 새로고침 버그를 분석하고, 최신 @Observable 매크로가 이를 어떻게 개선하는지 정리한다.
1. 중첩된 Observable 구조 분석 (과거 방식)
강좌에서는 예시로 ExpenseTracker(최상위 스토어) 내부에 Expenses(하위 스토어)라는 또 다른 ObservableObject가 포함된 구조를 보여준다.
class ExpenseTracker: ObservableObject {
@Published var name: String
@Published var expenses: Expenses
init() {
name = "My name"
expenses = Expenses()
}
}
class Expenses: ObservableObject {
@Published var name: String
@Published var items: [Expense]
init() {
name = "John Smith"
items = [
Expense(name: "Lunch", type: "Business", cost: 25.47, isDeletable: true),
Expense(name: "Taxi", type: "Business", cost: 17.0, isDeletable: true),
Expense(name: "Sports Tickets", type: "Personal", cost: 75.0, isDeletable: false)
]
}
}
- 구조:
ExpenseTracker(부모) ->Expenses(자식) ->items(데이터 배열). - 문제 상황: 부모 객체를 통해 자식 객체 내부의 배열(
items)에 데이터를 추가(append)하면, 데이터 자체는 정상적으로 늘어나지만 UI가 자동으로 갱신되지 않는다. - 원인:
ObservableObject는 자신의 직계 속성 변화만 감지할 뿐, 깊숙한 곳의 변화를 상위로 전달하는 기능이 없기 때문이다.- 구조적 흐름: 데이터의 변화는 가장 안쪽의
items에서 발생한다. - 감지의 단절:
ContentView는ExpenseTracker를 관찰하고 있다. 하지만ExpenseTracker는 자식인Expenses내부의items배열이 바뀌었다는 사실을 알 방법이 없다. - 결과: 최상위 객체인
ExpenseTracker가 변화를 인지하지 못하니, 결과적으로ContentView에 알림이 전달되지 않아 UI 변화가 일어나지 않는 것이다.
- 구조적 흐름: 데이터의 변화는 가장 안쪽의
- 💡 추가 분석: 왜 배열(Value Type)이라면 가능했을까?
- 만약
expenses가 클래스가 아닌 일반 배열([Expense])이었다면, 데이터 추가 시ExpenseTracker는 이를 본인 속성값의 변화로 즉시 인식하여 UI를 갱신한다. - 하지만 현재
expenses는 클래스(참조 타입)이다. 클래스 내부의 배열 값이 바뀌어도, 부모(ExpenseTracker)가 바라보고 있는 시점에서는expenses자체는 변해보이지 않는것이다. -
그러므로 부모 입장에서는 “내 속성인
expenses라는 대상 자체는 그대로인데?”라고 판단하게 되어, 그 안에서 벌어지는 세부적인 변화(배열 수정 등)를 뷰에 알리지 못하는 구조적 한계가 발생한다.
- 만약
혹시 이렇게는 안될까?
@Published 프로퍼티에 $를 붙여 바인딩 형태로 넘기면 해결될 것 같지만, 타입 시스템의 충돌로 인해 실패한다.
- 타입 불일치:
List는 순회 가능한 배열 타입을 기대하지만,$를 붙인 바인딩 객체는Publisher나Binding타입으로 취급되어 호환되지 않는다.
굳이 어거지로 하겠다고 한다면
struct ContentView: View {
@StateObject private var expenses = Expenses()
var body: some View {
VStack {
List($expenses.items) { $item in
Text(item.name)
}
Button("Add Expense") {
let expense = Expense(name: "Bread", type: "Groceries", cost: 4.50, isDeletable: false)
expenses.items.append(expense)
print(expenses.items.count)
}
}
.padding()
}
}
- 구조적 모순: 자식 객체인
Expenses를 직접@StateObject로 선언하여 관리하면 UI 업데이트는 가능해진다. 하지만 이 경우 최상위 관리자인ExpenseTracker가 존재해야 할 이유가 사라지며, 아키텍처가 파편화되는 결과를 초래한다.
2. Observation 프레임워크를 이용한 문제 해결
다시 본론으로 돌아와서 iOS 17에서 도입된 Observation 프레임워크의 @Observable 매크로를 사용하면 이 문제가 직관적으로 해결된다.
import Observation
@Observable
class Expenses {
var name: String
var items: [Expense]
init() {
name = "John Smith"
items = [
Expense(name: "Lunch", type: "Business", cost: 25.47, isDeletable: true),
Expense(name: "Taxi", type: "Business", cost: 17.0, isDeletable: true),
Expense(name: "Sports Tickets", type: "Personal", cost: 75.0, isDeletable: false)
]
}
}
@Observable
class ExpenseTracker {
var name: String
var expenses: Expenses
init() {
name = "My name"
expenses = Expenses()
}
}
struct ContentView: View {
@State private var expenseTracker = ExpenseTracker()
// 이하 생략
}
- 로직 요약:
- 부모 클래스와 자식 클래스 모두에
@Observable매크로를 적용한다. - 기존의
@Published키워드와ObservableObject프로토콜 채택을 삭제한다. - View에서
@StateObject대신 일반@State를 사용하여 인스턴스를 관리한다.
- 부모 클래스와 자식 클래스 모두에
3. 리팩토링 결과 및 특징
- UI 즉시 반영:
@Observable은 속성 추적(Property Tracking) 방식이 정밀하여, 중첩된 객체 내부의 데이터 변화도 SwiftUI가 정확히 감지하고 UI를 다시 그린다. - 코드 간소화:
@Published를 일일이 선언할 필요가 없어 코드가 담백해진다.
4. 근본적인 설계 고민 (Next Step)
Observation 프레임워크가 기술적인 문제를 해결해주긴 하지만, “과연 중첩된 Observable 구조가 최선인가?”라는 의문이 남는다.
- 한계: 기술적으로는 작동하더라도, 객체 간의 결합도가 높아지고 구조가 복잡해지는 단점은 여전하다.
핵심 요약
ObservableObject 시절의 중첩 구조는 UI가 갱신되지 않는 치명적인 단점이 있었으나, iOS 17의 @Observable은 이를 기술적으로 해결한다. 하지만 더 나은 아키텍처를 위해서는 중첩 구조 자체를 탈피하고 도메인별로 평탄화(Flattening)된 Store 설계를 지향해야 한다.