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)
}
}'감정일기(가칭)' 카테고리의 다른 글
| 📐 Compositional Layout에서 interGroupSpacing vs interItemSpacing 완벽 정리 (0) | 2025.10.29 |
|---|---|
| 🧩 HomeSection은 어디에 두는 게 맞을까? (0) | 2025.10.28 |
| 📘 Combine 기반 ViewModel 테스트: 시행착오로 배우는 HappinessViewModelTests 완성기 (0) | 2025.10.27 |
| 🤔 프로토콜을 어디에 붙여야 하나? - MVVM 설계의 성숙도 (0) | 2025.10.27 |
| 📘 단위 테스트 vs 통합 테스트– HappinessViewModel 사례로 배우는 Combine 테스트 구조 (0) | 2025.10.27 |