동기화하면 안 되는 설정 분리하기(SwiftData + CloudKit)
Frontend트러블슈팅

동기화하면 안 되는 설정 분리하기(SwiftData + CloudKit)

2026-07-271

iPad용 터미널 앱을 만들면서 iCloud 동기화를 붙였다. 호스트 목록, 그룹, 테마, 세션 상태가 기기 사이에서 자동으로 맞춰진다. 여기까지는 의도대로였다. 애초에 이 앱은 PC·아이패드·아이폰을 오가며 쓰려고 만든 것이라(muxpad 회고), 기기 간 상태를 맞추는 일이 기능의 절반쯤 된다.

문제는 그다음이었다. 동기화가 잘 될수록 불편해지는 설정이 있었다.

발단: 아이패드를 라이트로 바꿨더니 아이폰도 라이트가 됐다

밤에 아이폰으로 서버를 보다가 눈이 부셨다. 낮에 아이패드에서 테마를 밝은 걸로 바꿨기 때문이다. 자동 연결도 마찬가지였다. 아이패드는 켜자마자 모든 세션에 붙는 게 편하지만, 아이폰은 필요한 것만 수동으로 붙이고 싶다.

기기마다 다른 게 정상인 설정이 존재한다. 동기화 대상을 정하는 일은 기술 문제가 아니라 제품 결정이다.

동기화 경계

먼저 SwiftData + CloudKit의 제약

기술적 전제부터 정리하면 이렇다. NSPersistentCloudKitContainer 기반 SwiftData 모델에는 지켜야 할 규칙이 있다.

  • 모든 저장 프로퍼티는 옵셔널이거나 기본값을 가져야 한다.
  • 유니크 제약을 걸 수 없다. 식별은 UUID로 한다.
  • 관계도 옵셔널이어야 한다.

그래서 DTO를 설계할 때 이관 시점의 매핑을 미리 각서로 적어뒀다.

/// - `id: UUID`      → `@Attribute(.unique) var id: UUID = UUID()` (기본값)
/// - `hostRef: UUID` → `var hostRef: UUID?` (옵셔널)
/// - `tmuxSessionName: String` → `var tmuxSessionName: String = ""` (기본값)

비옵셔널 외래키를 코드 전반에 퍼뜨린 뒤에 옵셔널로 바꾸면 파급이 크다. DTO는 값 계약을 유지하고, 옵셔널화는 @Model 정의 시점에만 하도록 미뤘다.

"정의"와 "선택"을 분리한다

핵심 아이디어는 이거였다. 테마 정의는 동기화하고, 어떤 테마를 쓰는지는 동기화하지 않는다.

색 팔레트를 아이패드에서 만들었으면 아이폰에서도 그 팔레트를 쓸 수 있어야 한다. 그건 자산이다. 반면 "지금 이 기기에서 어떤 걸 고를지"는 기기의 상황(밝기, 화면 크기, 시간대)에 달렸다. 자산은 공유하고 선택은 나눈다.

같은 논리를 다른 설정에도 적용해 세 가지를 기기 로컬로 뺐다.

설정저장 위치이유
출력 알림 on/off기기 로컬알림 권한 자체가 기기별이라 스코프를 맞춘다
실행 시 전체 자동 연결기기 로컬iPad는 전부, iPhone은 수동이 자연스럽다
전역 테마 선택기기 로컬테마 정의는 계속 동기화

나머지 설정(멀티클라이언트 정책, 생체 인증 게이팅, 업로드 목적지 등)은 그대로 동기화 대상이다.

같은 애플 생태계라도 앱마다 답이 다르다. 노그미 개발기에서는 녹음 파일을 iCloud Drive로 통째 동기화했다. 녹음은 어느 기기에서 열어도 같은 실체여야 하니 그게 맞다. 반면 설정은 기기마다 달라도 되는 값이 섞여 있어서 한 덩어리로 다룰 수 없다.

저장소는 UserDefaults를 쓴다. 여기서 중요한 건 NSUbiquitousKeyValueStore를 쓰지 않는 것이다. iCloud 키-값 저장소는 이름만 보면 가벼운 설정용 같지만, 기기 간 동기화가 목적이므로 여기 넣으면 원점으로 돌아간다.

/// **기기별(비동기화) 설정** 저장소 — `UserDefaults` 기반.
/// CloudKit·SwiftData·iCloud KVS를 절대 쓰지 않는다.
@MainActor @Observable
final class DeviceLocalSettings {
    private enum Key {
        static let notificationsEnabled = "deviceLocal.notificationsEnabled"
        static let autoConnectAllOnLaunch = "deviceLocal.autoConnectAllOnLaunch"
        static let selectedGlobalThemeID = "deviceLocal.selectedGlobalThemeID"
        static let migrated = "deviceLocal.migrated"
    }
}

마이그레이션은 "앱 생애 1회"여야 한다

이미 동기화되고 있던 값들을 로컬로 옮겨야 했다. 기존 사용자의 설정을 잃지 않으려면 1회 시드가 필요하다.

처음 짠 규칙은 자연스러워 보였다. "이 기기에 로컬 값이 아직 없으면(키 부재) 레거시 값으로 시드한다." 이미 이 기기에서 토글한 적이 있으면 건드리지 않으니 안전해 보인다.

여기에 함정이 있었다.

마이그레이션 함정

전역 테마 선택은 nil이 유효한 값이다. nil = 내장 기본 프리셋을 쓴다는 뜻이고, 이때 구현은 removeObject(forKey:)로 키를 지운다. 즉 사용자가 "기본으로 리셋"을 누르면 키가 사라진다.

그 상태로 앱을 껐다 켜면? 마이그레이션이 "키가 없네, 아직 시드 안 됐구나" 하고 판단해 레거시 값으로 되덮는다. 사용자는 기본으로 돌렸는데 다음 실행에서 예전 테마가 부활한다.

해결은 단순하다. 필드별 키 존재 여부가 아니라 별도의 완료 플래그 하나로 판정한다.

func migrateFromLegacyIfNeeded(_ legacy: DeviceLocalSettingsSnapshot) {
    guard !migrated else { return }          // 앱 생애 1회
    if !hasStoredNotificationsEnabled {
        notificationsEnabled = legacy.notificationsEnabled
    }
    if !hasStoredAutoConnectAllOnLaunch {
        autoConnectAllOnLaunch = legacy.autoConnectAllOnLaunch
    }
    if !hasStoredSelectedGlobalThemeID, let legacyThemeID = legacy.selectedGlobalThemeID {
        selectedGlobalThemeID = legacyThemeID
    }
    defaults.set(true, forKey: Key.migrated)
}

⚠ 교훈은 이거다. "값이 없음"과 "값을 지웠음"은 다른 상태다. 키 부재를 상태 판정에 쓰면 이 둘이 구분되지 않는다. 삭제가 유효한 조작인 저장소에서는 항상 별도의 플래그가 필요하다.

테스트는 격리된 스위트를 주입해 돌린다. UserDefaults.standard를 직접 쓰면 테스트 간 상태가 샌다.

init(defaults: UserDefaults = .standard) { ... }   // 테스트는 suiteName 주입

순서 병합: 정수 인덱스를 버린다

목록 순서 동기화도 문제였다. sortIndex를 정수로 두면, 두 기기에서 동시에 재정렬했을 때 전체 리인덱싱이 충돌한다.

그래서 문자열 기반의 렉스 랭크(LexoRank류)를 쓴다. 두 항목 사이에 끼워 넣을 때 이웃한 두 키 사이의 값을 새로 만들면 되므로, 다른 항목을 건드리지 않는다.

if newHost.sortIndex.isEmpty {
    newHost.sortIndex = FlatOrdering.appendHostIndex(after: sortedHosts)
}
// 키가 너무 촘촘해지면 재분배
let ranks = LexoRank.distributed(count: ordered.count)

충돌 병합은 가환이어야 한다

가장 신경 쓴 부분은 세션 상태다. 어느 기기에서 마지막으로 붙었는지, 그때 창 레이아웃이 어땠는지를 담는다. 아이패드에서 작업하던 세션에 아이폰으로 이어 붙으려면 이 정보가 정확해야 한다.

var lastWindowLayout: String?
var lastAttachedAt: Date?
var lastActiveDeviceId: String?

병합 규칙은 이렇게 정했다.

  1. lastAttachedAt이 최신인 쪽이 승자
  2. 동시각이면 lastActiveDeviceId가 큰 쪽
  3. 그것도 같으면 id 비교

핵심은 이 우선순위가 전순서(total order) 라는 점이다. 그래야 merge(a, b) == merge(b, a)가 성립한다. CloudKit이 두 기기의 갱신을 어떤 순서로 병합하든 결과가 같아야 상태가 진동하지 않는다.

static func merge(_ lhs: SessionStateDTO, _ rhs: SessionStateDTO) -> SessionStateDTO {
    let lhsWins = prefersFirst(lhs, rhs)
    var winner = lhsWins ? lhs : rhs
    let loser  = lhsWins ? rhs : lhs
    // 승자에 없는 값은 패자로 보완(정보 손실 최소화)
    winner.lastWindowLayout   = winner.lastWindowLayout   ?? loser.lastWindowLayout
    winner.lastAttachedAt     = winner.lastAttachedAt     ?? loser.lastAttachedAt
    winner.lastActiveDeviceId = winner.lastActiveDeviceId ?? loser.lastActiveDeviceId
    return winner
}

"승자 독식"이 아니라 승자의 빈 필드를 패자로 채운다. 필드 단위로 보면 정보가 덜 사라진다.

📌 그리고 절대 규칙 하나. 자격증명은 CloudKit 레코드에 넣지 않는다. 비밀번호와 키는 Keychain에 두고, 동기화되는 모델에는 참조(secretRef)만 남긴다. 세션 상태에도 시크릿은 없다.

끝맺음

동기화 기능을 만들 때 가장 어려운 부분은 CloudKit API가 아니었다. 무엇을 동기화하지 않을지 정하는 일이었다.

정리하면 세 가지다.

  • 자산(정의)은 공유하고, 상황에 종속된 선택은 기기에 남긴다.
  • 삭제가 유효한 조작이면, "없음"과 "지움"을 구분할 플래그를 따로 둔다.
  • 병합 규칙은 순서에 의존하지 않게 — 전순서로 승자를 정하고 나머지는 보완한다.

지금은 아이패드에서 라이트 테마를 써도 아이폰은 계속 다크다. 그런데 아이패드에서 새로 만든 팔레트는 아이폰에서도 고를 수 있다. 원하던 그림이다.

같은 앱의 출시 자산을 자동으로 만드는 이야기는 App Store 스크린샷과 릴리스 노트 자동화에 따로 적었다.

참고링크

동기화하면 안 되는 설정 분리하기(SwiftData + CloudKit)