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 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..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,39 @@ 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 + + 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) + Log.info("recovery initiated timestamp recorded (10min grace started)") + } + + /// 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 생성/기본 데이터 생성을 유예하는 시간 (초) @@ -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") } @@ -204,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 @@ -324,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) { @@ -373,14 +407,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, @@ -466,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 @@ -513,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 @@ -615,30 +664,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] @@ -646,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)", @@ -655,11 +717,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,9 +796,18 @@ extension CoreDataStorage { // 안전 확인 통과 후에만 실제 객체를 fetch하여 삭제 if let orphans = try? context.fetch(orphanCountRequest) { - os_log(.info, log: .default, - "🔧 Orphan cleanup: %{public}@ → %d orphans / %d total 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 } @@ -837,26 +908,57 @@ 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 } // 기존 유저인데 UC가 0개 → CloudKit 미러 재구성 또는 re-import 대기 상태 // 빈 UC를 생성하면 CloudKit에 빈 데이터가 Export되어 기존 데이터를 오염시킬 수 있음 + // + // 예외: performCloudKitRecovery 직후 grace period(10분) 내에는 + // import가 지연되더라도 앱이 동작 가능하도록 UC 생성을 허용한다. + // 복구 자체가 "로컬 재생성 + CloudKit에서 재import" 플로우이므로 안전. if hasEverHadUserCollection { - os_log(.error, log: .default, - "🛡️ getUserCollection: No UC found but hasEverHadUserCollection=true — blocking empty UC creation to protect cloud data") + if isWithinRecoveryGracePeriod { + 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) + } + // 핵심 데이터 유실 증상: "기존 유저인데 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) } @@ -871,11 +973,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 +992,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,7 +1041,12 @@ extension CoreDataStorage { // 기존 유저 플래그 유지 — 재시작 후 CloudKit re-import 전까지 빈 UC 생성 방지 // (복구 = 기존 유저이므로 true 유지가 올바름) - os_log(.info, log: .default, "🔄 Recovery: store files deleted — app restart required for CloudKit re-import") + // Recovery grace period 시작 — 재시작 후 import가 지연되어도 + // 10분간은 UC 생성을 허용하여 앱이 동작 가능하도록 보장 + self.markRecoveryInitiated() + + 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) @@ -947,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/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/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 563f984..58526bc 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 @@ -257,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` 분포로 버전/일자별 발생 빈도 확인 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)