위젯을 달았는데 데이터가 비어 있다
JournalMind는 SwiftData + CloudKit으로 일기 데이터를 iCloud에 동기화하는 앱이에요. 홈 화면 위젯에 오늘 기분과 주간 그래프를 보여주려고 했는데, 위젯을 추가하면 항상 빈 화면만 나오더라고요.
앱 안에서는 데이터가 잘 보이는데, 위젯에서만 안 보이는 거예요.
원인: 위젯은 별도 프로세스
WidgetKit extension은 메인 앱과 별도 프로세스로 실행돼요. 같은 SwiftData ModelContainer를 만들어도, CloudKit 동기화는 메인 앱 프로세스에서만 일어나거든요.
위젯 프로세스에서 SwiftData를 열면 로컬 SQLite 파일은 읽을 수 있어요. 하지만 두 가지 문제가 있었어요:
- WAL(Write-Ahead Logging) 캐시: 메인 앱이 쓴 데이터가 아직 WAL 파일에만 있고 메인 DB에 체크포인트되지 않은 경우, 위젯에서 읽으면 최신 데이터가 안 보여요.
- 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초마다 재시도하면서 앱이 열릴 때까지 기다리게 했어요.

두 번째 문제: 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이에요. 매일 기분을 기록하고 패턴을 분석해주는 일기 앱입니다.