배경: LocalFirst의 한계
iEvent 초기에는 모든 데이터를 SwiftData로 로컬에 저장하고 백그라운드에서 서버와 비동기 동기화하는 LocalFirst 전략을 채택했습니다. 빠른 UI 반응성이 장점이었지만 가족 공유 기능을 추가하면서 치명적인 문제가 드러났습니다.
- 충돌 해결 복잡도: 엄마와 아빠가 동시에 같은 일정을 수정하면 어느 버전이 정답인가?
- 오프라인 변경 동기화 실패: 오프라인에서 학원을 삭제했는데 서버에 반영 안 되는 케이스
- ID 불일치: 로컬과 서버의 ID가 달라 중복 레코드가 생성되는 버그
Server-First 전략
쓰기 작업(POST/PUT/DELETE)은 서버를 먼저 거치고 성공 시에만 로컬 DB를 갱신합니다. 읽기(GET)는 로컬 DB를 먼저 반환해 빠른 UI를 유지합니다.
// ChildRepository.swift — Server-First 실제 구현
static func live(syncEngine: SyncEngineClient) -> ChildRepository {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return ChildRepository(
// 읽기: LocalDB 즉시 반환
fetchAll: {
let userId = UserDefaults.standard.string(forKey: "IVENT_USER_ID") ?? ""
let descriptor = FetchDescriptor<LocalChild>(
predicate: #Predicate { $0.userId == userId && $0.deletedAt == nil },
sortBy: [SortDescriptor(\LocalChild.createdAt)]
)
return try ModelContext.main.fetch(descriptor).map { $0.toChild() }
},
// 쓰기: 서버 먼저, 성공 시 LocalDB 갱신
create: { childCreate in
let created: Child = try await APIClient.shared.request(
"children/", method: "POST", body: encoder.encode(childCreate)
)
await MainActor.run {
let local = LocalChild(fromServer: created, userId: userId)
context.insert(local)
context.saveLogged("ChildRepository.create")
}
return created
},
delete: { childId in
try await APIClient.shared.request("children/\(childId)", method: "DELETE")
await MainActor.run {
if let local = try? context.fetch(
FetchDescriptor<LocalChild>(predicate: #Predicate { $0.id == childId })
).first {
local.deletedAt = Date()
try? context.save()
}
}
}
)
}
DependencyKey 패턴
private enum ChildRepositoryKey: DependencyKey {
static var liveValue: ChildRepository {
@Dependency(\.syncEngineClient) var syncEngine
return .live(syncEngine: syncEngine)
}
}
extension DependencyValues {
var childRepository: ChildRepository {
get { self[ChildRepositoryKey.self] }
set { self[ChildRepositoryKey.self] = newValue }
}
}
전환 후 달라진 점
- 충돌 해결 로직 완전 제거 — 서버가 단일 진실 소스(Single Source of Truth)
- 가족 구성원 간 데이터 일관성 보장
- SyncEngine 관련 버그 80% 감소
- 트레이드오프: 오프라인 쓰기 지원 포기
교훈
LocalFirst는 단일 사용자 앱에 적합하지만 다중 사용자 공유 앱에서는 충돌 해결 복잡도가 서버 왕복 비용을 초과합니다. 서버를 단일 진실 소스로 두고 읽기만 로컬 캐시를 활용하는 CQRS 스타일이 더 실용적입니다.