[codex] iCloud 동기화 데이터 초기화 방어 및 회귀 테스트 보강#179
Conversation
|
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 |
| func completeFirstImportWait(reason: FirstImportWaitCompletionReason) { | ||
| isWaitingForFirstImport = false | ||
| switch reason { | ||
| case .importArrived, .noICloud: | ||
| isFirstImportTimedOut = false | ||
| case .timeout: | ||
| isFirstImportTimedOut = lastSuccessfulImportDate == nil | ||
| } | ||
| Log.info("completeFirstImportWait reason=\(reason)") | ||
| } |
There was a problem hiding this comment.
recovery grace 자동 UC 생성 제거 — import 지속 실패 시 복구 유저가 빈 화면에 머무름
Pattern: getUserCollection()에서 isWithinRecoveryGracePeriod 예외 분기(기존 유일한 자동 escape hatch)가 제거됨.
Proof: performCloudKitRecovery()가 store를 비운 뒤 재시작하면 isFreshInstall()=true → markWaitingForFirstImport() → 10초 timeout → completeFirstImportWait(.timeout) → lastSuccessfulImportDate==nil이므로 isFirstImportTimedOut=true. 이후 import가 계속 실패하면 finishCloudImport(succeeded:false)가 플래그를 유지 → getUserCollection이 매번 .notFound, shouldSuppressDataCreation=true. 회귀 테스트 testRecoveryGraceDoesNotCreateEmptyUserCollectionForKnownUser가 이 차단 동작을 명시적으로 검증.
Impact: 느린/불안정 네트워크에서 iCloud 복원을 실행한 기존 유저는 import가 성공하기 전까지 모든 데이터 화면이 빈 상태/오류로 표시됨. 단, 이는 의도된 trade-off (빈 UC가 iCloud로 export되어 정상 데이터를 덮어쓰는 것을 막는 것이 PR 목적이며, 제거된 grace 분기 자체가 데이터 유실 경로였음). 크래시/하드 락아웃이 아닌 graceful degraded 상태이고, import 성공 또는 SafetySnapshot 수동 복원으로 해제됨. 팀에서 'degraded 상태 동안 UI 안내(스플래시 유지/복원 안내)' 추가 여부를 검토 권장.
| static var isUnitTesting: Bool { | ||
| ProcessInfo.processInfo.environment["IS_UNIT_TESTING"] == "1" | ||
| || NSClassFromString("XCTestCase") != nil | ||
| } |
There was a problem hiding this comment.
AppEnvironment.isUnitTesting의 NSClassFromString("XCTestCase") fallback 위험
Pattern: isUnitTesting이 IS_UNIT_TESTING==1 OR NSClassFromString("XCTestCase") != nil로 판정. 이 단일 boolean이 (1) AppDelegate Firebase 부트스트랩, (2) Log의 모든 Crashlytics/Analytics, (3) SceneDelegate 앱 셋업을 게이팅.
Proof: AppEnvironment.swift:13의 런타임 클래스 probe가 production 경로에서 평가됨. AppDelegate.swift:21-23, Log.swift:73이 #if DEBUG 없이 소비.
Impact (2가지 관점):
- Correctness/UI-test: XCUITest의 앱 프로세스에는 XCTest.framework가 주입되므로
NSClassFromString("XCTestCase")가 non-nil → 앱이 빈 화면으로 부팅. 향후 UI 테스트 도입 시 깨짐. - Security/Telemetry: App Store 빌드는 XCTest를 링크하지 않아 정상(=P2)이나, XCTest가 릴리스 바이너리에 새어 들어가면 크래시 리포팅/애널리틱스가 조용히 전면 비활성화되고 탐지 불가 — iCloud 데이터 유실을 진단하려는 릴리스에서 특히 치명적.
권장: Tuist 스킴이 이미 IS_UNIT_TESTING=1을 주입하므로 NSClassFromString 절을 제거하거나 #if DEBUG로 감쌀 것. (CO-3/SE-1/CQ-5 통합)
| init( | ||
| containerProvider: @escaping () -> NSPersistentContainer = { CoreDataStorage.shared.persistentContainer }, | ||
| snapshotDirectoryProvider: @escaping () -> URL? = { | ||
| FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first | ||
| }, | ||
| fileAttributeSetter: @escaping (URL, [FileAttributeKey: Any]) throws -> Void = { url, attributes in | ||
| try FileManager.default.setAttributes(attributes, ofItemAtPath: url.path) | ||
| }, | ||
| beforeApplyingSnapshot: ((NSManagedObjectContext) throws -> Void)? = nil | ||
| ) { | ||
| self.containerProvider = containerProvider | ||
| self.snapshotDirectoryProvider = snapshotDirectoryProvider | ||
| self.fileAttributeSetter = fileAttributeSetter | ||
| self.beforeApplyingSnapshot = beforeApplyingSnapshot | ||
| } |
There was a problem hiding this comment.
SafetySnapshotService: 테스트 전용 beforeApplyingSnapshot가 production initializer에 노출
Pattern: 4번째 DI 파라미터 beforeApplyingSnapshot: ((NSManagedObjectContext) throws -> Void)? = nil이 fault injection 전용. 다른 3개 DI(containerProvider/snapshotDirectoryProvider/fileAttributeSetter)는 실제 인프라 seam이지만 이것은 production에서 항상 nil이며 restore()의 try self.beforeApplyingSnapshot?(context)(diff line 207)는 production에서 항상 no-op.
Proof: non-nil로 주입하는 유일한 호출자는 테스트 testSafetySnapshotRestoreFailureAfterWipeRollsBackExistingCollection(test line 213).
Impact: production restore 경로에 죽은 라인이 남고, 의도가 파라미터 이름으로 전달되지 않음. #if DEBUG seam 또는 테스트 서브클래스로 격리 권장. (참고: context.rollback() 추가 자체는 정당한 production 개선이며 문제 아님.)
| static func resetPersistentSyncFlagsForTesting() { | ||
| UserDefaults.standard.removeObject(forKey: hasEverHadUserCollectionKey) | ||
| UserDefaults.standard.removeObject(forKey: recoveryInitiatedAtKey) | ||
| } | ||
| } |
There was a problem hiding this comment.
resetPersistentSyncFlagsForTesting가 UserDefaults.standard(공유 앱 suite)를 변경
Pattern: #if DEBUG resetPersistentSyncFlagsForTesting()가 UserDefaults.standard에서 hasEverHadUserCollectionKey, recoveryInitiatedAtKey를 제거. setUp/tearDown 양쪽에서 호출(test line 10, 14).
Proof: #if DEBUG 스코프는 올바르게 닫힘(diff line 1041-1064). 그러나 수동 QA에 쓰는 실기기에서 Debug 빌드로 make test를 돌리면 실제 앱의 known-user/recovery 영속 플래그를 변조. 다른 testing seam(markImportInProgressForTesting 등)은 in-memory lock만 변경하므로 안전.
Impact: 실기기 개발 반복 시 production 앱 동작 오염 가능(다음 실행 시 빈 UC 생성 여부에 영향). CI 클린 시뮬레이터에서는 낮음. 별도 UserDefaults suite 주입 또는 시뮬레이터 전용 실행 권장.
| _hasEverHadUserCollectionCached.withLock { cached in | ||
| guard cached != newValue else { | ||
| return | ||
| } | ||
| cached = newValue | ||
| UserDefaults.standard.set(newValue, forKey: Self.hasEverHadUserCollectionKey) | ||
| } |
There was a problem hiding this comment.
NIT) hasEverHadUserCollection setter가 OSAllocatedUnfairLock 임계영역 내에서 UserDefaults I/O 수행
비재귀 OSAllocatedUnfairLock.withLock{} 안에서 UserDefaults.standard.set(...) 호출(diff line 110-116). 동일 스레드 재진입 옵저버가 현재 코드베이스에 없어 데드락은 가설적이고, UserDefaults.set은 경량이라 컨텐션도 미미. 다만 잠재적 재진입 트랩이므로 newValue를 스냅샷해 락 밖에서 쓰는 1줄 수정 권장.
| static let waiting = "waiting" | ||
| static let importing = "importing" | ||
| static let reset = "reset" | ||
| static let timedOut = "timed_out" |
There was a problem hiding this comment.
NIT) Log.Snapshot 구조체에 isFirstImportTimedOut 필드 누락(Crashlytics 진단 공백)
isFirstImportTimedOut이 Log.Param/ucCreationSuppressed 이벤트에는 추가됐으나 Log.Snapshot 구조체(Log.swift:169-182)에는 빠져 Crashlytics 세션 custom key 스냅샷에 timeout 차원이 누락. 이 PR이 강화하려는 진단 표면에 공백. snapshot에 필드 추가 권장.
| guard let self else { | ||
| return | ||
| } | ||
| context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy | ||
|
|
There was a problem hiding this comment.
NIT) logSyncDiagnostics 클로저: guard owner 네이밍 미사용 + guard 뒤 빈 줄 누락
[weak self] 클로저의 guard let self else { return }(diff line 634-636)는 컨벤션 1.1.A(owner 사용)와 2.3(guard 뒤 빈 줄)을 위반하나, 이는 기존 single-line guard의 reformat으로 위반이 PR 이전부터 존재(공백만 PR이 도입). 재진입/누수 위험 없음. 라인을 이미 건드리는 김에 기회주의적으로 owner로 변경 + 빈 줄 추가 권장. (CV-1+CV-3 통합)
| // | ||
| // AppEnvironment.swift | ||
| // Animal-Crossing-Wiki | ||
| // | ||
| // Created by Codex on 2026/06/04. | ||
| // |
There was a problem hiding this comment.
NIT) AppEnvironment.swift: 'Created by Codex' 작성자 헤더
신규 파일에 Xcode 자동생성 헤더 블록 포함(diff line 1-6). 다만 저장소 모든 기존 파일이 동일 패턴을 가지므로 헤더 자체는 기존 관례를 따름. 실질 nit은 Created by Codex on 2026/06/04 작성자 줄(AI 툴 이름)이며 프로젝트 표준 작성자로 정렬하거나 헤더 제거(저장소 전체 정리 시) 권장.
| #if DEBUG | ||
| extension CoreDataStorage { |
There was a problem hiding this comment.
NIT) #if DEBUG extension CoreDataStorage — MARK 주석 누락
신규 #if DEBUG extension CoreDataStorage(diff line 1041-1042)에 // MARK: - 없음. 컨벤션 8 적용(신규 코드라 actionable). 예: // MARK: - Testing. 조직화 nit.
| extension CoreDataStorageICloudResetTests { | ||
|
|
There was a problem hiding this comment.
NIT) CoreDataStorageICloudResetTests helper extension — MARK 주석 누락
테스트 helper extension CoreDataStorageICloudResetTests(test line 249-250)에 MARK 없음. 컨벤션 8 nominal 적용이나 테스트 파일이라 가치 낮음. 선택적.
리뷰에서 타당하다고 판단한 항목을 패치한다.
- AppEnvironment: isUnitTesting의 NSClassFromString("XCTestCase") fallback 제거.
테스트 스킴이 IS_UNIT_TESTING=1을 주입하므로 환경 변수만으로 판정해 릴리스
텔레메트리 무력화/향후 UI 테스트 부팅 실패 리스크를 차단.
- SafetySnapshotService: 테스트 전용 beforeApplyingSnapshot seam을 #if DEBUG로
격리(production restore 죽은 코드 제거). backup 제외 실패를 Log.error로 보고.
- CoreDataStorage: 동기화 영속 플래그 저장소를 주입 가능한 userDefaults로 분리해
테스트가 .standard(실제 앱 suite)를 오염시키지 않도록 함. hasEverHadUserCollection
setter의 UserDefaults I/O를 lock 임계영역 밖으로 이동. completeFirstImportWait의
timedOut/waiting 갱신 순서를 fail-safe하게 조정해 TOCTOU 윈도우 제거.
- Log.Snapshot에 isFirstImportTimedOut 추가(Crashlytics 진단 공백 보완),
event/click의 isFirebaseConfigured 가드 스타일 통일.
- logSyncDiagnostics 클로저 owner 네이밍/guard 뒤 빈 줄, #if DEBUG 및 테스트 helper
extension MARK 추가, 신규 파일 작성자 헤더 정렬.
- 테스트: 격리된 UserDefaults suite 주입으로 전환하고 정적 reset helper 제거.
https://claude.ai/code/session_01L4kUZhiUNUtnr18SabrHzR
|
리뷰 코멘트 14개를 검토해, 타당한 12개를 반영했습니다. 변경은 이 브랜치( ✅ 반영 (12)
⏸️ 미반영 (2) — 사유
ℹ️ CI 참고
Generated by Claude Code |
[review] PR #179 리뷰 코멘트 반영 — 테스트 seam 격리·진단·컨벤션 보강
- cleanupOrphanedEntities가 첫 import 후 120초 grace period를 무시하고 일시적 orphan을 삭제할 수 있던 경로 차단 (isWithinGracePeriod 가드 추가) - 성공 import로 UC가 복구되면 recovery grace 타임스탬프를 정리하는 clearRecoveryInitiatedIfRecovered 연결 (기존 clearRecoveryInitiated 데드 API 해소) - 반복 sync 실패로 컬렉션 생성이 억제된 보호 모드 상태를 세션당 1회 Alert로 안내 (isAwaitingCloudDataWithoutCollection + SceneDelegate.handleCloudSyncError) - CoreDataStorage 파일 길이 한도 초과로 Data Recovery extension을 CoreDataStorage+Recovery.swift로 분리 - 회귀 테스트 6개 추가 (총 19개 통과) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
빌드의 SwiftLint autocorrect 단계가 수정한 unneeded_override 등 기계적 변경. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix iCloud sync reset guard * Harden iCloud reset regression coverage * Stabilize iCloud reset regression wait * ♻️ [refactor] PR #179 리뷰 코멘트 반영 — 테스트 seam 격리·진단·컨벤션 보강 리뷰에서 타당하다고 판단한 항목을 패치한다. - AppEnvironment: isUnitTesting의 NSClassFromString("XCTestCase") fallback 제거. 테스트 스킴이 IS_UNIT_TESTING=1을 주입하므로 환경 변수만으로 판정해 릴리스 텔레메트리 무력화/향후 UI 테스트 부팅 실패 리스크를 차단. - SafetySnapshotService: 테스트 전용 beforeApplyingSnapshot seam을 #if DEBUG로 격리(production restore 죽은 코드 제거). backup 제외 실패를 Log.error로 보고. - CoreDataStorage: 동기화 영속 플래그 저장소를 주입 가능한 userDefaults로 분리해 테스트가 .standard(실제 앱 suite)를 오염시키지 않도록 함. hasEverHadUserCollection setter의 UserDefaults I/O를 lock 임계영역 밖으로 이동. completeFirstImportWait의 timedOut/waiting 갱신 순서를 fail-safe하게 조정해 TOCTOU 윈도우 제거. - Log.Snapshot에 isFirstImportTimedOut 추가(Crashlytics 진단 공백 보완), event/click의 isFirebaseConfigured 가드 스타일 통일. - logSyncDiagnostics 클로저 owner 네이밍/guard 뒤 빈 줄, #if DEBUG 및 테스트 helper extension MARK 추가, 신규 파일 작성자 헤더 정렬. - 테스트: 격리된 UserDefaults suite 주입으로 전환하고 정적 reset helper 제거. https://claude.ai/code/session_01L4kUZhiUNUtnr18SabrHzR * 🐛 [fix] 동기화 잔존 버그 수정 — orphan cleanup grace 가드·recovery 플래그 정리·보호 모드 안내 - cleanupOrphanedEntities가 첫 import 후 120초 grace period를 무시하고 일시적 orphan을 삭제할 수 있던 경로 차단 (isWithinGracePeriod 가드 추가) - 성공 import로 UC가 복구되면 recovery grace 타임스탬프를 정리하는 clearRecoveryInitiatedIfRecovered 연결 (기존 clearRecoveryInitiated 데드 API 해소) - 반복 sync 실패로 컬렉션 생성이 억제된 보호 모드 상태를 세션당 1회 Alert로 안내 (isAwaitingCloudDataWithoutCollection + SceneDelegate.handleCloudSyncError) - CoreDataStorage 파일 길이 한도 초과로 Data Recovery extension을 CoreDataStorage+Recovery.swift로 분리 - 회귀 테스트 6개 추가 (총 19개 통과) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 🎨 [style] SwiftLint autocorrect 자동 수정 반영 (Catalog) 빌드의 SwiftLint autocorrect 단계가 수정한 unneeded_override 등 기계적 변경. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 🐛 [fix] 전 기능 영역 버그 일괄 수정 — 데이터 초기화 누락·매핑 오류·크래시·플레이어 옵저버 코드베이스 전수 조사에서 발견·검증된 버그 수정: - Items.reset()이 npcLike relay를 초기화하지 않아 데이터 초기화 후에도 NPC 좋아요 목록이 화면에 남던 문제 수정 - NPCLikeEntity.toDomain()이 genderAsia 대신 gender를 읽어 CoreData 왕복 시 genderAsia가 손실되던 매핑 오류 수정 - CloudKit 부분 import로 DailyTask progressList가 amount보다 짧아지면 toggle 시 index out of range로 크래시하던 문제 수정 — toDomain 정규화 + 모델/storage toggle 길이 보정 (자가 치유) - MusicPlayerManager: block 기반 곡 종료 옵저버가 removeObserver(self)로 제거되지 않아 누적 등록되고(자동 다음 곡 다중 실행), 특정 AVPlayerItem에 바인딩되어 곡 전환 후 알림을 못 받던 문제 수정 — 토큰 보관 + object nil + currentItem 검증, 등록 지점을 player 재생성 시점으로 일원화 - MusicPlayerManager: 잠금화면 아트워크 다운로드를 곡별 disposeBag으로 교체해 이전 곡 이미지가 늦게 도착해 now playing 정보를 덮어쓰던 문제 수정 - VillagersCellReactor/NPCCellReactor: updateLike/updateHouse 시 state를 갱신하지 않아 목록 셀의 하트/집 버튼이 시각적으로 토글되지 않던 문제 수정 - SafetySnapshotService.pendingWorkItem을 OSAllocatedUnfairLock으로 보호하고 flushNow를 전용 queue 동기 실행으로 변경 (멀티스레드 data race 해소) - AppCoordinator: AnimalsCoordinator만 setUpParent 누락되어 있던 비대칭 수정 - DashboardCoordinator가 사용하는 "Unknown error" 로컬라이제이션 키 추가 (ko/en) - 회귀 테스트 2개 추가 (progressList 손상 시나리오, 총 21개 통과) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 📝 [docs] 버그 수정 관련 문서 동기화 — music-player·dashboard·icloud-sync Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: 백종열 <jongyeol@baegjong-yeol-ui-Macmini.local> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Ari <lee_ari95@icloud.com>
요약
앱 재설치, iCloud 일시 오류, CloudKit sync reset/import 실패 상황에서 빈
UserCollectionEntity와 기본DailyTaskEntity가 생성되어 기존 iCloud 데이터를 덮어쓸 수 있는 경로를 차단합니다.실제 iCloud 로그인 계정 없이도 문제 경로를 결정적으로 재현할 수 있도록 CloudKit import/account 상태 seam을 테스트에 주입했고, 해당 회귀 테스트를 로컬
make test와 PRCheck build에 연결했습니다.변경 사항
.temporarilyUnavailable,.couldNotDetermineiCloud 계정 상태를 local-only가 아닌 timeout/unknown 경로로 처리합니다.hasEverHadUserCollection)를 lock으로 보호해 background context 간 race를 막습니다.IS_UNIT_TESTING환경을 분리했습니다.CoreDataStorageICloudResetTests13개를make test,make ci, PRCheck build에서 실행하도록 연결했습니다.fetchTasksexpectation이 흔들리지 않도록 회귀 테스트 대기 시간을 안정화했습니다.재현 및 검증 범위
적대적 검증 반영
테스트 및 CI
make test—CoreDataStorageICloudResetTests13개 통과swiftlint --config .swiftlint.yml --quiet— 통과, 기존 warning만 출력make validate— architecture/pattern/docs 검증 통과git diff --check— 통과Check Build— head6710d5b에서 통과Architecture Check— head6710d5b에서 통과CodeRabbit— pass/skipped남은 체크 상태
Claude Code Review체크는 실패 상태입니다. 로그 원인은 앱 코드나 테스트 실패가 아니라 GitHub Actions/조직 설정 문제입니다.따라서 이 PR의 앱 빌드 및 iCloud reset 회귀 테스트 CI는 통과했지만, 전체 PR 체크는 위 외부 Claude Code 설정 문제가 해결되어야 완전 green이 됩니다.