감정일기 앱 LemonLog는 MVVM 패턴을 기반으로,
데이터의 일관성과 반응성을 유지하기 위해
DiaryStore라는 전역 상태 관리용 ViewModel을 설계했어요.
🎯 설계 목표
1️⃣ SSOT(Single Source of Truth)
→ 앱 전역의 감정일기 상태를 하나의 객체에서만 관리
→ 데이터 충돌·중복 최소화
2️⃣ 읽기/쓰기 명확한 분리
→ 읽기: DiaryProviding 프로토콜로 읽기 전용 인터페이스 제공
→ 쓰기: DiaryStore 내부 전용 (save/update/delete)
3️⃣ UI 안전성 보장
→ UI와 연결되는 Store는 @MainActor에서 동작
→ Core Data I/O는 백그라운드에서 수행
4️⃣ 반응형 데이터 흐름 구축
→ Combine의 CurrentValueSubject를 사용해 ViewModel/UI 자동 갱신
5️⃣ 확장성과 테스트 용이성 확보
→ Core Data, Firebase, iCloud 등으로 교체해도 외부 코드는 그대로 유지
🧱 전체 구조 개요
[Core Data Manager] 💾
↓ (fetch/save)
[DiaryStore] 🧠 ← (Protocol) DiaryProviding 📡
↓ (Combine)
[ViewModel] 🎛️
↓
[View / UI] 🎨
⚙️ DiaryStore 핵심 코드
@MainActor
final class DiaryStore: DiaryProviding {
// ✅ 싱글톤
static let shared = DiaryStore(manager: DiaryCoreDataManager.shared)
// ✅ 의존성 (Core Data Manager)
private let manager: DiaryCoreDataManager
// ✅ 상태 컨테이너 + 브로드캐스터
private let diariesSubject = CurrentValueSubject<[EmotionDiaryModel], Never>([])
// ✅ 외부 구독용 Publisher
var diariesPublisher: AnyPublisher<[EmotionDiaryModel], Never> {
diariesSubject.eraseToAnyPublisher()
}
// ✅ 현재 스냅샷 (즉시 접근)
var snapshot: [EmotionDiaryModel] { diariesSubject.value }
// ✅ 초기화 시 데이터 로드
init(manager: DiaryCoreDataManager) {
self.manager = manager
Task { await reload() }
}
// ✅ Core Data에서 데이터 로드 (비동기)
func reload() async {
await Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
let diaries = await self.manager.fetchDiairesAsync(mode: .all)
let sorted = diaries.sorted(by: { $0.createdAt > $1.createdAt })
await MainActor.run {
self.diariesSubject.send(sorted)
}
}.value
}
...
}
🧩 구성요소 역할
🧠 1. diariesSubject
“현재 감정일기 상태를 메모리에 저장하고, 변경될 때마다 방송하는 역할”
private let diariesSubject = CurrentValueSubject<[EmotionDiaryModel], Never>([])
📌 핵심 포인트
상태 저장소 (State Container)
.value 속성으로 현재 감정일기 배열을 들고 있음.
변경 브로드캐스터 (Publisher)
.send() 호출 시 모든 구독자(ViewModel/UI)에 자동 전파.
명시적 제어 가능
@Published보다 테스트·합성·외부 제어에 유리.
⚙️ 데이터 흐름
1. Core Data → fetchDiariesAsync() 결과 전달
2. Store에서 .send()로 전체 전파
3. ViewModel이 구독 중이라면 자동으로 최신 UI 반영
📡 2. diariesPublisher
“외부(특히 ViewModel)에서 구독하는 읽기 전용 Publisher”
var diariesPublisher: AnyPublisher<[EmotionDiaryModel], Never> {
diariesSubject.eraseToAnyPublisher()
}
- ViewModel에서는 Combine의 .sink 또는 .assign으로 구독
- Core Data에서 데이터가 변경되면 UI가 자동 갱신
- 읽기 전용으로 노출되어 외부에서 Store 상태를 직접 수정할 수 없음
✅ 예시
store.diariesPublisher
.receive(on: DispatchQueue.main)
.assign(to: &$diaries)
→ diaries 프로퍼티가 자동으로 최신 상태를 반영해 UI가 갱신됨
🪞 3. snapshot
“실시간 구독이 필요 없는, 현재 상태의 즉시 복제본”
var snapshot: [EmotionDiaryModel] { diariesSubject.value }
→ “지금 메모리에 있는 최신 데이터를 한 번만 가져오고 싶어!”
→ 보통은 비동기 구독이 어려운 단발성 로직이나 필터링 연산, 탐색 등에 사용.
✅ 예시
위젯 확장 (WidgetKit) — Combine 안 됨 → snapshot 필수
백그라운드 알림 처리 — 구독 불가 영역 → snapshot으로 최신 데이터 확인
단위 테스트 / 빠른 접근 로직
⚙️ 즉, snapshot은 “옵션형 도구”야
diariesPublisher가 앱의 핵심 데이터 흐름을 담당한다면,
snapshot은 “한 번만, 지금 상태를 보고 싶을 때” 사용하는 보조 도구예요.
🔁 읽기 흐름 (READ)
func reload() async {
await Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
let diaries = await self.manager.fetchDiairesAsync(mode: .all)
let sorted = diaries.sorted(by: { $0.createdAt > $1.createdAt })
await MainActor.run {
self.diariesSubject.send(sorted)
}
}.value
}
1️⃣ Core Data에서 백그라운드로 fetch
2️⃣ 결과를 MainActor로 가져와 diariesSubject.send()
3️⃣ ViewModel에서 Combine 구독으로 자동 반영
Core Data에서 백그라운드 fetch를 하고 → 그 결과를 MainActor에서 전파하는 이유는,
“UI 스레드의 안정성과 데이터 일관성을 동시에 보장하기 위해서” 입니다.
1️⃣ Core Data fetch는 “백그라운드”에서 해야 한다
fetch(), save() 는 디스크 I/O 연산이므로
메인 스레드에서 실행하면 UI가 멈추거나 끊깁니다.
그래서 Task.detached, context.perform {}, 또는 newBackgroundContext()로 백그라운드에서 실행하는 거예요.
예시 👇
let diaries = await manager.fetchDiairesAsync(mode: .all)
이 시점에서는 Core Data가 백그라운드에서 데이터를 읽고
메모리에 [EmotionDiaryModel] 형태로 결과를 반환합니다.
2️⃣ 그런데 UI에서 그 데이터를 “보여주는” 순간은?
이제 가져온 데이터를 ViewModel이나 View(UI)에 반영해야 하죠.
그런데 ViewModel과 View는 항상 메인 스레드에서만 안전하게 동작합니다.
즉,
- Combine의 @Published, CurrentValueSubject
- SwiftUI의 @State, UIKit의 UI 업데이트 등
모두 Main Thread에서 접근해야 합니다.
따라서 이 부분 👇
await MainActor.run {
self.diariesSubject.send(sorted)
}
이게 필요한 이유는,
데이터를 백그라운드에서 불러온 뒤,
UI 스레드(MainActor) 에서 안전하게 반영하기 위해서예요.
3️⃣ 즉, 데이터의 “읽기(fetch)”는 백그라운드,
데이터의 “적용(send/update)”은 메인에서 해야 함
이건 역할 분리예요.
| 단계 | 실행 위치 | 이유 |
| Core Data fetch | 백그라운드 (I/O 전용) | 대용량 데이터 읽기, UI 프리즈 방지 |
| diariesSubject.send() | MainActor (UI 안전 영역) | Combine 구독자(UI)가 Main Thread에서 반응해야 함 |
4️⃣ “계속해서 백그라운드로 가져오면 안 되나?” 🤔
이건 부분적으로 맞아요.
만약 데이터가 너무 많아서 페이징 처리가 필요한 경우라면,
“fetch는 계속 백그라운드”에서 수행해야 맞아요.
하지만 문제는:
Combine Subject(diariesSubject)는 UI와 직접 연결된 객체라는 거예요.
즉,
diariesSubject.send() 자체를 백그라운드에서 호출하면
메인 스레드(UI)를 건드리는 코드들이
비동기 상태에서 섞여서 스레드 안전성(thread-safety) 문제를 일으킬 수 있어요.
그래서 항상 👇 이런 순서를 유지해야 합니다.
1️⃣ Core Data fetch (background)
↓
2️⃣ 정렬/가공 (background)
↓
3️⃣ MainActor.run { diariesSubject.send(...) } (UI 스레드)
이 순서면
UI는 항상 메인 스레드에서 안전하게 업데이트되고
Core Data는 백그라운드에서 부하 없이 작업합니다 ✅
5️⃣ 다이어그램으로 보면
🧱 Core Data (Background)
|
| fetch & decode
▼
📦 DiaryStore.reload()
|
| MainActor.run { send() }
▼
🧠 Combine Subject (Main Thread)
|
▼
🎛️ ViewModel @Published
|
▼
🎨 View(UI) 자동 갱신
👉 즉,
“백그라운드 → 메인스레드”로 단방향 안전한 데이터 이동 파이프라인이 만들어진 거예요.
✏️ 쓰기 흐름 (WRITE)
@discardableResult
func save(_ diary: EmotionDiaryModel) -> Bool {
let success = manager.saveDiary(diary)
if success {
var newList = diariesSubject.value
newList.append(diary)
diariesSubject.send(newList.sorted { $0.createdAt > $1.createdAt })
}
return success
}
- Core Data에 성공적으로 저장된 후에만
diariesSubject를 통해 메모리 상태도 업데이트
- ViewModel/UI는 별도 작업 없이 자동으로 최신 데이터로 반영
⚡️ 사용 흐름 요약
사용자 동작 (작성/삭제 등)
↓
DiaryStore.save/update/delete
↓
Core Data 반영 (백그라운드)
↓
diariesSubject.send()
↓
ViewModel 구독 (Combine)
↓
View(UI) 자동 갱신
💪 이 설계의 장점
| 항목 | 설명 |
| 🧩 SSOT 구조 | 감정일기의 단일 진실 공급원, 데이터 일관성 유지 |
| ⚡️ 반응형 UI | Core Data 변경 시 Combine으로 자동 업데이트 |
| 🧠 관심사 분리 | Store(상태), Manager(데이터 I/O), ViewModel(UI 로직) 명확히 분리 |
| 🧪 테스트 용이성 | Store를 Mock으로 대체하거나 snapshot으로 상태 검증 가능 |
| 🔐 UI 안전성 확보 | @MainActor로 상태 전파를 UI 스레드에서만 수행 |
| 🔄 확장성 우수 | Core Data → Firebase 교체도 Store 인터페이스 그대로 유지 |
🧭 결론
DiaryStore는
Core Data를 “저장소”가 아닌, “앱 전역 상태 관리 시스템”으로 진화시킨 클래스입니다
이 구조를 사용하면
- 데이터 흐름이 단방향 (Store → ViewModel → View)
- Core Data I/O와 UI 갱신이 완전히 분리
- 앱 전체에서 동일한 데이터 일관성 유지
즉,
UI, 데이터, 반응성 — 세 가지를 모두 잡은 설계라고 할 수 있습니다. 🎯
'감정일기(가칭)' 카테고리의 다른 글
| 🧪 Xcode에서 XCTestCase로 단위 테스트 작성하기— DiaryStore 테스트를 예시로 배우는 실전 가이드 (0) | 2025.10.23 |
|---|---|
| 🧭 DiaryProviding 프로토콜 — 읽기 전용 인터페이스로 ViewModel을 보호하기 (0) | 2025.10.23 |
| 🧩 @Published vs CurrentValueSubject — Combine에서의 타이밍 차이 완벽 정리 (0) | 2025.10.22 |
| 🧩 LemonLog Core Data 테스트 콘솔 로그 분석 (0) | 2025.10.21 |
| 🚀 Xcode에서 Core Data CRUD 테스트 실행하기 (0) | 2025.10.21 |