From c2d9d143289246974e8734b39139a8d9baba9c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EB=A6=AC?= Date: Wed, 22 Apr 2026 18:30:30 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[fix]=20iCloud=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B3=B5=EC=9B=90=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=B0=A9=EC=A7=80=20(#175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿ› [fix] iCloud ์ž๋™ ๋ณต์›์œผ๋กœ ์ธํ•œ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ๋ฐฉ์ง€ Change Token Expired(CKError 21) ๋ฐœ์ƒ ์‹œ NSCloudKitMirroringDelegate๊ฐ€ ๋กœ์ปฌ store๋ฅผ iOS ๋‚ด๋ถ€ ๋กœ์ง์œผ๋กœ purgeํ•˜๋ฉด์„œ ์‚ฌ์šฉ์ž ์ˆ˜์ง‘ ๊ธฐ๋ก์ด ์ดˆ๊ธฐํ™”๋˜๋Š” ๋ฒ„๊ทธ๋ฅผ ์ฐจ๋‹จํ•œ๋‹ค. ์šฐ๋ฆฌ ์ฝ”๋“œ์˜ ์ž๋™ ์‚ญ์ œ ๊ฒฝ๋กœ๋ฅผ ๋ชจ๋‘ ์ œ๊ฑฐํ•˜๊ณ , iOS๊ฐ€ ๋กœ์ปฌ์„ ์ง€์šฐ๋”๋ผ๋„ ์‚ด๋ฆด ์ˆ˜ ์žˆ๋„๋ก Documents์— ์•ˆ์ „ ์Šค๋ƒ…์ƒท์„ ์œ ์ง€ํ•œ๋‹ค. ## ์ž๋™ consolidation ์ œ๊ฑฐ (Stage 1) - handleCloudKitEvent() Import ์™„๋ฃŒ ํ›„ 5์ดˆ ์ง€์—ฐ consolidation ์ œ๊ฑฐ - SceneDelegate.setupApp() ์•ฑ ์‹œ์ž‘ ์‹œ consolidation ์ œ๊ฑฐ - consolidateUserCollections/cleanupOrphanedEntities๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •์—์„œ "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ" ๋ฒ„ํŠผ์„ ์ง์ ‘ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ ์‹คํ–‰ - ์‚ญ์ œ ์ง์ „ ๋กœ๊ทธ๋ฅผ os_log(.error)๋กœ ์Šน๊ฒฉํ•˜์—ฌ ํ”„๋กœ๋•์…˜ ๊ฐ์‚ฌ ๊ฐ€๋Šฅ ## Recovery grace period ์ถ”๊ฐ€ - performCloudKitRecovery ์‹คํ–‰ ์‹œ recoveryInitiatedAt ํƒ€์ž„์Šคํƒฌํ”„ ๊ธฐ๋ก - getUserCollection()์—์„œ 10๋ถ„ grace window ๋‚ด์—๋Š” hasEverHadUserCollection ์ฒดํฌ๋ฅผ ์šฐํšŒํ•˜์—ฌ UC ์ƒ์„ฑ ํ—ˆ์šฉ โ†’ ์žฌ์‹œ์ž‘ ํ›„ import ์ง€์—ฐ๋˜์–ด๋„ ์•ฑ ์ž ๊น€ ๋ฐฉ์ง€ ## ์„ค์ • ํ™”๋ฉด ๊ฐœ์„  - "iCloud์—์„œ ๋ณต์›" 2๋‹จ ํ™•์ธ Alert๋กœ ๊ฐ•ํ™” + ํŒŒ๊ดด์  ๋™์ž‘์ž„์„ ๋ฌธ๊ตฌ์— ๋ช…์‹œ - "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ" ๋ฒ„ํŠผ ์‹ ๊ทœ ์ถ”๊ฐ€ (ํ™•์ธ Alert ํ›„ ์ˆ˜๋™ ์‹คํ–‰) - "๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์›" ๋ฒ„ํŠผ + ๋งˆ์ง€๋ง‰ ๋ฐฑ์—… ์‹œ๊ฐ/ํ•ญ๋ชฉ์ˆ˜ ํ‘œ์‹œ ## LocalSafetySnapshot (Stage 1.5) - Documents/local_safety_snapshot.plist์— UC ๊ทธ๋ž˜ํ”„๋ฅผ attribute-dict๋กœ binary plist ์ง๋ ฌํ™”ํ•˜์—ฌ ์œ ์ง€ (debounce 30s, atomic write) - ์•ฑ ์‹œ์ž‘ ์‹œ purge ์˜์‹ฌ ์ƒํ™ฉ(isFreshInstall && hasEverHadUserCollection && snapshot exists) ๊ฐ์ง€๋˜๋ฉด ๋ณต์› ์˜ต์…˜ ์ž๋™ ์ œ์•ˆ - ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„์ž… ์ง์ „ pending ์Šค๋ƒ…์ƒท ๊ฐ•์ œ flush - ๋„๋ฉ”์ธ ๋ชจ๋ธ Codable ์˜์กด์„ฑ ์—†์Œ โ€” NSArray/NSDictionary transformable๋„ plist๊ฐ€ ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ ## ๋ฌธ์„œ - docs/features/icloud-sync.md โ€” ์ž๋™ consolidation ์ œ๊ฑฐ, grace period, ์ˆ˜๋™ ๋ณต์› ํ”Œ๋กœ์šฐ ๋ฐ˜์˜ - docs/plans/local-backup-split.md โ€” Stage 2 2-container ์„ค๊ณ„ ๋ฐ migration/ํ…Œ์ŠคํŠธ ์ „๋žต ์ดˆ์•ˆ Co-Authored-By: Claude Opus 4.7 (1M context) * ๐Ÿ“ [docs] iCloud ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ๋ฒ„๊ทธ ์ˆ˜์ • ์š”์•ฝ ๋ฌธ์„œ ์ถ”๊ฐ€ ๊ธฐํšยทCS ๋‹ด๋‹น์ž ๋“ฑ ๋น„๊ฐœ๋ฐœ ์ธ์›๋„ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋„๋ก ์ด๋ฒˆ ์ˆ˜์ •์˜ ๋ฐฐ๊ฒฝ/ํ•ด๊ฒฐ ๋ฐฉ์‹/QA ์ฒดํฌ๋ฆฌ์ŠคํŠธ๋ฅผ ์ •๋ฆฌ. Co-Authored-By: Claude Opus 4.7 (1M context) * ๐Ÿ› [fix] SafetySnapshot์ด ์‹ค์ œ๋กœ ์ž‘์„ฑ๋˜์ง€ ์•Š๋˜ ๋ฌธ์ œ ์ˆ˜์ • ๋กœ๊ทธ ๋ถ„์„ ๊ฒฐ๊ณผ, startObserving์ด NSManagedObjectContextDidSave๋งŒ ๊ด€์ฐฐํ•˜์—ฌ - ์‚ฌ์šฉ์ž ํŽธ์ง‘ ์—†์ด ์•ฑ์„ ์ผœ๊ณ  ๋‹ซ์€ ๊ฒฝ์šฐ ์Šค๋ƒ…์ƒท์ด ํ•œ ๋ฒˆ๋„ ์ž‘์„ฑ๋˜์ง€ ์•Š์Œ - CloudKit Import๋กœ ๋“ค์–ด์˜จ ๋ณ€๊ฒฝ์€ merge ๊ฒฝ๋กœ๋ผ DidSave๋ฅผ ํ•ญ์ƒ ํŠธ๋ฆฌ๊ฑฐํ•˜์ง€ ์•Š์Œ iOS purge๊ฐ€ ๋‹ค์Œ ์„ธ์…˜์— ๋ฐœ์ƒํ•˜๋ฉด Stage 1.5 ์•ˆ์ „๋ง์ด ๋น„์–ด์žˆ๋Š” ์ƒํƒœ๋กœ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์Œ. ์ˆ˜์ •: 1. startObserving ์‹œ initial snapshot์„ background queue์—์„œ ์ฆ‰์‹œ 1ํšŒ ์ž‘์„ฑ 2. CoreDataStorage.didFinishCloudImport / didReceiveRemoteChanges ์•Œ๋ฆผ๋„ ๊ตฌ๋…ํ•˜์—ฌ CloudKit์œผ๋กœ ์ˆ˜์‹ ๋œ ๋ฐ์ดํ„ฐ๋„ ์Šค๋ƒ…์ƒท์— ๋ฐ˜์˜ 3. ๊ธฐ์กด NSManagedObjectContextDidSave ๊ด€์ฐฐ์€ ์œ ์ง€ (๋กœ์ปฌ ํŽธ์ง‘ ๊ฒฝ๋กœ) ์ด๋กœ์จ ์•ฑ ์‹คํ–‰ ์งํ›„ "๐Ÿ›Ÿ SafetySnapshot written" ๋กœ๊ทธ๊ฐ€ ๋ฐ˜๋“œ์‹œ ์ฐํ˜€์•ผ ์ •์ƒ. Co-Authored-By: Claude Opus 4.7 (1M context) * ๐Ÿ› [fix] SafetySnapshot serialization ์‹คํŒจ ์ˆ˜์ • + WillReset ์ฆ‰์‹œ flush ## ๋ฌธ์ œ 1: serialization ์‹คํŒจ (์‹ค๊ธฐ๊ธฐ ๋กœ๊ทธ์—์„œ ํ™•์ธ) ``` ๐Ÿ›Ÿ SafetySnapshot write failed: (ACNH_wiki.SafetySnapshotError ์˜ค๋ฅ˜ 0.) ``` PropertyListSerialization์€ NSString/NSNumber/NSDate/NSData/NSArray/NSDictionary๋งŒ ํ—ˆ์šฉ. ItemEntity์˜ ์ผ๋ถ€ Transformable(variations, recipe ๋“ฑ)์€ NSArray/NSDictionary๋กœ declared ๋˜์–ด ์žˆ์ง€๋งŒ ๋‚ด๋ถ€์— ์ปค์Šคํ…€ NSCoding DTO๋ฅผ ํฌํ•จํ•˜๋ฏ€๋กœ plist ์ง๋ ฌํ™” ๋‹จ๊ณ„์—์„œ throw. ํ•ด๊ฒฐ: PropertyListSerialization โ†’ NSKeyedArchiver๋กœ ๊ต์ฒด. - NSCoding์„ ์ค€์ˆ˜ํ•˜๋Š” ๋ชจ๋“  ๊ฐ์ฒด๋ฅผ ์ฒ˜๋ฆฌ (Core Data Transformable์€ ์ด๋ฏธ NSCoding ์ „์ œ). - requiringSecureCoding=false: ์•ฑ์ด ์ž๊ธฐ ์ž์‹ ์ด ์“ด ํŒŒ์ผ๋งŒ ๋ณตํ˜ธํ™”ํ•˜๋ฏ€๋กœ ์•ˆ์ „ํ•˜๋ฉฐ, ๋ชจ๋“  ์ปค์Šคํ…€ DTO ํด๋ž˜์Šค๋ฅผ ํ—ˆ์šฉ ๋ชฉ๋ก์— ๋‚˜์—ดํ•  ํ•„์š” ์—†์Œ. - ํŒŒ์ผ์€ ์‚ฌ๋žŒ์ด ์ฝ๊ธฐ ์–ด๋ ต์ง€๋งŒ ๋‚ด๋ถ€ ์•ˆ์ „๋ง ์šฉ๋„์ด๋ฏ€๋กœ ์ˆ˜์šฉ. ## ๋ฌธ์ œ 2: iOS purge ์ง์ „ ๋งˆ์ง€๋ง‰ ์ˆœ๊ฐ„ flush ๋ˆ„๋ฝ ์‹ค๊ธฐ๊ธฐ ๋กœ๊ทธ์—์„œ CKError 134301 โ†’ WillResetSync ์•Œ๋ฆผ โ†’ iOS๊ฐ€ ๋กœ์ปฌ store purge ์‹œํ€€์Šค ๊ด€์ฐฐ๋จ. ํ•ด๊ฒฐ: SafetySnapshotService๊ฐ€ "NSCloudKitMirroringDelegateWillResetSyncNotificationName" ์•Œ๋ฆผ์„ ๊ตฌ๋… โ†’ debounce ์šฐํšŒํ•˜์—ฌ flushNow() ์ฆ‰์‹œ ์‹คํ–‰. WillReset ์‹œ์ ์—๋Š” ์•„์ง ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜จ์ „ํ•˜๋ฏ€๋กœ, ์ด ์ˆœ๊ฐ„ ๋””์Šคํฌ์— ๋คํ”„ํ•˜๋ฉด iOS purge ํ›„์—๋„ Documents/์— ๋งˆ์ง€๋ง‰ ์ •์ƒ ์ƒํƒœ๊ฐ€ ๋‚จ์Œ. Co-Authored-By: Claude Opus 4.7 (1M context) * โ™ป๏ธ [refactor] SafetySnapshot ์ค‘๋ณต ์ œ๊ฑฐ ๋ฐ ํšจ์œจํ™” ## ์ฃผ์š” ๋ณ€๊ฒฝ - **DateFormatters.syncRelativeDate ์žฌ์‚ฌ์šฉ** (3๊ฐœ ํŒŒ์ผ ์ค‘๋ณต ์ œ๊ฑฐ): SceneDelegate, AppSettingReactor, AppSettingView์—์„œ RelativeDateTimeFormatter๋ฅผ ๋งค๋ฒˆ ์ธ์Šคํ„ด์Šคํ™”ํ•˜๋˜ ๊ฒƒ์„ ๊ธฐ์กด static ์‹ฑ๊ธ€ํ†ค์œผ๋กœ ๊ต์ฒด. - **Metadata ๊ฒฝ๋Ÿ‰ ์บ์‹œ**: readMetadata()๊ฐ€ ๋งค๋ฒˆ 3MB+ ํŒŒ์ผ์„ unarchiveํ•˜๋˜ ๋ฌธ์ œ ์ˆ˜์ •. ์Šค๋ƒ…์ƒท ์ €์žฅ ์‹œ createdAt/childCount๋ฅผ UserDefaults์— ์‚ฌ์ด๋“œ์นด๋กœ ๊ธฐ๋กํ•˜๊ณ , UI๋Š” ์บ์‹œ์—์„œ O(1)๋กœ ์ฝ์Œ. ํŒŒ์ผ์ด ์™ธ๋ถ€์—์„œ ์‚ญ์ œ๋˜๋ฉด ์บ์‹œ๋„ ์ •๋ฆฌ. - **SyncResetNotification ์žฌ์‚ฌ์šฉ**: SafetySnapshotService๊ฐ€ ์‚ฌ์šฉํ•˜๋˜ raw string "NSCloudKitMirroringDelegateWillResetSyncNotificationName"์„ CoreDataStorage ๋‚ด๋ถ€ enum์„ internal๋กœ ๊ณต๊ฐœํ•œ ๋’ค ์ฐธ์กฐํ•˜๋„๋ก ๋ณ€๊ฒฝ. - **startObserving/flushNow/restore์—์„œ container ํŒŒ๋ผ๋ฏธํ„ฐ ์ œ๊ฑฐ**: ํ•ญ์ƒ CoreDataStorage.shared.persistentContainer๋ฅผ ๋ฐ›๋Š” ์ผ€์ด์Šค๋ฟ์ด๋ผ, ์„œ๋น„์Šค ๋‚ด๋ถ€์—์„œ ์ง์ ‘ ์ ‘๊ทผ. ํ˜ธ์ถœ๋ถ€ ๊ฐ„๊ฒฐํ™”. - **Metadata.fileSize ํ•„๋“œ ์‚ญ์ œ**: ์„ค์ •๋˜๊ธฐ๋งŒ ํ•˜๊ณ  ์‚ฌ์šฉ์ฒ˜๊ฐ€ ์—†๋˜ dead field. - **TOCTOU ์ œ๊ฑฐ**: restore()์—์„œ snapshotExists ์„ ๊ฒ€์‚ฌ โ†’ Data(contentsOf:) ์ด์ค‘ ํŒŒ์ผ ์ฒดํฌ๋ฅผ ์—†์• ๊ณ  ์ง์ ‘ read ํ›„ NSFileReadNoSuchFileError๋กœ .noSnapshot ๋ถ„๊ธฐ. - **force-unwrap ์ œ๊ฑฐ**: snapshotURL์˜ `.first!`๋ฅผ guard let + fatalError๋กœ ๊ต์ฒด (Documents ๋””๋ ‰ํ† ๋ฆฌ ๋ถ€์žฌ๋Š” ์‚ฌ์‹ค์ƒ ๋ณต๊ตฌ ๋ถˆ๊ฐ€ ์ƒํƒœ). - ์„œ์ˆ ํ˜• WHAT-comment ์ •๋ฆฌ: "์ž๋™ consolidation ์ œ๊ฑฐ๋จ" ๋“ฑ ์ฝ”๋“œ๊ฐ€ ์ž๋ช…ํ•˜๊ฒŒ ๋ณด์—ฌ์ฃผ๋Š” ๋‚ด์šฉ์€ ์ปค๋ฐ‹ ํžˆ์Šคํ† ๋ฆฌ๋กœ ์˜ฎ๊ธฐ๊ณ  ์ฃผ์„์€ WHY๋งŒ ๋‚จ๊น€. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../Resources/en.lproj/Localizable.strings | 20 +- .../Resources/ko.lproj/Localizable.strings | 20 +- .../CoreDataStorage/CoreDataStorage.swift | 119 +++++--- .../SafetySnapshotService.swift | 262 +++++++++++++++++ .../UserCollectionSnapshot.swift | 264 ++++++++++++++++++ .../Coordinator/DashboardCoordinator.swift | 13 +- .../ViewModels/AppSettingReactor.swift | 106 ++++++- .../Dashboard/Views/AppSettingView.swift | 92 +++++- .../Projects/App/Sources/SceneDelegate.swift | 63 ++++- docs/features/icloud-sync.md | 45 ++- docs/iCloud-data-loss-fix-summary.md | 200 +++++++++++++ docs/plans/local-backup-split.md | 188 +++++++++++++ 12 files changed, 1332 insertions(+), 60 deletions(-) create mode 100644 Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift create mode 100644 Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/UserCollectionSnapshot.swift create mode 100644 docs/iCloud-data-loss-fix-summary.md create mode 100644 docs/plans/local-backup-split.md diff --git a/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings b/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings index f11b68a..d4558c8 100644 --- a/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings +++ b/Animal-Crossing-Wiki/Projects/App/Resources/en.lproj/Localizable.strings @@ -61,12 +61,30 @@ Find the villagers you have visited and tap the home icon on the villager's page // MARK: - AppSettingView "System haptic" = "System haptic"; "Data reset" = "Data reset"; -"Recover data from iCloud" = "Recover data from iCloud"; +"Recover data from iCloud" = "Restore from iCloud (overwrites local)"; "iCloud Data Recovery" = "iCloud Data Recovery"; "This will reset local data and re-download from iCloud. Continue?" = "This will reset local data and re-download from iCloud. Continue?"; +"Recovery warning: local data will be erased" = "โš ๏ธ All collection data on this device will be deleted and replaced with the iCloud backup. This cannot be undone."; +"Are you absolutely sure?" = "Are you absolutely sure?"; +"Recovery final confirm" = "Final confirmation. Local data will be fully replaced by the iCloud backup. Make sure an up-to-date backup exists in iCloud."; "Recovery Complete" = "Recovery Complete"; "Please restart the app to complete data recovery from iCloud." = "Please restart the app to complete data recovery from iCloud."; "Recovery Failed" = "Recovery Failed"; +"Clean duplicate data" = "Clean duplicate & orphan data"; +"Consolidate warning" = "Merges duplicate collections caused by sync issues and removes dangling data. Use only when sync seems wrong."; +"Restore from local backup" = "Restore from local backup"; +"Local backup detected" = "Local backup detected"; +"Local backup detected message" = "Your local data appears to have been wiped by CloudKit sync. Restore from local backup saved %@ (%d items)?\n\nYou can wait for iCloud or restore from the local backup right now."; +"Wait for iCloud" = "Wait for iCloud"; +"Restore local backup" = "Restore local backup"; +"Restore complete" = "Restore complete"; +"Restored %d items" = "Restored %d items."; +"Restore failed" = "Restore failed"; +"No local backup found" = "No local backup found."; +"Local backup info" = "Last local backup"; +"Local backup info format" = "%@ ยท %d items"; +"No local backup" = "No local backup"; +"Restore local backup warning" = "Current local data will be replaced with the backup saved %@ (%d items). Continue?"; "iCloud sync status" = "iCloud sync"; "Syncing..." = "Syncing..."; "Waiting for iCloud data..." = "Waiting for iCloud data..."; diff --git a/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings b/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings index bed58dd..0d1b940 100644 --- a/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings +++ b/Animal-Crossing-Wiki/Projects/App/Resources/ko.lproj/Localizable.strings @@ -63,12 +63,30 @@ // MARK: - AppSettingView "System haptic" = "์‹œ์Šคํ…œ ํ–…ํ‹ฑ"; "Data reset" = "์ˆ˜์ง‘ ๋ฐ ์„ฌ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™”"; -"Recover data from iCloud" = "iCloud์—์„œ ๋ฐ์ดํ„ฐ ๋ณต๊ตฌ"; +"Recover data from iCloud" = "iCloud์—์„œ ๋ณต์› (๋กœ์ปฌ ๋ฐ์ดํ„ฐ ๋ฎ์–ด์”€)"; "iCloud Data Recovery" = "iCloud ๋ฐ์ดํ„ฐ ๋ณต๊ตฌ"; "This will reset local data and re-download from iCloud. Continue?" = "๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฅผ ์ดˆ๊ธฐํ™”ํ•˜๊ณ  iCloud์—์„œ ๋‹ค์‹œ ๋‹ค์šด๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. ๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?"; +"Recovery warning: local data will be erased" = "โš ๏ธ ํ˜„์žฌ ๊ธฐ๊ธฐ์˜ ๋ชจ๋“  ์ˆ˜์ง‘ ๊ธฐ๋ก์ด ์‚ญ์ œ๋˜๊ณ  iCloud ๋ฐฑ์—…์œผ๋กœ ๊ต์ฒด๋ฉ๋‹ˆ๋‹ค. ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."; +"Are you absolutely sure?" = "์ •๋ง ์ง„ํ–‰ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?"; +"Recovery final confirm" = "๋งˆ์ง€๋ง‰ ํ™•์ธ์ž…๋‹ˆ๋‹ค. ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ iCloud ๋ฐฑ์—…์œผ๋กœ ์™„์ „ํžˆ ๋Œ€์ฒด๋ฉ๋‹ˆ๋‹ค. iCloud์— ์ตœ์‹  ๋ฐฑ์—…์ด ์žˆ๋Š”์ง€ ๋ฐ˜๋“œ์‹œ ํ™•์ธํ•˜์„ธ์š”."; "Recovery Complete" = "๋ณต๊ตฌ ์™„๋ฃŒ"; "Please restart the app to complete data recovery from iCloud." = "iCloud์—์„œ ๋ฐ์ดํ„ฐ ๋ณต๊ตฌ๋ฅผ ์™„๋ฃŒํ•˜๋ ค๋ฉด ์•ฑ์„ ๋‹ค์‹œ ์‹œ์ž‘ํ•ด ์ฃผ์„ธ์š”."; "Recovery Failed" = "๋ณต๊ตฌ ์‹คํŒจ"; +"Clean duplicate data" = "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ"; +"Consolidate warning" = "๋™๊ธฐํ™” ๊ณผ์ •์—์„œ ์ƒ๊ธด ์ค‘๋ณต ์ˆ˜์ง‘ ๊ธฐ๋ก์„ ํ•˜๋‚˜๋กœ ํ•ฉ์น˜๊ณ , ์—ฐ๊ฒฐ์ด ๋Š๊ธด ๋ฐ์ดํ„ฐ๋ฅผ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ๋™๊ธฐํ™”๊ฐ€ ์ด์ƒํ•  ๋•Œ๋งŒ ์‚ฌ์šฉํ•˜์„ธ์š”."; +"Restore from local backup" = "๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์›"; +"Local backup detected" = "๋กœ์ปฌ ๋ฐฑ์—…์ด ๊ฐ์ง€๋์–ด์š”"; +"Local backup detected message" = "CloudKit ๋™๊ธฐํ™”๋กœ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์‚ฌ๋ผ์ง„ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. %@์— ์ €์žฅ๋œ ๋กœ์ปฌ ๋ฐฑ์—…(ํ•ญ๋ชฉ %d๊ฐœ)์—์„œ ๋ณต์›ํ•˜์‹œ๊ฒ ์–ด์š”?\n\niCloud์—์„œ ๊ฐ€์ ธ์˜ค๊ธฐ๋ฅผ ๊ธฐ๋‹ค๋ ค๋„ ๋˜๊ณ , ์ง€๊ธˆ ๋ฐ”๋กœ ๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์›ํ•ด๋„ ๋ฉ๋‹ˆ๋‹ค."; +"Wait for iCloud" = "iCloud ๊ธฐ๋‹ค๋ฆฌ๊ธฐ"; +"Restore local backup" = "๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์›"; +"Restore complete" = "๋ณต์› ์™„๋ฃŒ"; +"Restored %d items" = "%d๊ฐœ ํ•ญ๋ชฉ์„ ๋ณต์›ํ–ˆ์Šต๋‹ˆ๋‹ค."; +"Restore failed" = "๋ณต์› ์‹คํŒจ"; +"No local backup found" = "์ €์žฅ๋œ ๋กœ์ปฌ ๋ฐฑ์—…์ด ์—†์Šต๋‹ˆ๋‹ค."; +"Local backup info" = "๋งˆ์ง€๋ง‰ ๋กœ์ปฌ ๋ฐฑ์—…"; +"Local backup info format" = "%@ ยท ํ•ญ๋ชฉ %d๊ฐœ"; +"No local backup" = "๋กœ์ปฌ ๋ฐฑ์—… ์—†์Œ"; +"Restore local backup warning" = "ํ˜„์žฌ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฅผ %@์— ์ €์žฅ๋œ ๋ฐฑ์—…์œผ๋กœ ๊ต์ฒดํ•ฉ๋‹ˆ๋‹ค (ํ•ญ๋ชฉ %d๊ฐœ). ๊ณ„์†ํ•˜์‹œ๊ฒ ์–ด์š”?"; "iCloud sync status" = "iCloud ๋™๊ธฐํ™”"; "Syncing..." = "๋™๊ธฐํ™” ์ค‘..."; "Waiting for iCloud data..." = "iCloud ๋ฐ์ดํ„ฐ ๋Œ€๊ธฐ ์ค‘..."; diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift index c94bb48..1d81ba1 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift @@ -111,6 +111,38 @@ final class CoreDataStorage { os_log(.info, log: .default, "๐Ÿ›ก๏ธ hasEverHadUserCollection cleared (intentional reset)") } + // MARK: - Recovery Grace Period + + private static let recoveryInitiatedAtKey = "CoreDataStorage_recoveryInitiatedAt" + + /// performCloudKitRecovery ํ›„ ์žฌ์‹œ์ž‘ํ–ˆ๋Š”๋ฐ import๊ฐ€ ์ง€์—ฐ๋˜๋Š” ๋™์•ˆ + /// getUserCollection์ด .notFound๋ฅผ ์˜๊ตฌํžˆ throwํ•˜๋Š” ๊ฒƒ์„ ๋ง‰๊ธฐ ์œ„ํ•œ ์œ ์˜ˆ ์‹œ๊ฐ„ (10๋ถ„). + private static let recoveryGracePeriodSeconds: TimeInterval = 600 + + /// ๋ณต๊ตฌ ์‹œ์ž‘ ์‹œ๊ฐ ๊ธฐ๋ก โ€” ์žฌ์‹œ์ž‘ ํ›„ grace window ๊ณ„์‚ฐ์— ์‚ฌ์šฉ + func markRecoveryInitiated() { + UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.recoveryInitiatedAtKey) + os_log(.info, log: .default, "๐Ÿ”„ Recovery initiated timestamp recorded") + } + + /// Recovery ์™„๋ฃŒ ํ›„ UC๊ฐ€ ์ •์ƒ ๋ณต๊ตฌ๋˜๋ฉด ํ˜ธ์ถœํ•˜์—ฌ ํ”Œ๋ž˜๊ทธ ์ •๋ฆฌ + func clearRecoveryInitiated() { + UserDefaults.standard.removeObject(forKey: Self.recoveryInitiatedAtKey) + } + + /// ๋ณต๊ตฌ ์‹œ์ž‘ ํ›„ grace period ๋‚ด์ธ์ง€ ํ™•์ธ โ€” ์ด ๊ธฐ๊ฐ„์—๋Š” hasEverHadUserCollection ์ฒดํฌ๋ฅผ ์šฐํšŒํ•˜์—ฌ UC ์ƒ์„ฑ์„ ํ—ˆ์šฉ + var isWithinRecoveryGracePeriod: Bool { + let timestamp = UserDefaults.standard.double(forKey: Self.recoveryInitiatedAtKey) + guard timestamp > 0 else { return false } + let elapsed = Date().timeIntervalSince1970 - timestamp + if elapsed < 0 || elapsed > Self.recoveryGracePeriodSeconds { + // ๋งŒ๋ฃŒ ์‹œ ์ž๋™ ์ •๋ฆฌ + UserDefaults.standard.removeObject(forKey: Self.recoveryInitiatedAtKey) + return false + } + return true + } + /// ์ฒซ Import ์™„๋ฃŒ ํ›„ UC ์ƒ์„ฑ/๊ธฐ๋ณธ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ์„ ์œ ์˜ˆํ•˜๋Š” ์‹œ๊ฐ„ (์ดˆ) private static let gracePeriodSeconds: TimeInterval = 120 @@ -131,7 +163,7 @@ final class CoreDataStorage { // MARK: - Private API Notification Names (fragile) // These notification names are undocumented and may change without notice. // Verified working on iOS 16โ€“18. Remove if Apple provides a public API. - private enum SyncResetNotification { + enum SyncResetNotification { static let willReset = Notification.Name("NSCloudKitMirroringDelegateWillResetSyncNotificationName") static let didReset = Notification.Name("NSCloudKitMirroringDelegateDidResetSyncNotificationName") } @@ -373,14 +405,8 @@ final class CoreDataStorage { let hasChanges = hasImportedChanges() logSyncDiagnostics(phase: "Import-end") - // CloudKit์ด relationship์„ ํ•ด์†Œํ•  ์‹œ๊ฐ„์„ ํ™•๋ณดํ•˜๊ธฐ ์œ„ํ•ด 5์ดˆ ์ง€์—ฐ - // ์ด์ „ ํƒ€์ด๋จธ๊ฐ€ ์žˆ์œผ๋ฉด ์ทจ์†Œํ•˜์—ฌ ์ค‘๋ณต ์‹คํ–‰ ๋ฐฉ์ง€ - consolidationWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.consolidateUserCollections() - } - consolidationWorkItem = workItem - DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 5, execute: workItem) + // Import ํ›„ ์ž๋™ consolidation/orphan cleanup์ด ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•˜๋Š” ๋ฒ„๊ทธ๋กœ + // ์ œ๊ฑฐ๋จ โ€” ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •์—์„œ ๋ช…์‹œ์ ์œผ๋กœ ์‹คํ–‰ํ•  ๋•Œ๋งŒ ๋Œ์•„๊ฐ„๋‹ค. NotificationCenter.default.post( name: Self.didFinishCloudImport, @@ -615,30 +641,37 @@ struct SyncStatusInfo { extension CoreDataStorage { + /// ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ • ํ™”๋ฉด์—์„œ "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ" ๋ฒ„ํŠผ์„ ๋ˆŒ๋ €์„ ๋•Œ ํ˜ธ์ถœ. + /// ์ž‘์—… ์™„๋ฃŒ ํ›„ main queue๋กœ completion ํ˜ธ์ถœ. + func consolidateUserCollectionsManually(completion: @escaping () -> Void) { + performBackgroundTask { [weak self] context in + self?.consolidateAndCleanup(in: context) + DispatchQueue.main.async { completion() } + } + } + /// ์ค‘๋ณต UserCollectionEntity๋ฅผ ํ•˜๋‚˜๋กœ ํ†ตํ•ฉํ•˜๊ณ  ๊ณ ์•„ ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ •๋ฆฌ func consolidateUserCollections() { performBackgroundTask { [weak self] context in - guard let self else { - return - } - - context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + self?.consolidateAndCleanup(in: context) + } + } - let request = UserCollectionEntity.fetchRequest() - let allUCs: [UserCollectionEntity] - do { - allUCs = try context.fetch(request) - } catch { - os_log(.error, log: .default, - "๐Ÿ”ง Consolidation fetch failed: %{public}@", - error.localizedDescription) - return - } + private func consolidateAndCleanup(in context: NSManagedObjectContext) { + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy - guard allUCs.count > 1 else { - return - } + let request = UserCollectionEntity.fetchRequest() + let allUCs: [UserCollectionEntity] + do { + allUCs = try context.fetch(request) + } catch { + os_log(.error, log: .default, + "๐Ÿ”ง Consolidation fetch failed: %{public}@", + error.localizedDescription) + return + } + if allUCs.count > 1 { let sorted = allUCs.sorted { self.relationshipCount(of: $0) > self.relationshipCount(of: $1) } let keptUC = sorted[0] @@ -655,11 +688,11 @@ extension CoreDataStorage { context.delete(orphanUC) } context.saveContext() + } - self.cleanupOrphanedEntities(in: context) + self.cleanupOrphanedEntities(in: context) - os_log(.info, log: .default, "๐Ÿ”ง Consolidation: completed") - } + os_log(.info, log: .default, "๐Ÿ”ง Consolidation: completed") } /// orphan UC์˜ ๊ด€๊ณ„ ์—”ํ‹ฐํ‹ฐ๋ฅผ kept UC๋กœ ์ด์ „ (๋ฐ์ดํ„ฐ ์†์‹ค ๋ฐฉ์ง€) @@ -734,8 +767,9 @@ extension CoreDataStorage { // ์•ˆ์ „ ํ™•์ธ ํ†ต๊ณผ ํ›„์—๋งŒ ์‹ค์ œ ๊ฐ์ฒด๋ฅผ fetchํ•˜์—ฌ ์‚ญ์ œ if let orphans = try? context.fetch(orphanCountRequest) { - os_log(.info, log: .default, - "๐Ÿ”ง Orphan cleanup: %{public}@ โ†’ %d orphans / %d total deleted", + // ํ”„๋กœ๋•์…˜์—์„œ๋„ ์ถ”์  ๊ฐ€๋Šฅํ•˜๋„๋ก .error ๋ ˆ๋ฒจ๋กœ ๊ธฐ๋ก (๋ฐ์ดํ„ฐ ์‚ญ์ œ๋Š” ํ•ญ์ƒ ๊ฐ์‚ฌ ๋Œ€์ƒ) + os_log(.error, log: .default, + "๐Ÿ”ง Orphan cleanup: %{public}@ โ†’ %d orphans / %d total WILL BE DELETED", entity, orphans.count, totalCount) orphans.forEach { context.delete($0) } totalOrphans += orphans.count @@ -850,7 +884,16 @@ extension CoreDataStorage { // ๊ธฐ์กด ์œ ์ €์ธ๋ฐ UC๊ฐ€ 0๊ฐœ โ†’ CloudKit ๋ฏธ๋Ÿฌ ์žฌ๊ตฌ์„ฑ ๋˜๋Š” re-import ๋Œ€๊ธฐ ์ƒํƒœ // ๋นˆ UC๋ฅผ ์ƒ์„ฑํ•˜๋ฉด CloudKit์— ๋นˆ ๋ฐ์ดํ„ฐ๊ฐ€ Export๋˜์–ด ๊ธฐ์กด ๋ฐ์ดํ„ฐ๋ฅผ ์˜ค์—ผ์‹œํ‚ฌ ์ˆ˜ ์žˆ์Œ + // + // ์˜ˆ์™ธ: performCloudKitRecovery ์งํ›„ grace period(10๋ถ„) ๋‚ด์—๋Š” + // import๊ฐ€ ์ง€์—ฐ๋˜๋”๋ผ๋„ ์•ฑ์ด ๋™์ž‘ ๊ฐ€๋Šฅํ•˜๋„๋ก UC ์ƒ์„ฑ์„ ํ—ˆ์šฉํ•œ๋‹ค. + // ๋ณต๊ตฌ ์ž์ฒด๊ฐ€ "๋กœ์ปฌ ์žฌ์ƒ์„ฑ + CloudKit์—์„œ ์žฌimport" ํ”Œ๋กœ์šฐ์ด๋ฏ€๋กœ ์•ˆ์ „. if hasEverHadUserCollection { + if isWithinRecoveryGracePeriod { + os_log(.info, log: .default, + "๐Ÿ”„ getUserCollection: No UC found, within recovery grace period โ€” allowing UC creation") + return UserCollectionEntity(UserInfo(), context: context) + } os_log(.error, log: .default, "๐Ÿ›ก๏ธ getUserCollection: No UC found but hasEverHadUserCollection=true โ€” blocking empty UC creation to protect cloud data") throw CoreDataStorageError.notFound @@ -871,11 +914,13 @@ extension CoreDataStorage { } } -// MARK: - Data Recovery (TEMPORARY: Recovery) +// MARK: - Data Recovery extension CoreDataStorage { - /// TEMPORARY: Recovery โ€” ์•ˆ์ •ํ™” ํ›„ ์ œ๊ฑฐ ์˜ˆ์ • + /// ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •์—์„œ "iCloud์—์„œ ๋ณต์›"์„ ๋ช…์‹œ์ ์œผ๋กœ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ ์‹คํ–‰๋˜๋Š” ๋ณต์› ํ”Œ๋กœ์šฐ. + /// ๋กœ์ปฌ store๋ฅผ ์‚ญ์ œํ•˜๊ณ  ์•ฑ ์žฌ์‹œ์ž‘ ์‹œ CloudKit์—์„œ ์ „์ฒด re-import ์œ ๋„. + /// ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ iCloud ๋ฐฑ์—…์œผ๋กœ ์™„์ „ํžˆ ๋Œ€์ฒด๋˜๋ฏ€๋กœ ํŒŒ๊ดด์  ๋™์ž‘ โ€” 2๋‹จ ํ™•์ธ alert ํ›„์—๋งŒ ํ˜ธ์ถœ. enum RecoveryError: LocalizedError { case iCloudNotAvailable case storeNotFound @@ -888,7 +933,7 @@ extension CoreDataStorage { } } - /// TEMPORARY: Recovery โ€” ๋กœ์ปฌ store ํŒŒ์ผ์„ ์‚ญ์ œํ•˜๊ณ  ์•ฑ ์žฌ์‹œ์ž‘ ์‹œ CloudKit์—์„œ ์ „์ฒด ์žฌimport ์œ ๋„ + /// ๋กœ์ปฌ store ํŒŒ์ผ์„ ์‚ญ์ œํ•˜๊ณ  ์•ฑ ์žฌ์‹œ์ž‘ ์‹œ CloudKit์—์„œ ์ „์ฒด re-import ์œ ๋„. /// store๋ฅผ ๋Ÿฐํƒ€์ž„์— ์žฌ๋“ฑ๋กํ•˜๋ฉด CloudKit ์˜ต์…˜์ด ๋ˆ„๋ฝ๋˜๋ฏ€๋กœ, ํŒŒ์ผ๋งŒ ์‚ญ์ œํ•˜๊ณ  ์žฌ์‹œ์ž‘์„ ์•ˆ๋‚ดํ•œ๋‹ค. func performCloudKitRecovery(completion: @escaping (Result) -> Void) { checkiCloudAccountStatus { [weak self] status in @@ -937,6 +982,10 @@ extension CoreDataStorage { // ๊ธฐ์กด ์œ ์ € ํ”Œ๋ž˜๊ทธ ์œ ์ง€ โ€” ์žฌ์‹œ์ž‘ ํ›„ CloudKit re-import ์ „๊นŒ์ง€ ๋นˆ UC ์ƒ์„ฑ ๋ฐฉ์ง€ // (๋ณต๊ตฌ = ๊ธฐ์กด ์œ ์ €์ด๋ฏ€๋กœ true ์œ ์ง€๊ฐ€ ์˜ฌ๋ฐ”๋ฆ„) + // Recovery grace period ์‹œ์ž‘ โ€” ์žฌ์‹œ์ž‘ ํ›„ import๊ฐ€ ์ง€์—ฐ๋˜์–ด๋„ + // 10๋ถ„๊ฐ„์€ UC ์ƒ์„ฑ์„ ํ—ˆ์šฉํ•˜์—ฌ ์•ฑ์ด ๋™์ž‘ ๊ฐ€๋Šฅํ•˜๋„๋ก ๋ณด์žฅ + self.markRecoveryInitiated() + os_log(.info, log: .default, "๐Ÿ”„ Recovery: store files deleted โ€” app restart required for CloudKit re-import") completion(.success(())) } catch { diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift new file mode 100644 index 0000000..b10cbe0 --- /dev/null +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift @@ -0,0 +1,262 @@ +// +// SafetySnapshotService.swift +// Animal-Crossing-Wiki +// +// Documents/local_safety_snapshot.plist์— ์ตœ์‹  UC ์Šค๋ƒ…์ƒท์„ ์œ ์ง€. +// iOS์˜ NSCloudKitMirroringDelegate๊ฐ€ ๋กœ์ปฌ store๋ฅผ purgeํ•˜๋”๋ผ๋„ ์ด ํŒŒ์ผ์€ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์œผ๋ฏ€๋กœ +// ๋‹ค์Œ ์•ฑ ์‹œ์ž‘ ์‹œ ๋ณต์› ์˜ต์…˜์„ ์‚ฌ์šฉ์ž์—๊ฒŒ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ๋‹ค. +// + +import Foundation +import CoreData +import os + +final class SafetySnapshotService { + + static let shared = SafetySnapshotService() + + // MARK: - File Location + + private static let fileName = "local_safety_snapshot.plist" + + var snapshotURL: URL { + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + fatalError("Documents directory unavailable") + } + return documents.appendingPathComponent(Self.fileName) + } + + var snapshotExists: Bool { + FileManager.default.fileExists(atPath: snapshotURL.path) + } + + // MARK: - Lightweight Metadata Cache + // + // readMetadata()๊ฐ€ 3MB+ ์Šค๋ƒ…์ƒท์„ ๋งค๋ฒˆ unarchiveํ•˜์ง€ ์•Š๋„๋ก, + // ์Šค๋ƒ…์ƒท ์ €์žฅ ์‹œ์ ์— createdAt/childCount๋งŒ UserDefaults์— ๋ณ„๋„๋กœ ๊ธฐ๋กํ•œ๋‹ค. + // UserDefaults๊ฐ€ ๋น„์–ด์žˆ์œผ๋ฉด fallback์œผ๋กœ ํŒŒ์ผ์„ unarchiveํ•œ๋‹ค (์˜ˆ: ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ฒซ ์‹คํ–‰). + + private static let metadataCreatedAtKey = "SafetySnapshot_lastCreatedAt" + private static let metadataChildCountKey = "SafetySnapshot_lastChildCount" + + // MARK: - Debounced Save + + /// ์—ฐ์†๋œ CoreData ์ €์žฅ์„ ํ•ฉ์น˜๊ธฐ ์œ„ํ•œ ์ง€์—ฐ ์‹œ๊ฐ„. + /// ๋„ˆ๋ฌด ์งง์œผ๋ฉด ๋งค ์ˆ˜์ง‘๋งˆ๋‹ค ๋””์Šคํฌ I/O, ๋„ˆ๋ฌด ๊ธธ๋ฉด ์ตœ๊ทผ ๋ณ€๊ฒฝ์ด ์Šค๋ƒ…์ƒท์— ์—†์„ ์œ„ํ—˜. + private static let debounceSeconds: TimeInterval = 30 + + private let queue = DispatchQueue(label: "app.safety.snapshot", qos: .utility) + private var pendingWorkItem: DispatchWorkItem? + private var observers: [NSObjectProtocol] = [] + + private var container: NSPersistentContainer { + CoreDataStorage.shared.persistentContainer + } + + /// ์•ฑ ์‹œ์ž‘ ์‹œ 1ํšŒ ํ˜ธ์ถœ โ€” ๋‹ค์Œ ์ด๋ฒคํŠธ์— ๋Œ€ํ•ด ๋ชจ๋‘ ์Šค๋ƒ…์ƒท ์ž‘์„ฑ์„ ์˜ˆ์•ฝ: + /// 1. ๋กœ์ปฌ context save (์‚ฌ์šฉ์ž ํŽธ์ง‘) + /// 2. CloudKit Import ์™„๋ฃŒ (์›๊ฒฉ ๊ธฐ๊ธฐ์˜ ๋ณ€๊ฒฝ์„ ์ˆ˜์‹ ) + /// 3. ์›๊ฒฉ persistent store ๋ณ€๊ฒฝ ์•Œ๋ฆผ + /// 4. CloudKit Sync Reset ์ž„๋ฐ• (iOS๊ฐ€ ๋กœ์ปฌ purge ์ง์ „์— ๋งˆ์ง€๋ง‰ flush) + /// ๋˜ํ•œ ์ฆ‰์‹œ **initial snapshot**์„ ๋น„๋™๊ธฐ๋กœ ์ž‘์„ฑํ•˜์—ฌ, ์ด๋ฒˆ ์„ธ์…˜ ์ค‘ ์•„๋ฌด ํŽธ์ง‘์ด ์—†๋”๋ผ๋„ + /// ์ตœ์‹  ์ƒํƒœ์˜ ๋ฐฑ์—…์ด Documents/์— ์กด์žฌํ•˜๋„๋ก ๋ณด์žฅํ•œ๋‹ค. + func startObserving() { + stopObserving() + + let psc = container.persistentStoreCoordinator + let saveObserver = NotificationCenter.default.addObserver( + forName: .NSManagedObjectContextDidSave, + object: nil, + queue: nil + ) { [weak self] notification in + guard let ctx = notification.object as? NSManagedObjectContext, + ctx.persistentStoreCoordinator === psc else { + return + } + self?.scheduleSnapshot() + } + observers.append(saveObserver) + + let importObserver = NotificationCenter.default.addObserver( + forName: CoreDataStorage.didFinishCloudImport, object: nil, queue: nil + ) { [weak self] _ in self?.scheduleSnapshot() } + observers.append(importObserver) + + let remoteObserver = NotificationCenter.default.addObserver( + forName: CoreDataStorage.didReceiveRemoteChanges, object: nil, queue: nil + ) { [weak self] _ in self?.scheduleSnapshot() } + observers.append(remoteObserver) + + // iOS purge ์ง์ „ debounce ์šฐํšŒํ•˜์—ฌ ์ฆ‰์‹œ flush โ†’ purge ํ›„ ๋งˆ์ง€๋ง‰ ์ •์ƒ ์ƒํƒœ ๋ณด์กด + let willResetObserver = NotificationCenter.default.addObserver( + forName: CoreDataStorage.SyncResetNotification.willReset, object: nil, queue: nil + ) { [weak self] _ in + os_log(.error, log: .default, "๐Ÿ›Ÿ SafetySnapshot: sync-reset imminent โ€” flushing immediately") + self?.flushNow() + } + observers.append(willResetObserver) + + queue.async { [weak self] in + self?.writeSnapshotNow() + } + } + + func stopObserving() { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + observers.removeAll() + pendingWorkItem?.cancel() + pendingWorkItem = nil + } + + private func scheduleSnapshot() { + pendingWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.writeSnapshotNow() + } + pendingWorkItem = workItem + queue.asyncAfter(deadline: .now() + Self.debounceSeconds, execute: workItem) + } + + /// ๊ฐ•์ œ ์ €์žฅ โ€” ์•ฑ ์ข…๋ฃŒ ์ง์ „/sync-reset ์ง์ „ ๋“ฑ์—์„œ flushing ์šฉ๋„. + func flushNow() { + pendingWorkItem?.cancel() + pendingWorkItem = nil + writeSnapshotNow() + } + + private func writeSnapshotNow() { + let context = container.newBackgroundContext() + do { + let snapshot = try UserCollectionSnapshot.dump(from: context) + let data = try snapshot.toData() + try data.write(to: snapshotURL, options: [.atomic]) + updateMetadataCache(createdAt: snapshot.createdAt, childCount: snapshot.totalChildCount) + os_log(.info, log: .default, + "๐Ÿ›Ÿ SafetySnapshot written: %d children, %d bytes", + snapshot.totalChildCount, data.count) + } catch SafetySnapshotError.noUserCollection { + // UC ์—†์Œ = ์•„์ง ์•„๋ฌด ๊ฒƒ๋„ ์—†๋Š” ์ƒํƒœ. ๊ธฐ์กด ์Šค๋ƒ…์ƒท์€ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์Œ (์œ ์‹ค๋œ ์ƒํƒœ์—์„œ ๋ฎ์–ด์“ฐ์ง€ ์•Š๊ธฐ ์œ„ํ•ด) + os_log(.info, log: .default, "๐Ÿ›Ÿ SafetySnapshot skipped: no UC in current store") + } catch { + os_log(.error, log: .default, + "๐Ÿ›Ÿ SafetySnapshot write failed: %{public}@", + error.localizedDescription) + } + } + + // MARK: - Restore + + enum RestoreOutcome { + case success(totalRestored: Int) + case noSnapshot + case failed(Error) + } + + /// ์‚ฌ์šฉ์ž ํ™•์ธ ํ›„ ํ˜ธ์ถœ. ๊ธฐ์กด UC + ์ž์‹์„ ๋ชจ๋‘ ์‚ญ์ œํ•˜๊ณ  ์Šค๋ƒ…์ƒท์œผ๋กœ ์žฌ๊ตฌ์„ฑ. + /// **ํŒŒ๊ดด์  ๋™์ž‘** โ€” ํ˜ธ์ถœ ์ „ ์‚ฌ์šฉ์ž ๋ช…์‹œ์  ๋™์˜ ํ•„์š”. + func restore(completion: @escaping (RestoreOutcome) -> Void) { + container.performBackgroundTask { [weak self] context in + guard let self else { return } + let outcome: RestoreOutcome + do { + let data = try Data(contentsOf: self.snapshotURL) + let snapshot = try UserCollectionSnapshot.from(data: data) + try Self.wipeExistingCollection(in: context) + try snapshot.apply(to: context) + try context.save() + os_log(.error, log: .default, + "๐Ÿ›Ÿ SafetySnapshot RESTORED: %d children", + snapshot.totalChildCount) + outcome = .success(totalRestored: snapshot.totalChildCount) + } catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError { + outcome = .noSnapshot + } catch { + os_log(.error, log: .default, + "๐Ÿ›Ÿ SafetySnapshot restore FAILED: %{public}@", + error.localizedDescription) + outcome = .failed(error) + } + DispatchQueue.main.async { completion(outcome) } + } + } + + private static func wipeExistingCollection(in context: NSManagedObjectContext) throws { + let entityNames = [ + "ItemEntity", "DailyTaskEntity", "VillagersLikeEntity", + "VillagersHouseEntity", "NPCLikeEntity", "VariantCollectionEntity", + "UserCollectionEntity" + ] + for name in entityNames { + let request = NSFetchRequest(entityName: name) + let delete = NSBatchDeleteRequest(fetchRequest: request) + delete.resultType = .resultTypeObjectIDs + if let result = try context.execute(delete) as? NSBatchDeleteResult, + let objectIDs = result.result as? [NSManagedObjectID], !objectIDs.isEmpty { + let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: objectIDs] + NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [context]) + } + } + } + + // MARK: - Metadata for UI + + struct Metadata { + let createdAt: Date + let totalChildCount: Int + } + + /// UI์—์„œ "N๋ถ„ ์ „์— ์ €์žฅ๋œ ๋ฐฑ์—…์ด ์žˆ์Šต๋‹ˆ๋‹ค (์•„์ดํ…œ N๊ฐœ)"๋ฅผ ํ‘œ์‹œํ•˜๊ธฐ ์œ„ํ•œ ์š”์•ฝ. + /// UserDefaults ์บ์‹œ ์šฐ์„ , ์—†์œผ๋ฉด ํŒŒ์ผ์„ unarchiveํ•˜์—ฌ ์—ญ์œผ๋กœ ์บ์‹œ ์ฑ„์›€. + func readMetadata() -> Metadata? { + if let cached = readCachedMetadata() { + return cached + } + return readMetadataFromFile() + } + + private func readCachedMetadata() -> Metadata? { + guard let createdAt = UserDefaults.standard.object(forKey: Self.metadataCreatedAtKey) as? Date else { + return nil + } + let count = UserDefaults.standard.integer(forKey: Self.metadataChildCountKey) + // ํŒŒ์ผ์ด ์™ธ๋ถ€์—์„œ ์‚ญ์ œ๋œ ๊ฒฝ์šฐ ์บ์‹œ๊ฐ€ staleํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์‹ค์ œ ํŒŒ์ผ ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธ + guard snapshotExists else { + clearMetadataCache() + return nil + } + return Metadata(createdAt: createdAt, totalChildCount: count) + } + + private func readMetadataFromFile() -> Metadata? { + guard snapshotExists else { return nil } + do { + let data = try Data(contentsOf: snapshotURL) + let snapshot = try UserCollectionSnapshot.from(data: data) + updateMetadataCache(createdAt: snapshot.createdAt, childCount: snapshot.totalChildCount) + return Metadata(createdAt: snapshot.createdAt, totalChildCount: snapshot.totalChildCount) + } catch { + os_log(.error, log: .default, + "๐Ÿ›Ÿ SafetySnapshot metadata read failed: %{public}@", + error.localizedDescription) + return nil + } + } + + private func updateMetadataCache(createdAt: Date, childCount: Int) { + UserDefaults.standard.set(createdAt, forKey: Self.metadataCreatedAtKey) + UserDefaults.standard.set(childCount, forKey: Self.metadataChildCountKey) + } + + private func clearMetadataCache() { + UserDefaults.standard.removeObject(forKey: Self.metadataCreatedAtKey) + UserDefaults.standard.removeObject(forKey: Self.metadataChildCountKey) + } + + /// ๋ณต์› ์™„๋ฃŒ ํ›„ ๋˜๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์˜๋„์ ์œผ๋กœ ์ง€์šธ ๋•Œ. + func deleteSnapshot() { + try? FileManager.default.removeItem(at: snapshotURL) + clearMetadataCache() + } +} diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/UserCollectionSnapshot.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/UserCollectionSnapshot.swift new file mode 100644 index 0000000..3bd3324 --- /dev/null +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/UserCollectionSnapshot.swift @@ -0,0 +1,264 @@ +// +// UserCollectionSnapshot.swift +// Animal-Crossing-Wiki +// +// Stage 1.5 safety net for iOS-induced local data purges +// (NSCloudKitMirroringDelegate์˜ Change Token Expired ์‹œ ๋กœ์ปฌ wipe ๋Œ€์‘). +// +// - Core Data์˜ UserCollectionEntity + ์ž์‹ ์—”ํ‹ฐํ‹ฐ๋ฅผ attribute dict๋กœ ์ง๋ ฌํ™” +// - Foundation ๊ธฐ๋ณธ ํƒ€์ž…๋งŒ์œผ๋กœ ๊ตฌ์„ฑ๋œ NSDictionary tree โ†’ binary plist๋กœ ์ €์žฅ +// - ๋„๋ฉ”์ธ ๋ชจ๋ธ Codable ์˜์กด์„ฑ ์—†์Œ (NSArray/NSDictionary transformable๋„ plist๊ฐ€ ๊ทธ๋Œ€๋กœ ์ฒ˜๋ฆฌ) +// + +import Foundation +import CoreData +import os + +enum SafetySnapshotError: Error { + case serializationFailed(Error) + case deserializationFailed(Error) + case incompatibleVersion(found: Int, expected: Int) + case noUserCollection +} + +/// ์Šค๋ƒ…์ƒท ์Šคํ‚ค๋งˆ ๋ฒ„์ „. ํ•˜์œ„ ํ˜ธํ™˜ ๋ถˆ๊ฐ€ํ•œ ๋ณ€๊ฒฝ ์‹œ bump. +/// v1 โ€” 3.2.4 ์ตœ์ดˆ ๋„์ž… +private let kCurrentSafetySnapshotVersion: Int = 1 + +struct UserCollectionSnapshot { + + let version: Int + let createdAt: Date + let userInfo: [String: Any] + let items: [[String: Any]] + let dailyTasks: [[String: Any]] + let villagersLike: [[String: Any]] + let villagersHouse: [[String: Any]] + let npcLike: [[String: Any]] + let variants: [[String: Any]] + + var totalChildCount: Int { + items.count + dailyTasks.count + villagersLike.count + + villagersHouse.count + npcLike.count + variants.count + } + + // MARK: - Serialization + + func toData() throws -> Data { + // NSKeyedArchiver ์‚ฌ์šฉ ์ด์œ : + // - PropertyListSerialization์€ NSString/NSNumber/NSDate/NSData/NSArray/NSDictionary๋งŒ ํ—ˆ์šฉ. + // - ItemEntity์˜ ์ผ๋ถ€ Transformable(์˜ˆ: variations, recipe)์€ ๋‚ด๋ถ€์— ์ปค์Šคํ…€ NSCoding DTO๋ฅผ ํฌํ•จํ•˜์—ฌ plist ์ง๋ ฌํ™” ์‹œ ์‹คํŒจํ•จ (SafetySnapshotError.serializationFailed). + // - NSKeyedArchiver๋Š” NSCoding ์ค€์ˆ˜ ๊ฐ์ฒด ์ „๋ถ€๋ฅผ ์ฒ˜๋ฆฌ. Core Data Transformable์€ ์ด๋ฏธ NSCoding์„ ํ†ตํ•ด ์ €์žฅ๋˜๋ฏ€๋กœ ๊ทธ๋Œ€๋กœ ํ†ต๊ณผ๋จ. + // - ํŒŒ์ผ์€ ์‚ฌ๋žŒ์ด ์ฝ๊ธฐ ์–ด๋ ต์ง€๋งŒ ๋‚ด๋ถ€ ์•ˆ์ „๋ง ์šฉ๋„์ด๋ฏ€๋กœ ์ˆ˜์šฉ ๊ฐ€๋Šฅ. + let root: [String: Any] = [ + "version": version, + "createdAt": createdAt, + "userInfo": userInfo, + "items": items, + "dailyTasks": dailyTasks, + "villagersLike": villagersLike, + "villagersHouse": villagersHouse, + "npcLike": npcLike, + "variants": variants + ] + do { + // requiringSecureCoding=false: ์•ฑ์ด ์ž๊ธฐ ์ž์‹ ์ด ์“ด ํŒŒ์ผ๋งŒ ๋ณตํ˜ธํ™”ํ•˜๋ฏ€๋กœ ์ž„์˜ ๊ฐ์ฒด ์ฃผ์ž… ์œ„ํ—˜ ์—†์Œ. + // true๋กœ ํ•˜๋ฉด unarchive ์‹œ ์ „์ฒด ํด๋ž˜์Šค ํ—ˆ์šฉ ๋ชฉ๋ก์„ ์•Œ๋ ค์ค˜์•ผ ํ•˜๋Š”๋ฐ, + // Transformable ๋‚ด๋ถ€์˜ ๋ชจ๋“  ์ปค์Šคํ…€ DTO ํƒ€์ž…์„ ๋‚˜์—ดํ•˜๊ธฐ ์–ด๋ ค์›€. + return try NSKeyedArchiver.archivedData( + withRootObject: root as NSDictionary, + requiringSecureCoding: false + ) + } catch { + throw SafetySnapshotError.serializationFailed(error) + } + } + + static func from(data: Data) throws -> UserCollectionSnapshot { + let unarchiver: NSKeyedUnarchiver + do { + unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + } catch { + throw SafetySnapshotError.deserializationFailed(error) + } + unarchiver.requiresSecureCoding = false + guard let root = unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? [String: Any] else { + unarchiver.finishDecoding() + throw SafetySnapshotError.deserializationFailed( + NSError(domain: "SafetySnapshot", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Malformed snapshot root"]) + ) + } + unarchiver.finishDecoding() + + guard let version = root["version"] as? Int, + let createdAt = root["createdAt"] as? Date, + let userInfo = root["userInfo"] as? [String: Any] else { + throw SafetySnapshotError.deserializationFailed( + NSError(domain: "SafetySnapshot", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Malformed snapshot fields"]) + ) + } + guard version == kCurrentSafetySnapshotVersion else { + throw SafetySnapshotError.incompatibleVersion( + found: version, expected: kCurrentSafetySnapshotVersion + ) + } + return UserCollectionSnapshot( + version: version, + createdAt: createdAt, + userInfo: userInfo, + items: (root["items"] as? [[String: Any]]) ?? [], + dailyTasks: (root["dailyTasks"] as? [[String: Any]]) ?? [], + villagersLike: (root["villagersLike"] as? [[String: Any]]) ?? [], + villagersHouse: (root["villagersHouse"] as? [[String: Any]]) ?? [], + npcLike: (root["npcLike"] as? [[String: Any]]) ?? [], + variants: (root["variants"] as? [[String: Any]]) ?? [] + ) + } + + // MARK: - Dump from Core Data + + /// LocalStore(๋˜๋Š” main store)์˜ context์—์„œ UC ๊ทธ๋ž˜ํ”„ ์ „์ฒด๋ฅผ ๋คํ”„. + /// UC๊ฐ€ ์—ฌ๋Ÿฌ ๊ฐœ๋ฉด ๊ด€๊ณ„๊ฐ€ ๊ฐ€์žฅ ๋งŽ์€ ํ•˜๋‚˜๋ฅผ ์„ ํƒ (๊ธฐ์กด getUserCollection ์ •์ฑ…๊ณผ ๋™์ผ). + static func dump(from context: NSManagedObjectContext) throws -> UserCollectionSnapshot { + var result: UserCollectionSnapshot? + var caughtError: Error? + + context.performAndWait { + do { + let request = UserCollectionEntity.fetchRequest() + let ucs = try context.fetch(request) + guard let uc = ucs.sorted(by: { relationshipCount(of: $0) > relationshipCount(of: $1) }).first else { + caughtError = SafetySnapshotError.noUserCollection + return + } + + let userInfoDict = attributeDict(of: uc) + let items = children(of: uc, key: "critters") + let tasks = children(of: uc, key: "dailyTasks") + let vLikes = children(of: uc, key: "villagersLike") + let vHouses = children(of: uc, key: "villagersHouse") + let npcs = children(of: uc, key: "npcLike") + let variants = children(of: uc, key: "variants") + + result = UserCollectionSnapshot( + version: kCurrentSafetySnapshotVersion, + createdAt: Date(), + userInfo: userInfoDict, + items: items, + dailyTasks: tasks, + villagersLike: vLikes, + villagersHouse: vHouses, + npcLike: npcs, + variants: variants + ) + } catch { + caughtError = error + } + } + + if let error = caughtError { + throw error + } + guard let snapshot = result else { + throw SafetySnapshotError.noUserCollection + } + return snapshot + } + + // MARK: - Apply to Core Data + + /// ๋Œ€์ƒ context์— UC๋ฅผ ์žฌ๊ตฌ์„ฑ. ํ˜ธ์ถœ์ž๋Š” ์‚ฌ์ „์— ๊ธฐ์กด UC + ์ž์‹์„ ์‚ญ์ œํ–ˆ์–ด์•ผ ํ•œ๋‹ค. + /// ๋‹จ์ผ background context ์•ˆ์—์„œ wipe โ†’ apply โ†’ save๋ฅผ ์›์ž์ ์œผ๋กœ ๋ฌถ๋Š” ๊ฒƒ์ด ๊ถŒ์žฅ. + func apply(to context: NSManagedObjectContext) throws { + var caughtError: Error? + context.performAndWait { + do { + // UC ์ƒ์„ฑ + guard let ucEntity = NSEntityDescription.entity( + forEntityName: "UserCollectionEntity", in: context + ) else { + throw SafetySnapshotError.deserializationFailed( + NSError(domain: "SafetySnapshot", code: -2, + userInfo: [NSLocalizedDescriptionKey: "UserCollectionEntity not in model"]) + ) + } + let uc = NSManagedObject(entity: ucEntity, insertInto: context) + applyAttributes(userInfo, to: uc) + + try insertChildren(items, entityName: "ItemEntity", + parent: uc, relationshipKey: "userColletion", in: context) + try insertChildren(dailyTasks, entityName: "DailyTaskEntity", + parent: uc, relationshipKey: "userCollection", in: context) + try insertChildren(villagersLike, entityName: "VillagersLikeEntity", + parent: uc, relationshipKey: "userCollection", in: context) + try insertChildren(villagersHouse, entityName: "VillagersHouseEntity", + parent: uc, relationshipKey: "userCollection", in: context) + try insertChildren(npcLike, entityName: "NPCLikeEntity", + parent: uc, relationshipKey: "userCollection", in: context) + try insertChildren(variants, entityName: "VariantCollectionEntity", + parent: uc, relationshipKey: "userCollection", in: context) + } catch { + caughtError = error + } + } + if let error = caughtError { + throw error + } + } + + private func insertChildren( + _ dicts: [[String: Any]], + entityName: String, + parent: NSManagedObject, + relationshipKey: String, + in context: NSManagedObjectContext + ) throws { + guard let entity = NSEntityDescription.entity(forEntityName: entityName, in: context) else { + throw SafetySnapshotError.deserializationFailed( + NSError(domain: "SafetySnapshot", code: -3, + userInfo: [NSLocalizedDescriptionKey: "\(entityName) not in model"]) + ) + } + for dict in dicts { + let obj = NSManagedObject(entity: entity, insertInto: context) + applyAttributes(dict, to: obj) + obj.setValue(parent, forKey: relationshipKey) + } + } + + // MARK: - Attribute Helpers + + private static func attributeDict(of object: NSManagedObject) -> [String: Any] { + var dict: [String: Any] = [:] + for (name, _) in object.entity.attributesByName { + if let value = object.value(forKey: name) { + // NSDate, NSString, NSNumber, NSData, NSArray, NSDictionary ๋ชจ๋‘ plist ํ˜ธํ™˜ + dict[name] = value + } + } + return dict + } + + private static func children(of uc: NSManagedObject, key: String) -> [[String: Any]] { + guard let set = uc.value(forKey: key) as? Set else { return [] } + return set.map { attributeDict(of: $0) } + } + + private static func relationshipCount(of uc: NSManagedObject) -> Int { + let keys = ["critters", "dailyTasks", "villagersLike", "villagersHouse", "npcLike", "variants"] + return keys.reduce(0) { acc, key in + let count = (uc.value(forKey: key) as? Set)?.count ?? 0 + return acc + count + } + } + + private func applyAttributes(_ dict: [String: Any], to object: NSManagedObject) { + for (key, _) in object.entity.attributesByName { + if let value = dict[key] { + object.setValue(value, forKey: key) + } + } + } +} diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Coordinator/DashboardCoordinator.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Coordinator/DashboardCoordinator.swift index 510f742..239fb5d 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Coordinator/DashboardCoordinator.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Coordinator/DashboardCoordinator.swift @@ -158,7 +158,18 @@ final class DashboardCoordinator: Coordinator { return currentVC.showAlert(title: title, message: message) } - // TEMPORARY: Recovery + /// Stage 1.5 ๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์› ์™„๋ฃŒ/์‹คํŒจ ๊ฒฐ๊ณผ๋ฅผ ์‚ฌ์šฉ์ž์—๊ฒŒ ์•ˆ๋‚ด. + func showLocalRestoreResult(success: Bool, message: String?) { + DispatchQueue.main.async { [weak self] in + guard let currentVC = self?.rootViewController.visibleViewController else { return } + let title = success ? "Restore complete".localized : "Restore failed".localized + let body = message ?? "" + let alert = UIAlertController(title: title, message: body, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK".localized, style: .default)) + currentVC.present(alert, animated: true) + } + } + func showRecoveryResultAlert(success: Bool, message: String?) { DispatchQueue.main.async { [weak self] in guard let currentVC = self?.rootViewController.visibleViewController else { return } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/ViewModels/AppSettingReactor.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/ViewModels/AppSettingReactor.swift index 49a3164..414d82f 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/ViewModels/AppSettingReactor.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/ViewModels/AppSettingReactor.swift @@ -13,21 +13,30 @@ final class AppSettingReactor: Reactor { enum Action { case toggleSwitch case reset - case recoverFromCloud // TEMPORARY: Recovery + case recoverFromCloud + case consolidateManually + case restoreLocalBackup case loadSyncStatus + case loadLocalBackupMetadata } enum Mutation { case setHapticState(_ isOn: Bool) case reset(_ isReset: Bool) - case setRecoveryInProgress(Bool) // TEMPORARY: Recovery + case setRecoveryInProgress(Bool) + case setConsolidationInProgress(Bool) + case setLocalRestoreInProgress(Bool) case setSyncStatus(SyncStatusInfo) + case setLocalBackupMetadata(SafetySnapshotService.Metadata?) } struct State { var currentHapticState: Bool = HapticManager.shared.mode == .on - var isRecoveryInProgress: Bool = false // TEMPORARY: Recovery + var isRecoveryInProgress: Bool = false + var isConsolidationInProgress: Bool = false + var isLocalRestoreInProgress: Bool = false var syncStatus: SyncStatusInfo? + var localBackupMetadata: SafetySnapshotService.Metadata? } let initialState: State @@ -62,13 +71,20 @@ final class AppSettingReactor: Reactor { return Disposables.create() } - // TEMPORARY: Recovery case .recoverFromCloud: + // 2๋‹จ ํ™•์ธ: ๋ณต์›์€ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋ถ€ ์ง€์šฐ๋ฏ€๋กœ ํŒŒ๊ดด์  ๋™์ž‘ return coordinator .showAlert( title: "iCloud Data Recovery".localized, - message: "This will reset local data and re-download from iCloud. Continue?".localized + message: "Recovery warning: local data will be erased".localized ) + .flatMap { [weak self] firstConfirmed -> Observable in + guard let self, firstConfirmed else { return .just(false) } + return self.coordinator.showAlert( + title: "Are you absolutely sure?".localized, + message: "Recovery final confirm".localized + ) + } .flatMap { confirmed -> Observable in guard confirmed else { return .empty() } return Observable.concat( @@ -77,10 +93,78 @@ final class AppSettingReactor: Reactor { ) } .observe(on: MainScheduler.asyncInstance) + + case .consolidateManually: + return coordinator + .showAlert( + title: "Clean duplicate data".localized, + message: "Consolidate warning".localized + ) + .flatMap { confirmed -> Observable in + guard confirmed else { return .empty() } + return Observable.concat( + .just(.setConsolidationInProgress(true)), + self.performConsolidation() + ) + } + .observe(on: MainScheduler.asyncInstance) + + case .loadLocalBackupMetadata: + let metadata = SafetySnapshotService.shared.readMetadata() + return .just(.setLocalBackupMetadata(metadata)) + + case .restoreLocalBackup: + guard let metadata = SafetySnapshotService.shared.readMetadata() else { + return .empty() + } + let relative = DateFormatters.syncRelativeDate.localizedString(for: metadata.createdAt, relativeTo: Date()) + let message = String( + format: "Restore local backup warning".localized, + relative, metadata.totalChildCount + ) + return coordinator + .showAlert(title: "Restore from local backup".localized, message: message) + .flatMap { confirmed -> Observable in + guard confirmed else { return .empty() } + return Observable.concat( + .just(.setLocalRestoreInProgress(true)), + self.performLocalRestore() + ) + } + .observe(on: MainScheduler.asyncInstance) + } + } + + private func performLocalRestore() -> Observable { + return Observable.create { [weak self] observer in + SafetySnapshotService.shared.restore { outcome in + switch outcome { + case .success(let count): + Items.shared.refreshUserCollection() + self?.coordinator.showLocalRestoreResult(success: true, message: String(format: "Restored %d items".localized, count)) + case .noSnapshot: + self?.coordinator.showLocalRestoreResult(success: false, message: "No local backup found".localized) + case .failed(let error): + self?.coordinator.showLocalRestoreResult(success: false, message: error.localizedDescription) + } + observer.onNext(.setLocalRestoreInProgress(false)) + observer.onNext(.setLocalBackupMetadata(SafetySnapshotService.shared.readMetadata())) + observer.onCompleted() + } + return Disposables.create() + } + } + + private func performConsolidation() -> Observable { + return Observable.create { observer in + CoreDataStorage.shared.consolidateUserCollectionsManually { + observer.onNext(.setConsolidationInProgress(false)) + observer.onCompleted() + } + return Disposables.create() } } - // TEMPORARY: Recovery private func performRecovery() -> Observable { return Observable.create { observer in CoreDataStorage.shared.performCloudKitRecovery { [weak self] result in @@ -110,12 +194,20 @@ final class AppSettingReactor: Reactor { coordinator.transition(for: .dismiss) } - // TEMPORARY: Recovery case .setRecoveryInProgress(let inProgress): newState.isRecoveryInProgress = inProgress + case .setConsolidationInProgress(let inProgress): + newState.isConsolidationInProgress = inProgress + + case .setLocalRestoreInProgress(let inProgress): + newState.isLocalRestoreInProgress = inProgress + case .setSyncStatus(let info): newState.syncStatus = info + + case .setLocalBackupMetadata(let metadata): + newState.localBackupMetadata = metadata } return newState } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Views/AppSettingView.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Views/AppSettingView.swift index 26f5631..73120fb 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Views/AppSettingView.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Presentation/Dashboard/Views/AppSettingView.swift @@ -12,8 +12,20 @@ final class AppSettingView: UIView { private let disposeBag = DisposeBag() private let resetTapGesture = UITapGestureRecognizer() - private let recoverTapGesture = UITapGestureRecognizer() // TEMPORARY: Recovery - private lazy var recoveryIndicator = UIActivityIndicatorView(style: .medium) // TEMPORARY: Recovery + private let recoverTapGesture = UITapGestureRecognizer() + private let consolidateTapGesture = UITapGestureRecognizer() + private let localRestoreTapGesture = UITapGestureRecognizer() + private lazy var recoveryIndicator = UIActivityIndicatorView(style: .medium) + private lazy var consolidateIndicator = UIActivityIndicatorView(style: .medium) + private lazy var localRestoreIndicator = UIActivityIndicatorView(style: .medium) + private lazy var localBackupInfoLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(for: .caption1, weight: .regular) + label.textColor = .secondaryLabel + label.numberOfLines = 1 + label.textAlignment = .right + return label + }() private lazy var syncStatusLabel: UILabel = { let label = UILabel() @@ -52,19 +64,35 @@ final class AppSettingView: UIView { backgroundStackView.heightAnchor.constraint(equalTo: heightAnchor) ]) let resetView = InfoContentView(title: "Data reset".localized) - // TEMPORARY: Recovery let recoverView = InfoContentView( title: "Recover data from iCloud".localized, contentView: recoveryIndicator ) + let consolidateView = InfoContentView( + title: "Clean duplicate data".localized, + contentView: consolidateIndicator + ) + let localBackupInfoView = InfoContentView( + title: "Local backup info".localized, + contentView: localBackupInfoLabel + ) + let localRestoreView = InfoContentView( + title: "Restore from local backup".localized, + contentView: localRestoreIndicator + ) backgroundStackView.addArrangedSubviews( InfoContentView(title: "System haptic".localized, contentView: hapticSwitch), InfoContentView(title: "iCloud sync status".localized, contentView: syncStatusLabel), + localBackupInfoView, + localRestoreView, + consolidateView, recoverView, resetView ) resetView.addGestureRecognizer(resetTapGesture) - recoverView.addGestureRecognizer(recoverTapGesture) // TEMPORARY: Recovery + recoverView.addGestureRecognizer(recoverTapGesture) + consolidateView.addGestureRecognizer(consolidateTapGesture) + localRestoreView.addGestureRecognizer(localRestoreTapGesture) } func bind(to reactor: AppSettingReactor) { @@ -83,13 +111,16 @@ final class AppSettingView: UIView { .bind(to: hapticSwitch.rx.isOn) .disposed(by: disposeBag) - // TEMPORARY: Recovery recoverTapGesture.rx.event .map { _ in AppSettingReactor.Action.recoverFromCloud } .bind(to: reactor.action) .disposed(by: disposeBag) - // TEMPORARY: Recovery โ€” activity indicator + consolidateTapGesture.rx.event + .map { _ in AppSettingReactor.Action.consolidateManually } + .bind(to: reactor.action) + .disposed(by: disposeBag) + reactor.state.map { $0.isRecoveryInProgress } .distinctUntilChanged() .observe(on: MainScheduler.instance) @@ -102,6 +133,42 @@ final class AppSettingView: UIView { }) .disposed(by: disposeBag) + localRestoreTapGesture.rx.event + .map { _ in AppSettingReactor.Action.restoreLocalBackup } + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state.map { $0.isLocalRestoreInProgress } + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] inProgress in + if inProgress { + self?.localRestoreIndicator.startAnimating() + } else { + self?.localRestoreIndicator.stopAnimating() + } + }) + .disposed(by: disposeBag) + + reactor.state.map { $0.localBackupMetadata } + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] metadata in + self?.updateLocalBackupInfo(metadata) + }) + .disposed(by: disposeBag) + + reactor.state.map { $0.isConsolidationInProgress } + .distinctUntilChanged() + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] inProgress in + if inProgress { + self?.consolidateIndicator.startAnimating() + } else { + self?.consolidateIndicator.stopAnimating() + } + }) + .disposed(by: disposeBag) + // Sync status display reactor.state.compactMap { $0.syncStatus } .observe(on: MainScheduler.instance) @@ -112,6 +179,19 @@ final class AppSettingView: UIView { // Load sync status on appear reactor.action.onNext(.loadSyncStatus) + reactor.action.onNext(.loadLocalBackupMetadata) + } + + private func updateLocalBackupInfo(_ metadata: SafetySnapshotService.Metadata?) { + guard let metadata else { + localBackupInfoLabel.text = "No local backup".localized + return + } + let relative = DateFormatters.syncRelativeDate.localizedString(for: metadata.createdAt, relativeTo: Date()) + localBackupInfoLabel.text = String( + format: "Local backup info format".localized, + relative, metadata.totalChildCount + ) } private func updateSyncStatusLabel(_ info: SyncStatusInfo) { diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift index 99219fa..f09a992 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/SceneDelegate.swift @@ -45,7 +45,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { CoreDataStorage.shared.clearWaitingForFirstImport() CoreDataStorage.shared.logSyncDiagnostics(phase: "Pre-setup") - CoreDataStorage.shared.consolidateUserCollections() appCoordinator = AppCoordinator() appCoordinator?.start() @@ -56,6 +55,65 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { observeAccountChanges() checkiCloudAccount() CoreDataStorage.shared.cleanupPersistentHistory() + + SafetySnapshotService.shared.startObserving() + + // Purge ์˜์‹ฌ ์ƒํ™ฉ(fresh install=true && hasEverHadUserCollection=true)์—์„œ ๋กœ์ปฌ ์Šค๋ƒ…์ƒท์ด ์žˆ๋‹ค๋ฉด + // ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณต์› ์˜ต์…˜์„ ์ œ์•ˆ. CloudKit import๊ฐ€ ์‹คํŒจ/์ง€์—ฐ๋˜์–ด๋„ ๋ฐ์ดํ„ฐ๋ฅผ ๋˜์‚ด๋ฆด ์ˆ˜ ์žˆ์Œ. + offerSafetySnapshotRestoreIfNeeded() + } + + private func offerSafetySnapshotRestoreIfNeeded() { + guard CoreDataStorage.shared.hasEverHadUserCollection, + CoreDataStorage.shared.isFreshInstall(), + let metadata = SafetySnapshotService.shared.readMetadata() else { + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + self?.presentSafetySnapshotPrompt(metadata: metadata) + } + } + + private func presentSafetySnapshotPrompt(metadata: SafetySnapshotService.Metadata) { + guard let root = window?.rootViewController else { return } + let relative = DateFormatters.syncRelativeDate.localizedString(for: metadata.createdAt, relativeTo: Date()) + let message = String( + format: "Local backup detected message".localized, + relative, metadata.totalChildCount + ) + let alert = UIAlertController( + title: "Local backup detected".localized, + message: message, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Wait for iCloud".localized, style: .cancel)) + alert.addAction(UIAlertAction(title: "Restore local backup".localized, style: .destructive) { [weak self] _ in + self?.performSafetySnapshotRestore() + }) + root.present(alert, animated: true) + } + + private func performSafetySnapshotRestore() { + SafetySnapshotService.shared.restore { [weak self] outcome in + guard let root = self?.window?.rootViewController else { return } + let title: String + let message: String + switch outcome { + case .success(let count): + title = "Restore complete".localized + message = String(format: "Restored %d items".localized, count) + Items.shared.refreshUserCollection() + case .noSnapshot: + title = "Restore failed".localized + message = "No local backup found".localized + case .failed(let error): + title = "Restore failed".localized + message = error.localizedDescription + } + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK".localized, style: .default)) + root.present(alert, animated: true) + } } // MARK: - Fresh Install: Wait for CloudKit Import @@ -243,6 +301,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidEnterBackground(_ scene: UIScene) { ToastManager.shared.dismiss() + // Stage 1.5: ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ง„์ž… ์ง์ „ pending ์Šค๋ƒ…์ƒท์„ ๊ฐ•์ œ flush + SafetySnapshotService.shared.flushNow() + // Extend execution time for pending CloudKit sync operations (import/export) var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid let endTask = { diff --git a/docs/features/icloud-sync.md b/docs/features/icloud-sync.md index 563f984..08fc884 100644 --- a/docs/features/icloud-sync.md +++ b/docs/features/icloud-sync.md @@ -5,6 +5,11 @@ `NSPersistentCloudKitContainer`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์—ฌ๋Ÿฌ ๊ธฐ๊ธฐ ๊ฐ„ ์ˆ˜์ง‘ ๊ธฐ๋ก์„ ์ž๋™ ๋™๊ธฐํ™”. ์‚ฌ์šฉ์ž ๊ฐœ์ž… ์—†์ด ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ๋™์ž‘ํ•˜๋ฉฐ, Import ์‹œ ํ† ์ŠคํŠธ ์•Œ๋ฆผ์œผ๋กœ ์ƒํƒœ๋ฅผ ์•ˆ๋‚ด. +> **3.2.4 ๋ณ€๊ฒฝ์‚ฌํ•ญ**: ์ž๋™ consolidation/orphan cleanup์ด ์‚ฌ์šฉ์ž ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•˜๋Š” ๋ฒ„๊ทธ +> ๋•Œ๋ฌธ์— **์ž๋™ ํ˜ธ์ถœ์„ ๋ชจ๋‘ ์ œ๊ฑฐ**ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ค‘๋ณต ์ •๋ฆฌ ๋ฐ ๋ณต์›์€ ์„ค์ • ํ™”๋ฉด์—์„œ ์‚ฌ์šฉ์ž๊ฐ€ ๋ช…์‹œ์ ์œผ๋กœ +> ํŠธ๋ฆฌ๊ฑฐํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ƒ์„ธ๋Š” ์•„๋ž˜ "Manual Consolidation" / "Data Recovery" ์„น์…˜ ์ฐธ์กฐ. +> (์ค‘๊ธฐ ๊ณ„ํš โ€” `docs/plans/local-backup-split.md` ์ฐธ์กฐ: ๋กœ์ปฌ/๋ฐฑ์—… store ๋ถ„๋ฆฌ ์•„ํ‚คํ…์ฒ˜๋กœ ์ด๊ด€ ์˜ˆ์ •) + ## Architecture ```text @@ -219,22 +224,46 @@ Import ์™„๋ฃŒ ํ›„ Path-B(`setUpUserCollection`)๊ฐ€ ์žฌ์‹คํ–‰๋˜์–ด ๋ฐ์ดํ„ฐ๊ฐ€ - CloudKit ๋™๊ธฐํ™” ์ž‘์—…์ด ์™„๋ฃŒ๋  ์‹œ๊ฐ„ ํ™•๋ณด - expiration handler์™€ ํƒ€์ด๋จธ ์–‘์ชฝ์—์„œ idempotentํ•˜๊ฒŒ ์ข…๋ฃŒ (์ด์ค‘ ํ˜ธ์ถœ ๋ฐฉ์ง€) -## Data Recovery (TEMPORARY) +## Data Recovery (์ˆ˜๋™ ๋ณต์›) -์„ค์ • ํ™”๋ฉด์—์„œ "iCloud์—์„œ ๋ฐ์ดํ„ฐ ๋ณต๊ตฌ" ๊ธฐ๋Šฅ ์ œ๊ณต. ์•ˆ์ •ํ™” ํ›„ ์ œ๊ฑฐ ์˜ˆ์ •. +์„ค์ • ํ™”๋ฉด์—์„œ "iCloud์—์„œ ๋ณต์› (๋กœ์ปฌ ๋ฐ์ดํ„ฐ ๋ฎ์–ด์”€)" ๊ธฐ๋Šฅ ์ œ๊ณต. 3.2.4๋ถ€ํ„ฐ ์ •์‹ ๊ธฐ๋Šฅ์œผ๋กœ ์Šน๊ฒฉ. +**ํŒŒ๊ดด์  ๋™์ž‘** โ€” 2๋‹จ ํ™•์ธ Alert ํ›„์—๋งŒ ์‹คํ–‰๋จ. **๋™์ž‘ ์›๋ฆฌ**: 1. iCloud ๊ณ„์ • ํ™•์ธ โ†’ store coordinator์—์„œ ๊ธฐ์กด store ๋ถ„๋ฆฌ 2. SQLite ํŒŒ์ผ (.sqlite, -shm, -wal) + ckAssets ํด๋” ์‚ญ์ œ -3. ์•ฑ ์ข…๋ฃŒ (`exit(0)`) โ†’ ์žฌ์‹œ์ž‘ ์‹œ `loadPersistentStores`๊ฐ€ ๋นˆ store ์ƒ์„ฑ -4. `NSPersistentCloudKitContainer`๊ฐ€ CloudKit์—์„œ ์ „์ฒด ๋ฐ์ดํ„ฐ ์ž๋™ import +3. `recoveryInitiatedAt` ํƒ€์ž„์Šคํƒฌํ”„ ๊ธฐ๋ก (10๋ถ„ grace period) +4. ์•ฑ ์ข…๋ฃŒ (`exit(0)`) โ†’ ์žฌ์‹œ์ž‘ ์‹œ `loadPersistentStores`๊ฐ€ ๋นˆ store ์ƒ์„ฑ +5. `NSPersistentCloudKitContainer`๊ฐ€ CloudKit์—์„œ ์ „์ฒด ๋ฐ์ดํ„ฐ ์ž๋™ import + +**Recovery Grace Period (10๋ถ„)**: +- ์žฌ์‹œ์ž‘ ํ›„ CloudKit import๊ฐ€ ์ง€์—ฐ๋˜๊ฑฐ๋‚˜ ์‹คํŒจํ•ด๋„ ์•ฑ์ด ์‚ฌ์šฉ ๊ฐ€๋Šฅ ์ƒํƒœ๊ฐ€ ๋˜๋„๋ก ๋ณด์žฅ +- `getUserCollection()`์—์„œ `hasEverHadUserCollection == true`์ด๋”๋ผ๋„ grace ๊ธฐ๊ฐ„ ๋‚ด์—๋Š” UC ์‹ ๊ทœ ์ƒ์„ฑ ํ—ˆ์šฉ +- 10๋ถ„ ๊ฒฝ๊ณผ ๋˜๋Š” ์ •์ƒ import ์™„๋ฃŒ ์‹œ ํ”Œ๋ž˜๊ทธ ์ž๋™ ์ •๋ฆฌ -**๊ด€๋ จ ํŒŒ์ผ** (๋ชจ๋‘ `// TEMPORARY: Recovery` ์ฃผ์„): -- `CoreDataStorage.performCloudKitRecovery()`, `RecoveryError` -- `AppSettingReactor` โ€” `.recoverFromCloud` Action, `.setRecoveryInProgress` Mutation +**๊ด€๋ จ ํŒŒ์ผ**: +- `CoreDataStorage.performCloudKitRecovery()`, `RecoveryError`, `markRecoveryInitiated`, `isWithinRecoveryGracePeriod` +- `AppSettingReactor` โ€” `.recoverFromCloud` Action (2๋‹จ ํ™•์ธ), `.setRecoveryInProgress` Mutation - `AppSettingView` โ€” ๋ณต๊ตฌ ๋ฒ„ํŠผ + ActivityIndicator - `DashboardCoordinator.showRecoveryResultAlert()` -- `Localizable.strings` (ko/en) โ€” ๋ณต๊ตฌ ๊ด€๋ จ ๋ฌธ์ž์—ด 6๊ฐœ +- `Localizable.strings` (ko/en) โ€” ๋ณต๊ตฌ ๊ด€๋ จ ๋ฌธ์ž์—ด + +## Manual Consolidation (์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ) + +**3.2.4๋ถ€ํ„ฐ ์ž๋™ consolidation ์ œ๊ฑฐ๋จ** โ€” ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์˜๋„์น˜ ์•Š๊ฒŒ ์‚ญ์ œ๋˜๋Š” ๋ฒ„๊ทธ๋กœ ์ธํ•ด, +์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •์—์„œ "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ" ๋ฒ„ํŠผ์„ ์ง์ ‘ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ ์‹คํ–‰. + +**์ œ๊ฑฐ๋œ ์ž๋™ ํ˜ธ์ถœ๋ถ€**: +- ~~`CoreDataStorage.handleCloudKitEvent()` Import ์™„๋ฃŒ ํ›„ 5์ดˆ ์ง€์—ฐ~~ +- ~~`SceneDelegate.setupApp()` ์•ฑ ์‹œ์ž‘ ์‹œ~~ + +**์ˆ˜๋™ ํ˜ธ์ถœ**: +- `CoreDataStorage.consolidateUserCollectionsManually(completion:)` โ€” ์„ค์ • ๋ฒ„ํŠผ์—์„œ๋งŒ ํ˜ธ์ถœ +- `consolidateUserCollections()` (๊ธฐ์กด ํ•จ์ˆ˜)๋Š” ์œ ์ง€ํ•˜๋˜ ์ž๋™ ํ˜ธ์ถœ์ฒ˜ ์—†์Œ + +**์‚ญ์ œ ์‹œ ๋กœ๊น…**: +- `cleanupOrphanedEntities` ๋‚ด๋ถ€ ์‚ญ์ œ ์ง์ „ `os_log(.error)`๋กœ ๋Œ€์ƒ ์ˆ˜๋Ÿ‰ ๊ธฐ๋ก (Release์—์„œ๋„ ์ถ”์  ๊ฐ€๋Šฅ) +- Console.app์—์„œ `"๐Ÿ”ง Orphan cleanup"` ํ•„ํ„ฐ๋กœ ์‹ค์ œ ์‚ญ์ œ ์ด๋ ฅ ๊ฐ์‚ฌ ๊ฐ€๋Šฅ ## Sync Status Display diff --git a/docs/iCloud-data-loss-fix-summary.md b/docs/iCloud-data-loss-fix-summary.md new file mode 100644 index 0000000..73c8f47 --- /dev/null +++ b/docs/iCloud-data-loss-fix-summary.md @@ -0,0 +1,200 @@ +# iCloud ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ๋ฒ„๊ทธ ์ˆ˜์ • ์ •๋ฆฌ (๋„ˆ๊ตดํฌํ„ธ+ 3.2.4) + +> ์ด ๋ฌธ์„œ๋Š” "์‚ฌ์šฉ์ž ์ˆ˜์ง‘ ๊ธฐ๋ก์ด ์–ด๋А ๋‚  ๊ฐ‘์ž๊ธฐ ์‚ฌ๋ผ์ง„๋‹ค"๋Š” ๋ฒ„๊ทธ๋ฅผ ์™œ, ์–ด๋–ป๊ฒŒ ๊ณ ์ณค๋Š”์ง€๋ฅผ +> ๊ธฐ์ˆ  ์šฉ์–ด ์—†์ด ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค. +> +> ๋Œ€์ƒ ๋…์ž: ๊ธฐํšยท๋””์ž์ธยทCS ๋‹ด๋‹น์ž, ํŒ€ ์™ธ๋ถ€ ๊ฒ€ํ† ์ž + +--- + +## ๐Ÿ› ์–ด๋–ค ๋ฒ„๊ทธ๊ฐ€ ์žˆ์—ˆ๋‚˜? + +์‚ฌ์šฉ์ž๋“ค๋กœ๋ถ€ํ„ฐ ๊พธ์ค€ํžˆ ๊ฐ™์€ ์ œ๋ณด๊ฐ€ ๋“ค์–ด์™”์Šต๋‹ˆ๋‹ค. + +> "์–ด์ œ๊นŒ์ง€ ๋ฉ€์ฉกํ•˜๋˜ ์ˆ˜์ง‘ ๊ธฐ๋ก์ด ์˜ค๋Š˜ ์•ฑ์„ ์ผฐ๋”๋‹ˆ ์ „๋ถ€ ์‚ฌ๋ผ์ ธ ์žˆ์–ด์š”." + +์ด ํ˜„์ƒ์˜ ํ•ต์‹ฌ์€ **iCloud ๋™๊ธฐํ™”๊ฐ€ ์ž๋™์œผ๋กœ ๋™์ž‘ํ•˜๋Š” ๊ตฌ์กฐ**์˜€์Šต๋‹ˆ๋‹ค. + +``` +ํ‰์ƒ์‹œ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” ์ž๋™ ์—…๋กœ๋“œ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๋‚ด ํฐ โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ†’ โ”‚ iCloud โ”‚ +โ”‚ (822๊ฐœ) โ”‚ โ”‚ (๋ฐฑ์—…) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๋‚ด ํฐ โ”‚ โ†โ”€ ์ž๋™ ๋ณต์› โ”€โ”€ โ”‚ iCloud โ”‚ +โ”‚ (0๊ฐœ) โ”‚ (์ผ๋ถ€๋งŒ ๋„์ฐฉ) โ”‚ (822๊ฐœ) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†‘ + ์‚ฌ์šฉ์ž๊ฐ€ ๋†€๋žŒ +``` + +--- + +## ๐Ÿ”Ž ์™œ ์ด ์ผ์ด ์ผ์–ด๋‚˜๋‚˜? + +iOS์—๋Š” **"CloudKit ๋™๊ธฐํ™” ํ† ํฐ์ด ๋งŒ๋ฃŒ๋๋‹ค"** ๊ณ  ํŒ๋‹จ๋˜๋ฉด **์šฐ๋ฆฌ ์•ฑ์ด ๊ฐœ์ž…ํ•  ์ˆ˜ ์—†๋Š” ์˜์—ญ์—์„œ** +๋กœ์ปฌ ๊ธฐ๋ก์„ ํ•œ ๋ฒˆ ์‹น ์ง€์šฐ๊ณ  iCloud์—์„œ ๋‹ค์‹œ ๊ฐ€์ ธ์˜ค๋Š” ๋กœ์ง์ด ์žˆ์Šต๋‹ˆ๋‹ค. + +- "์‹น ์ง€์šด๋‹ค"๊นŒ์ง€๋Š” iOS๊ฐ€ ์•Œ์•„์„œ ํ•จ โ†’ โœ… ์•ฑ์— ์ฑ…์ž„ ์—†์Œ +- "iCloud์—์„œ ๋‹ค์‹œ ๊ฐ€์ ธ์˜จ๋‹ค"๊ฐ€ **๋„คํŠธ์›Œํฌ/ํƒ€์ด๋ฐ ๋ฌธ์ œ๋กœ ๋ถ€๋ถ„ ์‹คํŒจ** โ†’ โŒ ๊ฒฐ๊ณผ: ๊ธฐ๊ธฐ์—” 0๊ฐœ, iCloud์—” 822๊ฐœ + +์—ฌ๊ธฐ์— ์šฐ๋ฆฌ ์•ฑ ์ฝ”๋“œ์˜ **์ถ”๊ฐ€ ์š”์ธ**๋„ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. + +- iCloud ๋™๊ธฐํ™” ์ค‘ ์ผ์‹œ์ ์œผ๋กœ "์ฃผ์ธ์ด ์—†๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ด๋Š” ๋ฐ์ดํ„ฐ"๊ฐ€ ์ƒ๊ธธ ๋•Œ, + ์•ฑ์ด **์ด๊ฑธ ์ž๋™์œผ๋กœ ์ •๋ฆฌ(์‚ญ์ œ)** ํ•˜๋Š” ๋กœ์ง์ด ์žˆ์—ˆ์Œ +- ๋™๊ธฐํ™”๊ฐ€ ์™„์ „ํžˆ ๋๋‚˜๊ธฐ ์ „์— ์ด ์ž๋™ ์ •๋ฆฌ๊ฐ€ ๋Œ๋ฉด **๋ฉ€์ฉกํ•œ ๋ฐ์ดํ„ฐ๊นŒ์ง€ ๊ฐ™์ด ์ง€์›Œ์ง€๋Š” ์ผ€์ด์Šค** ๋ฐœ์ƒ + +์ฆ‰, **iOS๊ฐ€ ์ง€์šฐ๋Š” ๊ฒฝ๋กœ + ์•ฑ์ด ์ง€์šฐ๋Š” ๊ฒฝ๋กœ** ๋‘ ๊ฐ€์ง€๊ฐ€ ๋™์‹œ์— ์กด์žฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +--- + +## โœ… ์ด๋ฒˆ์— ๋ฌด์—‡์„ ๊ณ ์ณค๋‚˜? + +### 3๊ฒน์˜ ์•ˆ์ „์žฅ์น˜๋กœ ์ ‘๊ทผํ–ˆ์Šต๋‹ˆ๋‹ค + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 1๏ธโƒฃ ์•ฑ์ด ์‚ฌ์šฉ์ž ํ—ˆ๋ฝ ์—†์ด ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฅผ ์ง€์šฐ์ง€ ์•Š์Œ โ”‚ โ† Stage 1 +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 2๏ธโƒฃ ๋กœ์ปฌ์— ๋ณ„๋„ "์•ˆ์ „ ๋ฐฑ์—… ํŒŒ์ผ"์„ ์œ ์ง€ํ•ด์„œ โ”‚ +โ”‚ iOS๊ฐ€ ๋กœ์ปฌ์„ ์ง€์›Œ๋„ ๋˜์‚ด๋ฆด ์ˆ˜ ์žˆ๊ฒŒ ํ•จ โ”‚ โ† Stage 1.5 +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 3๏ธโƒฃ ๋ฌธ์ œ ๊ฐ์ง€ ์‹œ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณต์›ํ• ์ง€ ๋ฌผ์–ด๋ด„ โ”‚ โ† Stage 1.5 +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +--- + +### 1๏ธโƒฃ ์ž๋™ ์‚ญ์ œ ๊ฒฝ๋กœ ๋ชจ๋‘ ์ œ๊ฑฐ + +- iCloud ๋™๊ธฐํ™”๊ฐ€ ๋๋‚  ๋•Œ๋งˆ๋‹ค ์ž๋™์œผ๋กœ ๋Œ๋˜ "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ"๋ฅผ **์™„์ „ํžˆ ๊ป์Šต๋‹ˆ๋‹ค.** +- ์ด์ œ ์ด ์ •๋ฆฌ ์ž‘์—…์€ **์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ์„ค์ •์—์„œ ๋ฒ„ํŠผ์„ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ** ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. +- ๋ฐ์ดํ„ฐ๊ฐ€ ์ง€์›Œ์งˆ ๋•Œ **๋กœ๊ทธ๊ฐ€ ํ•ญ์ƒ ๋‚จ๋„๋ก** ๊ฐœ์„ ํ•ด์„œ, CS ์ œ๋ณด๊ฐ€ ๋“ค์–ด์™”์„ ๋•Œ ์›์ธ ์ถ”์ ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. + +### 2๏ธโƒฃ ๋กœ์ปฌ ์•ˆ์ „ ์Šค๋ƒ…์ƒท (์™„์ „ํžˆ ์ƒˆ๋กœ ์ถ”๊ฐ€) + +์•ฑ์ด ๋ฐ์ดํ„ฐ๋ฅผ ์ €์žฅํ•  ๋•Œ๋งˆ๋‹ค, **์•ฑ ๋‚ด๋ถ€ ์ €์žฅ์†Œ์˜ ๋ณ„๋„ ํŒŒ์ผ**์— ์‚ฌ์šฉ์ž ์ˆ˜์ง‘ ๊ธฐ๋ก ์ „์ฒด์˜ ์‚ฌ๋ณธ์„ ๋– ๋‘ก๋‹ˆ๋‹ค. + +- ์ด ํŒŒ์ผ์€ **iCloud์™€ ์•„๋ฌด ๊ด€๊ณ„๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.** iOS๊ฐ€ CloudKit ์ €์žฅ์†Œ๋ฅผ ์ง€์›Œ๋„ ์ด ํŒŒ์ผ์€ ๋‚จ์Šต๋‹ˆ๋‹ค. +- 30์ดˆ ๋‹จ์œ„๋กœ ์ž๋™ ๊ฐฑ์‹  (์—ฐ์† ์ˆ˜์ง‘ ์‹œ ํ•œ ๋ฒˆ์— ๋ฌถ์–ด ์ €์žฅ) +- ์•ฑ์„ ๋ฐฑ๊ทธ๋ผ์šด๋“œ๋กœ ๋ณด๋‚ด๊ธฐ ์ง์ „์—๋„ ํ•œ ๋ฒˆ ๋” ์ €์žฅ + +``` +๊ธฐ๊ธฐ ๋‚ด๋ถ€: +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Core Data โ”‚ โ”€ ์ €์žฅโ†’ โ”‚ local_safety_ โ”‚ +โ”‚ (์•ฑ ๋ณธ์ฒด ์ €์žฅ์†Œ) โ”‚ โ”‚ snapshot.plist โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ (์•ˆ์ „ ์‚ฌ๋ณธ) โ”‚ + โ†‘ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ””โ”€ iOS๊ฐ€ ์ด๊ฑธ ์ง€์šธ ์ˆ˜ ์žˆ์Œ โ†‘ + โ””โ”€ iOS๊ฐ€ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์Œ +``` + +### 3๏ธโƒฃ ์ž๋™ ๋ณต์› ์•ˆ๋‚ด + ์ˆ˜๋™ ๋ณต์› ๋ฒ„ํŠผ + +์•ฑ์„ ์‹œ์ž‘ํ•  ๋•Œ "์›๋ž˜ ๊ธฐ๋ก์ด ์žˆ์—ˆ๋˜ ์‚ฌ์šฉ์ž์ธ๋ฐ ์ง€๊ธˆ์€ ๋น„์–ด์žˆ๋‹ค"๋Š” ์‹ ํ˜ธ๊ฐ€ ๊ฐ์ง€๋˜๋ฉด +**์ž๋™์œผ๋กœ ํŒ์—…**์ด ๋œน๋‹ˆ๋‹ค. + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐Ÿ›Ÿ ๋กœ์ปฌ ๋ฐฑ์—…์ด ๊ฐ์ง€๋์–ด์š” โ”‚ +โ”‚ โ”‚ +โ”‚ CloudKit ๋™๊ธฐํ™”๋กœ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์‚ฌ๋ผ์ง„ โ”‚ +โ”‚ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. 5๋ถ„ ์ „์— ์ €์žฅ๋œ ๋ฐฑ์—…(ํ•ญ๋ชฉ โ”‚ +โ”‚ 822๊ฐœ)์—์„œ ๋ณต์›ํ•˜์‹œ๊ฒ ์–ด์š”? โ”‚ +โ”‚ โ”‚ +โ”‚ [iCloud ๊ธฐ๋‹ค๋ฆฌ๊ธฐ] [๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์›] โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +๋˜ํ•œ ์„ค์ • ํ™”๋ฉด์—๋„ **์ƒˆ ๋ฉ”๋‰ด 3๊ฐœ**๊ฐ€ ์ถ”๊ฐ€๋์Šต๋‹ˆ๋‹ค. + +| ๋ฉ”๋‰ด | ์–ธ์ œ ์“ฐ๋Š”์ง€ | +|------|--------| +| **๋งˆ์ง€๋ง‰ ๋กœ์ปฌ ๋ฐฑ์—…** | "5๋ถ„ ์ „ ยท ํ•ญ๋ชฉ 822๊ฐœ" ํ˜•์‹์œผ๋กœ ๊ฐ€์žฅ ์ตœ๊ทผ ๋ฐฑ์—…์ด ์–ธ์ œ ๋–ด๋Š”์ง€ ํ‘œ์‹œ | +| **๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์›** | ๋ฌธ์ œ ๊ฐ์ง€ ํŒ์—…์„ ๋†“์ณค๊ฑฐ๋‚˜, ๊ทธ๋ƒฅ ์ด์ „ ๋ฐฑ์—…์œผ๋กœ ๋Œ๋ฆฌ๊ณ  ์‹ถ์„ ๋•Œ | +| **์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ** | ๋™๊ธฐํ™”๊ฐ€ ์ด์ƒํ•˜๋‹ค๊ณ  ๋А๋‚„ ๋•Œ๋งŒ ์‚ฌ์šฉ (์˜ˆ์ „์—๋Š” ์ž๋™์œผ๋กœ ๋Œ๋˜ ๊ฒƒ) | +| **iCloud์—์„œ ๋ณต์› (๋กœ์ปฌ ๋ฐ์ดํ„ฐ ๋ฎ์–ด์”€)** | ๊ธฐ์กด ๊ธฐ๋Šฅ. ์ด์ œ **2๋‹จ ํ™•์ธ** ํ•„์ˆ˜ + "ํŒŒ๊ดด์  ๋™์ž‘" ๋ฌธ๊ตฌ ๋ช…์‹œ | + +--- + +## ๐Ÿ›ก๏ธ ์‚ฌ์šฉ์ž ๋ณดํ˜ธ ๊ฐ•ํ™” ํฌ์ธํŠธ + +### "์˜๊ตฌ์ ์œผ๋กœ ์•ฑ์ด ์ž ๊ธฐ๋Š” ๋ฌธ์ œ" ๋ฐฉ์ง€ + +๊ธฐ์กด์—๋Š” "iCloud์—์„œ ๋ณต์›"์„ ๋ˆ„๋ฅด๊ณ  ์•ฑ ์žฌ์‹œ์ž‘ ํ›„, iCloud ๋„คํŠธ์›Œํฌ๊ฐ€ ๋А๋ฆฌ๋ฉด +**์•ฑ์ด ์•„๋ฌด ๋ฐ์ดํ„ฐ๋„ ๋ชป ๋ณด์—ฌ์ฃผ๊ณ  ๋ฌดํ•œ ๋Œ€๊ธฐ**ํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. + +์ด์ œ๋Š” ๋ณต์› ์‹œ์ž‘ ํ›„ **10๋ถ„ ๋™์•ˆ ์œ ์˜ˆ ์‹œ๊ฐ„**์„ ๋‘์–ด, +iCloud ๋ณต๊ตฌ๊ฐ€ ๋Šฆ์–ด์ง€๋”๋ผ๋„ ์•ฑ์ด ์ •์ƒ์ ์œผ๋กœ ์—ด๋ฆฌ๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค. + +### "์‹ค์ˆ˜ ๋ฐฉ์ง€"๋ฅผ ์œ„ํ•œ 2๋‹จ ํ™•์ธ + +"iCloud์—์„œ ๋ณต์›"์€ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ๋ฅผ **์™„์ „ํžˆ ๋ฎ์–ด์“ฐ๋Š” ํŒŒ๊ดด์  ์ž‘์—…**์ด๋ฏ€๋กœ: + +``` +1๋‹จ๊ณ„ ํ™•์ธ: โš ๏ธ ํ˜„์žฌ ๊ธฐ๊ธฐ์˜ ๋ชจ๋“  ์ˆ˜์ง‘ ๊ธฐ๋ก์ด ์‚ญ์ œ๋˜๊ณ  + iCloud ๋ฐฑ์—…์œผ๋กœ ๊ต์ฒด๋ฉ๋‹ˆ๋‹ค. ์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + โ†“ +2๋‹จ๊ณ„ ํ™•์ธ: ๋งˆ์ง€๋ง‰ ํ™•์ธ์ž…๋‹ˆ๋‹ค. iCloud์— ์ตœ์‹  ๋ฐฑ์—…์ด ์žˆ๋Š”์ง€ + ๋ฐ˜๋“œ์‹œ ํ™•์ธํ•˜์„ธ์š”. + โ†“ + ์‹ค์ œ ๋ณต์› ์‹คํ–‰ +``` + +--- + +## ๐Ÿ“Š ์ด๋ฒˆ ์ˆ˜์ •์œผ๋กœ ์ปค๋ฒ„๋˜๋Š” ์‹œ๋‚˜๋ฆฌ์˜ค + +| ์ƒํ™ฉ | ์ด์ „ ๋ฒ„์ „ | ์ด๋ฒˆ ์ˆ˜์ • ํ›„ | +|------|-----------|--------------| +| iOS๊ฐ€ ๋กœ์ปฌ์„ ์ง€์› ๋Š”๋ฐ iCloud ๋ณต๊ตฌ ์„ฑ๊ณต | โœ… ๋ณต๊ตฌ๋จ | โœ… ๋ณต๊ตฌ๋จ (๋™์ผ) | +| iOS๊ฐ€ ๋กœ์ปฌ์„ ์ง€์› ๋Š”๋ฐ iCloud ๋ณต๊ตฌ ์‹คํŒจ | โŒ **๋ฐ์ดํ„ฐ ์œ ์‹ค** | โœ… ๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์› ๊ฐ€๋Šฅ | +| iCloud ๋™๊ธฐํ™” ๋„์ค‘ ์šฐ๋ฆฌ ์•ฑ์ด ์‹ค์ˆ˜๋กœ ์‚ญ์ œ | โŒ **๋ฐ์ดํ„ฐ ์œ ์‹ค** | โœ… ์‚ญ์ œ ๊ฒฝ๋กœ๊ฐ€ ๋ง‰ํ˜”์Œ | +| ์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ๋ณต์› ๋ฒ„ํŠผ ๋ˆ„๋ฆ„ | โš ๏ธ 1๋‹จ ํ™•์ธ | โœ… 2๋‹จ ํ™•์ธ + ๊ฒฝ๊ณ  ๋ฌธ๊ตฌ | +| ๋ณต์› ํ›„ ์žฌ์‹œ์ž‘ ์‹œ iCloud ๋А๋ฆผ | โŒ ์•ฑ ์ž ๊น€ | โœ… 10๋ถ„ ์œ ์˜ˆ ํ›„ ์ •์ƒ ๋™์ž‘ | + +--- + +## ๐Ÿšง ์•„์ง ๋‚จ์€ ๊ฒƒ (๋‹ค์Œ ๋‹จ๊ณ„ / Stage 2) + +์ด๋ฒˆ ์ˆ˜์ •์€ **"์œ ์‹ค ์‚ฌ๊ณ ๊ฐ€ ์ผ์–ด๋‚ฌ์„ ๋•Œ ๋˜์‚ด๋ฆด ์ˆ˜ ์žˆ๊ฒŒ"** ํ•˜๋Š” ๋ฐ ์ดˆ์ ์„ ๋งž์ท„์Šต๋‹ˆ๋‹ค. + +๊ทผ๋ณธ์ ์œผ๋กœ๋Š” **"iOS๊ฐ€ ๋กœ์ปฌ์„ ์ง€์šธ ์ˆ˜ ์žˆ๋Š” ๊ตฌ์กฐ ์ž์ฒด๋ฅผ ์—†์• ๋Š” ๊ฒƒ"** ์ด ์ •๋‹ต์ด๊ณ , +์ด๊ฑธ ์œ„ํ•ด ์•ฑ์˜ ์ €์žฅ์†Œ ๊ตฌ์กฐ๋ฅผ ์ƒˆ๋กœ ์„ค๊ณ„ํ•˜๋Š” ๊ณ„ํš์ด **Stage 2**๋กœ ๋ณ„๋„ ์ค€๋น„ ์ค‘์ž…๋‹ˆ๋‹ค. +(๊ฐœ๋ฐœ์ž์šฉ ์ƒ์„ธ ์„ค๊ณ„ ๋ฌธ์„œ: `docs/plans/local-backup-split.md`) + +Stage 2๋Š” ํ…Œ์ŠคํŠธ/QA ๋ฆฌ์Šคํฌ๊ฐ€ ํฌ๊ธฐ ๋•Œ๋ฌธ์— **์ด๋ฒˆ 3.2.4 ๋ฆด๋ฆฌ์Šค์—๋Š” ํฌํ•จ๋˜์ง€ ์•Š๊ณ **, +๋‹ค์Œ ๋ฒ„์ „(3.3.x ์ดํ›„)์—์„œ beta ํ…Œ์ŠคํŠธ๋ฅผ ๊ฑฐ์ณ ๋‹จ๊ณ„์ ์œผ๋กœ ๋ฐฐํฌ๋  ์˜ˆ์ •์ž…๋‹ˆ๋‹ค. + +--- + +## ๐Ÿ“ฆ ์ด๋ฒˆ ๋ฆด๋ฆฌ์Šค ์ •๋ณด + +- **๋ธŒ๋žœ์น˜**: `fix/icloud-auto-restore-data-loss` +- **๊ธฐ๋ฐ˜ ๋ฒ„์ „**: `release/3.2.3` +- **๋Œ€์ƒ ๋ฒ„์ „**: `3.2.4` ์˜ˆ์ • +- **์ปค๋ฐ‹**: `๐Ÿ› [fix] iCloud ์ž๋™ ๋ณต์›์œผ๋กœ ์ธํ•œ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ๋ฐฉ์ง€` +- **๋ณ€๊ฒฝ ๊ทœ๋ชจ**: 11๊ฐœ ํŒŒ์ผ, +1072 / -59์ค„ + +## ๐Ÿงช ๋ฐฐํฌ ์ „ ์ฒดํฌ๋ฆฌ์ŠคํŠธ (QA ์ฐธ๊ณ ) + +- [ ] ์ˆ˜์ง‘ ๊ธฐ๋ก์ด ์žˆ๋Š” ๊ธฐ๊ธฐ์—์„œ ์•ฑ ์ข…๋ฃŒ ํ›„ ์žฌ์‹คํ–‰ ์‹œ "๋งˆ์ง€๋ง‰ ๋กœ์ปฌ ๋ฐฑ์—…"์ด ํ‘œ์‹œ๋˜๋Š”๊ฐ€ +- [ ] ์„ค์ • โ†’ "๋กœ์ปฌ ๋ฐฑ์—…์—์„œ ๋ณต์›" ๋ˆ„๋ฅด๋ฉด 2๋‹จ ํ™•์ธ ํ›„ ์‹ค์ œ ๋ณต์›๋˜๋Š”๊ฐ€ +- [ ] ์„ค์ • โ†’ "iCloud์—์„œ ๋ณต์›" 2๋‹จ ํ™•์ธ ํŒ์—… ๋ฌธ๊ตฌ๊ฐ€ ์ž์—ฐ์Šค๋Ÿฌ์šด๊ฐ€ +- [ ] "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ" ๋ฒ„ํŠผ์ด ์‚ฌ์šฉ์ž ํ—ˆ๋ฝ ์—†์ด๋Š” ์‹คํ–‰๋˜์ง€ ์•Š๋Š”๊ฐ€ +- [ ] ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์ „ํ™˜ ์‹œ ์Šค๋ƒ…์ƒท ๊ฐฑ์‹  ๋กœ๊ทธ(`๐Ÿ›Ÿ SafetySnapshot written`)๊ฐ€ ์ฐํžˆ๋Š”๊ฐ€ +- [ ] ko/en ์–‘์ชฝ ์–ธ์–ด์—์„œ ์ƒˆ ๋ฌธ์ž์—ด์ด ๋ชจ๋‘ ๋ฒˆ์—ญ๋ผ ์žˆ๋Š”๊ฐ€ + +--- + +## ๐Ÿ“š ๊ธฐ์ˆ  ๋ฌธ์„œ (๊ฐœ๋ฐœ์ž์šฉ) + +| ๋ฌธ์„œ | ๋‚ด์šฉ | +|------|------| +| [docs/features/icloud-sync.md](features/icloud-sync.md) | iCloud ๋™๊ธฐํ™” ์ „์ฒด ๋™์ž‘ ์ƒ์„ธ (์ด๋ฒˆ ์ˆ˜์ • ๋ฐ˜์˜๋จ) | +| [docs/plans/local-backup-split.md](plans/local-backup-split.md) | Stage 2 ์•„ํ‚คํ…์ฒ˜ ์„ค๊ณ„์•ˆ (2-container ๋ถ„๋ฆฌ) | +| [docs/gotchas.md](gotchas.md) | ํ”„๋กœ์ ํŠธ ์ „๋ฐ˜์˜ ํ•จ์ • ๋ชฉ๋ก | diff --git a/docs/plans/local-backup-split.md b/docs/plans/local-backup-split.md new file mode 100644 index 0000000..6954006 --- /dev/null +++ b/docs/plans/local-backup-split.md @@ -0,0 +1,188 @@ +# Stage 2 ์„ค๊ณ„์•ˆ: Local/Backup Store ๋ถ„๋ฆฌ ์•„ํ‚คํ…์ฒ˜ + +> **์ƒํƒœ**: ์„ค๊ณ„ ๋ฆฌ๋ทฐ ์ค‘ (๊ตฌํ˜„ ์ „ ํŒ€ ๋ฆฌ๋ทฐ ํ•„์ˆ˜) +> **๋ชฉํ‘œ**: iCloud ์ž๋™ ๋ณต์›์œผ๋กœ ์ธํ•œ ๋กœ์ปฌ ๋ฐ์ดํ„ฐ ์†์‹ค ๋ฒ„๊ทธ๋ฅผ ๊ตฌ์กฐ์ ์œผ๋กœ ์ œ๊ฑฐ +> **์ „์ œ**: Stage 1 ํ•ซํ”ฝ์Šค(`3.2.4`)๋กœ ์ž๋™ ์‚ญ์ œ ๊ฒฝ๋กœ๋Š” ์ด๋ฏธ ์ฐจ๋‹จ๋œ ์ƒํƒœ + +## 1. ๋ฐฐ๊ฒฝ + +ํ˜„์žฌ ์•ฑ์€ ๋‹จ์ผ `NSPersistentCloudKitContainer`์—์„œ ๋กœ์ปฌ CoreData์™€ CloudKit ๋ฏธ๋Ÿฌ๊ฐ€ ํ•œ ๋ชธ์ด๋‹ค. +CloudKit Import๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๋กœ์ปฌ ๊ฐ์ฒด ๊ทธ๋ž˜ํ”„๊ฐ€ ์›๊ฒฉ ์ƒํƒœ๋กœ merge๋˜๋ฉฐ, relationship ํ•ด์†Œ ๊ณผ์ •์—์„œ +orphan์œผ๋กœ ๋ณด์ด๋Š” child entity๊ฐ€ ์ž๋™ cleanup๋˜์–ด ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๊ฐ€ ์†์‹ค๋˜๋Š” ์‚ฌ๋ก€๊ฐ€ ๋ฐ˜๋ณต ๋ณด๊ณ ๋จ. + +Stage 1์—์„œ ์ž๋™ ์‚ญ์ œ ๊ฒฝ๋กœ๋ฅผ ๋ชจ๋‘ ๋Š์—ˆ์ง€๋งŒ, **์ž๋™ Import ์ž์ฒด๊ฐ€ ๋กœ์ปฌ์„ touchํ•˜๋Š” ๊ตฌ์กฐ**๋Š” ์—ฌ์ „ํžˆ +์ž ์žฌ ์œ„ํ—˜. Stage 2๋Š” ์ด ๊ทผ๋ณธ์„ ๋ถ„๋ฆฌํ•œ๋‹ค. + +## 2. ๋ชฉํ‘œ ์•„ํ‚คํ…์ฒ˜ + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ LocalStore โ”‚ โ”‚ BackupStore โ”‚ +โ”‚ NSPersistentContainer โ”‚ โ”‚ NSPersistentCloudKitContainer โ”‚ +โ”‚ (CloudKit ๋ฏธ๋Ÿฌ ์—†์Œ) โ”‚ โ”‚ (CloudKit ๋ฏธ๋Ÿฌ๋จ) โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ ํ˜„ํ–‰ ๋ชจ๋ธ ๊ทธ๋Œ€๋กœ: โ”‚โ”€serializeโ†’โ”‚ ๋‹จ์ผ Entity: โ”‚ +โ”‚ UserCollection, Item, โ”‚ โ”‚ BackupSnapshotEntity { โ”‚ +โ”‚ DailyTask, Villager, โ€ฆ โ”‚โ†deserial.โ”€โ”‚ deviceId: String, โ”‚ +โ”‚ โ”‚ โ”‚ version: Int, โ”‚ +โ”‚ ์•ฑ์˜ ๋ชจ๋“  read/write ๋Œ€์ƒ โ”‚ โ”‚ createdAt: Date, โ”‚ +โ”‚ โ”‚ โ”‚ payload: Data (JSON) โ”‚ +โ”‚ โ”‚ โ”‚ } โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### ํ•ต์‹ฌ ์›์น™ +1. **LocalStore๋Š” CloudKit์„ ๋ชจ๋ฅธ๋‹ค** โ€” mirroring์ด ์—†์œผ๋ฏ€๋กœ ์ž๋™ Import ์ž์ฒด๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Œ +2. **BackupStore๋Š” "์Šค๋ƒ…์ƒท ์ €์žฅ์†Œ"** โ€” ์•ฑ ๋ฐ์ดํ„ฐ์˜ ์ง๋ ฌํ™”๋œ ์ตœ์‹  ์ƒํƒœ 1ํ–‰(per device) ์œ ์ง€ +3. **๋‘ ๊ฐœ์˜ ๋ณ„๋„ Container** โ€” 1-container multi-description ๋ฐฉ์‹์€ CKMD_* ๋ฉ”ํƒ€ ์ถฉ๋Œ ๋ฆฌ์Šคํฌ๋กœ ์ œ์™ธ +4. **Restore๋Š” ํ•ญ์ƒ ์ˆ˜๋™** โ€” ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ • ๋ฒ„ํŠผ์„ ๋ˆŒ๋ €์„ ๋•Œ๋งŒ BackupStore โ†’ LocalStore ์ ์šฉ + +## 3. ๋ฐ์ดํ„ฐ ํ”Œ๋กœ์šฐ + +### ์ž๋™ ๋ฐฑ์—… (Export) +``` +User edits data โ†’ LocalStore.save() + โ†“ +NSManagedObjectContextDidSave ๊ด€์ฐฐ (debounce 10s) + โ†“ +BackupService.createSnapshot() + - LocalStore์—์„œ ๋ชจ๋“  ์—”ํ‹ฐํ‹ฐ fetch + - Codable ๊ตฌ์กฐ์ฒด๋กœ ๋ณ€ํ™˜ โ†’ JSONEncoder + - payload: Data + โ†“ +BackupStore upsert: + - fetch BackupSnapshotEntity where deviceId == self + - ์žˆ์œผ๋ฉด payload/version/createdAt ๊ฐฑ์‹ , ์—†์œผ๋ฉด insert + - save() + โ†“ +NSPersistentCloudKitContainer ์ž๋™ Export โ†’ CloudKit ๋ฐ˜์˜ +``` + +### ์ˆ˜๋™ ๋ณต์› (Restore) +``` +User taps "iCloud์—์„œ ๋ณต์›" (2๋‹จ ํ™•์ธ) + โ†“ +RestoreService.restore() + - BackupStore์—์„œ ์ตœ์‹  BackupSnapshotEntity fetch + (์—ฌ๋Ÿฌ device์˜ snapshot ์ค‘ createdAt ์ตœ์‹ ) + - payload Data โ†’ JSONDecoder โ†’ Codable ๊ตฌ์กฐ์ฒด + โ†“ +LocalStore ์™„์ „ ๊ต์ฒด: + - ๊ธฐ์กด UC/์ž์‹ ์ „๋ถ€ delete + - Codable ๊ตฌ์กฐ์ฒด ๊ธฐ๋ฐ˜์œผ๋กœ ์žฌ๊ตฌ์„ฑ (์ƒˆ ObjectID) + - save() + โ†“ +Items.shared.setUpUserCollection() โ†’ UI ๊ฐฑ์‹  +``` + +## 4. ๋ฐ์ดํ„ฐ ๋ชจ๋ธ + +### LocalStore +- ํ˜„ํ–‰ `.xcdatamodeld`๋ฅผ **๊ทธ๋Œ€๋กœ ์žฌ์‚ฌ์šฉ** +- ๋ชจ๋“  description์—์„œ `cloudKitContainerOptions = nil` +- ๊ธฐ์กด sqlite ํŒŒ์ผ์€ **์žฌ์‚ฌ์šฉ ๋ถˆ๊ฐ€** (CKMD_* ์ž”์žฌ ์œ„ํ—˜) โ€” ์‹ ๊ทœ ํŒŒ์ผ์— export + +### BackupStore (์‹ ๊ทœ `Backup.xcdatamodeld`) +```swift +entity BackupSnapshotEntity { + deviceId: String // UserDefaults UUID (identifierForVendor ๊ธˆ์ง€ โ€” ๋ณผ๋ฅจ ๋งˆ์šดํŠธ ์‹œ ๋ณ€๊ฒฝ๋จ) + version: Int // ์Šคํ‚ค๋งˆ ๋ฒ„์ „ (decoder ํ˜ธํ™˜์„ฑ ํŒ์ •) + createdAt: Date // ์Šค๋ƒ…์ƒท ์ƒ์„ฑ ์‹œ๊ฐ + payload: Data // JSON ์ง๋ ฌํ™”๋œ CollectionSnapshot + deviceName: String // ์‚ฌ์šฉ์ž๊ฐ€ ๊ธฐ๊ธฐ ์‹๋ณ„์šฉ (์„ ํƒ) +} +``` + +### CollectionSnapshot (Codable ์ง๋ ฌํ™” ํ˜•์‹) +```swift +struct CollectionSnapshot: Codable { + let version: Int + let userInfo: UserInfoSnapshot + let items: [ItemSnapshot] // Transformable 14๊ฐœ ํ•„๋“œ โ†’ Codable + let dailyTasks: [DailyTaskSnapshot] + let villagersLike: [VillagerLikeSnapshot] + let villagersHouse: [VillagerHouseSnapshot] + let npcLike: [NPCLikeSnapshot] + let variants: [VariantSnapshot] +} +``` + +**ItemEntity ์ง๋ ฌํ™” ์ฃผ์˜์ **: ํ˜„์žฌ Transformable 14๊ฐœ (colors, concepts, keywords, recipe, variations, +translations ๋“ฑ)๋Š” `NSSecureUnarchiveFromData`๋กœ NSArray/NSDictionary ์ €์žฅ. Codable ๋ณ€ํ™˜ ๋ ˆ์ด์–ด๋ฅผ ์ถ”๊ฐ€ํ•˜๋˜, +**๋™์ผ ํ•„๋“œ์˜ ์›๋ณธ ํƒ€์ž…์€ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š์Œ** (LocalStore์—์„œ๋Š” ๊ธฐ์กด๋Œ€๋กœ ๋™์ž‘). + +## 5. Migration ์ „๋žต + +### ๊ธฐ์กด ์œ ์ € (3.2.x โ†’ 3.3.0) +1. ์ฒซ ์‹คํ–‰ ์‹œ `NSPersistentCloudKitContainer` ํ•œ ๋ฒˆ ๋” ๊ธฐ๋™ โ†’ CloudKit์—์„œ legacy ๋ฐ์ดํ„ฐ ๋งˆ์ง€๋ง‰ import ์‹œ๋„ (์ตœ๋Œ€ 15์ดˆ) +2. ๊ธฐ์กด sqlite ๋‚ด์šฉ์„ **์‹ ๊ทœ LocalStore ํŒŒ์ผ์— export** (CoreData์—์„œ CoreData๋กœ ๋ณต์‚ฌ โ€” ๊ด€๊ณ„ ํฌํ•จ) +3. Export ์„ฑ๊ณต ํ›„ ํ˜„์žฌ `BackupStore`์— ์ตœ์ดˆ ์Šค๋ƒ…์ƒท ์ƒ์„ฑ +4. ์„ฑ๊ณต ์‹œ `UserDefaults["migratedToLocalBackupSplit"] = true` +5. ๊ธฐ์กด `CoreDataStorage.sqlite` ํŒŒ์ผ์€ **์‚ญ์ œํ•˜์ง€ ์•Š๊ณ  ์œ ์ง€** (๋กค๋ฐฑ ๋Œ€๋น„) +6. ๋‹ค์Œ ๋ฒ„์ „์—์„œ legacy ํŒŒ์ผ cleanup + +### ์‹ ๊ทœ ์„ค์น˜ (์•ฑ ์ตœ์ดˆ ์„ค์น˜) +1. LocalStore ๋นˆ ์ƒํƒœ๋กœ ์‹œ์ž‘ +2. BackupStore์—์„œ CloudKit import 10์ดˆ ๋Œ€๊ธฐ (Splash ์žฌ์‚ฌ์šฉ) +3. Import ๋„์ฐฉ & snapshot ์กด์žฌ โ†’ "iCloud ๋ฐฑ์—… ๋ฐœ๊ฒฌ. ๋ณต์›ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?" ์ œ์•ˆ +4. ์‚ฌ์šฉ์ž ์ˆ˜๋ฝ ์‹œ RestoreService ์‹คํ–‰ +5. ๊ฑฐ์ ˆ ์‹œ ๋นˆ LocalStore๋กœ ์‹œ์ž‘ (๊ธฐ์กด ์ž๋™ ์ˆ˜์‹  ๋™์ž‘๊ณผ UX ์ฐจ์ด โ€” ์˜จ๋ณด๋”ฉ ๋ฌธ๊ตฌ ๋ช…์‹œ) + +### ์—ฌ๋Ÿฌ ๊ธฐ๊ธฐ ์šด์˜ +- ๊ฐ device๊ฐ€ ์ž๊ธฐ deviceId๋กœ ๋ณ„๋„ snapshot row ์ƒ์„ฑ +- ๋‹ค๋ฅธ ๊ธฐ๊ธฐ์˜ ๋ณ€๊ฒฝ์„ ๋ณด๋ ค๋ฉด ์ˆ˜๋™ ๋ณต์› ํ•„์š” +- **์‹ค์‹œ๊ฐ„ ๋™๊ธฐํ™” ์ƒ์‹ค์€ ์˜๋„๋œ ํŠธ๋ ˆ์ด๋“œ์˜คํ”„** โ€” ์„ค์ • ํ™”๋ฉด ๋ฌธ๊ตฌ๋ฅผ "๋งˆ์ง€๋ง‰ ๋ฐฑ์—…: HH:MM" ํ˜•ํƒœ๋กœ ๊ต์ฒด + +## 6. Items.shared ์˜ํ–ฅ + +| ๊ธฐ์กด | ์‹ ๊ทœ | +|------|------| +| `didReceiveRemoteChanges` / `didFinishCloudImport` ๊ตฌ๋… โ†’ `setUpUserCollection()` | **์ œ๊ฑฐ** (LocalStore๋Š” remote ์ด๋ฒคํŠธ ์—†์Œ) | +| `sceneDidBecomeActive`์—์„œ ์ค‘๋ณต ํ˜ธ์ถœ ์•ˆ ํ•จ | ๋™์ผ | +| `setUpUserCollection()` ์ง์ ‘ ํ˜ธ์ถœ ๊ฒฝ๋กœ | ๋™์ผ + `RestoreService` ์™„๋ฃŒ ํ›„ ๋ช…์‹œ ํ˜ธ์ถœ | + +## 7. ํ…Œ์ŠคํŠธ ์ „๋žต (ํ•„์ˆ˜ ์‹ ์„ค) + +ํ˜„์žฌ ํ”„๋กœ์ ํŠธ๋Š” CoreData/CloudKit ๊ด€๋ จ ์œ ๋‹› ํ…Œ์ŠคํŠธ๊ฐ€ **0๊ฑด**. Stage 2๋Š” ๋‹ค์Œ ์ตœ์†Œ ํ…Œ์ŠคํŠธ์™€ ํ•จ๊ป˜ ๊ฐ„๋‹ค: + +1. **SnapshotCodec**: ๋ชจ๋“  Entity โ†’ Codable round-trip (nil/๋นˆ ๋ฐฐ์—ด/Transformable ํ•„๋“œ ํฌํ•จ fixture) +2. **StoreMigrator**: legacy sqlite โ†’ LocalStore export ์„ฑ๊ณต ์ผ€์ด์Šค + ์ค‘๋‹จ ๋ณต๊ตฌ +3. **BackupService**: debounce ๋™์ž‘, ๋™์ผ deviceId upsert, ๋‹ค๋ฅธ deviceId ์ถ”๊ฐ€ insert +4. **RestoreService**: ๊ธฐ์กด LocalStore ์™„์ „ ๊ต์ฒด + UC 1๊ฐœ ๋ณด์žฅ + ๊ด€๊ณ„ ๋ณต์› +5. **ํ†ตํ•ฉ**: ๋ฐฑ์—… โ†’ ๋กœ์ปฌ wipe โ†’ ๋ณต์› โ†’ ์›๋ณธ๊ณผ ๋™์ผํ•œ์ง€ ๊ฒ€์ฆ + +## 8. ๋ฆฌ์Šคํฌ ์ •๋ฆฌ + +| ๋ฆฌ์Šคํฌ | ๊ฐ€๋Šฅ์„ฑ | ์˜ํ–ฅ | ์™„ํ™”์ฑ… | +|-------|-------|------|-------| +| Migration ์ค‘ ์•ฑ ํฌ๋ž˜์‹œ โ†’ ๋ฐ์ดํ„ฐ ๋ถ„์‹ค | ๋‚ฎ์Œ | ์น˜๋ช…์  | ์›๋ณธ sqlite ์œ ์ง€, ํ”Œ๋ž˜๊ทธ ์„ธํŒ…์€ ์„ฑ๊ณต ํ›„์—๋งŒ | +| CloudKit 1MB CKRecord ํ•œ๋„ ์ดˆ๊ณผ | ์ค‘๊ฐ„ | ๋™๊ธฐํ™” ์‹คํŒจ | Data ํ•„๋“œ โ‰ฅ1MB๋Š” ์ž๋™ CKAsset (iOS๊ฐ€ ์ฒ˜๋ฆฌ) โ€” ํ…Œ์ŠคํŠธ๋กœ ๊ฒ€์ฆ | +| ๋‹ค๊ธฐ๊ธฐ ๋™์‹œ ํŽธ์ง‘ ์‹œ last-write-wins | ์ค‘๊ฐ„ | UX ์•…ํ™” | ํ˜„์žฌ๋„ ๋‹จ์ผ snapshot์ด๋ฏ€๋กœ "์ˆ˜๋™ ๋ณต์›" ์›์น™์œผ๋กœ ๋ช…์‹œ | +| deviceId ๋ณ€๊ฒฝ (iOS ๋ณต๊ตฌ/TestFlightโ†’App Store) | ๋‚ฎ์Œ | ์ค‘๋ณต snapshot row | UserDefaults UUID ์‚ฌ์šฉ (persistent), ์ค‘๋ณต ๊ฐ์ง€ ์‹œ ์˜ค๋ž˜๋œ row ์ •๋ฆฌ | +| Transformable โ†’ Codable ๋ณ€ํ™˜ ์˜ค๋ฅ˜ | ์ค‘๊ฐ„ | ์Šค๋ƒ…์ƒท ์†์ƒ | version ํ•„๋“œ๋กœ ์Šคํ‚ค๋งˆ ํ˜ธํ™˜์„ฑ ํŒ์ •, fallback decoder | +| ์•ฑ first-launch ์‹œ CloudKit ๋Œ€๊ธฐ UX ํ›„ํ‡ด | ํ™•์‹ค | UX | ์˜จ๋ณด๋”ฉ ๋ฌธ๊ตฌ ์—…๋ฐ์ดํŠธ + "์ž๋™์œผ๋กœ ๋ณต์›ํ• ๊นŒ์š”?" ์ œ์•ˆ ํ”Œ๋กœ์šฐ | + +## 9. ๊ตฌํ˜„ ํŽ˜์ด์ฆˆ (2์ฃผ) + +- **Day 1-2**: BackupStore ๋ชจ๋ธ + `BackupSnapshotEntity` + Codable Snapshot ๊ตฌ์กฐ์ฒด +- **Day 3-4**: `SnapshotCodec` (Entity โ†” Codable), round-trip ํ…Œ์ŠคํŠธ +- **Day 5-6**: `BackupService` (debounce + upsert) + `RestoreService` +- **Day 7**: `StoreMigrator` (legacy sqlite โ†’ LocalStore export) +- **Day 8**: CloudKitContainer 2๊ฐœ๋กœ ๊ธฐ๋™, LocalStore๋Š” `NSPersistentContainer`๋กœ ๊ต์ฒด +- **Day 9**: `Items.shared` ๊ตฌ๋… ๊ฒฝ๋กœ ์ œ๊ฑฐ, UI ๋ฌธ๊ตฌ ์—…๋ฐ์ดํŠธ +- **Day 10**: 2๋‹จ ๋ณต์› UI + ์‹ ๊ทœ ์„ค์น˜ ๋ณต์› ์ œ์•ˆ UI +- **Day 11-12**: QA (๊ธฐ์กด ์œ ์ € ์—…๋ฐ์ดํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค, ์‹ ๊ทœ ์„ค์น˜, ๊ธฐ๊ธฐ ์ „ํ™˜, ์˜คํ”„๋ผ์ธ) +- **Day 13-14**: ๋‹จ๊ณ„์  ๋กค์•„์›ƒ ์ค€๋น„, ๋กค๋ฐฑ ๊ฐ€์ด๋“œ ๋ฌธ์„œํ™” + +## 10. ๊ฒฐ์ • ๋Œ€๊ธฐ ํ•ญ๋ชฉ + +- [ ] LocalStore ํŒŒ์ผ๋ช…์„ `Local.sqlite`๋กœ ์‹ ์„คํ• ์ง€, ๊ธฐ์กด `CoreDataStorage.sqlite`๋ฅผ ๊ทธ๋Œ€๋กœ ์“ฐ๋˜ CloudKit ์˜ต์…˜๋งŒ ์ œ๊ฑฐํ• ์ง€ (safer: ์‹ ์„ค) +- [ ] ์Šค๋ƒ…์ƒท ๋ฒ„์ „ ์ •์ฑ… โ€” ๋งค save๋งˆ๋‹ค version++ vs. ๋‹จ์ผ row overwrite๋งŒ +- [ ] ๊ธฐ๊ธฐ๋ณ„ snapshot ์ˆ˜ ์ƒํ•œ (์˜ˆ: 3๊ฐœ ์ดˆ๊ณผ ์‹œ ์˜ค๋ž˜๋œ ๊ฒƒ ์ •๋ฆฌ) +- [ ] ์‚ฌ์šฉ์ž๊ฐ€ "์ž๋™ ๋ฐฑ์—… ๋„๊ธฐ" ์˜ต์…˜์„ ์›ํ•  ๊ฒฝ์šฐ ์ œ๊ณตํ• ์ง€ + +## 11. ๊ด€๋ จ ๋ฌธ์„œ + +- [Stage 1 ํ•ซํ”ฝ์Šค ๋ฐ˜์˜๋œ icloud-sync.md](../features/icloud-sync.md) +- [Coordinator ํŒจํ„ด](../patterns/coordinator-pattern.md) +- [๋ฐ์ดํ„ฐ ํ๋ฆ„ (Items.shared)](../patterns/data-flow.md) From 9377abd76d2b47cb99d42db5df494ec78c7de4cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Apr 2026 09:52:42 +0000 Subject: [PATCH 2/3] chore: bump marketing version to 3.2.3 --- Animal-Crossing-Wiki/Configurations/TargetVersion.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Animal-Crossing-Wiki/Configurations/TargetVersion.xcconfig b/Animal-Crossing-Wiki/Configurations/TargetVersion.xcconfig index 9f0ddf4..477039d 100644 --- a/Animal-Crossing-Wiki/Configurations/TargetVersion.xcconfig +++ b/Animal-Crossing-Wiki/Configurations/TargetVersion.xcconfig @@ -1,2 +1,2 @@ // ๋ฐฐํฌ ๋ฒ„์ „ ์ œ์–ด์šฉ. ์›Œํฌํ”Œ๋กœ์šฐ์—์„œ TARGET_VERSION ๊ฐ’์„ ๋ฎ์–ด์”๋‹ˆ๋‹ค. -TARGET_VERSION = 3.2.2 +TARGET_VERSION = 3.2.3 From 65d8fe6fb5e30d6f68f956de7a37a09fa65dfc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EB=A6=AC?= Date: Wed, 22 Apr 2026 19:58:05 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20[feat]=20iCloud=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=B6=94=EC=A0=81=EC=9A=A9=20=EC=9B=90?= =?UTF-8?q?=EA=B2=A9=20=EB=A1=9C=EA=B7=B8=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=20(#176)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3.2.0 ์ดํ›„ "๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์—ˆ๋‹ค"๋Š” ํด๋ ˆ์ž„์„ ์›๊ฒฉ์—์„œ ์ถ”์ ํ•˜๊ธฐ ์œ„ํ•ด Firebase Crashlytics + Analytics ํ†ตํ•ฉ ๋ž˜ํผ Log ๋„์ž…. - Utility/Log.swift: 3-way fan-out ๋กœ๊น… (os_log / Crashlytics breadcrumb / Analytics) verboseยทdebugยทinfoยทwarningยทerror ๋ ˆ๋ฒจ + eventยทclick ์ง‘๊ณ„ ์ด๋ฒคํŠธ + snapshot - CoreDataStorage 6๊ฐœ ๊ฒฐ์ • ์ง€์  instrument: recovery / orphan cleanup / UC ํ†ตํ•ฉ / token expired / UC missing / UC ์ƒ์„ฑ ์–ต์ œ - logSyncDiagnostics + captureTelemetrySnapshot ํ†ตํ•ฉ (์ค‘๋ณต fetch ์ œ๊ฑฐ) - SuppressionReasonยทUCCreationPath enum์œผ๋กœ stringly-typed ํŒŒ๋ผ๋ฏธํ„ฐ ์ œ๊ฑฐ - docs/features/icloud-sync.md ์— Remote Telemetry ์„น์…˜ ์ถ”๊ฐ€ Co-authored-by: Claude Opus 4.7 (1M context) --- .../CoreDataStorage/CoreDataStorage.swift | 156 ++++++++++---- .../Projects/App/Sources/Utility/Log.swift | 204 ++++++++++++++++++ docs/features/icloud-sync.md | 66 ++++++ 3 files changed, 383 insertions(+), 43 deletions(-) create mode 100644 Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift index 1d81ba1..88da826 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift @@ -108,7 +108,7 @@ final class CoreDataStorage { /// ์˜๋„์  ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ์‹œ ํ˜ธ์ถœ โ€” ์ƒˆ UC ์ƒ์„ฑ์„ ๋‹ค์‹œ ํ—ˆ์šฉ func clearHasEverHadUserCollection() { hasEverHadUserCollection = false - os_log(.info, log: .default, "๐Ÿ›ก๏ธ hasEverHadUserCollection cleared (intentional reset)") + Log.info("hasEverHadUserCollection cleared (intentional reset)") } // MARK: - Recovery Grace Period @@ -122,7 +122,7 @@ final class CoreDataStorage { /// ๋ณต๊ตฌ ์‹œ์ž‘ ์‹œ๊ฐ ๊ธฐ๋ก โ€” ์žฌ์‹œ์ž‘ ํ›„ grace window ๊ณ„์‚ฐ์— ์‚ฌ์šฉ func markRecoveryInitiated() { UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.recoveryInitiatedAtKey) - os_log(.info, log: .default, "๐Ÿ”„ Recovery initiated timestamp recorded") + Log.info("recovery initiated timestamp recorded (10min grace started)") } /// Recovery ์™„๋ฃŒ ํ›„ UC๊ฐ€ ์ •์ƒ ๋ณต๊ตฌ๋˜๋ฉด ํ˜ธ์ถœํ•˜์—ฌ ํ”Œ๋ž˜๊ทธ ์ •๋ฆฌ @@ -236,13 +236,14 @@ final class CoreDataStorage { /// ์‹ ๊ทœ ์„ค์น˜ ์‹œ ํ˜ธ์ถœ โ€” CloudKit Import ์™„๋ฃŒ๊นŒ์ง€ ๋กœ์ปฌ UC ์ƒ์„ฑ์„ ์–ต์ œ func markWaitingForFirstImport() { isWaitingForFirstImport = true - os_log(.info, log: .default, "๐Ÿš€ Marked waiting for first CloudKit import") + Log.info("markWaitingForFirstImport (fresh install path)") + Log.setContext(Log.Key.isFreshInstall, true) } /// Import ๋Œ€๊ธฐ ํ”Œ๋ž˜๊ทธ ํ•ด์ œ โ€” setupApp() ๋˜๋Š” no-iCloud ๊ฒฝ๋กœ์—์„œ ํ˜ธ์ถœ func clearWaitingForFirstImport() { isWaitingForFirstImport = false - os_log(.info, log: .default, "๐Ÿš€ Cleared waiting for first CloudKit import") + Log.info("clearWaitingForFirstImport") } // MARK: - Persistent History Cleanup @@ -356,7 +357,8 @@ final class CoreDataStorage { @objc private func handleSyncWillReset(_ notification: Notification) { isSyncResetInProgress = true - os_log(.info, log: .default, "๐Ÿ”„ Sync reset detected (WillReset) โ€” orphan cleanup suppressed") + Log.warning("sync reset WillReset โ€” Change Token Expired, orphan cleanup suppressed") + Log.event(.tokenExpired) } @objc private func handleSyncDidReset(_ notification: Notification) { @@ -492,6 +494,12 @@ final class CoreDataStorage { object: nil, userInfo: ["reason": reason] ) + + Log.warning("cloud sync failed reason=\(reason) code=\(nsError.code)") + Log.event(.cloudSyncFailed, parameters: [ + Log.Param.reason: reason, + Log.Param.code: nsError.code + ]) } // MARK: - Account Change Observation @@ -539,49 +547,64 @@ extension CoreDataStorage { return counts } - /// CloudKit Import/Export ์ด๋ฒคํŠธ ํ›„ ๋ฐ์ดํ„ฐ ์ƒํƒœ๋ฅผ ๋กœ๊น… (5์ดˆ throttle) - func logSyncDiagnostics(phase: String) { - let shouldProceed = lastDiagnosticsDate.withLock { lastDate -> Bool in - let now = Date() - guard now.timeIntervalSince(lastDate) >= 5 else { - return false + /// CloudKit ์ด๋ฒคํŠธ ํ›„ ๋ฐ์ดํ„ฐ ์ƒํƒœ๋ฅผ os_log์— ๊ธฐ๋กํ•˜๊ณ  Crashlytics ์„ธ์…˜ ์Šค๋ƒ…์ƒท์„ ๊ฐฑ์‹ ํ•œ๋‹ค. + /// ํ•œ ๋ฒˆ์˜ background fetch๋กœ ๋‘ ์šฉ๋„๋ฅผ ๋ชจ๋‘ ์ฒ˜๋ฆฌ. + /// + /// - Parameters: + /// - phase: ํ˜ธ์ถœ ์ง€์ ์„ ์‹๋ณ„ํ•˜๋Š” ๋ผ๋ฒจ (์˜ˆ: "Import-end", "UC-missing") + /// - throttled: true๋ฉด 5์ดˆ ๋‚ด ์žฌํ˜ธ์ถœ ์‹œ skip. UC-missing์ฒ˜๋Ÿผ ์ฆ‰์‹œ ์ปจํ…์ŠคํŠธ๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ false. + func logSyncDiagnostics(phase: String, throttled: Bool = true) { + if throttled { + let shouldProceed = lastDiagnosticsDate.withLock { lastDate -> Bool in + let now = Date() + guard now.timeIntervalSince(lastDate) >= 5 else { return false } + lastDate = now + return true + } + guard shouldProceed else { + os_log(.info, log: .default, "๐Ÿ“Š [%{public}@] skipped (throttled)", phase) + return } - - lastDate = now - return true - } - - guard shouldProceed else { - os_log(.info, log: .default, "๐Ÿ“Š [%{public}@] skipped (throttled)", phase) - return } - persistentContainer.performBackgroundTask { context in + persistentContainer.performBackgroundTask { [weak self] context in + guard let self else { return } context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy let counts = self.entityCounts(in: context) + let ucCount = counts["UserCollectionEntity"] ?? -1 + let itemCount = counts["ItemEntity"] ?? -1 os_log(.info, log: .default, "๐Ÿ“Š [%{public}@] UC=%d Items=%d Tasks=%d VLike=%d VHouse=%d NPC=%d Variants=%d", phase, - counts["UserCollectionEntity"] ?? -1, - counts["ItemEntity"] ?? -1, + ucCount, itemCount, counts["DailyTaskEntity"] ?? -1, counts["VillagersLikeEntity"] ?? -1, counts["VillagersHouseEntity"] ?? -1, counts["NPCLikeEntity"] ?? -1, counts["VariantCollectionEntity"] ?? -1) + Log.snapshot(Log.Snapshot( + ucCount: ucCount, + itemCount: itemCount, + taskCount: counts["DailyTaskEntity"] ?? -1, + villagerCount: (counts["VillagersLikeEntity"] ?? 0) + (counts["VillagersHouseEntity"] ?? 0), + hasEverHadUC: self.hasEverHadUserCollection, + isFreshInstall: nil, + isWaitingForFirstImport: self.isWaitingForFirstImport, + isImportInProgress: self.isImportInProgress, + isSyncResetInProgress: self.isSyncResetInProgress, + isWithinRecoveryGracePeriod: self.isWithinRecoveryGracePeriod, + lastImportDate: self.lastSuccessfulImportDate, + lastExportDate: self.lastSuccessfulExportDate + )) + // UC๊ฐ€ 2๊ฐœ ์ด์ƒ์ผ ๋•Œ๋งŒ ์ƒ์„ธ ์ง„๋‹จ (์ค‘๋ณต ํƒ์ง€) - let ucCount = counts["UserCollectionEntity"] ?? 0 - guard ucCount > 1 else { - return - } + guard ucCount > 1 else { return } let ucRequest = UserCollectionEntity.fetchRequest() - guard let ucResults = try? context.fetch(ucRequest) else { - return - } + guard let ucResults = try? context.fetch(ucRequest) else { return } for (index, uc) in ucResults.enumerated() { let critters = uc.critters?.count ?? 0 @@ -679,6 +702,12 @@ extension CoreDataStorage { "๐Ÿ”ง Consolidation: %d UCs found, keeping UC with %d relationships", allUCs.count, self.relationshipCount(of: keptUC)) + Log.info("consolidating \(allUCs.count) UCs, kept relationships=\(self.relationshipCount(of: keptUC))") + Log.event(.ucConsolidated, parameters: [ + Log.Param.ucTotal: allUCs.count, + Log.Param.keptRelationships: self.relationshipCount(of: keptUC) + ]) + for orphanUC in sorted.dropFirst() { os_log(.info, log: .default, "๐Ÿ”ง Consolidation: reassigning & deleting UC id=%{public}@ (%d relationships)", @@ -767,10 +796,18 @@ extension CoreDataStorage { // ์•ˆ์ „ ํ™•์ธ ํ†ต๊ณผ ํ›„์—๋งŒ ์‹ค์ œ ๊ฐ์ฒด๋ฅผ fetchํ•˜์—ฌ ์‚ญ์ œ if let orphans = try? context.fetch(orphanCountRequest) { - // ํ”„๋กœ๋•์…˜์—์„œ๋„ ์ถ”์  ๊ฐ€๋Šฅํ•˜๋„๋ก .error ๋ ˆ๋ฒจ๋กœ ๊ธฐ๋ก (๋ฐ์ดํ„ฐ ์‚ญ์ œ๋Š” ํ•ญ์ƒ ๊ฐ์‚ฌ ๋Œ€์ƒ) - os_log(.error, log: .default, - "๐Ÿ”ง Orphan cleanup: %{public}@ โ†’ %d orphans / %d total WILL BE DELETED", - entity, orphans.count, totalCount) + // ๋ฐ์ดํ„ฐ ์‚ญ์ œ๋Š” ํ•ญ์ƒ ๊ฐ์‚ฌ ๋Œ€์ƒ โ€” Crashlytics ๋น„์น˜๋ช… ์—๋Ÿฌ๋กœ ์Šน๊ฒฉ + let userInfo: [String: Any] = [ + Log.Param.entity: entity, + Log.Param.deleted: orphans.count, + Log.Param.total: totalCount + ] + Log.event(.orphanCleanup, parameters: userInfo) + Log.error( + name: "OrphanCleanupDelete", + reason: "\(entity) \(orphans.count)/\(totalCount) deleted", + userInfo: userInfo + ) orphans.forEach { context.delete($0) } totalOrphans += orphans.count } @@ -871,14 +908,21 @@ extension CoreDataStorage { // 4. ์ฒซ Import ์™„๋ฃŒ ํ›„ 120์ดˆ ์œ ์˜ˆ (relationship ํ•ด์†Œ ์‹œ๊ฐ„ ํ™•๋ณด) // 5. ๊ธฐ์กด ์œ ์ € โ€” ์ด์ „์— UC๊ฐ€ ์กด์žฌํ–ˆ์œผ๋ฏ€๋กœ, CloudKit re-import ๋Œ€๊ธฐ ํ•„์š” if isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress { - os_log(.info, log: .default, - "โณ getUserCollection: No UC found โ€” skipping creation (waiting=%{public}@, importing=%{public}@, reset=%{public}@)", - isWaitingForFirstImport.description, isImportInProgress.description, isSyncResetInProgress.description) + Log.info("getUserCollection: No UC โ€” skipping (waiting=\(isWaitingForFirstImport), importing=\(isImportInProgress), reset=\(isSyncResetInProgress))") + Log.event(.ucCreationSuppressed, parameters: [ + Log.Param.reason: SuppressionReason.syncInProgress.rawValue, + Log.Param.waiting: isWaitingForFirstImport.description, + Log.Param.importing: isImportInProgress.description, + Log.Param.reset: isSyncResetInProgress.description + ]) throw CoreDataStorageError.notFound } if isWithinGracePeriod { - os_log(.info, log: .default, "โณ getUserCollection: No UC found, within grace period (%.0fs) โ€” skipping creation", Self.gracePeriodSeconds) + Log.info("getUserCollection: No UC, within \(Int(Self.gracePeriodSeconds))s grace period โ€” skipping") + Log.event(.ucCreationSuppressed, parameters: [ + Log.Param.reason: SuppressionReason.gracePeriod.rawValue + ]) throw CoreDataStorageError.notFound } @@ -890,16 +934,31 @@ extension CoreDataStorage { // ๋ณต๊ตฌ ์ž์ฒด๊ฐ€ "๋กœ์ปฌ ์žฌ์ƒ์„ฑ + CloudKit์—์„œ ์žฌimport" ํ”Œ๋กœ์šฐ์ด๋ฏ€๋กœ ์•ˆ์ „. if hasEverHadUserCollection { if isWithinRecoveryGracePeriod { - os_log(.info, log: .default, - "๐Ÿ”„ getUserCollection: No UC found, within recovery grace period โ€” allowing UC creation") + Log.info("UC created within recovery grace period (hasEverHadUC=true)") + Log.event(.ucCreated, parameters: [Log.Param.path: UCCreationPath.recoveryGrace.rawValue]) + logSyncDiagnostics(phase: "UC-created-recovery", throttled: false) return UserCollectionEntity(UserInfo(), context: context) } - os_log(.error, log: .default, - "๐Ÿ›ก๏ธ getUserCollection: No UC found but hasEverHadUserCollection=true โ€” blocking empty UC creation to protect cloud data") + // ํ•ต์‹ฌ ๋ฐ์ดํ„ฐ ์œ ์‹ค ์ฆ์ƒ: "๊ธฐ์กด ์œ ์ €์ธ๋ฐ UC๊ฐ€ ์‚ฌ๋ผ์ง". + // 3.2.0 ์ดํ›„ ํด๋ ˆ์ž„์˜ ์ฃผ ์ฆ์ƒ์œผ๋กœ ์ถ”์ •๋˜๋Š” ์ƒํƒœ. + Log.warning("UC missing but hasEverHadUC=true โ€” user data appears reset, blocking empty UC to protect cloud") + Log.event(.ucMissing) + logSyncDiagnostics(phase: "UC-missing", throttled: false) + Log.error( + name: "UserCollectionMissing", + reason: "hasEverHadUserCollection=true but UC count=0", + userInfo: [ + Log.Param.waiting: isWaitingForFirstImport, + Log.Param.importing: isImportInProgress, + Log.Param.reset: isSyncResetInProgress, + Log.Param.recoveryGrace: isWithinRecoveryGracePeriod + ] + ) throw CoreDataStorageError.notFound } - os_log(.info, log: .default, "โš ๏ธ getUserCollection: No UC found (fresh user) โ€” creating new one") + Log.info("UC created for fresh user") + Log.event(.ucCreated, parameters: [Log.Param.path: UCCreationPath.freshUser.rawValue]) return UserCollectionEntity(UserInfo(), context: context) } @@ -986,7 +1045,8 @@ extension CoreDataStorage { // 10๋ถ„๊ฐ„์€ UC ์ƒ์„ฑ์„ ํ—ˆ์šฉํ•˜์—ฌ ์•ฑ์ด ๋™์ž‘ ๊ฐ€๋Šฅํ•˜๋„๋ก ๋ณด์žฅ self.markRecoveryInitiated() - os_log(.info, log: .default, "๐Ÿ”„ Recovery: store files deleted โ€” app restart required for CloudKit re-import") + Log.warning("recovery: local store wiped, awaiting restart + CloudKit re-import") + Log.event(.recoveryTriggered) completion(.success(())) } catch { os_log(.error, log: .default, "๐Ÿ”„ Recovery failed: %{public}@", error.localizedDescription) @@ -996,6 +1056,16 @@ extension CoreDataStorage { } } +private enum UCCreationPath: String { + case freshUser = "fresh_user" + case recoveryGrace = "recovery_grace" +} + +private enum SuppressionReason: String { + case syncInProgress = "sync_in_progress" + case gracePeriod = "grace_period" +} + extension NSManagedObjectContext { func saveContext() { if self.hasChanges { diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift new file mode 100644 index 0000000..63a4c57 --- /dev/null +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift @@ -0,0 +1,204 @@ +// +// Log.swift +// ACNH-wiki +// +// Created by Ari on 4/22/26. +// + +import Foundation +import OSLog +import FirebaseCrashlytics +import FirebaseAnalytics + +enum Log { + + // MARK: - Event Names (Analytics) + + enum Event: String { + case recoveryTriggered = "sync_recovery_triggered" + case orphanCleanup = "sync_orphan_cleanup" + case ucConsolidated = "sync_uc_consolidated" + case ucCreated = "sync_uc_created" + case tokenExpired = "sync_token_expired" + case ucMissing = "sync_user_collection_missing" + case ucCreationSuppressed = "sync_uc_creation_suppressed" + case cloudSyncFailed = "sync_cloud_failed" + } + + /// Analytics event parameter keys. ๋™์ผ ํ‚ค๊ฐ€ ์—ฌ๋Ÿฌ ์ด๋ฒคํŠธ์—์„œ ์žฌ์‚ฌ์šฉ๋˜๋ฏ€๋กœ ์ค‘์•™ํ™”. + enum Param { + static let reason = "reason" + static let path = "path" + static let code = "code" + static let entity = "entity" + static let deleted = "deleted" + static let total = "total" + static let ucTotal = "uc_total" + static let keptRelationships = "kept_relationships" + static let message = "message" + static let errorName = "name" + + // Sync flag keys (shared with Key below) + static let waiting = "waiting" + static let importing = "importing" + static let reset = "reset" + static let recoveryGrace = "recovery_grace" + } + + // MARK: - Custom Key Names (Crashlytics) + + enum Key { + static let ucCount = "sync_uc_count" + static let itemCount = "sync_item_count" + static let taskCount = "sync_task_count" + static let villagerCount = "sync_villager_count" + static let hasEverHadUC = "sync_has_ever_had_uc" + static let isFreshInstall = "sync_is_fresh_install" + static let isWaitingForFirstImport = "sync_waiting_first_import" + static let isImportInProgress = "sync_import_in_progress" + static let isSyncResetInProgress = "sync_reset_in_progress" + static let isWithinRecoveryGracePeriod = "sync_within_recovery_grace" + static let lastImportDate = "sync_last_import_at" + static let lastExportDate = "sync_last_export_at" + static let appVersion = "sync_app_version" + } + + // MARK: - Internal + + private static let crashlytics = Crashlytics.crashlytics() + private static let analyticsStringLimit = 100 + + private static func truncate(_ message: String) -> String { + guard message.count > analyticsStringLimit else { + return message + } + return String(message.prefix(analyticsStringLimit)) + } + + // MARK: - Level-based Logging + // + // ๋ชจ๋“  ๋ ˆ๋ฒจ์€ ์„ธ ๊ฐˆ๋ž˜๋กœ ์ „์†ก๋œ๋‹ค: + // 1) os_log โ€” Console.app / Xcode์—์„œ ๋กœ์ปฌ ํ™•์ธ + // 2) Crashlytics.log โ€” ์„ธ์…˜ breadcrumb (ํฌ๋ž˜์‹œ/๋น„์น˜๋ช… ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ํ•จ๊ป˜ ์—…๋กœ๋“œ) + // 3) Analytics.logEvent โ€” Firebase Analytics ์ฝ˜์†”์— ์ง‘๊ณ„ (์—๋Ÿฌ ์—†์ด๋„ ๊ฐ€์‹œ์„ฑ ํ™•๋ณด) + // + // Analytics ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฐ’์€ 100์ž๋กœ ์ž๋™ truncate๋œ๋‹ค. + // verbose/debug๋Š” Analytics ์ฟผํ„ฐ ๋ณดํ˜ธ๋ฅผ ์œ„ํ•ด DEBUG ๋นŒ๋“œ์—์„œ๋งŒ Analytics๋กœ ์ „์†ก๋œ๋‹ค. + + private static func emit(level: String, symbol: String, osLogType: OSLogType, message: String, sendToAnalytics: Bool) { + crashlytics.log("[\(level.uppercased())] \(message)") + os_log(osLogType, log: .default, "%{public}@ %{public}@", symbol, message) + guard sendToAnalytics else { + return + } + Analytics.logEvent("log_\(level)", parameters: [Param.message: truncate(message)]) + } + + static func verbose(_ message: String) { + #if DEBUG + emit(level: "verbose", symbol: "๐Ÿ”", osLogType: .debug, message: message, sendToAnalytics: true) + #else + emit(level: "verbose", symbol: "๐Ÿ”", osLogType: .debug, message: message, sendToAnalytics: false) + #endif + } + + static func debug(_ message: String) { + #if DEBUG + emit(level: "debug", symbol: "๐Ÿ›", osLogType: .debug, message: message, sendToAnalytics: true) + #else + emit(level: "debug", symbol: "๐Ÿ›", osLogType: .debug, message: message, sendToAnalytics: false) + #endif + } + + static func info(_ message: String) { + emit(level: "info", symbol: "โ„น๏ธ", osLogType: .info, message: message, sendToAnalytics: true) + } + + static func warning(_ message: String) { + emit(level: "warning", symbol: "โš ๏ธ", osLogType: .error, message: message, sendToAnalytics: true) + } + + // MARK: - Non-fatal Error + + /// Crashlytics ๋น„์น˜๋ช… ์—๋Ÿฌ ์—…๋กœ๋“œ. ์„ธ์…˜์˜ breadcrumb + custom keys๊ฐ€ ํ•จ๊ป˜ ์ „์†ก๋œ๋‹ค. + /// ์‚ฌ์šฉ์ž ํด๋ ˆ์ž„ ์ถ”์ ์˜ ํ•ต์‹ฌ ์ง„์ž…์ . + static func error( + name: String, + reason: String, + userInfo: [String: Any] = [:] + ) { + var info = userInfo + info[NSLocalizedDescriptionKey] = reason + let nsError = NSError(domain: "Log.\(name)", code: 0, userInfo: info) + crashlytics.record(error: nsError) + os_log(.error, log: .default, "โ—๏ธ non-fatal: %{public}@ โ€” %{public}@", name, reason) + Analytics.logEvent("log_error", parameters: [ + Param.errorName: truncate(name), + Param.reason: truncate(reason) + ]) + } + + // MARK: - Analytics + + static func event(_ event: Event, parameters: [String: Any] = [:]) { + Analytics.logEvent(event.rawValue, parameters: parameters) + crashlytics.log("[EVENT] \(event.rawValue) \(parameters)") + os_log(.info, log: .default, "๐Ÿ“ˆ %{public}@", event.rawValue) + } + + /// ์‚ฌ์šฉ์ž ํƒญ/ํด๋ฆญ ์ถ”์ . Firebase Analytics์˜ `select_content` ์Šคํ‚ค๋งˆ๋กœ ๊ธฐ๋ก. + static func click(_ name: String, parameters: [String: Any] = [:]) { + var params = parameters + params[AnalyticsParameterItemID] = name + params[AnalyticsParameterContentType] = "click" + Analytics.logEvent(AnalyticsEventSelectContent, parameters: params) + os_log(.info, log: .default, "๐Ÿ‘† click=%{public}@", name) + } + + // MARK: - Context (Crashlytics custom keys) + + static func setContext(_ key: String, _ value: Any?) { + guard let value else { + crashlytics.setCustomValue("", forKey: key) + return + } + crashlytics.setCustomValue(value, forKey: key) + } + + /// ์—”ํ‹ฐํ‹ฐ ์นด์šดํŠธ์™€ sync ํ”Œ๋ž˜๊ทธ๋ฅผ ํ•œ ๋ฒˆ์— custom keys๋กœ ์ „์†ก. + struct Snapshot { + var ucCount: Int + var itemCount: Int + var taskCount: Int + var villagerCount: Int + var hasEverHadUC: Bool + var isFreshInstall: Bool? + var isWaitingForFirstImport: Bool + var isImportInProgress: Bool + var isSyncResetInProgress: Bool + var isWithinRecoveryGracePeriod: Bool + var lastImportDate: Date? + var lastExportDate: Date? + } + + static func snapshot(_ snapshot: Snapshot) { + setContext(Key.ucCount, snapshot.ucCount) + setContext(Key.itemCount, snapshot.itemCount) + setContext(Key.taskCount, snapshot.taskCount) + setContext(Key.villagerCount, snapshot.villagerCount) + setContext(Key.hasEverHadUC, snapshot.hasEverHadUC) + if let isFreshInstall = snapshot.isFreshInstall { + setContext(Key.isFreshInstall, isFreshInstall) + } + setContext(Key.isWaitingForFirstImport, snapshot.isWaitingForFirstImport) + setContext(Key.isImportInProgress, snapshot.isImportInProgress) + setContext(Key.isSyncResetInProgress, snapshot.isSyncResetInProgress) + setContext(Key.isWithinRecoveryGracePeriod, snapshot.isWithinRecoveryGracePeriod) + setContext(Key.lastImportDate, snapshot.lastImportDate?.timeIntervalSince1970 ?? 0) + setContext(Key.lastExportDate, snapshot.lastExportDate?.timeIntervalSince1970 ?? 0) + + if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + setContext(Key.appVersion, version) + } + } +} diff --git a/docs/features/icloud-sync.md b/docs/features/icloud-sync.md index 08fc884..58526bc 100644 --- a/docs/features/icloud-sync.md +++ b/docs/features/icloud-sync.md @@ -286,3 +286,69 @@ Import ์™„๋ฃŒ ํ›„ Path-B(`setUpUserCollection`)๊ฐ€ ์žฌ์‹คํ–‰๋˜์–ด ๋ฐ์ดํ„ฐ๊ฐ€ ๋ชจ๋“  Storage ํด๋ž˜์Šค์˜ ์—๋Ÿฌ ๋กœ๊น…์ด `debugPrint()` (Release ๋นŒ๋“œ์—์„œ ๋ฌด์‹œ๋จ)์—์„œ `os_log(.error)` (Release์—์„œ๋„ ๊ธฐ๋ก)๋กœ ๊ฐ•ํ™”๋จ. Console.app ๋˜๋Š” Xcode์—์„œ `CoreDataStorage`, `ItemsStorage`, `DailyTaskStorage` ๋“ฑ์œผ๋กœ ํ•„ํ„ฐํ•˜์—ฌ ํ”„๋กœ๋•์…˜ ์—๋Ÿฌ ์ถ”์  ๊ฐ€๋Šฅ. + +## Remote Telemetry (Firebase) + +3.2.0 ์ดํ›„ "๋กœ์ปฌ ๋ฐ์ดํ„ฐ๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์—ˆ๋‹ค"๋Š” ํด๋ ˆ์ž„์„ ์›๊ฒฉ์—์„œ ์ถ”์ ํ•˜๊ธฐ ์œ„ํ•ด, `Utility/Log.swift`์—์„œ +Crashlytics(์„ธ์…˜ breadcrumb + custom keys + ๋น„์น˜๋ช… ์—๋Ÿฌ)์™€ Analytics(์ง‘๊ณ„ ์ด๋ฒคํŠธ/ํด๋ฆญ)๋ฅผ ํ†ตํ•ฉ ๋ž˜ํ•‘. + +### Logging API + +๋ชจ๋“  ๋ ˆ๋ฒจ(`verbose` ~ `error`)์€ **3-way fan-out**: `os_log` + Crashlytics breadcrumb + Analytics ์ด๋ฒคํŠธ. +์—๋Ÿฌ/ํฌ๋ž˜์‹œ ์—†์ด๋„ Analytics ์ฝ˜์†”์—์„œ ๋กœ๊ทธ๊ฐ€ ๋ณด์ด๋ฉฐ, ๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ์—” Crashlytics breadcrumb์œผ๋กœ ์„ธ์…˜ ๋งฅ๋ฝ์ด ํ•จ๊ป˜ ์—…๋กœ๋“œ๋จ. + +| ํ˜ธ์ถœ | ์šฉ๋„ | Analytics ์ด๋ฒคํŠธ๋ช… | +|------|------|-------------------| +| `Log.verbose(_:)` | ์ƒ์„ธ ์ถ”์  | `log_verbose` | +| `Log.debug(_:)` | ๋””๋ฒ„๊ทธ ํ๋ฆ„ | `log_debug` | +| `Log.info(_:)` | ์ผ๋ฐ˜ ํ๋ฆ„, ๊ฒฐ์ • ์ง€์  | `log_info` | +| `Log.warning(_:)` | ์ฃผ์˜ ์ƒํƒœ | `log_warning` | +| `Log.error(name:reason:userInfo:)` | **๋น„์น˜๋ช… ์—๋Ÿฌ ์—…๋กœ๋“œ** | `log_error` + Crashlytics `recordError` | +| `Log.event(_:parameters:)` | ๊ตฌ์กฐํ™”๋œ ์ง‘๊ณ„ ์ด๋ฒคํŠธ | (Event enum) | +| `Log.click(_:parameters:)` | ์‚ฌ์šฉ์ž ํƒญ ์ถ”์  | `select_content` | +| `Log.setContext(_:_:)` | ์ปค์Šคํ…€ ํ‚ค ๋‹จ๊ฑด ์„ค์ • | โ€” (Crashlytics ์ „์šฉ) | +| `Log.snapshot(...)` | ์—”ํ‹ฐํ‹ฐ ์นด์šดํŠธ/ํ”Œ๋ž˜๊ทธ ์ผ๊ด„ ์ „์†ก | โ€” (Crashlytics ์ „์šฉ) | + +**๋ฉ”์‹œ์ง€ ๊ธธ์ด**: Analytics ํŒŒ๋ผ๋ฏธํ„ฐ ์ œํ•œ(100์ž)์— ๋งž์ถฐ ์ž๋™ ์ž˜๋ฆผ. + +### Analytics Events (์ง‘๊ณ„) + +| Event | ๋ฐœ์ƒ ์ง€์  | +|-------|-----------| +| `sync_recovery_triggered` | `performCloudKitRecovery` ์„ฑ๊ณต โ€” ์‚ฌ์šฉ์ž๊ฐ€ ์„ค์ •์—์„œ ๋ณต์› ์‹คํ–‰ | +| `sync_orphan_cleanup` | `cleanupOrphanedEntities`๊ฐ€ ์‹ค์ œ๋กœ ๋ ˆ์ฝ”๋“œ ์‚ญ์ œ (entity/count ํŒŒ๋ผ๋ฏธํ„ฐ) | +| `sync_uc_consolidated` | ์ค‘๋ณต UC๊ฐ€ ํ†ตํ•ฉ๋จ (uc_total/kept_relationships) | +| `sync_uc_created` | ์ƒˆ UC ์ƒ์„ฑ (path: fresh_user \| recovery_grace) | +| `sync_uc_creation_suppressed` | UC ์ƒ์„ฑ์ด ์–ต์ œ๋จ (reason: sync_in_progress \| grace_period) | +| `sync_token_expired` | `NSCloudKitMirroringDelegateWillReset` ๊ฐ์ง€ | +| `sync_user_collection_missing` | **ํ•ต์‹ฌ ์ฆ์ƒ**: hasEverHadUC=true์ธ๋ฐ UC=0 | +| `sync_cloud_failed` | CloudKit Export/Import ์‹คํŒจ (reason/code) | + +### Crashlytics Custom Keys (์„ธ์…˜ ์Šค๋ƒ…์ƒท) + +`logSyncDiagnostics(phase:throttled:)` ํ˜ธ์ถœ ์‹œ os_log ์ง„๋‹จ + `Log.snapshot` ๊ฐฑ์‹ ์„ ํ•œ ๋ฒˆ์˜ background fetch๋กœ ์ˆ˜ํ–‰. +Import ์ข…๋ฃŒ, UC ์ƒ์„ฑ(recovery grace), UC missing ์‹œ์ ์— ์ž๋™ ์ „์†ก. UC missing์ฒ˜๋Ÿผ ์ฆ‰์‹œ ์ปจํ…์ŠคํŠธ๊ฐ€ ํ•„์š”ํ•œ +๊ฒฝ์šฐ `throttled: false` ๋กœ ํ˜ธ์ถœํ•˜์—ฌ 5์ดˆ throttle์„ ์šฐํšŒ. + +- `sync_uc_count` / `sync_item_count` / `sync_task_count` / `sync_villager_count` +- `sync_has_ever_had_uc` / `sync_is_fresh_install` +- `sync_waiting_first_import` / `sync_import_in_progress` / `sync_reset_in_progress` +- `sync_within_recovery_grace` +- `sync_last_import_at` / `sync_last_export_at` (epoch seconds) +- `sync_app_version` + +### ๋น„์น˜๋ช… ์—๋Ÿฌ (Crashlytics `recordError`) + +์„ธ์…˜ breadcrumb๊ณผ custom keys๋ฅผ ํฌํ•จํ•œ ์ƒ์„ธ ์ปจํ…์ŠคํŠธ๋ฅผ Firebase Crashlytics์— ์—…๋กœ๋“œ: + +- `Log.UserCollectionMissing` โ€” hasEverHadUC=true + UC=0 ๊ด€์ธก +- `Log.OrphanCleanupDelete` โ€” ์‹ค์ œ orphan ์‚ญ์ œ ๋ฐœ์ƒ (๊ฐ์‚ฌ ๋กœ๊ทธ) + +### ์กฐ์‚ฌ ๊ฐ€์ด๋“œ + +์‚ฌ์šฉ์ž ํด๋ ˆ์ž„ ์ ‘์ˆ˜ ์‹œ: + +1. Firebase Crashlytics โ†’ Non-fatal issues โ†’ `UserCollectionMissing` ํ•„ํ„ฐ +2. ํ•ด๋‹น ์„ธ์…˜์˜ breadcrumb(`log()` ๋ฌธ์ž์—ด)์œผ๋กœ ์ด๋ฒคํŠธ ์ˆœ์„œ ์žฌ๊ตฌ์„ฑ +3. custom keys๋กœ ๊ทธ ์ˆœ๊ฐ„์˜ ์—”ํ‹ฐํ‹ฐ ์ˆ˜/ํ”Œ๋ž˜๊ทธ ์ƒํƒœ ํ™•์ธ +4. Analytics โ†’ `sync_user_collection_missing` ๋ถ„ํฌ๋กœ ๋ฒ„์ „/์ผ์ž๋ณ„ ๋ฐœ์ƒ ๋นˆ๋„ ํ™•์ธ