3 분 소요

중첩된 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)
        ]
    }   
}

Image

  • 구조: ExpenseTracker(부모) -> Expenses(자식) -> items(데이터 배열).
  • 문제 상황: 부모 객체를 통해 자식 객체 내부의 배열(items)에 데이터를 추가(append)하면, 데이터 자체는 정상적으로 늘어나지만 UI가 자동으로 갱신되지 않는다.
  • 원인: ObservableObject는 자신의 직계 속성 변화만 감지할 뿐, 깊숙한 곳의 변화를 상위로 전달하는 기능이 없기 때문이다.
    • 구조적 흐름: 데이터의 변화는 가장 안쪽의 items에서 발생한다.
    • 감지의 단절: ContentViewExpenseTracker를 관찰하고 있다. 하지만 ExpenseTracker는 자식인 Expenses 내부의 items 배열이 바뀌었다는 사실을 알 방법이 없다.
    • 결과: 최상위 객체인 ExpenseTracker가 변화를 인지하지 못하니, 결과적으로 ContentView에 알림이 전달되지 않아 UI 변화가 일어나지 않는 것이다.
  • 💡 추가 분석: 왜 배열(Value Type)이라면 가능했을까?
    • 만약 expenses가 클래스가 아닌 일반 배열([Expense])이었다면, 데이터 추가 시 ExpenseTracker는 이를 본인 속성값의 변화로 즉시 인식하여 UI를 갱신한다.
    • 하지만 현재 expenses클래스(참조 타입)이다. 클래스 내부의 배열 값이 바뀌어도, 부모(ExpenseTracker)가 바라보고 있는 시점에서는 expenses자체는 변해보이지 않는것이다.
    • 그러므로 부모 입장에서는 “내 속성인 expenses라는 대상 자체는 그대로인데?”라고 판단하게 되어, 그 안에서 벌어지는 세부적인 변화(배열 수정 등)를 뷰에 알리지 못하는 구조적 한계가 발생한다.

혹시 이렇게는 안될까?

@Published 프로퍼티에 $를 붙여 바인딩 형태로 넘기면 해결될 것 같지만, 타입 시스템의 충돌로 인해 실패한다.

Image

  • 타입 불일치: List는 순회 가능한 배열 타입을 기대하지만, $를 붙인 바인딩 객체는 PublisherBinding 타입으로 취급되어 호환되지 않는다.

굳이 어거지로 하겠다고 한다면

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 설계를 지향해야 한다.