본문 바로가기
감정일기(가칭)

🏠 HomeViewModel에 HappinessViewModel을 통합한 이유와 구조 정리

by 밤새는 탐험가89 2025. 10. 28.
728x90
SMALL

UICollectionView + CompositionalLayout 기반의 홈 화면을 구성할 때,
데이터가 여러 출처(ViewModel)에서 들어오는 경우가 많습니다.


이번 글에서는 HomeViewModel 내부에 HappinessViewModel을 통합한 이유와 구조적 장점,
그리고 초기화 순서와 바인딩 타이밍까지 한눈에 정리해볼게요 💡


🎯 왜 HomeViewModel 안에 HappinessViewModel을 넣었을까?

1️⃣ 책임 분리 (Single Responsibility Principle)

- HomeViewModel → 홈 화면의 UI 상태 관리 담당
(최근 일기, 주간 감정요약, 사진, 명언 등 화면에 필요한 모든 데이터의 단일 진실 공급원, SSOT)

- HappinessViewModel → 명언 도메인 전용 로직 담당
(API 통신, 모델 파싱, 에러 처리 등)

 

➕ 이렇게 역할을 나누면 책임이 명확해지고, 테스트와 유지보수가 훨씬 쉬워집니다.

 

2️⃣ 1 ViewController : 1 ViewModel 원칙

UICollectionView + CompositionalLayout은 섹션이 많고 셀이 다양하죠.
이때 ViewController가 여러 ViewModel을 직접 다루면 금방 복잡해집니다 😵‍💫

 

그래서 홈 화면에서는 HomeViewModel 하나가
다른 ViewModel(여기서는 HappinessViewModel)을 내부적으로 소유하고
데이터를 흘려보내는 구조가 가장 깔끔합니다.

 

🎨 즉, ViewController는 “한 개의 ViewModel”만 구독하고,
HomeViewModel이 여러 도메인의 상태를 통합해서 관리합니다.

 

3️⃣ 재사용성과 독립 테스트 강화

- HappinessViewModel은 다른 화면에서도 그대로 재사용 가능 💪

- HomeViewModel은 명언 로직을 몰라도 됨 — 단지 quote만 받으면 OK

- 각각 독립적인 유닛 테스트 가능 → 테스트 범위 명확


🧱 설계 패턴 요약

방법 개요 추천도
① HomeViewModel이
HappinessViewModel을 소유
HomeVM이 내부적으로 HappinessVM을
가지고 $quote를 바인딩
✅ 강력 추천
② ViewController가 두 VM을 직접 구독 ViewController가 복잡해짐 ⚠️ 비권장
③ Service를 직접 HomeVM에서 호출 도메인 단순할 때만 고려 🔸 상황 한정

 

💬 이번 케이스처럼 명언 기능이 독립적인 도메인이라면,
①번 구조가 가장 확장성 있고 실무에서도 자주 쓰입니다.


🏗️ 통합된 HomeViewModel이 “모범 설계”인 이유

✅ 1. Combine 바인딩의 정석

.receive(on: DispatchQueue.main)
.sink { [weak self] quote in self?.quote = quote }
.store(in: &cancellables)

 

- UI 업데이트는 항상 main thread 보장

- [weak self] 캡처로 메모리 누수 방지

- store(in:)으로 구독 수명 명확히 관리

 

✅ 2. 접근 제어가 명확

@Published private(set)으로 외부에서 상태 수정 불가
→ View는 데이터만 구독(read-only), 변경은 ViewModel 내부만

 

✅ 3. 초기화 순서가 안전

observeStore()
bindHappinessQuote()
Task { await loadDiaryImages() }
happinessViewModel.loadQuote()

 

 

- 바인딩 먼저, 그 다음 데이터 로드

- Combine에서 구독 전에 값을 발행하면

첫 이벤트가 누락되므로 이 순서가 정답입니다 ✅

 

✅ 4. 의존성 주입 & 테스트 용이성

- DiaryProviding 프로토콜 기반으로 대체 주입 가능

- HappinessViewModel 독립 테스트도 쉬움


🔄 데이터 흐름 한눈에 보기

HappinessService
   ↓
HappinessViewModel.$quote
   ↓
HomeViewModel.quote
   ↓
HomeViewController
   ↓
DiffableDataSource
   ↓
UICollectionView(UI)

 

 

🧩 HomeViewModel이 화면 전체의 SSOT 역할을 수행하고,
모든 섹션 데이터를 통합 관리합니다.


⚙️ 초기화 순서 (버그 방지 핵심)

1️⃣ observeStore() — 감정일기 스트림 구독
2️⃣ bindHappinessQuote() — 💡 먼저 구독 설정
3️⃣ Task { await loadDiaryImages() } — 비동기 리소스 로드
4️⃣ happinessViewModel.loadQuote() — 💬 그 다음 데이터 발행

 

🔥 구독 전에 값을 발행하면 첫 이벤트가 누락되므로, 반드시 이 순서로!


🎨 CompositionalLayout과의 연동

enum HomeSection: Int, CaseIterable {
    case quote, moodSummary, recentDiaries, photoGallery
}

 

 

- HomeViewModel이 모든 섹션 데이터를 소유

- VC는 단 하나의 ViewModel(HomeVM)만 구독

- DiffableDataSource는 HomeVM의 읽기 전용 상태로 스냅샷 구성

var snapshot = NSDiffableDataSourceSnapshot<HomeSection, AnyHashable>()
snapshot.appendSections(HomeSection.allCases)
snapshot.appendItems([viewModel.quote].compactMap { $0 }, toSection: .quote)
snapshot.appendItems(viewModel.recentDiaries, toSection: .recentDiaries)
dataSource.apply(snapshot, animatingDifferences: true)

 


🚫 피해야 할 안티패턴

❌ ViewController가 여러 ViewModel을 직접 구독

→ 상태 경쟁, 스냅샷 충돌, 타이밍 이슈 발생

❌ loadQuote()를 바인딩 이전에 호출

→ 첫 이벤트 누락으로 UI 갱신 안 됨

❌ @Published를 외부에서 set 가능하게 노출

→ 데이터 일관성 붕괴 ⚠️


✅ 요약 체크리스트

1. HomeViewModel이 SSOT(Single Source of Truth)

2. 도메인별 ViewModel(HappinessVM)은 HomeVM 내부에 소유

3. 바인딩 먼저, 데이터 발행 나중

4. @Published private(set)으로 외부 수정 차단

5. VC는 오직 HomeVM만 구독

6. DiffableDataSource 스냅샷은 HomeVM 상태 기반


✅ HomeViewModel.swift

import Foundation
import Combine
import UIKit


@MainActor
final class HomeViewModel: ObservableObject {
    
    
    // MARK: ✅ Dependencies
    private let happinessViewModel = HappinessViewModel()
    private let store: DiaryProviding
    private var cancellables = Set<AnyCancellable>()
    
    
    // MARK: ✅ Published Properties (UI에 바인딩)
    @Published private(set) var recentDiaries: [EmotionDiaryModel] = []
    @Published private(set) var emotionSummary: [EmotionCategory: Int] = [:]
    @Published private(set) var diaryImages: [(image: UIImage?, diaryID: String)] = []
    @Published private(set) var quote: HappinessQuote?
    
    // MARK: ✅ Init
    init(store: DiaryProviding? = nil) {
        
        // Swift 6 - safe 초기화
        self.store = store ?? DiaryStore.shared
        observeStore()
        bindHappinessQuote()
        Task {
            await loadDiaryImages()
        }
        happinessViewModel.loadQuote()   // 홈 진입시 명언을 바로 호출
    }
    
    
    // MARK: ✅ Observe DiaryStore Updates
    private func observeStore() {
        store.diariesPublisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] diaries in
                guard let self else { return }
                
                // 최근 일기 5개만 보이기
                self.recentDiaries = Array(diaries.prefix(5))
                
                // 주간 감정 비율 계산
                self.emotionSummary = store.countByEmotion(inWeekOf: Date())
                
            }
            .store(in: &cancellables)
    }
    
    
    // MARK: ✅ Load Diary Images
    func loadDiaryImages() async {
        let results = await store.fetchFirstImages()
        await MainActor.run {
            self.diaryImages = results
        }
    }
    
    
    // MARK: ✅ Bind HappinessViewModel
    private func bindHappinessQuote() {
        happinessViewModel.$quote
            .receive(on: DispatchQueue.main)
            .sink { [weak self] quote in
                self?.quote = quote
            }
            .store(in: &cancellables)
    }

}

 

✅ HappinessViewModel.swift

import Foundation
import Combine


@MainActor
final class HappinessViewModel: ObservableObject {
    
    
    // MARK: ✅ Published Properties
    @Published var quote: HappinessQuote?   // 모델 단위로 관리
    
    private let service: HappinessServiceProviding
    private var cancellables = Set<AnyCancellable>()
    
    
    // MARK: ✅ Init
    init(service: HappinessServiceProviding = HappinessService.shared) {
        self.service = service
    }
    
    
    // MARK: ✅ Method
    func loadQuote() {
        service.fetchRandomQuote()
            .sink(receiveCompletion: { completion in
                if case .failure(let error) = completion {
                    LogManager.print(.error, "명언 불러오기 실패: \(error.localizedDescription)")
                }
            }, receiveValue: { [weak self] quoteData in
                self?.quote = quoteData
                LogManager.print(.success, "명언 업데이트 완료")
            })
            .store(in: &cancellables)
    }
}
728x90
LIST