달력에서 특정 날짜를 탭하면, 그 날 작성된 감정일기만 모아서 리스트로 보여주고 싶다.
LemonLog에서는 DiaryStore가 전체 일기 목록의 변화를 diariesPublisher로 송출하고,
DiaryListViewModel이 이를 구독해서 선택한 날짜에 해당하는 일기만 필터링하는 방식으로 구현했다.
이 글에서는 다음 두 가지를 자세히 설명한다.
- Date.isSameDay(as:)는 왜 필요하고 어떻게 구현하는가?
- bind()에서 Combine을 어떻게 연결하는가? ([selectedDate], .assign(to:
1) isSameDay(as:)가 왜 필요한가?
Date는 “하루”가 아니라 초 단위 타임스탬프다.
- 2025-12-16 09:00:00
- 2025-12-16 18:30:00
두 값은 “같은 날”이지만 == 비교하면 다르다.
우리가 달력 UI에서 원하는 건:
“연/월/일이 같은지” (시간은 무시)
그래서 Date끼리 “같은 날짜인지”를 비교하는 유틸이 필요하다.
2) 가장 안전한 구현: Calendar.isDate(_:inSameDayAs:)
Swift에서 “같은 날” 비교는 직접 문자열로 바꾸거나 컴포넌트를 뜯기보다
Calendar가 제공하는 공식 API를 쓰는 게 안전하다.
import Foundation
extension Date {
/// 두 Date가 같은 '연/월/일'인지 비교한다 (시간은 무시)
func isSameDay(as other: Date, calendar: Calendar = .current) -> Bool {
calendar.isDate(self, inSameDayAs: other)
}
}
왜 이게 좋은가?
- 타임존/로케일 영향을 Calendar가 처리해줌
- DST(서머타임) 같은 edge case에서도 상대적으로 안전
- “하루 비교”라는 의도가 코드에서 명확함
3) bind()는 뭘 하는 함수인가?
DiaryListViewModel의 목표는 단 하나다.
Store가 가진 “전체 일기 변화”를 계속 받아서
그 중 “선택한 날짜의 일기만” @Published diaries에 넣는다.
즉, bind()는 Store(데이터) → ViewModel(UI상태) 연결을 만드는 함수다
4) 전체 예시 코드
ViewModel
import Foundation
import Combine
@MainActor
final class DiaryListViewModel {
@Published private(set) var diaries: [EmotionDiaryModel] = []
private let selectedDate: Date
private let store: DiaryProviding
init(date: Date, store: DiaryProviding? = nil) {
self.selectedDate = date
self.store = store ?? DiaryStore.shared
bind()
}
private func bind() {
store.diariesPublisher
.map { [selectedDate] diaries in
diaries.filter { $0.createdAt.isSameDay(as: selectedDate) }
}
.assign(to: &$diaries)
}
}
5) bind()를 한 줄씩 뜯어보기
5-1) store.diariesPublisher
이 Publisher는 전체 감정일기 배열을 지속적으로 송출한다.
- 새 일기 작성 → 전체 배열이 업데이트되어 다시 송출
- 삭제/수정 → 전체 배열이 업데이트되어 다시 송출
즉, ViewModel이 구독하고 있으면 리스트 화면은 자동으로 최신 상태가 된다.
5-2) .map { ... }에서 “필터된 결과로 변환”
.map { diaries in
diaries.filter { ... }
}
Publisher가 [EmotionDiaryModel]을 보내면,a
map 안에서 “선택한 날짜에 해당하는 것만 남긴 배열”로 바꿔준다.
결과적으로 ViewModel이 받는 값은:
- 전체 일기 배열 ❌
- 선택 날짜의 일기 배열 ✅
6) [selectedDate]가 뭔데?
.map { [selectedDate] diaries in
diaries.filter { $0.createdAt.isSameDay(as: selectedDate) }
}
이건 클로저 캡처 리스트야.
왜 쓰냐?
Combine 체인은 내부적으로 클로저들을 강하게 잡을 수 있어.
그리고 클로저에서 self.selectedDate 같은 걸 쓰면, 자연스럽게 self를 캡처한다.
- self 캡처가 반드시 문제가 되는 건 아니지만
- 습관적으로 self 캡처를 최소화하면
- 순환 참조 위험을 줄이고
- 코드 의도를 더 명확히 할 수 있다.
[selectedDate]의 의미
“이 클로저 안에서는 self.selectedDate 대신
현재 값 selectedDate를 복사해서 사용하겠다.”
즉,
- self에 의존하지 않고
- 필요한 값만 안전하게 클로저로 가져오는 패턴
✅ 선택 날짜가 ViewModel 생명주기 동안 고정이라면 특히 적합
7) .assign(to: &$diaries)가 뭔데?
여기가 가장 자주 헷갈리는 부분이다.
7-1) diaries는 그냥 배열이 아니라 @Published 프로퍼티
@Published private(set) var diaries: [EmotionDiaryModel] = []
@Published는 내부적으로 “Publisher”를 자동으로 만들고,
값이 바뀌면 SwiftUI/구독자에게 변경을 알려준다.
7-2) assign(to:)는 “Publisher가 보내는 값을 특정 프로퍼티에 자동 대입”해주는 연산자
.assign(to: &$diaries)
- Publisher가 새 배열을 보낼 때마다
- self.diaries = 새배열을 자동으로 해준다
7-3) 왜 &$diaries지? &diaries가 아닌 이유
- diaries → 배열 값
- $diaries → @Published가 만들어주는 Publisher
- &$diaries → Combine이 “이 Published에 값을 연결해라”라고 이해할 수 있는 형태
그래서 예전에 나왔던 에러가 이거였지:
assign(to: &diaries)
→ 배열에 assign 하려고 해서 타입이 안 맞음
추가 팁: 정렬도 같이 하고 싶다면
.map { [selectedDate] diaries in
diaries
.filter { $0.createdAt.isSameDay(as: selectedDate) }
.sorted { $0.createdAt > $1.createdAt }
}
.assign(to: &$diaries)
'감정일기(가칭)' 카테고리의 다른 글
| 빈 상태 UI(Placeholder)와 Combine 상태 설계 .assign에서 .sink로 전환해야 하는 정확한 순간 (0) | 2025.12.16 |
|---|---|
| Combine의 .assign vs .sink (0) | 2025.12.16 |
| 📘 diariesPublisher vs fetchDiaryByDay (0) | 2025.12.16 |
| 📅 날짜 선택 → 감정일기 리스트 화면 설계하기 (LemonLog 사례) (0) | 2025.12.16 |
| 📱 UICollectionView 내부 셀의 날짜 갱신이 안 되는 이유와 해결 방법— 특히 “셀 자체를 전달하는 방식”의 강력함 (0) | 2025.12.10 |