iEvent는 가족이 함께 자녀의 일정을 관리하는 앱이다. 초기에는 “빠른 응답성”을 위해 LocalFirst 전략을 채택했다. 사용자 입력을 즉시 로컬 DB에 저장하고, 백그라운드에서 서버와 동기화하는 방식이었다. 그런데 이 전략이 가족 공유 앱에서 구조적인 버그의 온상이 되었다.
LocalFirst의 구조적 문제
serverId ?? localId.uuidString 패턴의 위험성
LocalFirst에서 가장 흔한 패턴은 서버 ID가 없으면 로컬 UUID를 임시 ID로 사용하는 것이다. 코드 곳곳에 이런 표현이 있었다.
// ❌ LocalFirst의 전형적인 안티패턴
func getAcademyId() -> String {
return serverId ?? localId.uuidString // 서버 동기화 전: "550e8400-e29b..."
// 동기화 후: "server-real-id-123"
}
// 문제: UI가 로컬 UUID로 화면을 그린 상태에서 동기화가 완료되면
// UI는 여전히 이전 UUID를 참조하고 있음 → stale reference
처음에는 동기화 완료 시 UI를 강제 업데이트하는 방식을 검토했다. 하지만 참조 전파 경로가 너무 많았다. 자녀 ID가 학원에서, 학원이 일정에서, 일정이 알림에서 참조된다. 서버 ID 변경을 전파하는 비용이 버그를 추적하는 비용보다 더 컸다.
가족 구성원 간 충돌
// 시나리오: 엄마와 아빠가 동시에 같은 자녀의 학원을 추가하는 경우
// 엄마: localId = "uuid-A", serverId = nil (pending)
// 아빠: localId = "uuid-B", serverId = nil (pending)
// 서버 동기화 후: 둘 다 serverChild.id = "server-child-123"
// → UI에서 자녀가 두 번 표시되거나 참조가 엉킴
에러 처리의 함정
LocalFirst에서 서버 동기화 실패는 “silent retry”로 처리된다. 사용자는 저장이 성공했다고 믿지만, 실제로는 서버에 반영되지 않은 상태다. 가족 앱에서 이 문제는 치명적이다. 엄마가 추가한 일정이 아빠 폰에는 보이지 않을 수 있다.
결정: Server-First 전략으로 전환
2026년 6월, LocalFirst를 포기하고 Server-First로 전환했다. 핵심 원칙은 단순하다.
| 작업 | 정책 |
|---|---|
| POST / PUT / DELETE | 서버 API 먼저 → 성공 시 LocalDB 반영 |
| GET / 목록 조회 | LocalDB 즉시 반환 → 백그라운드 서버 pull |
| 서버 실패 시 | throw → UI에 에러 노출, 로컬 저장 없음 |
실제 구현: ChildRepository
create — 서버 먼저, 성공 시 LocalDB
// ChildRepository.swift — Server-First create
create: { childCreate in
let encoder = JSONEncoder()
// 1. 서버 API 호출 (실패 시 throw → UI에 에러 표시)
let created: Child = try await APIClient.shared.request(
"children/", method: "POST", body: encoder.encode(childCreate)
)
// 2. 서버 응답(정본)을 LocalDB에 저장
await MainActor.run {
let context = DatabaseContainer.shared.mainContext
let userId = SessionContext.currentUserId
let all = (try? context.fetch(FetchDescriptor())) ?? []
// 중복 방지: serverId로 이미 존재하면 update, 없으면 insert
if let existing = all.first(where: { $0.serverId == created.id }) {
existing.applyServerUpdate(created)
} else {
let local = LocalChild(fromServer: created, userId: userId)
context.insert(local)
}
context.saveLogged("ChildRepository.create")
}
return created // 서버 응답이 정본
},
softDelete — 서버 먼저, cascade 처리
// ChildRepository.swift — Server-First softDelete
softDelete: { id in
// 1. 로컬에서 serverId 조회
let (serverId, _): (String?, UUID?) = await MainActor.run {
let context = DatabaseContainer.shared.mainContext
let all = (try? context.fetch(FetchDescriptor())) ?? []
let local = all.first(where: { $0.serverId == id || $0.localId.uuidString == id })
return (local?.serverId, local?.localId)
}
// 2. 서버 삭제 (404는 이미 삭제된 것으로 허용)
if let serverId {
do {
let _: EmptyResponse = try await APIClient.shared.request(
"children/\(serverId)", method: "DELETE"
)
} catch {
if case NetworkError.requestFailedWithStatus(let code) = error, code == 404 {
// 이미 서버에서 삭제됨 — 로컬만 정리
} else {
throw error // 다른 에러는 propagate
}
}
}
// 3. 로컬 DB cascade 삭제 (학원 → 일정 → 다이어리)
await MainActor.run {
let context = DatabaseContainer.shared.mainContext
let all = (try? context.fetch(FetchDescriptor())) ?? []
guard let local = all.first(where: { $0.serverId == id || $0.localId.uuidString == id }) else { return }
// @Relationship(deleteRule: .cascade) 대신 수동 cascade
let academies = (try? context.fetch(FetchDescriptor())) ?? []
for a in academies where a.childLocalId == local.localId { context.delete(a) }
let schedules = (try? context.fetch(FetchDescriptor())) ?? []
for s in schedules where s.childLocalId == local.localId { context.delete(s) }
let diaries = (try? context.fetch(FetchDescriptor())) ?? []
for d in diaries where d.childLocalId == local.localId { context.delete(d) }
context.delete(local)
context.saveLogged("ChildRepository.softDelete")
}
},
fetchAll — LocalDB 즉시 반환, 백그라운드 sync
// ChildRepository.swift — Server-First fetchAll
fetchAll: {
await MainActor.run {
let context = DatabaseContainer.shared.mainContext
let userId = SessionContext.currentUserId
// LocalDB 즉시 반환 (빠른 응답성 유지)
let descriptor = FetchDescriptor(
predicate: #Predicate {
$0.deletedAt == nil && ($0.userId == userId || $0.userId == "")
},
sortBy: [SortDescriptor(\.createdAt)]
)
return (try? context.fetch(descriptor))?.map { $0.toDomain() } ?? []
}
// 백그라운드 pull은 별도로 triggererd (syncFromServer)
},
syncFromServer: {
guard SyncThrottle.shouldSync(key: "children") else { return }
guard let serverChildren = try? await APIClient.shared.request("children/") as [Child] else { return }
SyncThrottle.markSynced(key: "children")
await MainActor.run {
let context = DatabaseContainer.shared.mainContext
let existing = (try? context.fetch(FetchDescriptor())) ?? []
for serverChild in serverChildren {
if let local = existing.first(where: { $0.serverId == serverChild.id }) {
// 서버 데이터로 업데이트
guard local.syncStatus == SyncStatus.synced.rawValue else { continue }
local.applyServerUpdate(serverChild)
} else {
// 새 자녀 (다른 가족 구성원이 추가한 것)
let local = LocalChild(fromServer: serverChild, userId: userId)
context.insert(local)
}
}
context.saveLogged("ChildRepository.syncFromServer")
}
}
SyncEngine 단순화
가장 큰 변화 중 하나는 SyncEngine이 대폭 단순해진 것이다. 기존에는 push와 pull 양방향을 모두 처리했다.
// Before (LocalFirst): SyncEngine이 push+pull 모두 처리
// performSync() → pushPendingCreates/Updates/Deletes → pullLatestChanges()
// After (Server-First): pull만 처리
// performSync() → pullLatestChanges()
// push 계열 메서드 전부 제거
// 신규 레코드는 항상 .synced 상태 (pending 없음)
let local = LocalChild(fromServer: created, userId: userId)
local.syncStatus = SyncStatus.synced.rawValue // ← 항상 synced
기존 사용자 마이그레이션 브릿지
LocalFirst → Server-First 전환 시 기존 사용자의 pendingCreate/Update/Delete 레코드가 LocalDB에 남아 있었다. 이를 한 번만 push하고 Server-First로 넘어가는 브릿지가 필요했다.
// AppFeature.swift — splash.onAppear
// 앱 버전 1에서 2로 마이그레이션
let version = UserDefaults.standard.integer(forKey: "IVENT_DB_SCHEMA_VERSION")
if version < 2 {
// 기존 pending 레코드를 1회만 push (레거시 SyncEngine.performSyncImmediate())
await SyncEngine.shared.performSyncImmediate()
UserDefaults.standard.set(2, forKey: "IVENT_DB_SCHEMA_VERSION")
// 이후에는 SyncStatus 필드가 남아 있어도 pendingCreate 참조 코드 없음
}
포기한 접근법
낙관적 업데이트 (Optimistic UI)
로컬 먼저 보여주고 서버 실패 시 롤백하는 방식도 검토했다. 하지만 가족 공유 데이터에서 롤백 로직은 너무 복잡해진다. 엄마가 추가한 학원을 아빠 폰에서 롤백해야 한다면? 서버가 정본이어야 하는 앱에서 낙관적 업데이트는 오히려 혼란을 가중시킨다.
오프라인 쓰기 지원
Server-First에서 오프라인 상태에서는 쓰기 작업이 실패한다. 의도된 동작이다. 오프라인 큐를 구현하면 LocalFirst의 문제가 다시 재현된다. 현재 앱 특성(가족 공유 데이터)에서 오프라인 쓰기는 이점보다 복잡도가 크다.
전환 후 달라진 점
- ID 불일치 버그 제거: 서버 응답이 항상 정본이므로
serverId ?? localId패턴 완전 제거 - 가족 동기화 신뢰성: 서버에 저장된 데이터만 화면에 표시, 가족 간 불일치 없음
- 에러 가시성: 서버 실패가 즉시 UI에 에러로 표시 (silent failure 없음)
- 코드 단순화: SyncEngine push 계열 제거, pending 상태 처리 코드 제거
가족 공유 앱에서 동기화 전략은 단순히 기술적 선택이 아니다. 어느 데이터가 "정본"인지에 대한 비즈니스 결정이다. iEvent에서는 서버가 정본이어야 한다는 결론이 Server-First 전환을 이끌었다.