Skip to content

[fix] iCloud 자동 복원으로 인한 로컬 데이터 초기화 방지#175

Merged
leeari95 merged 5 commits into
release/3.2.3from
fix/icloud-auto-restore-data-loss
Apr 22, 2026
Merged

[fix] iCloud 자동 복원으로 인한 로컬 데이터 초기화 방지#175
leeari95 merged 5 commits into
release/3.2.3from
fix/icloud-auto-restore-data-loss

Conversation

@leeari95

@leeari95 leeari95 commented Apr 21, 2026

Copy link
Copy Markdown
Owner

🐛 버그 요약

사용자들로부터 지속적으로 제보되는 버그 — "어느 날 갑자기 수집 기록(아이템/마을주민/데일리 등)이 전부 사라졌다".
여러 세션에 걸쳐 로그를 수집한 결과, iCloud 동기화 중 NSCloudKitMirroringDelegate의 내부 로직이 로컬 Core Data store를 자발적으로 purge하는 현상이 원인으로 확인됐습니다.

🔎 근본 원인 분석

원인 1 — iOS 자체의 로컬 purge (앱이 개입 불가)

실기기 로그에서 실시간으로 관측된 시퀀스:

CloudKit Export failed: Cocoa 134301 "Persistent History Token is expired"
  ↓
NSCloudKitMirroringDelegate.recoverFromError
  ↓
'NSCloudKitMirroringDelegateWillResetSyncNotificationName' with reason 'HistoryExpired'
  ↓
[대규모 WAL checkpoint + incremental_vacuum 연속 발생 ← iOS가 로컬 store purge]
  ↓
'NSCloudKitMirroringDelegateDidResetSyncNotificationName'
  ↓
UC=0, Items=0  (로컬 데이터 소실)
  ↓
(iCloud import가 성공하면 복구, 실패/지연되면 유실 확정)

이 경로는 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/>→ 정상 데이터까지 삭제 가능
Loading

💡 해결 전략 — 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
Loading

왜 이 전략인가 — 고려한 대안들

대안 판단 이유
2-store 분리 (LocalStore + BackupStore) ⏭️ Stage 2로 미룸 본질적 해결이지만 Codec 신규 구현(~1,200 LOC) + 마이그레이션 + beta 필요. 유실이 계속 발생 중이라 긴급 핫픽스 우선.
NSPersistentCloudKitContainer mirroring 내부 gate ❌ 불가 API 미공개 + 동작 opaque
consolidateUserCollections에 더 긴 grace window ❌ 미봉책 iOS purge 경로는 여전히 안 막힘
Documents 사이드 파일에 스냅샷 ✅ 채택 iOS의 mirror reset이 Documents는 건드리지 않음. Codec 최소. Stage 2로 갈 때 그대로 재활용 가능.

🔧 주요 변경

1) 자동 consolidation 제거 — CoreDataStorage.swift, SceneDelegate.swift

  • handleCloudKitEvent() Import 완료 후 5초 지연 consolidation 제거
  • setupApp() 앱 시작 시 consolidation 제거
  • cleanupOrphanedEntities() 삭제 직전 로그를 os_log(.info)os_log(.error)로 승격 (Release 빌드 추적 가능)

2) Recovery grace period — CoreDataStorage.swift

  • performCloudKitRecovery() 실행 시 recoveryInitiatedAt 타임스탬프 기록
  • getUserCollection()에서 10분 grace window 내엔 hasEverHadUserCollection 체크 우회하여 UC 생성 허용
  • 재시작 후 CloudKit re-import가 느리거나 실패해도 앱이 잠기지 않도록 보장

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)]
Loading

안전 property:

  • noUserCollection 상황에선 기존 스냅샷 보존 (유실 상태를 덮어쓰지 않음)
  • atomic write로 중간 종료 시에도 이전 파일 유지
  • UserDefaults 사이드카 캐시로 metadata 조회 O(1) (3MB 파일 매번 unarchive 방지)

4) 복원 UX — AppSetting*, DashboardCoordinator, SceneDelegate

자동 프롬프트 (SceneDelegate):

  • isFreshInstall && hasEverHadUserCollection 감지 시 (= purge 의심 신호)
  • 스냅샷 존재하면 alert로 "로컬 백업에서 복원" 제안
  • 기본값은 "iCloud 기다리기" — 파괴적 선택 아님

설정 화면 3개 버튼 추가/강화:

버튼 동작 확인 단계
🆕 로컬 백업에서 복원 Documents 스냅샷 → LocalStore 재구성 1단 (백업 시각/항목수 표시)
🆕 중복/고아 데이터 정리 기존 auto-consolidation 수동 버전 1단
🔺 iCloud에서 복원 (강화) 기존 기능 + 2단 확인 + "파괴적 동작" 문구 2단

마지막 백업 정보 표시:

마지막 로컬 백업   5분 전 · 항목 844개

5) 리팩터 — ♻️ [refactor] SafetySnapshot 중복 제거 및 효율화

  • DateFormatters.syncRelativeDate 싱글톤 재사용 (3곳 중복 제거)
  • readMetadata() UserDefaults 캐시화 (3MB unarchive → O(1))
  • raw string "NSCloudKitMirroringDelegate..."CoreDataStorage.SyncResetNotification.willReset 참조
  • startObserving/flushNow/restore container 파라미터 제거 (싱글톤 내부 접근)
  • dead field Metadata.fileSize 삭제
  • restore() TOCTOU 제거
  • 서술형 WHAT 주석 정리

📊 시나리오 커버리지

시나리오 이전 (3.2.3) 이후 (3.2.4)
iOS purge + iCloud 복구 성공 ✅ 복구 ✅ 복구
iOS purge + iCloud 복구 실패/지연 유실 확정 ✅ 로컬 스냅샷에서 복원 가능
동기화 중 앱 자동 삭제 실수 ❌ 유실 ✅ 경로 차단
복원 재시작 후 iCloud 느림 ❌ 앱 잠김 ✅ 10분 grace
복원 실수 유도 UX ⚠️ 1단 확인 ✅ 2단 확인 + 경고 문구
재설치 ✅ CloudKit 수신 ✅ CloudKit 수신 → 수신 완료 후 즉시 snapshot 작성

🧪 실기기 검증 경과

PR 작업 동안 실기기 로그로 단계별 검증:

  1. 수정 전 빌드: CKError 134301 → WillResetSync (HistoryExpired) 실시간 관측 → 가설 100% 검증됨
  2. Stage 1.5 1차: SafetySnapshot 시도되나 serializationFailed 발생 → NSKeyedArchiver로 수정
  3. Stage 1.5 2차: 🛟 SafetySnapshot written: 844 children, 3377717 bytes 정상 동작 확인
  4. 재설치 시나리오: fresh install → CloudKit import → 즉시 snapshot 작성 → 이 기기도 보호됨 확인

🚧 이번 PR 밖 — Stage 2 (향후)

구조적 해결안 LocalStore + BackupStore 분리 설계는 docs/plans/local-backup-split.md에 별도 문서화. beta 테스트 후 3.3.x에서 단계적 배포 예정.

📂 변경 파일 요약

파일 변경 성격
CoreDataStorage/CoreDataStorage.swift 자동 consolidation 제거, recovery grace period, SyncResetNotification 내부 enum → internal
CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift 🆕 debounce + 4경로 관찰 + metadata 캐시
CoreDataStorage/SafetySnapshot/UserCollectionSnapshot.swift 🆕 NSKeyedArchiver 기반 UC 그래프 dump/restore
Presentation/Dashboard/ViewModels/AppSettingReactor.swift 복원/정리 Action · Mutation 추가
Presentation/Dashboard/Views/AppSettingView.swift 버튼 + ActivityIndicator + 백업 정보 라벨
Presentation/Dashboard/Coordinator/DashboardCoordinator.swift showLocalRestoreResult 추가
SceneDelegate.swift 자동 프롬프트, startObserving 호출, background flush
Resources/{ko,en}.lproj/Localizable.strings 신규 문자열 10여 개 (양쪽 번역 포함)
docs/features/icloud-sync.md 자동 consolidation 제거, grace period, 수동 복원 문서화
docs/plans/local-backup-split.md 🆕 Stage 2 아키텍처 설계안
docs/iCloud-data-loss-fix-summary.md 🆕 비개발 담당자용 요약

✅ 리뷰 시 중점 확인 요청

  1. SafetySnapshotService.startObserving()의 4개 notification 구독 — debounce 코얼레스가 올바른지
  2. UserCollectionSnapshot.wipeExistingCollection(in:) — 7개 entity batch delete + mergeChanges 순서가 안전한지
  3. getUserCollection()의 새 grace period 분기 — 기존 hasEverHadUserCollection 보호 로직과의 상호작용
  4. NSKeyedArchiver(requiringSecureCoding: false) 선택 사유 주석 (UserCollectionSnapshot.swift:49-54 참조) — 내부 파일 한정이라 안전하나 리뷰어 의견 환영
  5. showLocalRestoreResult 코디네이터 API와 기존 showRecoveryResultAlert 패턴 일관성

leeari95 and others added 2 commits April 21, 2026 19:18
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>
@leeari95 leeari95 added the fix 버그 혹은 에러사항 개선 label Apr 21, 2026
@leeari95 leeari95 self-assigned this Apr 21, 2026
@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 99b0cc2e-327d-4ed1-9147-13e497cfdcfa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/icloud-auto-restore-data-loss

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@leeari95
leeari95 marked this pull request as ready for review April 21, 2026 21:18
leeari95 and others added 3 commits April 22, 2026 06:21
로그 분석 결과, 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>
@leeari95
leeari95 merged commit c2d9d14 into release/3.2.3 Apr 22, 2026
1 of 2 checks passed
@leeari95
leeari95 deleted the fix/icloud-auto-restore-data-loss branch April 22, 2026 09:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix 버그 혹은 에러사항 개선

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant