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

🍋 DiaryStore 설계 총정리 — “하나의 진실(Single Source of Truth)” 패턴으로 Core Data 관리하기

by 밤새는 탐험가89 2025. 10. 23.
728x90
SMALL

감정일기 앱 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, 데이터, 반응성 — 세 가지를 모두 잡은 설계라고 할 수 있습니다. 🎯

728x90
LIST