3 분 소요

iOS 17 이전 환경에서의 Nested Observable 문제 해결 (View Composition)

@Observable 매크로를 사용할 수 없는 환경에서, 중첩된 객체의 변화를 UI에 반영하기 위한 SwiftUI의 정석적인 해결 방법을 정리한다.

1. 문제의 재확인 (ObservableObject의 한계)

ObservableObject를 사용하는 부모 뷰(ContentView)는 부모 객체의 직계 속성 변화만 감지한다. 자식 객체 내부의 items 배열이 바뀌어도 부모 뷰는 리렌더링되지 않는다.

2. 해결 전략: 뷰의 세분화 (View Composition)

문제를 해결하는 방법은 큰 뷰를 작은 뷰로 쪼개고, 변화가 발생하는 자식 객체를 해당 작은 뷰가 직접 관찰하게 만드는 것이다.

struct ExpenseListView: View {
    
    @ObservedObject var expenses: Expenses
    
    var body: some View {
        List(expenses.items) { item in
            Text(item.name)
        }
    }
}

struct ContentView: View {
    @StateObject private var expenseTracker = ExpenseTracker()
    
    var body: some View {
        VStack {
            ExpenseListView(expenses: expenseTracker.expenses) // new
            Button("Add Expense") {
               // 생략
            }
        }
        .padding()
    }
}
  • 로직 요약:
    • ExpenseListView라는 별도의 작은 뷰를 생성한다.
    • 이 뷰는 부모로부터 Expenses(자식 객체)를 전달받으며, 이를 @ObservedObject로 선언한다.
    • @ObservedObject로 선언된 자식 뷰는 이제 Expenses 내부의 items 배열이 변할 때마다 이를 직접 감지하고 자신을 다시 그린다.

3. @ObservedObject의 역할과 중요성

  • 감지의 주체: 부모 뷰(ContentView)는 여전히 변화를 모르지만, 자식 뷰인 ExpenseListView는 자신이 들고 있는 expenses를 직접 지켜보고 있으므로 데이터 추가 시 즉시 UI를 업데이트한다.
  • 성능 최적화: SwiftUI는 변경된 데이터와 직접 연관된 작은 뷰만 다시 계산하므로 효율적으로 동작한다.

4. 권장되지 않는 방식

  • 물론 didChange, willChange 같은 방법으로 하는 사람들도 있으나, 이러한 방식은 SwiftUI의 Natural한 방식은 아니다.
  • 결론: 항상 뷰를 작고 재사용 가능하게 쪼개고, 필요한 데이터(의존성)를 직접 주입받아 관찰하게 하는 것이 정석이다.

핵심 요약

iOS 17 이전 버전에서는 중첩된 객체 문제를 해결하기 위해 뷰를 쪼개고 @ObservedObject를 통해 관찰의 주체를 하위 뷰로 옮기는 전략을 사용한다. 이를 통해 부모가 인지하지 못하는 변화도 자식 뷰가 스스로 감지하여 UI를 갱신할 수 있게 된다.


대규모 앱의 Store 간 통신 및 의존성 설계 전략

병원앱 프로젝트 통해 서로 다른 Bounded Context(환자 관리, 예약 관리 등)가 데이터를 주고받아야 할 때 발생하는 설계적 문제와 해결책을 정리한다.

1. 도메인 분리와 개별 Store의 책임

대규모 앱에서는 비즈니스 로직의 경계에 따라 Store를 분리한다.

@Observable
class PatientManagementStore {
        
    let httpClient: HTTPClient
    var patients: [Patient] = []
    
    init(httpClient: HTTPClient) {
        self.httpClient = httpClient
    }
    
    func loadPatients() async throws {
        patients = try await httpClient.load(.patients)
    }
    
    func patientById(_ patientId: UUID) -> Patient? {
        return nil
    }
    
    func allergiesByPatientId(_ patientId: UUID) async throws -> [Allergy] {
        let allergies: [Allergy] = try await httpClient.load(.allergies(patientId))
        return allergies
    }
}

@Observable
class AppointmentManagementStore {
    
    let httpClient: HTTPClient
    
    init(httpClient: HTTPClient) {
        self.httpClient = httpClient
    }
    
    func scheduleAppointment(_ patient: Patient) async throws -> Appointment {

    }
    
}
  • 환자 관리 스토어(PatientManagementStore): 환자의 기본 정보, 알레르기 내역 등을 담당한다.
  • 예약 관리 스토어(AppointmentManagementStore): 진료 예약 스케줄링을 담당한다.
  • 공통 의존성: 모든 Store는 서버와의 통신을 위해 HTTPClient에 의존한다.

2. Store 간 통신의 문제점: 중첩된 의존성(Nested Dependency)

예약 관리 스토어에서 특정 환자의 예약을 잡을 때, 환자의 알레르기 정보가 필요할 수 있다. 이때 예약 스토어가 환자 스토어의 기능을 사용하기 위해 환자 스토어를 통째로 내부에 들고 있는 것은 지양해야 한다.

@Observable
class AppointmentManagementStore {
    
    let httpClient: HTTPClient
    let patientManagementStore: PatientManagementStore

    // 이하 생략
}
  • 문제점: Store 내부에 다른 Store를 주입하기 시작하면 의존성이 복잡해지고, 앞서 다룬 중첩된 Observable(Nested Observable) 구조가 발생하여 관리가 어려워진다.

3. 해결 전략 1: 데이터 원천(Source) 직접 접근

특정 정보(알레르기 등)가 필요할 때, 다른 Store에 의존하는 대신 그 데이터가 시작된 원천(HTTPClient나 API 엔티티)에 직접 접근하는 방식을 취한다.

func scheduleAppointment(_ patient: Patient) async throws -> Appointment {
    
    // get allergies 
    let allergies: [Allergy] = try await httpClient.load(.allergies(patient.id!))
    return Appointment(patient: patient, allergies: allergies)
}    
  • 로직 요약: 예약 스토어는 환자 스토어를 거치지 않고, HTTPClient를 통해(httpClient.load)서버의 알레르기 엔드포인트에서 필요한 데이터를 직접 가져온다. 이를 통해 두 Store 사이의 불필요한 결합도를 낮춘다.

4. 해결 전략 2: 공통 로직의 추출 및 일반화(Generic)

정렬(Sorting)과 같이 여러 Store에서 공통으로 사용되는 비즈니스 로직은 특정 Store에 종속시키지 않고 독립적인 유틸리티나 일반화된 함수로 추출한다.

func customSort<T>(_ array: [T], by comparator: (T, T) -> Bool) -> [T] {
    return array.sorted(by: comparator)
}
  • 로직 요약: 정렬 알고리즘을 개별 Store 내부에 두지 않고, 공통 계층으로 분리하여 필요한 곳에서 호출하게 한다. 필요하다면 환경 변수(Environment)로 주입하여 뷰나 Store에서 접근하게 할 수 있다.

5. View 계층에서의 통합 관리

뷰에서는 각각의 Store를 독립적으로 주입받아 사용하며, 이를 통해 데이터 간의 독립성을 유지한다.

struct ContentView: View {
    
    @Environment(PatientManagementStore.self) private var patientManagementStore
    @Environment(AppointmentManagementStore.self) private var appointmentManagementStore
    
    var body: some View {
        VStack {
             PatientListView(patients: patientManagementStore.patients)
             AppointmentListView(appointments: appointmentManagementStore.appointments)
        }
        .padding()
    }
}
  • 주입 방식: 최상위 Root 뷰에서 .environment를 사용하여 필요한 Store들을 각각 주입한다. (강의에선 Preview에만 적용하긴 했음.)
  • 하위 뷰 전달 최적화: 하위 뷰(PatientListView 등)에 Store 전체를 넘기기보다, 해당 뷰가 실제로 필요로 하는 순수 데이터(환자 배열 등)만 넘긴다.
  • 이점: 이렇게 필요한 데이터만 부분적으로 전달하면 SwiftUI의 데이터 추적(Tracking) 효율이 극대화되어 성능이 향상된다.

핵심 요약

Store 간의 무리한 중첩이나 직접적인 참조는 아키텍처를 복잡하게 만든다. 데이터가 필요할 때는 해당 데이터의 원천(네트워크 레이어 등)에 직접 접근하거나 공통 로직을 분리하는 방식을 우선적으로 고려해야 한다. 뷰에서는 필요한 Store들을 평탄하게 관리하고, 하위 뷰에는 최소한의 데이터만 전달하는 것이 SwiftUI다운 설계이다.