위젯을 달았는데 데이터가 비어 있다

JournalMind는 SwiftData + CloudKit으로 일기 데이터를 iCloud에 동기화하는 앱이에요. 홈 화면 위젯에 오늘 기분과 주간 그래프를 보여주려고 했는데, 위젯을 추가하면 항상 빈 화면만 나오더라고요.

앱 안에서는 데이터가 잘 보이는데, 위젯에서만 안 보이는 거예요.

원인: 위젯은 별도 프로세스

WidgetKit extension은 메인 앱과 별도 프로세스로 실행돼요. 같은 SwiftData ModelContainer를 만들어도, CloudKit 동기화는 메인 앱 프로세스에서만 일어나거든요.

위젯 프로세스에서 SwiftData를 열면 로컬 SQLite 파일은 읽을 수 있어요. 하지만 두 가지 문제가 있었어요:

  1. WAL(Write-Ahead Logging) 캐시: 메인 앱이 쓴 데이터가 아직 WAL 파일에만 있고 메인 DB에 체크포인트되지 않은 경우, 위젯에서 읽으면 최신 데이터가 안 보여요.
  2. CloudKit 동기화 타이밍: 위젯 프로세스가 올라올 때 CloudKit에서 데이터를 새로 받아올 보장이 없어요.

처음에는 위젯에도 동일한 ModelContainer를 쓰면 되겠지 싶었는데, 이걸로는 해결이 안 됐어요.

해결: App Group 파일 브릿지

결국 메인 앱이 데이터를 JSON으로 직렬화해서 App Group 공유 파일에 쓰고, 위젯은 그 파일을 읽는 구조로 바꿨어요.

enum WidgetDataBridge {
    private static var bridgeFileURL: URL {
        FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.app")!
            .appending(path: "widget-bridge.json")
    }

    static func save(_ entry: CachedEntry) {
        guard let data = try? JSONEncoder().encode(entry) else { return }
        try? data.write(to: bridgeFileURL, options: .atomic)
    }

    static func load() -> CachedEntry? {
        guard let data = try? Data(contentsOf: bridgeFileURL) else { return nil }
        return try? JSONDecoder().decode(CachedEntry.self, from: data)
    }
}

메인 앱에서 데이터가 바뀔 때마다 WidgetDataBridge.refreshFromContext(context)를 호출해서 최신 상태를 파일에 저장하고, WidgetCenter.shared.reloadTimelines(ofKind:)로 위젯 갱신을 요청하는 거예요.

위젯 쪽 TimelineProvider에서는 브릿지 파일을 먼저 확인하고, 없을 때만 SwiftData fallback으로 읽어요:

static func loadFromBridgeOrFetch(from context: ModelContext) -> JournalWidgetEntry {
    if let cached = WidgetDataBridge.load(),
       Calendar.current.isDateInToday(cached.date) {
        return JournalWidgetEntry(from: cached)
    }
    // 브릿지 미스 — SwiftData에서 직접 읽기
    return fetchCurrentEntry(from: context)
}

핵심은 오늘 날짜 체크예요. 자정이 지나면 어제 캐시는 무시하고, 위젯이 60초마다 재시도하면서 앱이 열릴 때까지 기다리게 했어요.

풀 플레이어 blur 배경

두 번째 문제: iCloud 동기화 중복 엔트리

위젯 문제를 해결하고 나니 다른 문제가 보이더라고요. 같은 날짜에 일기가 2개씩 생기는 거예요.

iPhone에서 기록하고, iPad에서 앱을 열면 CloudKit이 동기화하면서 같은 날짜에 엔트리가 중복 생성돼요. SwiftData + CloudKit은 서버 기준 머지를 하는데, 같은 날짜인지 판단하는 유니크 제약(unique constraint)을 CloudKit이 지원하지 않거든요.

단순 삭제의 함정

처음에는 “날짜가 같으면 나중 걸 삭제하자"고 했는데, 이게 위험해요. iPhone에서 사진을 붙인 엔트리가 있고 iPad에서 텍스트를 쓴 엔트리가 있으면, 하나를 삭제하면 데이터가 날아가요.

richnessScore 기반 머지

그래서 엔트리의 데이터 풍부도를 점수화해서, 풍부한 쪽을 남기고 나머지 필드를 병합하는 방식으로 바꿨어요:

@Transient var richnessScore: Int {
    photoDataArray.count * 10 + (hasText ? 5 : 0) + activityTagIDs.count
}

사진이 10점, 텍스트 5점, 태그 개당 1점. 이 점수가 높은 엔트리를 “keeper"로 두고, 나머지 엔트리에서 keeper에 없는 데이터를 병합해요:

static func mergeDeduplicates(in context: ModelContext) {
    // 날짜별로 그룹핑
    let grouped = Dictionary(grouping: allEntries) { entry in
        calendar.startOfDay(for: entry.date)
    }

    for (_, entries) in grouped where entries.count > 1 {
        let sorted = entries.sorted { a, b in
            if a.richnessScore != b.richnessScore { return a.richnessScore > b.richnessScore }
            return a.updatedAt > b.updatedAt
        }

        let keeper = sorted[0]
        for other in sorted.dropFirst() {
            // keeper에 없는 데이터만 병합
            if !keeper.hasText, other.hasText { keeper.text = other.text }
            if keeper.photoDataArray.isEmpty { keeper.photoDataArray = other.photoDataArray }
            keeper.activityTagIDs = Array(Set(keeper.activityTagIDs + other.activityTagIDs))
            context.delete(other)
        }
    }
}

이 머지 로직은 앱 시작 시 + iCloud 동기화 완료 시 자동 실행되게 했어요.

배운 점

WidgetKit + CloudKit 조합을 쓴다면, SwiftData를 직접 공유하려 하지 말고 App Group 파일 브릿지를 처음부터 설계하는 게 맞아요. 그리고 iCloud 동기화를 쓴다면 중복 데이터 머지 전략은 선택이 아니라 필수예요 — 단순 삭제는 사용자 데이터를 잃는 가장 빠른 방법이거든요.


이 글에서 다룬 앱은 JournalMind이에요. 매일 기분을 기록하고 패턴을 분석해주는 일기 앱입니다.