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

LemonLog: isSameDay와 bind()로 날짜별 일기 리스트 만들기 (Combine + @MainActor)

by 밤새는 탐험가89 2025. 12. 16.
728x90
SMALL

달력에서 특정 날짜를 탭하면, 그 날 작성된 감정일기만 모아서 리스트로 보여주고 싶다.
LemonLog에서는 DiaryStore가 전체 일기 목록의 변화를 diariesPublisher로 송출하고,
DiaryListViewModel이 이를 구독해서 선택한 날짜에 해당하는 일기만 필터링하는 방식으로 구현했다.

이 글에서는 다음 두 가지를 자세히 설명한다.

  1. Date.isSameDay(as:)는 왜 필요하고 어떻게 구현하는가?
  2. 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)
 
728x90
LIST