iOS 위젯은 왜 내 앱 데이터를 못 읽을까? (App Group으로 데이터 넘기는 두 가지 방법)

요즘 개인 프로젝트로 QuestKeeper라는 iOS 앱을 만들고 있습니다. 할 일에 마감시간을 걸어두면 픽셀 용사가 던전에서 몬스터(=할 일)와 싸우고, 마감을 놓치면 용사가 죽는… 네, 게임화된 투두앱입니다. (용사를 살리려고 설거지를 하게 되는 구조입니다. 효과는 굉장했다.)
그리고 당연히 홈 화면 위젯을 넣고 싶었습니다. 앱을 열지 않아도 오늘의 던전이 홈 화면에 보이는 것. 위젯 하나 추가하는 게 뭐 어렵겠어요?
어렵더라고요.
위젯은 내 앱이 아니다
제일 먼저 부딪히는 사실: 위젯은 앱과 같은 프로세스가 아닙니다.
WidgetKit 위젯은 별도의 익스텐션 프로세스에서 돌아갑니다. 앱이 메모리에 들고 있는 그 예쁜 데이터들, 위젯은 하나도 못 봅니다. 같은 번들에 들어있으니 한 식구 같지만, 실제로는 옆집입니다. 옆집에 김치 나눠주려면 그릇에 담아서 문 밖에 내놔야 하죠.
그 "문 밖 공용 공간"이 바로 App Group입니다.
두 타겟(앱, 위젯)에 같은 App Group entitlement를 걸면, FileManager.containerURL(forSecurityApplicationGroupIdentifier:)로 접근하는 공유 컨테이너 디렉터리가 생깁니다.
여기까지는 애플이 깔아준 길입니다.
문제는 그 다음입니다. 공유 컨테이너에 "무엇을" 놓을 것인가?
두 가지 선택지
애플 공식 가이드는 놀랍게도 여기서 손을 놓습니다. "shared database나 user defaults 같은 걸 쓸 수 있다" 정도로만 말하고, 뭐가 정답인지는 안 알려줍니다. (애플: 알아서 하세요. 저: 네…?)
그래서 선택지는 크게 둘입니다.
A안 — SwiftData 스토어 자체를 공유하기.
ModelConfiguration(groupContainer: .identifier("group.…"))로 SQLite 스토어를 App Group 컨테이너에 두고, 앱과 위젯이 각자 ModelContainer를 열어 같은 DB를 읽는 방식입니다. (iOS 17+)
B안 — JSON 스냅샷 던져주기.
앱이 필요한 데이터만 Codable 구조체로 직렬화해서 공유 컨테이너에 JSON 파일로 쓰고, 위젯은 그 파일만 읽는 방식입니다.
"당연히 A지! DB가 하나면 진실도 하나잖아?" 싶으시죠. 네. 저도 그랬습니다. 그런데 A안을 파보면 함정이 줄줄이 나옵니다.
A안의 함정 다섯 개
애플 개발자 포럼(그중 둘은 애플 DTS 엔지니어의 공식 답변)과 테크니컬 노트에 기록된, 실제로 겪을 수 있는 함정들입니다.
1. 스토어가 말없이 이사 간다. 스토어 URL을 명시하지 않은 SwiftData는 entitlement를 뒤져서 App Group이 있으면 기본 스토어 위치를 그쪽으로 바꿔버립니다. 즉, 위젯 붙이려고 App Group entitlement만 추가했는데 코드 한 줄 안 바꾼 기존 앱의 DB 경로가 바뀝니다. 기존 사용자 데이터가 증발한 것처럼 보이는, 문서화된 데이터 손실 트리거입니다. (포럼 스레드 789173, DTS 답변)
2. 마이그레이션 경주. 위젯이 앱보다 먼저 공유 스토어를 열 수 있습니다. 앱이 스키마 마이그레이션을 돌리기 전에 위젯이 스토어를 만들어버리면? 축하합니다, 레이스 컨디션입니다. (스레드 756615)
3. 저장 안 하고 리로드하면 옛날 데이터가 보인다.
앱에서 데이터 바꾸고 WidgetCenter.shared.reloadAllTimelines()를 불러도, 변경분이 아직 메모리에만 있으면 위젯은 디스크의 옛 데이터를 읽습니다.
modelContext.save()를 먼저 부르고 리로드해야 합니다. autosave는 그 타이밍에 디스크에 있다는 보장을 안 해줍니다. (스레드 760621)
4. Swift 6 동시성과의 궁합.
SwiftData 모델 인스턴스는 Sendable이 아니라서 액터 경계를 못 넘습니다.
넘길 수 있는 건 persistentModelID뿐이고, 받는 쪽에서 다시 fetch해야 합니다. (스레드 805409, DTS 답변)
5. 0xdead10cc.
공유 컨테이너의 파일 락을 쥔 채로 프로세스가 서스펜드되면 iOS가 그 프로세스를 죽입니다.
크래시 리포트에 찍히는 코드가 0xdead10cc입니다. dead10cc… 소리내어 읽어보면 "데드락(deadlock)"입니다. 애플식 유머죠. (TN2408)
살아있는 SQLite 스토어를 두 프로세스가 공유하면 이 리스크를 안고 갑니다.
한편 양쪽 접근 모두 공통인 제약이 하나 있는데, 위젯은 어느 쪽이든 앱의 변경을 실시간으로 관찰하지 못합니다.
앱이 WidgetCenter로 리로드를 직접 요청해야 합니다.
"DB를 공유하면 위젯이 알아서 갱신되겠지"는 애초에 성립하지 않는 기대였습니다.
그래서 B안: 사실만 담은 JSON 한 장
읽기 전용 위젯이라면 B안이 저 함정들을 통째로 우회합니다.
공유 스토어가 없으니 마이그레이션 경주도 없고, 파일 하나 원자적으로 쓰는 거니 락 문제도 없고, 스냅샷은 그냥 Sendable한 값이라 동시성 걱정도 없습니다.
QuestKeeper의 실제 스냅샷 페이로드는 이렇게 생겼습니다.
nonisolated struct WidgetDungeonPayload: Codable, Sendable, Equatable {
static let currentSchemaVersion = 1
let schemaVersion: Int
let generatedAt: Date
let quests: [WidgetQuestPayload]
}
nonisolated struct WidgetQuestPayload: Codable, Sendable, Identifiable, Equatable {
let id: UUID
let title: String
let deadline: Date
let completedAt: Date?
let importanceRawValue: Int
}
여기서 포인트 하나.
페이로드에 용사 HP나 몬스터 레벨 같은 게임 상태가 없습니다.
deadline, completedAt, importanceRawValue — 변하지 않는 사실(fact)만 담습니다.
HP나 "죽었는지"는 위젯이 렌더링하는 순간 현재 시각으로 계산합니다.
이건 QuestKeeper 전체를 관통하는 원칙(persist facts only, derive state)인데, 위젯에서 특히 빛납니다. 파생 상태를 스냅샷에 담으면 "스냅샷 쓴 시각의 HP"가 홈 화면에 박제되지만, 사실만 담으면 위젯이 그려질 때마다 그 시점 기준으로 정확한 상태가 나옵니다. 스냅샷이 좀 오래됐어도 마감시간은 변하지 않는 사실이니까요.
읽는 쪽은 스키마 버전만 확인하고, 뭔가 이상하면 그냥 빈 상태로 떨어집니다. 위젯이 크래시 나는 것보다 빈 던전이 낫습니다.
func load() -> WidgetDungeonPayload {
guard let fileURL else { return .empty }
do {
let data = try Data(contentsOf: fileURL)
let payload = try JSONDecoder.widgetDungeon.decode(WidgetDungeonPayload.self, from: data)
guard payload.schemaVersion == WidgetDungeonPayload.currentSchemaVersion else {
return .empty
}
return payload
} catch {
return .empty
}
}
쓰는 쪽 순서도 함정 3번을 그대로 반영합니다: 디스크에 스냅샷을 성공적으로 쓴 다음에만 reloadTimelines를 부릅니다.
저장 실패했는데 리로드부터 하면 위젯이 옛 파일을 읽는, A안에서 봤던 것과 똑같은 stale read가 나거든요.
반전: "읽기 전용"은 오래가지 않았다
여기서 끝나면 깔끔한 승리 서사인데, 개발이 그렇게 흘러가질 않죠.
위젯에서 퀘스트를 탭 한 번으로 완료하고 싶어졌습니다. 홈 화면에서 몬스터를 바로 처치하는 거죠. (앱 열기 귀찮잖아요. 이 앱의 존재 이유가 귀찮음 극복인데 앱 여는 게 귀찮으면 안 되니까…)
그런데 인터랙티브 위젯의 AppIntent는 위젯 익스텐션 프로세스에서 실행됩니다.
"완료했다"는 사실을 DB에 써야 하는데, DB는 앱에 있고… 잠깐, 이거 어디서 많이 본 상황인데?
네. 결국 A안이 돌아왔습니다. 위젯이 데이터를 쓰려면 공유 SwiftData 스토어를 여는 수밖에 없습니다.
그래서 QuestKeeper의 최종 구조는 하이브리드입니다.
- 표시(읽기) 경로: JSON 스냅샷 (B안) — 타임라인은 끝까지 스냅샷만 읽습니다.
- 위젯 탭(쓰기) 경로: 공유 SwiftData 스토어 (A안) —
completedAt사실 하나만 쓰고, 스냅샷을 다시 쓰고, 리로드합니다.
물론 A안을 다시 들이는 이상 함정들을 정면으로 상대해야 합니다. 함정 1(스토어 이사)은 양쪽 타겟이 명시적으로 같은 App Group을 지정한 컨테이너 하나만 열게 해서 막았습니다.
enum QuestModelContainer {
nonisolated static func make() throws -> ModelContainer {
let schema = Schema([Quest.self])
let configuration = ModelConfiguration(
schema: schema,
groupContainer: .identifier("group.kr.donminzzi.QuestKeeper")
)
return try ModelContainer(for: schema, configurations: [configuration])
}
}
암묵적 기본 경로에 맡기지 않고 스토어 주소를 결정론적으로 못 박는 것. 이게 A안을 쓸 때의 최소한의 안전벨트입니다. 쓰기는 짧게 치고 빠지고(락을 오래 쥐지 않고), 완료 처리는 멱등이라 스테일 더블탭도 no-op입니다.
참고로 이 스냅샷 저장소와 페이로드, 타임라인 정책에는 전부 유닛 테스트가 붙어 있습니다. 프로세스 두 개가 파일 하나로 대화하는 구조는 눈으로 디버깅하기 제일 나쁜 종류라서, 테스트가 없으면 잠을 못 잡니다. 린트보다 테스트를 믿으세요!!
정리
| 상황 | 추천 |
|---|---|
| 위젯이 데이터를 보여주기만 한다 | B안 (JSON 스냅샷) — 함정 다섯 개를 통째로 우회 |
| 위젯이 데이터를 써야 한다 (인터랙티브) | A안이 필수 — 단, groupContainer 명시 + save 후 reload + 짧은 트랜잭션 |
| 둘 다 | 하이브리드 — 읽기는 스냅샷, 쓰기는 공유 스토어 |
"A안 vs B안 뭐가 정답이냐"는 질문 자체가 함정이었습니다. 읽기 경로와 쓰기 경로가 서로 다른 답을 골라도 됩니다. 그리고 스냅샷에는 파생 상태 말고 사실만 담으세요. 위젯이 언제 그려지든 계산은 그때 하면 됩니다.
여러분의 위젯은 읽기 전용인가요? …지금은요? ㅎㅎ 탭 하나 넣고 싶어지는 순간이 반드시 옵니다. 그때 이 글이 생각나면 성공입니다.
혹시 틀린 내용을 발견하셨다면 지적해주세요. 포럼 스레드 번호까지 다 적어둔 이유입니다.
참고 자료
- Apple Developer Forums thread 789173 — App Group 추가 시 SwiftData 기본 스토어 위치 변경 (DTS 답변)
- Apple Developer Forums thread 756615 — 위젯이 앱보다 먼저 공유 스토어를 여는 문제
- Apple Developer Forums thread 760621 —
save()없이 리로드 시 stale read - Apple Developer Forums thread 805409 — SwiftData 모델의 non-Sendable 제약 (DTS 답변)
- Apple Technical Note TN2408 — 공유 컨테이너 파일 락과
0xdead10cc