Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ final class CoreDataStorage {
/// ์˜๋„์  ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” ์‹œ ํ˜ธ์ถœ โ€” ์ƒˆ UC ์ƒ์„ฑ์„ ๋‹ค์‹œ ํ—ˆ์šฉ
func clearHasEverHadUserCollection() {
hasEverHadUserCollection = false
clearUCMissingObservation()
Log.info("hasEverHadUserCollection cleared (intentional reset)")
}

Expand Down Expand Up @@ -143,6 +144,25 @@ final class CoreDataStorage {
return true
}

// MARK: - UC Missing Self-Heal

private static let ucMissingFirstObservedAtKey = "CoreDataStorage_ucMissingFirstObservedAt"

/// UC ๋ถ€์žฌ ์ƒํƒœ(hasEverHadUC=true && UC=0)๊ฐ€ ์ด ์‹œ๊ฐ„ ์ด์ƒ ์ง€์†๋˜๋ฉด
/// import ์„ฑ๊ณต ์—ฌ๋ถ€์™€ ๋ฌด๊ด€ํ•˜๊ฒŒ ์ƒˆ UC ์ƒ์„ฑ์„ ํ—ˆ์šฉํ•˜๋Š” ๋ฐฑ์Šคํ†ฑ (24์‹œ๊ฐ„)
private static let ucMissingSelfHealSeconds: TimeInterval = 60 * 60 * 24

/// ๋งˆ์ง€๋ง‰์œผ๋กœ ํ™•์ธํ•œ iCloud ๊ณ„์ • ์ƒํƒœ โ€” checkiCloudAccountStatus()์—์„œ ๊ฐฑ์‹ .
/// ๊ณ„์ •์ด ์—†์œผ๋ฉด CloudKit re-import๊ฐ€ ์˜์›ํžˆ ์˜ค์ง€ ์•Š์œผ๋ฏ€๋กœ self-heal ํŒ์ •์— ์‚ฌ์šฉ.
private let _lastKnownAccountStatus = OSAllocatedUnfairLock<CKAccountStatus?>(initialState: nil)
private(set) var lastKnownAccountStatus: CKAccountStatus? {
get { _lastKnownAccountStatus.withLock { $0 } }
set { _lastKnownAccountStatus.withLock { $0 = newValue } }
}

/// Import ์™„๋ฃŒ ํ›„์—๋„ UC๊ฐ€ ์—†์„ ๋•Œ grace period ๋งŒ๋ฃŒ ์งํ›„ Path-B ๊ฐฑ์‹ ์„ ์žฌํŠธ๋ฆฌ๊ฑฐํ•˜๋Š” ํƒ€์ด๋จธ
private var selfHealRecheckWorkItem: DispatchWorkItem?

/// ์ฒซ Import ์™„๋ฃŒ ํ›„ UC ์ƒ์„ฑ/๊ธฐ๋ณธ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ์„ ์œ ์˜ˆํ•˜๋Š” ์‹œ๊ฐ„ (์ดˆ)
private static let gracePeriodSeconds: TimeInterval = 120

Expand All @@ -160,6 +180,55 @@ final class CoreDataStorage {
isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress || isWithinGracePeriod
}

// MARK: - UC Missing Self-Heal Judgement

/// "๊ธฐ์กด ์œ ์ €์ธ๋ฐ UC๊ฐ€ 0๊ฐœ"์ธ ์ƒํƒœ์—์„œ CloudKit re-import๊ฐ€ UC๋ฅผ ๋˜๋Œ๋ ค์ค„ ๊ฐ€๋Šฅ์„ฑ์ด
/// ์‚ฌ์‹ค์ƒ ์—†๋‹ค๊ณ  ํŒ๋‹จ๋˜๋ฉด self-heal(์ƒˆ UC ์ƒ์„ฑ ํ—ˆ์šฉ) ์‚ฌ์œ ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ์•„๋‹ˆ๋ฉด nil.
///
/// ์ด ํŒ์ •์ด ์—†์œผ๋ฉด ํด๋ผ์šฐ๋“œ์— UC๊ฐ€ ์—†๋Š” ์‚ฌ์šฉ์ž๋Š” ๋ชจ๋“  ์ €์žฅ์ด ์˜๊ตฌ์ ์œผ๋กœ ์‹คํŒจํ•˜๋Š”
/// ๋ธŒ๋ฆญ ์ƒํƒœ์— ๋น ์ง„๋‹ค (์„ค์ • ํ™”๋ฉด "iCloud ๋ฐ์ดํ„ฐ ๋Œ€๊ธฐ ์ค‘..."์ด ์˜์›ํžˆ ์ง€์†).
private func ucMissingSelfHealReason() -> String? {
// ์ด ์„ธ์…˜์—์„œ CloudKit Import๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋๋Š”๋ฐ๋„ UC๊ฐ€ ์—†์Œ.
// grace period(์ฒซ Import ํ›„ 120์ดˆ)๋ฅผ ์ด๋ฏธ ํ†ต๊ณผํ•œ ๋’ค์—๋งŒ ๋„๋‹ฌํ•˜๋ฏ€๋กœ,
// ํ›„์† import batch๋ฅผ ๊ธฐ๋‹ค๋ฆด ๋งŒํผ ๊ธฐ๋‹ค๋ฆฐ ์ƒํƒœ โ†’ ํด๋ผ์šฐ๋“œ์— ๋ณต์›ํ•  UC๊ฐ€ ์—†๋‹ค๊ณ  ํŒ๋‹จ.
if lastSuccessfulImportDate != nil {
return "import_settled"
}
// iCloud ๊ณ„์ •์ด ์—†๊ฑฐ๋‚˜ ์ œํ•œ๋จ โ€” re-import ์ž์ฒด๊ฐ€ ์˜ฌ ์ˆ˜ ์—†์œผ๋ฏ€๋กœ ์ฆ‰์‹œ ํ—ˆ์šฉ.
// (๊ณ„์ • ๋ฏธ๋กœ๊ทธ์ธ ์‹œ NSPersistentCloudKitContainer๊ฐ€ ๋กœ์ปฌ ๋ฏธ๋Ÿฌ ๋ฐ์ดํ„ฐ๋ฅผ ์ œ๊ฑฐํ•˜๋Š”
// ๊ฒฝ์šฐ๊ฐ€ ์žˆ์–ด, ์ด ์ผ€์ด์Šค๋ฅผ ๋ง‰์œผ๋ฉด ๋น„ iCloud ์‚ฌ์šฉ์ž๋Š” ์•ฑ์„ ์•„์˜ˆ ์“ธ ์ˆ˜ ์—†๊ฒŒ ๋จ)
if let status = lastKnownAccountStatus, status == .noAccount || status == .restricted {
return "no_icloud_account"
}
// ๋ฐฑ์Šคํ†ฑ: UC ๋ถ€์žฌ ์ƒํƒœ๊ฐ€ 24์‹œ๊ฐ„ ๋„˜๊ฒŒ ์ง€์† (์˜คํ”„๋ผ์ธ ๋“ฑ ์œ„ ์กฐ๊ฑด์œผ๋กœ ๋ชป ์žก๋Š” ์ผ€์ด์Šค)
let observedAt = UserDefaults.standard.double(forKey: Self.ucMissingFirstObservedAtKey)
if observedAt > 0 {
let elapsed = Date().timeIntervalSince1970 - observedAt
if elapsed > Self.ucMissingSelfHealSeconds {
return "persisted_24h"
}
if elapsed < 0 {
// ์‹œ๊ณ„ ์—ญํ–‰ โ€” ํƒ€์ž„์Šคํƒฌํ”„ ์žฌ๊ธฐ๋ก
markUCMissingObserved(force: true)
}
}
return nil
}

/// UC ๋ถ€์žฌ ์ƒํƒœ๋ฅผ ์ฒ˜์Œ ๊ด€์ธกํ•œ ์‹œ๊ฐ์„ ๊ธฐ๋ก (24์‹œ๊ฐ„ ๋ฐฑ์Šคํ†ฑ ํŒ์ •์šฉ, ์ตœ์ดˆ 1ํšŒ๋งŒ)
private func markUCMissingObserved(force: Bool = false) {
let defaults = UserDefaults.standard
guard force || defaults.double(forKey: Self.ucMissingFirstObservedAtKey) <= 0 else { return }
defaults.set(Date().timeIntervalSince1970, forKey: Self.ucMissingFirstObservedAtKey)
}

/// UC๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์กฐํšŒ๋˜๋ฉด ๋ถ€์žฌ ๊ด€์ธก ๊ธฐ๋ก์„ ์ •๋ฆฌ
private func clearUCMissingObservation() {
let defaults = UserDefaults.standard
guard defaults.object(forKey: Self.ucMissingFirstObservedAtKey) != nil else { return }
defaults.removeObject(forKey: Self.ucMissingFirstObservedAtKey)
}

Comment on lines +190 to +231

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ“ Maintainability & Code Quality | ๐ŸŸก Minor | โšก Quick win

Self-heal decision logic looks correct. Backward clock-skew is re-recorded, and each reason gates cleanly before the .notFound fallthrough.

Minor: SwiftLint conditional_returns_on_newline flags the same-line guard โ€ฆ else { return } on Line 221 and Line 228 (and also Line 514/516/518 in scheduleSelfHealRecheckIfNeeded). These are auto-fixable via swiftlint --config .swiftlint.yml --fix.

As per coding guidelines ("SwiftLint ์„ค์ •์— ๋”ฐ๋ผ ๋ฆฐํŠธ ๊ฒ€์‚ฌ๋ฅผ ์ˆ˜ํ–‰ํ•˜๊ณ , ์ž๋™ ์ˆ˜์ • ๊ฐ€๋Šฅํ•œ ๋ฌธ์ œ๋Š” swiftlint --config .swiftlint.yml --fix๋กœ ์ฒ˜๋ฆฌ").

๐Ÿงฐ Tools
๐Ÿช› SwiftLint (0.65.0)

[Warning] 221-221: Conditional statements should always return on the next line

(conditional_returns_on_newline)


[Warning] 228-228: Conditional statements should always return on the next line

(conditional_returns_on_newline)

๐Ÿค– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Animal-Crossing-Wiki/Projects/App/Sources/CoreDataStorage/CoreDataStorage.swift`
around lines 190 - 231, SwiftLint is flagging same-line guard-else returns in
the self-heal helpers, so update the affected `guard` statements in
`markUCMissingObserved`, `clearUCMissingObservation`, and the related
`scheduleSelfHealRecheckIfNeeded` logic to use newline-form `else` blocks that
satisfy `conditional_returns_on_newline`; then run the projectโ€™s SwiftLint
auto-fix flow to apply the formatting consistently.

Sources: Coding guidelines, Linters/SAST tools

// 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.
Expand Down Expand Up @@ -211,10 +280,11 @@ final class CoreDataStorage {
// MARK: - iCloud Account Status

func checkiCloudAccountStatus(completion: ((CKAccountStatus) -> Void)? = nil) {
CKContainer(identifier: "iCloud.leeari.NookPortalPlus").accountStatus { status, error in
CKContainer(identifier: "iCloud.leeari.NookPortalPlus").accountStatus { [weak self] status, error in
if let error {
os_log(.error, log: .default, "iCloud account status check failed: %{public}@", error.localizedDescription)
}
self?.lastKnownAccountStatus = status
DispatchQueue.main.async {
completion?(status)
}
Expand Down Expand Up @@ -415,6 +485,8 @@ final class CoreDataStorage {
object: nil,
userInfo: hasChanges ? ["hasChanges": true] : nil
)

scheduleSelfHealRecheckIfNeeded()
}
if event.type == .export {
if event.error != nil {
Expand All @@ -432,6 +504,35 @@ final class CoreDataStorage {
}
}

// MARK: - UC Self-Heal Recheck

/// Import๊ฐ€ ๋๋‚ฌ๋Š”๋ฐ UC๊ฐ€ ์—ฌ์ „ํžˆ ์—†์œผ๋ฉด, grace period ๋งŒ๋ฃŒ ์งํ›„ Path-B ๊ฐฑ์‹ ์„
/// ํ•œ ๋ฒˆ ๋” ํŠธ๋ฆฌ๊ฑฐํ•œ๋‹ค. ์ด ์‹œ์ ์˜ getUserCollection()์—์„œ self-heal ์กฐ๊ฑด
/// (import_settled)์ด ์ถฉ์กฑ๋˜์–ด ์ƒˆ UC๊ฐ€ ์ƒ์„ฑ๋˜๊ณ  UI๊ฐ€ ์ฑ„์›Œ์ง„๋‹ค.
/// ์ด ์žฌํŠธ๋ฆฌ๊ฑฐ๊ฐ€ ์—†์œผ๋ฉด ์‚ฌ์šฉ์ž๊ฐ€ ํ™”๋ฉด์„ ์กฐ์ž‘ํ•  ๋•Œ๊นŒ์ง€ ์•ฑ์ด ๋นˆ ์ƒํƒœ๋กœ ๋‚จ๋Š”๋‹ค.
private func scheduleSelfHealRecheckIfNeeded() {
guard hasEverHadUserCollection else { return }
persistentContainer.performBackgroundTask { [weak self] context in
guard let self else { return }
let ucCount = (try? context.count(for: UserCollectionEntity.fetchRequest())) ?? 0
guard ucCount == 0 else { return }

DispatchQueue.main.async {
self.selfHealRecheckWorkItem?.cancel()
let workItem = DispatchWorkItem {
os_log(.info, log: .default,
"๐Ÿ”„ Self-heal recheck โ€” re-triggering data refresh after grace period")
NotificationCenter.default.post(name: Self.didReceiveRemoteChanges, object: nil)
}
self.selfHealRecheckWorkItem = workItem
DispatchQueue.main.asyncAfter(
deadline: .now() + Self.gracePeriodSeconds + 5,
execute: workItem
)
}
}
}

// MARK: - Export Retry

private func retryExportAfterMergeError() {
Expand Down Expand Up @@ -898,6 +999,7 @@ extension CoreDataStorage {
if !hasEverHadUserCollection {
hasEverHadUserCollection = true
}
clearUCMissingObservation()
return existing
}

Expand Down Expand Up @@ -939,8 +1041,26 @@ extension CoreDataStorage {
logSyncDiagnostics(phase: "UC-created-recovery", throttled: false)
return UserCollectionEntity(UserInfo(), context: context)
}

// Self-heal: CloudKit re-import๊ฐ€ UC๋ฅผ ๋˜๋Œ๋ ค์ค„ ๊ฐ€๋Šฅ์„ฑ์ด ์—†๋‹ค๊ณ  ํŒ๋‹จ๋˜๋ฉด
// ์ƒˆ UC ์ƒ์„ฑ์„ ํ—ˆ์šฉํ•œ๋‹ค. ์ด ํŒ์ •์ด ์—†์œผ๋ฉด ํด๋ผ์šฐ๋“œ์— UC๊ฐ€ ์—†๋Š” ์‚ฌ์šฉ์ž๋Š”
// ๋ชจ๋“  ์ €์žฅ/๋™๊ธฐํ™”๊ฐ€ ์˜๊ตฌ์ ์œผ๋กœ ์‹คํŒจํ•˜๋Š” ๋ธŒ๋ฆญ ์ƒํƒœ์— ๋น ์ง„๋‹ค.
// (์ƒˆ UC๋Š” ์ƒˆ CKRecord๋กœ export๋˜๋ฏ€๋กœ ํด๋ผ์šฐ๋“œ์˜ ๊ธฐ์กด UC๋ฅผ ๋ฎ์–ด์“ฐ์ง€ ์•Š์œผ๋ฉฐ,
// ์ดํ›„ import๋กœ ๊ธฐ์กด UC๊ฐ€ ๋„์ฐฉํ•ด๋„ relationship์ด ๋งŽ์€ ์ชฝ์„ ์šฐ์„  ์กฐํšŒ +
// ์ˆ˜๋™ "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ"๋กœ ๋ณ‘ํ•ฉ ๊ฐ€๋Šฅ)
if let healReason = ucMissingSelfHealReason() {
Log.warning("UC missing self-heal โ€” creating new UC (reason=\(healReason))")
Log.event(.ucCreated, parameters: [
Log.Param.path: UCCreationPath.selfHeal.rawValue,
Log.Param.reason: healReason
])
logSyncDiagnostics(phase: "UC-created-self-heal", throttled: false)
return UserCollectionEntity(UserInfo(), context: context)
}

// ํ•ต์‹ฌ ๋ฐ์ดํ„ฐ ์œ ์‹ค ์ฆ์ƒ: "๊ธฐ์กด ์œ ์ €์ธ๋ฐ UC๊ฐ€ ์‚ฌ๋ผ์ง".
// 3.2.0 ์ดํ›„ ํด๋ ˆ์ž„์˜ ์ฃผ ์ฆ์ƒ์œผ๋กœ ์ถ”์ •๋˜๋Š” ์ƒํƒœ.
markUCMissingObserved()
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)
Expand Down Expand Up @@ -1059,6 +1179,7 @@ extension CoreDataStorage {
private enum UCCreationPath: String {
case freshUser = "fresh_user"
case recoveryGrace = "recovery_grace"
case selfHeal = "self_heal"
}

private enum SuppressionReason: String {
Expand Down
28 changes: 26 additions & 2 deletions docs/features/icloud-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ waitForCloudKitImport(timeout: 10)
2. `isImportInProgress` โ€” Import๊ฐ€ ์ง„ํ–‰ ์ค‘ (timeout ํ›„์—๋„ import๊ฐ€ ๋๋‚˜์ง€ ์•Š์€ ๊ฒฝ์šฐ)
3. `isSyncResetInProgress` โ€” Change Token Expired ํ›„ re-import ๋Œ€๊ธฐ
4. `_firstImportCompletedAt` grace period โ€” ์ฒซ Import ์™„๋ฃŒ ํ›„ 120์ดˆ๊ฐ„ UC ์ƒ์„ฑ ์œ ์˜ˆ
5. `hasEverHadUserCollection` โ€” ๊ธฐ์กด ์œ ์ € ๋ณดํ˜ธ (์•„๋ž˜ ์ฐธ์กฐ)
5. `hasEverHadUserCollection` โ€” ๊ธฐ์กด ์œ ์ € ๋ณดํ˜ธ (์•„๋ž˜ ์ฐธ์กฐ, Self-Heal ์˜ˆ์™ธ ์žˆ์Œ)

๋ชจ๋“  Storage ํ˜ธ์ถœ์€ `.notFound` ์—๋Ÿฌ๋ฅผ gracefulํ•˜๊ฒŒ ์ฒ˜๋ฆฌ (`try?` โ†’ nil, do-catch โ†’ `os_log`).
Import ์™„๋ฃŒ ํ›„ Path-B(`setUpUserCollection`)๊ฐ€ ์žฌ์‹คํ–‰๋˜์–ด ๋ฐ์ดํ„ฐ๊ฐ€ ์ •์ƒ ๋กœ๋“œ๋จ.
Expand All @@ -182,6 +182,30 @@ Import ์™„๋ฃŒ ํ›„ Path-B(`setUpUserCollection`)๊ฐ€ ์žฌ์‹คํ–‰๋˜์–ด ๋ฐ์ดํ„ฐ๊ฐ€
- CloudKit re-import์ด ์™„๋ฃŒ๋˜๋ฉด ์ •์ƒ ๋ณต๊ตฌ๋จ
- `performCloudKitRecovery()`์—์„œ๋„ ํ”Œ๋ž˜๊ทธ ์œ ์ง€ (๋ณต๊ตฌ = ๊ธฐ์กด ์œ ์ €)

### UC Missing Self-Heal (์˜๊ตฌ ๋ธŒ๋ฆญ ๋ฐฉ์ง€)

**๋ฌธ์ œ**: `hasEverHadUserCollection=true`์ธ๋ฐ CloudKit re-import๊ฐ€ UC๋ฅผ ๋˜๋Œ๋ ค์ฃผ์ง€ ๋ชปํ•˜๋Š” ๊ฒฝ์šฐ
(ํด๋ผ์šฐ๋“œ์— UC๊ฐ€ ์—†์Œ, iCloud ๋กœ๊ทธ์•„์›ƒ/์ œํ•œ, ๊ณ„์ • ์ „ํ™˜์œผ๋กœ ์ธํ•œ ๋กœ์ปฌ ๋ฏธ๋Ÿฌ purge ๋“ฑ),
`getUserCollection()`์ด ์˜๊ตฌํžˆ `.notFound`๋ฅผ throwํ•˜์—ฌ **๋ชจ๋“  ์ €์žฅ์ด ์กฐ์šฉํžˆ ์‹คํŒจ**ํ•˜๋Š” ๋ธŒ๋ฆญ ์ƒํƒœ๊ฐ€ ๋จ.
์„ค์ • ํ™”๋ฉด์€ "iCloud ๋ฐ์ดํ„ฐ ๋Œ€๊ธฐ ์ค‘..."์ด ์˜์›ํžˆ ์ง€์†๋˜๊ณ , ๋งค ์‹คํ–‰๋งˆ๋‹ค fresh-install ์Šคํ”Œ๋ž˜์‹œ๊ฐ€ ๋œธ.

**ํ•ด๊ฒฐ**: re-import๊ฐ€ ์˜ฌ ๊ฐ€๋Šฅ์„ฑ์ด ์‚ฌ์‹ค์ƒ ์—†๋‹ค๊ณ  ํŒ๋‹จ๋˜๋ฉด ์ƒˆ UC ์ƒ์„ฑ์„ ํ—ˆ์šฉ (`ucMissingSelfHealReason()`):

| ์‚ฌ์œ  (Analytics `reason`) | ์กฐ๊ฑด |
|---------------------------|------|
| `import_settled` | ์ด ์„ธ์…˜์—์„œ CloudKit Import๊ฐ€ ์„ฑ๊ณตํ–ˆ๊ณ  grace period(120์ดˆ)๋„ ์ง€๋‚ฌ๋Š”๋ฐ UC๊ฐ€ ์—†์Œ โ†’ ํด๋ผ์šฐ๋“œ์— ๋ณต์›ํ•  UC๊ฐ€ ์—†๋‹ค๊ณ  ํŒ๋‹จ |
| `no_icloud_account` | ๋งˆ์ง€๋ง‰ ํ™•์ธ๋œ ๊ณ„์ • ์ƒํƒœ๊ฐ€ `.noAccount`/`.restricted` โ†’ re-import ์ž์ฒด๊ฐ€ ๋ถˆ๊ฐ€๋Šฅ |
| `persisted_24h` | UC ๋ถ€์žฌ ์ƒํƒœ๊ฐ€ 24์‹œ๊ฐ„ ์ด์ƒ ์ง€์† (UserDefaults ํƒ€์ž„์Šคํƒฌํ”„ ๊ธฐ๋ฐ˜ ๋ฐฑ์Šคํ†ฑ) |

- ์ƒˆ UC๋Š” ์ƒˆ CKRecord๋กœ Export๋˜๋ฏ€๋กœ ํด๋ผ์šฐ๋“œ์˜ ๊ธฐ์กด UC ๋ ˆ์ฝ”๋“œ๋ฅผ **๋ฎ์–ด์“ฐ์ง€ ์•Š์Œ** โ€” ์ดํ›„ import๋กœ
๊ธฐ์กด UC๊ฐ€ ๋„์ฐฉํ•˜๋ฉด relationship์ด ๋งŽ์€ ์ชฝ์„ ์šฐ์„  ์กฐํšŒํ•˜๊ณ , ์ˆ˜๋™ "์ค‘๋ณต/๊ณ ์•„ ๋ฐ์ดํ„ฐ ์ •๋ฆฌ"๋กœ ๋ณ‘ํ•ฉ ๊ฐ€๋Šฅ
- UC ๋ถ€์žฌ ์ตœ์ดˆ ๊ด€์ธก ์‹œ๊ฐ์€ `CoreDataStorage_ucMissingFirstObservedAt`(UserDefaults)์— ๊ธฐ๋ก,
UC๊ฐ€ ์ •์ƒ ์กฐํšŒ๋˜๋ฉด ์ž๋™ ์ •๋ฆฌ
- ๊ณ„์ • ์ƒํƒœ๋Š” `checkiCloudAccountStatus()` ํ˜ธ์ถœ ์‹œ `lastKnownAccountStatus`์— ์บ์‹ฑ
- **Import ํ›„ ์žฌํ™•์ธ**: Import๊ฐ€ ๋๋‚ฌ๋Š”๋ฐ UC๊ฐ€ ์—†์œผ๋ฉด grace period ๋งŒ๋ฃŒ ์งํ›„(125์ดˆ ํ›„)
`didReceiveRemoteChanges`๋ฅผ ํ•œ ๋ฒˆ ๋” postํ•˜์—ฌ Path-B๊ฐ€ self-heal๋œ UC๋ฅผ UI์— ๋ฐ˜์˜
(`scheduleSelfHealRecheckIfNeeded()`)

**`shouldSuppressDataCreation` ํ†ตํ•ฉ ํ”„๋กœํผํ‹ฐ**: DailyTask ๋“ฑ ์™ธ๋ถ€ Storage์—์„œ๋„ ๊ธฐ๋ณธ๊ฐ’ ์ƒ์„ฑ ์–ต์ œ ํŒ๋‹จ์— ์‚ฌ์šฉ:
- `isWaitingForFirstImport || isImportInProgress || isSyncResetInProgress` ์ค‘ ํ•˜๋‚˜๋ผ๋„ true
- `isWithinGracePeriod` โ€” ์ฒซ Import ์™„๋ฃŒ ํ›„ `gracePeriodSeconds` (120์ดˆ) ๋‚ด
Expand Down Expand Up @@ -318,7 +342,7 @@ Crashlytics(์„ธ์…˜ breadcrumb + custom keys + ๋น„์น˜๋ช… ์—๋Ÿฌ)์™€ Analytics(์ง‘
| `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_created` | ์ƒˆ UC ์ƒ์„ฑ (path: fresh_user \| recovery_grace \| self_heal, self_heal์€ reason ํŒŒ๋ผ๋ฏธํ„ฐ ํฌํ•จ) |
| `sync_uc_creation_suppressed` | UC ์ƒ์„ฑ์ด ์–ต์ œ๋จ (reason: sync_in_progress \| grace_period) |
| `sync_token_expired` | `NSCloudKitMirroringDelegateWillReset` ๊ฐ์ง€ |
| `sync_user_collection_missing` | **ํ•ต์‹ฌ ์ฆ์ƒ**: hasEverHadUC=true์ธ๋ฐ UC=0 |
Expand Down
Loading