SwiftUI에서 부모-자식 ViewModel 간의 상태 동기화: 더 나은 설계는?

Minsang Kang·2025년 6월 19일
0

iOS Develop

목록 보기
9/12
post-thumbnail

SwiftUI 방식으로 기록 통계 화면을 만들면서 고민되었던 property 간의 동기화 방법에 대해 gemini-2.5-pro 와 논의했던 내용을 토대로 gemini가 정리해준 내용을 남기고자 한다


네, 그럼요. 저희가 나눈 이야기를 바탕으로 멋진 블로그 글을 작성하실 수 있도록 핵심 내용을 마크다운 형식으로 정리해 드릴게요.


SwiftUI에서 부모-자식 ViewModel 간의 상태 동기화: 더 나은 설계는?

SwiftUI와 Combine을 사용해 앱을 만들다 보면 여러 ViewModel 간에 상태를 동기화해야 하는 상황을 마주하게 됩니다. 특히 부모-자식 관계를 갖는 View와 ViewModel 구조에서 자식의 상태 변화를 다른 자식에게 전달해야 할 때, 어떻게 설계하는 것이 가장 효율적이고 확장성이 좋을까요?

1. 고민 상황: selectedDate를 어떻게 공유할까?

세 개의 ViewModel이 다음과 같은 부모-자식 관계를 맺고 있습니다.

  • LogViewModel (부모)
    • CalendarViewModel (자식)
    • LogSummaryViewModel (자식)

CalendarView에서 날짜를 선택하면(CalendarViewModelselectedDate 변경) 이 변경 사항이 LogSummaryView에도 반영되어야 합니다. 즉, 여러 ViewModel이 selectedDate라는 하나의 상태를 공유해야 합니다.

핵심 코드: ViewModel 간의 관계

// LogViewModel (Parent)
final class LogViewModel: ObservableObject {
    // 부모가 직접 selectedDate를 소유하려 하지만,
    @Published private(set) var selectedDate: Date = Date()

    // 자식 ViewModel들을 프로퍼티로 소유함
    var calendarViewModel = CalendarViewModel()
    var summaryViewModel = LogSummaryViewModel()

    private var cancellables = Set<AnyCancellable>()

    init() {
        // 자식(Calendar)의 변화를 구독하여 자신의 상태를 업데이트
        calendarViewModel.$selectedDate
            .sink { [weak self] date in
                self?.selectedDate = date
                // TODO: 이 시점에 summaryViewModel에도 알려줘야 하나?
            }
            .store(in: &cancellables)
    }
}

// CalendarViewModel (Child)
final class CalendarViewModel: ObservableObject {
    // 자식이 직접 selectedDate를 @Published로 소유
    @Published private(set) var selectedDate: Date

    // ...
    func send(_ action: Action) {
        switch action {
        case .selectDate(let day):
            // CalendarView의 액션에 따라 자신의 상태를 직접 변경
            selectedDate = day
        // ...
        }
    }
}

// LogSummaryViewModel (Child)
final class LogSummaryViewModel: ObservableObject {
    // 이 ViewModel도 selectedDate가 필요하지만, 어떻게 받아야 할까?
    @Published private(set) var selectedDate: Date = Date()
    // ...
}

2. 첫 번째 접근: 단방향 파이프라인 (Unidirectional Pipeline)

처음에는 Combine의 sink를 이용해 데이터 흐름을 연쇄적인 파이프라인처럼 구성했습니다.

흐름:
CalendarView에서 날짜 선택 → ① CalendarViewModel.selectedDate 변경 → ② LogViewModelsink로 변경 감지 후 LogViewModel.selectedDate 업데이트 → ③ LogViewModelLogSummaryViewModelupdate 함수 호출

이 방식은 "누가, 누구에게, 무엇을 전달하는지" 흐름이 명확하게 보인다는 장점이 있습니다.

핵심 코드: 파이프라인 방식 구현

// LogViewModel.swift
init() {
    calendarViewModel.$selectedDate
        .sink { [weak self] newDate in
            // 1. 자식의 변화를 감지하여 자신의 상태를 동기화
            self?.selectedDate = newDate
            // 2. 다른 자식에게 변경사항을 직접 전달 (함수 호출 방식)
            self?.summaryViewModel.send(.selectDate(newDate))
        }
        .store(in: &cancellables)
}

// LogSummaryViewModel.swift
final class LogSummaryViewModel: ObservableObject {
    // ...
    enum Action {
        case selectDate(Date)
    }

    func send(_ action: Action) {
        switch action {
        case .selectDate(let date):
            self.selectedDate = date
            // ... 날짜 변경에 따른 UI 업데이트 로직
        }
    }
    // ...
}

3. 개선안: 단일 진실 공급원 (Single Source of Truth)

파이프라인 방식은 단순한 흐름에선 명확하지만, 앱이 복잡해지면 문제가 생길 수 있습니다. 예를 들어, LogSummaryView에 "오늘로 이동" 버튼이 생긴다면 어떻게 될까요? SummaryVMLogVMCalendarVM 으로 이어지는 역방향/순방향 데이터 흐름이 거미줄처럼 얽히게 됩니다.

이것이 "단일 진실 공급원 (Single Source of Truth, SSoT)" 패턴이 더 나은 개선안이 되는 이유입니다.

  • 핵심 아이디어: 공유되어야 할 상태(selectedDate)는 오직 하나의 객체(부모, LogViewModel)만이 소유하고 관리합니다. 다른 자식 View들은 이 원천 데이터를 빌려 쓰거나(@Binding), 변경을 구독할 뿐입니다.

  • 장점:

    1. 중앙 관리: 상태 변경 로직이 부모 ViewModel에 집중되어 관리가 편합니다.
    2. 유연한 확장성: 어떤 자식이 상태를 변경하든, 그 변경은 중앙 허브(LogViewModel)에서 일어나고 모든 구독자에게 자동으로 전파됩니다. 새로운 기능이 추가되어도 허브에 연결만 하면 되므로 구조가 단순하게 유지됩니다.
    3. 예측 가능성: 데이터 흐름이 항상 허브 ↔ 자식 형태로 단순화되어 사이드 이펙트를 예측하기 쉽습니다.

핵심 코드: SSoT 방식 리팩토링

// LogViewModel (The Hub - Source of Truth)
final class LogViewModel: ObservableObject {
    // 1. 부모가 유일하게 상태를 소유하고 외부에 공개 (@Published)
    @Published var selectedDate: Date = Date()
    // ...
}

// LogView (Parent View)
struct LogView: View {
    @StateObject private var viewModel = LogViewModel()

    var body: some View {
        VStack {
            // 2. 자식 View에게 상태를 Binding으로 전달
            //    이제 CalendarView는 selectedDate를 직접 수정하는 '권한'이 아닌
            //    '변경을 요청하는 통로'를 갖게 됨
            CalendarView(selectedDate: $viewModel.selectedDate)

            // 3. 다른 자식 View에게는 읽기 전용으로 전달
            LogSummaryView(selectedDate: viewModel.selectedDate)
        }
    }
}

// CalendarView (Child View)
struct CalendarView: View {
    // 4. @Binding으로 부모의 상태와 양방향 연결
    @Binding var selectedDate: Date

    var body: some View {
        // DatePicker 등 selectedDate를 직접 변경하는 UI 요소들
        DatePicker("Select Date", selection: $selectedDate, displayedComponents: .date)
            .labelsHidden()
    }
}

// LogSummaryView (Child View)
struct LogSummaryView: View {
    // 5. 부모로부터 받은 읽기 전용 상태
    let selectedDate: Date

    var body: some View {
        Text("Selected: \(selectedDate, formatter: DateFormatter())")
            // selectedDate가 바뀌면 이 View는 자동으로 업데이트됨
    }
}

위 코드에서 ViewModel을 View에 직접 주입하는 대신, selectedDate 값 자체를 주입하여 View가 더 독립적이고 재사용 가능하도록 설계했습니다.

4. 요약 및 결론

구분파이프라인 방식Single Source of Truth 방식
핵심 개념역할에 따른 데이터 흐름의 방향성상태의 중앙 집중 관리
데이터 흐름자식1 → 부모 → 자식2 (연쇄적)자식 ↔ 부모 (허브-스포크)
장점단순한 구조에서 흐름이 명확함확장성, 유연성, 유지보수 용이성
단점복잡한 요구사항 발생 시 스파게티 코드화@Binding의 동작에 대한 초기 이해 필요

결론적으로, 여러 View와 ViewModel이 동일한 상태를 공유하고 수정해야 한다면 Single Source of Truth 패턴을 적용하는 것이 장기적으로 훨씬 유리합니다.

앞으로 앱을 설계할 때 다음과 같은 질문을 던져보세요.

"이 상태 데이터는 궁극적으로 누가 소유해야 하는가?"

이 질문에 대한 답을 찾고 그 객체를 "진실의 원천"으로 삼으세요. 그리고 다른 모든 컴포넌트는 그 원천을 참조하거나, 변경을 요청하는 방식으로 설계한다면 훨씬 더 견고하고 확장 가능한 SwiftUI 앱을 만들 수 있을 것입니다.


프롬프트 정리

추가로 질문했었던 프롬프트, 그리고 블로그 글 정리 요청 프롬프트도 남깁니다~!

질문 프롬프트

@Published selectedDate 값 변화에 대해 자식 -> 부모, 혹은 부모 -> 자식으로 전달하는 방법에 대해 내가 맞게 접근했는지, 올바른 방법이 있는지를 확인해줘.

먼저 세 가지 ViewModel 및 View 간의 관계는 다음과 같아.
- LogViewModel 이 CalendarViewModel, LogSummaryViewModel 를 자식으로 지니고 있음
- ViewModel 간의 관계와 마찬가지로 LogView 화면 내에서 CalendarView, LogSummaryView 를 표시하고 있음

확인하고 싶은 상황은 다음과 같아.
- CalendarView 내에서 날짜를 선택하면 -> CalendarViewModel 의 @Published var selectedDate 값이 변화됌
- LogViewModel 내에서는 이 calendarViewModel 의 $selectedDate 값을 sink 하여 변화가 인지되면 -> LogViewModel 의 @Published var selectedDate 값을 변화시킴

이렇게 동작은 되고 있는데 올바르게 접근한게 맞을까?
그리고 아래 상황을 추가하고 싶다면 어떻게 접근하는게 좋을까?
- LogSummaryView 에서도 selectedDate 값 변화에 따른 UI 변화가 필요함
- LogViewModel 내에서는 이 calendarViewModel 의 $selectedDate 값을 sink 하는 시점에 logSummaryViewModel.updateSelectedDate(to: Date) 와 같은 함수를 호출해서 전달해야 하는지

-> 현재 접근 방식에 대한 검토, 그리고 Single Source of Truth 패턴에 대한 제안 등을 알려주면서 아래 결론 내용을 답변했습니다.

추가 질문 프롬프트

추가 질문이야. 
장기적인 개선: 앱이 더 복잡해진다면, LogViewModel이 selectedDate를 직접 @Published로 소유하고, 자식 View들에게는 Binding 이나 Combine 구독을 통해 상태를 전달하는 "단일 진실 공급원" 패턴으로 리팩토링하는 것을 고려해보세요.

이 방식을 사용하게 되면
장점으로는 -> LogViewModel 내에만 단일 프로퍼티로 존재하기에 여러 프로퍼티간의 "동기화"시킬 과정 없이 "single source of truth" 의미가 강해짐

내가 느낀 단점은 -> 자식, 부모 모두 해당 값을 수정하고 변화를 수신받을 수 있게 되므로 -> 어느 시점에 어디서 값을 변경시키는지, 값이 변경되었을 때 어떻게 동작되는지가 조금 모호해진 형태로 느껴질수도 있을 것 같다고 생각돼

만약 지금 구조를 유지한다면 -> 부모는 자식의 변화를 assign 한다는 방향이 생기고, 부모는 자식에게 send 한다 라는 방향이 생기게 되므로 좀 더 흐름이 읽히지 않을까? 라는 생각이 들어서말이야

single source of truth 에 대해 이런 프로퍼티의 수정, 변화 수신 관점에 대해 더 좋은 방향이 맞는지를 구체적으로 좀 더 설명해줄 수 있을까?

-> 장단점 모두 매우 정확하게 파악한다는 입바른 소리를 시작으로 현재 구조의 흐름 VS SSoT의 흐름을 비교해줬고
-> "모호함"에 대한 우려에 대해 Binding 동작에 대한 구체적인 설명을 말해줬고
-> SSoT 패턴이 확장성에서 뛰어난 이유에 대해 가정 상황을 토대로 설명해줬고
-> 결론 내용을 답변했습니다.

블로그 글 정리 요청 프롬프트

와우... 똑똑하구나 잘 이해했고 잘 설명해줬고 정리까지 만족스러운데?
논의했던 내용을 토대로 블로그글을 작성해보고 싶어. 핵심 내용들만 추스려서 MarkDown 스타일로 만들어줄 수 있을까?

# 고민 상황
부모 자식간의 관계가 있는 ViewModel, View 간의 상황과
부모, 자식간의 @Published var selectedDate 프로퍼티 변화에 대한 공유가 필요한 상황

## 고민 상황에 대한 핵심 코드들
LogViewModel, CalendarViewModel, LogSummaryViewModel 코드를 참고해서 셋 간의 관계 및 프로퍼티들간의 관계에 대해 이해가 될 수 있는 핵심 코드 정리

# 접근 방법 및 이 방법에 대한 검토
내가 구현했었던 방법(단방향 파이프라인), 이 방법에 대한 분석
언제 어디서 -> 어디로 라는 방향이 잘 인지되도록 구체적으로 설명

## 접근 방법에 대한 핵심 코드들
LogViewModel, CalendarViewModel, LogSummaryViewModel 코드를 참고해서 내가 어떻게 구현했었는지 이해가 될 수 있는 핵심 코드 정리

# 개선안
단 파이프라인 방식을 썼을 때의 장점과 단점은 무엇인지
그리고 SSoT 방식은 왜 더 개선안이 될 수 있는지

## 개선안 핵심 코드들
SSoT 방식으로 개선했을 때 기준으로 핵심 구조가 이해될 수 있는 코드 정리

# 요약
그래서 핵심은 무엇이였고, 어떤 것을 앞으로 중점으로 설계해야 하는지, 앞으로 어떤 상황이 생기면 어떤 것을 고려해야하는지 등을 요약

-> 이 결과로 지금 글이 작성됐습니다!

profile
 iOS Developer

0개의 댓글