diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift index c421063..a9a627c 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift @@ -101,19 +101,23 @@ final class CoreDataStorage { /// 한 번이라도 UserCollectionEntity가 존재했는지 여부 (UserDefaults 기반, 메모리 캐싱) /// 이 플래그가 true인데 UC가 0개면, 빈 UC 자동 생성 대신 .notFound를 throw - private let _hasEverHadUserCollectionCached = OSAllocatedUnfairLock( - initialState: UserDefaults.standard.bool(forKey: CoreDataStorage.hasEverHadUserCollectionKey) - ) + /// 초기값은 init에서 주입된 `userDefaults`로부터 읽는다. + private let _hasEverHadUserCollectionCached: OSAllocatedUnfairLock private(set) var hasEverHadUserCollection: Bool { get { _hasEverHadUserCollectionCached.withLock { $0 } } set { - _hasEverHadUserCollectionCached.withLock { cached in + // UserDefaults I/O는 unfair lock 임계영역 밖에서 수행한다 (잠재적 재진입 트랩 회피). + let didChange = _hasEverHadUserCollectionCached.withLock { cached -> Bool in guard cached != newValue else { - return + return false } cached = newValue - UserDefaults.standard.set(newValue, forKey: Self.hasEverHadUserCollectionKey) + return true + } + guard didChange else { + return } + userDefaults.set(newValue, forKey: Self.hasEverHadUserCollectionKey) } } @@ -133,25 +137,25 @@ final class CoreDataStorage { /// 복구 시작 시각 기록 — 재시작 후 grace window 계산에 사용 func markRecoveryInitiated() { - UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: Self.recoveryInitiatedAtKey) + userDefaults.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) + userDefaults.removeObject(forKey: Self.recoveryInitiatedAtKey) } /// 복구 시작 후 grace period 내인지 확인 — 상태 표시에만 사용하고, UC 생성 허용에는 사용하지 않는다. var isWithinRecoveryGracePeriod: Bool { - let timestamp = UserDefaults.standard.double(forKey: Self.recoveryInitiatedAtKey) + let timestamp = userDefaults.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) + userDefaults.removeObject(forKey: Self.recoveryInitiatedAtKey) return false } return true @@ -190,8 +194,19 @@ final class CoreDataStorage { private let injectedPersistentContainer: NSPersistentCloudKitContainer? - private init(persistentContainer: NSPersistentCloudKitContainer? = nil) { + /// 동기화 영속 플래그(known-user/recovery)의 저장소. production은 `.standard`, + /// 테스트는 격리된 suite를 주입해 실기기의 실제 앱 상태를 오염시키지 않는다. + private let userDefaults: UserDefaults + + private init( + persistentContainer: NSPersistentCloudKitContainer? = nil, + userDefaults: UserDefaults = .standard + ) { self.injectedPersistentContainer = persistentContainer + self.userDefaults = userDefaults + self._hasEverHadUserCollectionCached = OSAllocatedUnfairLock( + initialState: userDefaults.bool(forKey: Self.hasEverHadUserCollectionKey) + ) } lazy var persistentContainer: NSPersistentCloudKitContainer = { @@ -284,13 +299,16 @@ final class CoreDataStorage { /// Fresh install 첫 CloudKit import 대기 종료. /// timeout은 데이터 없음의 증거가 아니므로 UC 생성을 계속 억제한다. func completeFirstImportWait(reason: FirstImportWaitCompletionReason) { - isWaitingForFirstImport = false + // timedOut 플래그를 먼저 확정한 뒤 waiting을 해제한다. 순서를 뒤집으면 두 플래그가 + // 모두 false가 되는 찰나에 shouldSuppressDataCreation이 잠깐 풀리는 TOCTOU 윈도우가 + // 생긴다 (timeout 경로). 이 순서면 억제는 항상 fail-safe하게 유지된다. switch reason { case .importArrived, .noICloud: isFirstImportTimedOut = false case .timeout: isFirstImportTimedOut = lastSuccessfulImportDate == nil } + isWaitingForFirstImport = false Log.info("completeFirstImportWait reason=\(reason)") } @@ -466,6 +484,8 @@ final class CoreDataStorage { guard succeeded else { // Failed import means CloudKit data is still unknown. Keep waiting/timeout/reset // suppression flags so the app cannot create and export an empty UC after grace. + // _exportRetryCount is export-only (never incremented by import), so the success + // path's reset is intentionally absent here. logSyncDiagnostics(phase: "Import-failed", throttled: false) return } @@ -631,12 +651,13 @@ extension CoreDataStorage { } persistentContainer.performBackgroundTask { [weak self] context in - guard let self else { + guard let owner = self else { return } + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy - let counts = self.entityCounts(in: context) + let counts = owner.entityCounts(in: context) let ucCount = counts["UserCollectionEntity"] ?? -1 let itemCount = counts["ItemEntity"] ?? -1 @@ -655,14 +676,15 @@ extension CoreDataStorage { itemCount: itemCount, taskCount: counts["DailyTaskEntity"] ?? -1, villagerCount: (counts["VillagersLikeEntity"] ?? 0) + (counts["VillagersHouseEntity"] ?? 0), - hasEverHadUC: self.hasEverHadUserCollection, + hasEverHadUC: owner.hasEverHadUserCollection, isFreshInstall: nil, - isWaitingForFirstImport: self.isWaitingForFirstImport, - isImportInProgress: self.isImportInProgress, - isSyncResetInProgress: self.isSyncResetInProgress, - isWithinRecoveryGracePeriod: self.isWithinRecoveryGracePeriod, - lastImportDate: self.lastSuccessfulImportDate, - lastExportDate: self.lastSuccessfulExportDate + isWaitingForFirstImport: owner.isWaitingForFirstImport, + isFirstImportTimedOut: owner.isFirstImportTimedOut, + isImportInProgress: owner.isImportInProgress, + isSyncResetInProgress: owner.isSyncResetInProgress, + isWithinRecoveryGracePeriod: owner.isWithinRecoveryGracePeriod, + lastImportDate: owner.lastSuccessfulImportDate, + lastExportDate: owner.lastSuccessfulExportDate )) // UC가 2개 이상일 때만 상세 진단 (중복 탐지) @@ -1039,9 +1061,13 @@ extension CoreDataStorage { } #if DEBUG +// MARK: - Testing extension CoreDataStorage { - convenience init(testingPersistentContainer: NSPersistentCloudKitContainer) { - self.init(persistentContainer: testingPersistentContainer) + convenience init( + testingPersistentContainer: NSPersistentCloudKitContainer, + userDefaults: UserDefaults = .standard + ) { + self.init(persistentContainer: testingPersistentContainer, userDefaults: userDefaults) } func markImportInProgressForTesting() { @@ -1055,11 +1081,6 @@ extension CoreDataStorage { func finishCloudImportForTesting(succeeded: Bool) { finishCloudImport(succeeded: succeeded) } - - static func resetPersistentSyncFlagsForTesting() { - UserDefaults.standard.removeObject(forKey: hasEverHadUserCollectionKey) - UserDefaults.standard.removeObject(forKey: recoveryInitiatedAtKey) - } } #endif diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift index 7d426d0..582b9ab 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/SafetySnapshot/SafetySnapshotService.swift @@ -52,7 +52,13 @@ final class SafetySnapshotService { private let containerProvider: () -> NSPersistentContainer private let snapshotDirectoryProvider: () -> URL? private let fileAttributeSetter: (URL, [FileAttributeKey: Any]) throws -> Void - private let beforeApplyingSnapshot: ((NSManagedObjectContext) throws -> Void)? + + #if DEBUG + /// 테스트 전용 fault-injection seam. `restore()`가 기존 데이터를 wipe한 직후, + /// 스냅샷을 적용하기 직전에 호출한다. production 빌드에는 컴파일되지 않으므로 + /// 복원 경로에 죽은 코드가 남지 않는다. + var beforeApplyingSnapshotForTesting: ((NSManagedObjectContext) throws -> Void)? + #endif init( containerProvider: @escaping () -> NSPersistentContainer = { CoreDataStorage.shared.persistentContainer }, @@ -61,13 +67,11 @@ final class SafetySnapshotService { }, 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 } private var container: NSPersistentContainer { @@ -178,9 +182,12 @@ final class SafetySnapshotService { do { try url.setResourceValues(values) } catch { - os_log(.error, log: .default, - "🛟 SafetySnapshot resource value update failed: %{public}@", - error.localizedDescription) + // 백업 제외(isExcludedFromBackup) 회귀를 Crashlytics에서 관측할 수 있도록 비치명 에러로 보고한다. + // 파일 보호 등급은 write 옵션 + setAttributes로 이미 적용되므로 암호화 경계 자체는 유지된다. + Log.error( + name: "SafetySnapshotBackupExclusion", + reason: error.localizedDescription + ) } } @@ -204,7 +211,9 @@ final class SafetySnapshotService { let data = try Data(contentsOf: self.snapshotURL) let snapshot = try UserCollectionSnapshot.from(data: data) try Self.wipeExistingCollection(in: context) - try self.beforeApplyingSnapshot?(context) + #if DEBUG + try self.beforeApplyingSnapshotForTesting?(context) + #endif try snapshot.apply(to: context) try context.save() os_log(.error, log: .default, diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift index 317ca04..85cb443 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/AppEnvironment.swift @@ -2,14 +2,19 @@ // AppEnvironment.swift // Animal-Crossing-Wiki // -// Created by Codex on 2026/06/04. +// Created by Ari on 2026/06/04. // import Foundation enum AppEnvironment { + /// 단위 테스트 실행 여부. + /// + /// 테스트 스킴(`Project.swift`)이 `IS_UNIT_TESTING=1`을 주입하므로 환경 변수만으로 판정한다. + /// `NSClassFromString("XCTestCase")` 같은 런타임 probe는 (1) XCTest를 링크하는 향후 UI 테스트에서 + /// 앱이 빈 화면으로 부팅되고, (2) 릴리스 바이너리에 XCTest가 새어 들어갈 경우 크래시 리포팅/애널리틱스가 + /// 조용히 비활성화될 수 있어 사용하지 않는다. static var isUnitTesting: Bool { ProcessInfo.processInfo.environment["IS_UNIT_TESTING"] == "1" - || NSClassFromString("XCTestCase") != nil } } diff --git a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift index 3cc30a0..b4030ad 100644 --- a/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift +++ b/Animal-Crossing-Wiki/Projects/App/Sources/Utility/Log.swift @@ -57,6 +57,7 @@ enum Log { static let hasEverHadUC = "sync_has_ever_had_uc" static let isFreshInstall = "sync_is_fresh_install" static let isWaitingForFirstImport = "sync_waiting_first_import" + static let isFirstImportTimedOut = "sync_first_import_timed_out" static let isImportInProgress = "sync_import_in_progress" static let isSyncResetInProgress = "sync_reset_in_progress" static let isWithinRecoveryGracePeriod = "sync_within_recovery_grace" @@ -159,22 +160,24 @@ enum Log { // MARK: - Analytics static func event(_ event: Event, parameters: [String: Any] = [:]) { - if isFirebaseConfigured { - Analytics.logEvent(event.rawValue, parameters: parameters) - crashlytics?.log("[EVENT] \(event.rawValue) \(parameters)") - } os_log(.info, log: .default, "📈 %{public}@", event.rawValue) + guard isFirebaseConfigured else { + return + } + Analytics.logEvent(event.rawValue, parameters: parameters) + crashlytics?.log("[EVENT] \(event.rawValue) \(parameters)") } /// 사용자 탭/클릭 추적. Firebase Analytics의 `select_content` 스키마로 기록. static func click(_ name: String, parameters: [String: Any] = [:]) { + os_log(.info, log: .default, "👆 click=%{public}@", name) + guard isFirebaseConfigured else { + return + } var params = parameters params[AnalyticsParameterItemID] = name params[AnalyticsParameterContentType] = "click" - if isFirebaseConfigured { - Analytics.logEvent(AnalyticsEventSelectContent, parameters: params) - } - os_log(.info, log: .default, "👆 click=%{public}@", name) + Analytics.logEvent(AnalyticsEventSelectContent, parameters: params) } // MARK: - Context (Crashlytics custom keys) @@ -196,6 +199,7 @@ enum Log { var hasEverHadUC: Bool var isFreshInstall: Bool? var isWaitingForFirstImport: Bool + var isFirstImportTimedOut: Bool var isImportInProgress: Bool var isSyncResetInProgress: Bool var isWithinRecoveryGracePeriod: Bool @@ -213,6 +217,7 @@ enum Log { setContext(Key.isFreshInstall, isFreshInstall) } setContext(Key.isWaitingForFirstImport, snapshot.isWaitingForFirstImport) + setContext(Key.isFirstImportTimedOut, snapshot.isFirstImportTimedOut) setContext(Key.isImportInProgress, snapshot.isImportInProgress) setContext(Key.isSyncResetInProgress, snapshot.isSyncResetInProgress) setContext(Key.isWithinRecoveryGracePeriod, snapshot.isWithinRecoveryGracePeriod) diff --git a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift index fefcb1b..004b4f1 100644 --- a/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift +++ b/Animal-Crossing-Wiki/Projects/App/Tests/CoreDataStorage/CoreDataStorageICloudResetTests.swift @@ -5,13 +5,21 @@ import RxSwift final class CoreDataStorageICloudResetTests: XCTestCase { + /// 테스트마다 격리된 UserDefaults suite. `.standard`(실제 앱 suite)를 건드리지 않아 + /// 실기기에서 `make test`를 돌려도 production 앱의 known-user/recovery 플래그를 오염시키지 않는다. + private var testSuiteName: String! + private var testDefaults: UserDefaults! + override func setUp() { super.setUp() - CoreDataStorage.resetPersistentSyncFlagsForTesting() + testSuiteName = "CoreDataStorageICloudResetTests.\(UUID().uuidString)" + testDefaults = UserDefaults(suiteName: testSuiteName) } override func tearDown() { - CoreDataStorage.resetPersistentSyncFlagsForTesting() + testDefaults.removePersistentDomain(forName: testSuiteName) + testDefaults = nil + testSuiteName = nil super.tearDown() } @@ -209,9 +217,9 @@ final class CoreDataStorageICloudResetTests: XCTestCase { let service = SafetySnapshotService( containerProvider: { storage.persistentContainer }, - snapshotDirectoryProvider: { snapshotDirectory }, - beforeApplyingSnapshot: { _ in throw InjectedRestoreFailure.failure } + snapshotDirectoryProvider: { snapshotDirectory } ) + service.beforeApplyingSnapshotForTesting = { _ in throw InjectedRestoreFailure.failure } let expectation = expectation(description: "restore completes") service.restore { outcome in @@ -246,6 +254,8 @@ final class CoreDataStorageICloudResetTests: XCTestCase { } } +// MARK: - Helpers + extension CoreDataStorageICloudResetTests { private func makeStorage() throws -> CoreDataStorage { @@ -264,7 +274,7 @@ extension CoreDataStorageICloudResetTests { throw loadError } - return CoreDataStorage(testingPersistentContainer: container) + return CoreDataStorage(testingPersistentContainer: container, userDefaults: testDefaults) } private func makeManagedObjectModel() throws -> NSManagedObjectModel {