cellForRowAt에서 viewModel.diaries를 바로 써도 되는 이유
MVVM 구조에서 UITableView(또는 UICollectionView)를 구현하다 보면
자주 드는 고민이 있다.
“ViewController에서
ViewModel의 배열을 바로 써도 되는 걸까?”
결론부터 말하면 된다.
다만 언제까지 괜찮은지, 어디서부터 리팩토링 신호인지를
명확히 알고 쓰는 게 중요하다.
1️⃣ 기본 전제: ViewController의 역할
MVVM에서 ViewController는 다음 역할만 가지는 것이 이상적이다.
- ViewModel의 상태를 관찰
- 화면에 그대로 반영
- 사용자 입력을 ViewModel에 전달
즉,
❌ 계산하지 않는다
❌ 판단하지 않는다
❌ 비즈니스 로직을 모른다
✅ “보여주기”만 한다
2️⃣ 지금 구조에서의 ViewModel
@Published private(set) var diaries: [EmotionDiaryModel] = []
여기서 중요한 포인트는:
- private(set)
- ViewController는 읽기만 가능
- 상태 변경 권한은 ViewModel에만 있음
- diaries는
- 이미 ViewModel에서 필터링/정제된 최종 상태
즉,
VC가 이 배열을 읽는 것은 책임 범위에 정확히 들어간다.
3️⃣ cellForRowAt에서 바로 써도 되는 이유
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: DiaryListTableViewCell.reuseIdentifier,
for: indexPath
) as! DiaryListTableViewCell
let diary = diaryListVM.diaries[indexPath.row]
cell.configure(with: diary)
return cell
}
이 코드는 MVVM 관점에서 완전히 정상이다.
왜 문제가 없을까?
- VC는 단순히
- “n번째 아이템을 가져와”
- “셀에 전달”
- 로직이 없음
- 판단이 없음
- 상태 변경이 없음
👉 ViewModel의 상태를 **소비(consume)**만 하고 있다.
4️⃣ 흔한 오해: “이러면 MVVM이 깨지는 거 아닌가요?”
아니다.
MVVM에서 문제가 되는 건 이거다 👇
❌ ViewController가
ViewModel의 비즈니스 규칙을 아는 것
하지만 아래는 전혀 문제 아니다 👇
✅ ViewController가
ViewModel의 결과 상태를 읽는 것
지금 구조는 정확히 이 선을 지키고 있다.
5️⃣ 리팩토링이 필요한 “신호”는 언제일까?
아래 코드가 보이기 시작하면
그때가 다음 단계로 갈 시점이다.
let diary = diaryListVM.diaries[indexPath.row]
let title = diary.emotion.category.meta.displayName
let color = diary.emotion.category.meta.backgroundColor
let subText = diary.emotion.subEmotion.joined(separator: ", ")
이런 신호들이 나타나면
- VC가 모델 내부 구조를 너무 많이 앎
- 셀 구성 로직이 VC로 새어 나옴
- 모델 구조 변경 시 VC도 같이 수정해야 함
👉 이때는 구조 개선이 필요
6️⃣ 1단계 개선: ViewModel에 “접근용 API” 추가
func numberOfItems() -> Int {
diaries.count
}
func item(at index: Int) -> EmotionDiaryModel {
diaries[index]
}
VC에서는:
func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
diaryListVM.numberOfItems()
}
func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let diary = diaryListVM.item(at: indexPath.row)
cell.configure(with: diary)
return cell
}
👉 결합도가 한 단계 낮아진다.
7️⃣ 2단계 개선: 셀 전용 ViewModel 도입 (확장 단계)
규모가 더 커지면
셀을 위한 ViewModel을 따로 만든다.
struct DiaryCellViewModel {
let dateText: String
let emotionText: String
let subEmotions: [String]
let backgroundColor: UIColor
}
func cellViewModel(at index: Int) -> DiaryCellViewModel {
let diary = diaries[index]
return DiaryCellViewModel(
dateText: diary.createdAt.dayString(),
emotionText: "\(diary.emotion.category.meta.emoji) \(diary.emotion.category.meta.displayName)",
subEmotions: diary.emotion.subEmotion,
backgroundColor: diary.emotion.category.meta.backgroundColor
)
}
VC는 이제 모델을 전혀 모른다.
cell.configure(with: viewModel)
8️⃣ 언제까지 “바로 접근”해도 될까? (기준 정리)
✅ 지금처럼 써도 되는 경우
- VC가 단순히 배열을 읽기만 할 때
- 셀에 그대로 전달만 할 때
- ViewModel이 이미 상태를 책임지고 있을 때
⚠️ 리팩토링을 고민해야 할 경우
- VC에서 모델 내부를 해석하기 시작할 때
- 셀 구성 로직이 VC에 쌓일 때
- 같은 코드가 여러 VC에서 반복될 때
9️⃣ 핵심 정리 (이 문장만 기억해도 충분)
ViewController가 “어떻게 보여줄지”만 알고
“무엇을 계산할지”를 모른다면
그건 좋은 MVVM이다.
지금 구조에서
cellForRowAt에서 viewModel.diaries[indexPath.row]를
바로 사용하는 것은 올바른 선택이다.
그리고 문제가 생기는 시점이 오면,
그때 한 단계씩 리팩토링하면 된다.
'감정일기(가칭)' 카테고리의 다른 글
| Combine에서 .sink와 .assign을 “상태 관리”에 쓰는 2가지 방식 (0) | 2025.12.17 |
|---|---|
| 빈 상태 UI(Placeholder)와 Combine 상태 설계 .assign에서 .sink로 전환해야 하는 정확한 순간 (0) | 2025.12.16 |
| Combine의 .assign vs .sink (0) | 2025.12.16 |
| LemonLog: isSameDay와 bind()로 날짜별 일기 리스트 만들기 (Combine + @MainActor) (0) | 2025.12.16 |
| 📘 diariesPublisher vs fetchDiaryByDay (0) | 2025.12.16 |