안녕하세요! 오늘은 Swift와 UIKit을 이용해 무한 캘린더를 구현하면서 겪었던 흥미로운 버그와 그 해결 과정을 공유하려 합니다.
캘린더 앱을 만드는데, 월별로 끊김 없이 스크롤되는 무한 캘린더 기능을 구현해야 했어요. 그런데 개발 과정에서 예상치 못한 문제가 발생했죠.
🔍 문제점: 무한 스크롤이 끊기는 현상
초기 구현에서는 캘린더를 좌우로 스크롤하면 다음 달, 혹은 이전 달이 부드럽게 나타났습니다. 하지만 한 달을 넘어가서 다시 이전 달로 돌아올 때, 캘린더의 일별 데이터가 사라지거나, 월 순서가 뒤죽박죽이 되는 문제가 발생했어요. 헤더의 월과 수입/지출 정보는 올바르게 업데이트되었지만, 캘린더의 날짜 칸이 텅 비어버리는 현상이었죠.
이 현상의 핵심 원인은 CalendarViewModel에서 월 데이터를 관리하는 방식과 CalendarViewController의 스크롤 로직이 서로 동기화되지 않았기 때문입니다.

🤔 문제의 원인: 비효율적인 데이터 모델과 로직
- 무한정 커지는 months 배열: 처음에는 months 배열에 이전 달, 현재 달, 다음 달을 미리 담아두고, 스크롤이 끝에 도달할 때마다 새로운 월을 추가했습니다. 이 방식은 메모리 사용량이 계속 증가하고, 배열의 인덱스가 복잡해져 관리가 어려워지는 문제를 초래했습니다.
- 부적절한 scrollViewDidEndDecelerating 로직: 뷰컨트롤러의 scrollViewDidEndDecelerating 메서드는 사용자가 캘린더의 양 끝(첫 번째 월 또는 마지막 월)에 도달했을 때만 새로운 월을 추가하도록 되어 있었습니다.
// 초기 문제의 코드 (비효율적인 로직)
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// ...
let page = Int(scrollView.contentOffset.x / scrollView.frame.width)
if page == 0 || page == calendarViewModel.months.count - 1 {
calendarViewModel.loadNewMonths() // 맨 끝에 도달했을 때만 호출
}
}
이로 인해 한 달을 넘어갔다가 다시 돌아오는 경우, 배열이 제때 확장되지 않아 데이터가 비어 보이거나, 월의 순서가 뒤섞이는 문제가 발생했습니다.
✨ 해결책: 데이터 모델의 재설계와 코드 수정
이 문제를 해결하기 위해 months 배열의 크기를 항상 3개로 고정하고, 스크롤 방향에 따라 배열의 내용을 교체하는 방식으로 재설계했습니다. 이 방식은 메모리 효율성을 높이고 로직을 더 명확하게 만들어줍니다.
1. CalendarViewModel의 역할 변경
CalendarViewModel은 이제 스크롤 방향을 받아 months 배열의 내용을 완전히 교체하는 핵심 로직을 담당합니다.
// CalendarViewModel.swift (수정된 코드)
func loadNewMonths(isForward: Bool) {
guard let currentCenterMonth = self.months[safe: 1] else { return }
if isForward {
// 다음 달로 스크롤한 경우: 현재 달을 이전 달로, 다음 달을 현재 달로, 새 다음 달을 추가
let newCurrentMonth = calendar.date(byAdding: .month, value: 1, to: currentCenterMonth)!
let newNextMonth = calendar.date(byAdding: .month, value: 1, to: newCurrentMonth)!
self.months = [currentCenterMonth, newCurrentMonth, newNextMonth]
self.currentMonth = newCurrentMonth
} else {
// 이전 달로 스크롤한 경우: 현재 달을 다음 달로, 이전 달을 현재 달로, 새 이전 달을 추가
let newCurrentMonth = calendar.date(byAdding: .month, value: -1, to: currentCenterMonth)!
let newPreviousMonth = calendar.date(byAdding: .month, value: -1, to: newCurrentMonth)!
self.months = [newPreviousMonth, newCurrentMonth, currentCenterMonth]
self.currentMonth = newCurrentMonth
}
// 새로운 월 데이터를 계산하여 뷰에 전달
self.recalculateCalendarData(from: transactionManager.transactions)
}
2. CalendarViewController의 역할 변경
CalendarViewController는 ViewModel의 months 배열이 업데이트될 때마다 UICollectionView를 리로드하고, 항상 가운데 항목(인덱스 1)으로 다시 스크롤하는 역할을 합니다. 이를 통해 사용자는 무한히 스크롤하는 것처럼 느끼게 됩니다.
// CalendarViewController.swift (수정된 코드)
// MARK: - UICollectionViewDelegate
extension CalendarViewController: UICollectionViewDelegate {
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if scrollView == calendarCollectionView {
let page = Int(scrollView.contentOffset.x / scrollView.frame.width)
let isForward = page > 1
// ViewModel에게 스크롤 방향을 알리고 새로운 월을 요청
calendarViewModel.loadNewMonths(isForward: isForward)
// ViewModel의 months 배열이 업데이트된 후,
// UICollectionView를 현재 가운데 항목(인덱스 1)으로 재배치하여
// 무한 스크롤 효과를 유지합니다.
let indexPath = IndexPath(item: 1, section: 0)
calendarCollectionView.reloadData()
calendarCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
}
}
}
📝 결론
이번 경험을 통해 MVVM 패턴에서 뷰모델의 데이터 관리 방식이 얼마나 중요한지 다시 한번 깨달았습니다. 단순하게 배열을 확장하는 대신, 고정된 크기의 배열을 재활용하는 방식으로 문제를 해결함으로써, 메모리 효율성과 함께 훨씬 안정적인 사용자 경험을 제공할 수 있게 되었죠.