[fix] iCloud 자동 복원으로 인한 로컬 데이터 초기화 방지#175
Merged
Merged
Conversation
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) <noreply@anthropic.com>
기획·CS 담당자 등 비개발 인원도 이해할 수 있도록 이번 수정의 배경/해결 방식/QA 체크리스트를 정리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
leeari95
marked this pull request as ready for review
April 21, 2026 21:18
로그 분석 결과, 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) <noreply@anthropic.com>
## 문제 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) <noreply@anthropic.com>
## 주요 변경 - **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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🐛 버그 요약
사용자들로부터 지속적으로 제보되는 버그 — "어느 날 갑자기 수집 기록(아이템/마을주민/데일리 등)이 전부 사라졌다".
여러 세션에 걸쳐 로그를 수집한 결과, iCloud 동기화 중
NSCloudKitMirroringDelegate의 내부 로직이 로컬 Core Data store를 자발적으로 purge하는 현상이 원인으로 확인됐습니다.🔎 근본 원인 분석
원인 1 — iOS 자체의 로컬 purge (앱이 개입 불가)
실기기 로그에서 실시간으로 관측된 시퀀스:
이 경로는 Apple 공개 API로 차단 불가합니다.
NSPersistentCloudKitContainer가 mirroring을 자동 관리하므로 앱 코드가 끼어들 틈이 없습니다.원인 2 — 앱 자체의 자동 삭제 로직
CloudKit Import 완료 5초 후 자동 실행되는
consolidateUserCollections()→cleanupOrphanedEntities()가, 동기화 도중 일시적으로 orphan처럼 보이는 child entity를 정상 데이터까지 섞어 삭제할 수 있었습니다.sequenceDiagram participant User participant App participant iOS as NSCloudKitMirroringDelegate participant CloudKit Note over App: 평상시 동작 User->>App: 아이템 수집 App->>CloudKit: Export (automatic) Note over iOS,CloudKit: 문제 발생 시나리오 CloudKit->>iOS: CKError 134301 (Token Expired) iOS->>iOS: WillResetSync (HistoryExpired) iOS->>iOS: 로컬 store purge iOS->>iOS: DidResetSync iOS->>CloudKit: Re-import 시도 alt Import 성공 CloudKit-->>App: 데이터 복구 else Import 실패/지연 Note over App: ❌ 데이터 유실 확정 end Note over App: 추가 위험 (앱 코드 버그) App->>App: Import 완료 5s 후<br/>consolidateUserCollections() 자동 실행 App->>App: cleanupOrphanedEntities()<br/>→ 정상 데이터까지 삭제 가능💡 해결 전략 — 3-layer 안전망
graph TB subgraph Layer1["🛡️ Layer 1 — 자동 삭제 경로 차단 (Stage 1)"] A1[handleCloudKitEvent의 자동 consolidation 제거] A2[SceneDelegate.setupApp의 자동 consolidation 제거] A3[수동 정리 버튼만 유지] end subgraph Layer2["💾 Layer 2 — 로컬 안전 스냅샷 (Stage 1.5)"] B1[Documents/local_safety_snapshot.plist에<br/>UC 그래프 주기 덤프] B2[CloudKit과 무관한 경로 → iOS purge 불가] B3[WillResetSync 알림 시 debounce 우회 즉시 flush] end subgraph Layer3["🚑 Layer 3 — 복원 UX"] C1[purge 감지 시 자동 복원 프롬프트] C2[설정에서 수동 복원 버튼] C3[iCloud 복원 2단 확인 + 10분 grace period] end Layer1 --> Layer2 --> Layer3왜 이 전략인가 — 고려한 대안들
consolidateUserCollections에 더 긴 grace window🔧 주요 변경
1) 자동 consolidation 제거 —
CoreDataStorage.swift,SceneDelegate.swifthandleCloudKitEvent()Import 완료 후 5초 지연 consolidation 제거setupApp()앱 시작 시 consolidation 제거cleanupOrphanedEntities()삭제 직전 로그를os_log(.info)→os_log(.error)로 승격 (Release 빌드 추적 가능)2) Recovery grace period —
CoreDataStorage.swiftperformCloudKitRecovery()실행 시recoveryInitiatedAt타임스탬프 기록getUserCollection()에서 10분 grace window 내엔hasEverHadUserCollection체크 우회하여 UC 생성 허용3) LocalSafetySnapshot —
SafetySnapshot/*.swift(신규 약 420 LOC)직렬화 전략:
PropertyListSerialization사용 → 실기기 테스트에서SafetySnapshotError.serializationFailed발생ItemEntity의 Transformable 14개 중 일부(variations,recipe,hemispheres)가NSArray/NSDictionary로 declared 되어 있지만 내부에 커스텀 NSCoding DTO 포함 → plist 포맷 거부NSKeyedArchiver(requiringSecureCoding: false)로 교체. Core Data Transformable이 이미 NSCoding 전제이므로 그대로 통과. 파일 크기 ~3.3MB (844 children 기준).트리거 4경로:
flowchart LR A[앱 시작<br/>startObserving] -->|즉시| D[writeSnapshotNow] B1[.NSManagedObjectContextDidSave<br/>사용자 편집] -->|debounce 30s| S[scheduleSnapshot] B2[CoreDataStorage.didFinishCloudImport<br/>원격 기기 변경 수신] -->|debounce 30s| S B3[CoreDataStorage.didReceiveRemoteChanges<br/>remote merge] -->|debounce 30s| S B4[SyncResetNotification.willReset<br/>iOS purge 직전] -->|즉시 우회| F[flushNow] S --> D F --> D D -->|atomic write| File[(Documents/<br/>local_safety_snapshot.plist)]안전 property:
noUserCollection상황에선 기존 스냅샷 보존 (유실 상태를 덮어쓰지 않음)4) 복원 UX —
AppSetting*,DashboardCoordinator,SceneDelegate자동 프롬프트 (SceneDelegate):
isFreshInstall && hasEverHadUserCollection감지 시 (= purge 의심 신호)설정 화면 3개 버튼 추가/강화:
마지막 백업 정보 표시:
5) 리팩터 —
♻️ [refactor] SafetySnapshot 중복 제거 및 효율화DateFormatters.syncRelativeDate싱글톤 재사용 (3곳 중복 제거)readMetadata()UserDefaults 캐시화 (3MB unarchive → O(1))"NSCloudKitMirroringDelegate..."→CoreDataStorage.SyncResetNotification.willReset참조startObserving/flushNow/restorecontainer 파라미터 제거 (싱글톤 내부 접근)Metadata.fileSize삭제restore()TOCTOU 제거📊 시나리오 커버리지
🧪 실기기 검증 경과
PR 작업 동안 실기기 로그로 단계별 검증:
CKError 134301 → WillResetSync (HistoryExpired)실시간 관측 → 가설 100% 검증됨serializationFailed발생 → NSKeyedArchiver로 수정🛟 SafetySnapshot written: 844 children, 3377717 bytes정상 동작 확인🚧 이번 PR 밖 — Stage 2 (향후)
구조적 해결안
LocalStore + BackupStore분리 설계는docs/plans/local-backup-split.md에 별도 문서화. beta 테스트 후 3.3.x에서 단계적 배포 예정.📂 변경 파일 요약
CoreDataStorage/CoreDataStorage.swiftCoreDataStorage/SafetySnapshot/SafetySnapshotService.swiftCoreDataStorage/SafetySnapshot/UserCollectionSnapshot.swiftPresentation/Dashboard/ViewModels/AppSettingReactor.swiftPresentation/Dashboard/Views/AppSettingView.swiftPresentation/Dashboard/Coordinator/DashboardCoordinator.swiftshowLocalRestoreResult추가SceneDelegate.swiftResources/{ko,en}.lproj/Localizable.stringsdocs/features/icloud-sync.mddocs/plans/local-backup-split.mddocs/iCloud-data-loss-fix-summary.md✅ 리뷰 시 중점 확인 요청
SafetySnapshotService.startObserving()의 4개 notification 구독 — debounce 코얼레스가 올바른지UserCollectionSnapshot.wipeExistingCollection(in:)— 7개 entity batch delete + mergeChanges 순서가 안전한지getUserCollection()의 새 grace period 분기 — 기존hasEverHadUserCollection보호 로직과의 상호작용NSKeyedArchiver(requiringSecureCoding: false)선택 사유 주석 (UserCollectionSnapshot.swift:49-54 참조) — 내부 파일 한정이라 안전하나 리뷰어 의견 환영showLocalRestoreResult코디네이터 API와 기존showRecoveryResultAlert패턴 일관성