--- id: mobile-offline-first title: Offline-first — Local-first / Sync / Conflict category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [mobile, offline, sync, vibe-coding] tech_stack: { language: "TS / Swift / Kotlin", applicable_to: ["iOS", "Android", "React Native"] } applied_in: [] aliases: [offline-first, local-first, sync, optimistic UI, queue, retry] --- # Offline-first > Network = unreliable. **Local DB 가 truth → background sync → optimistic UI**. 사용자가 즉시 반응 + sync 가 invisible. Notion / Linear / Figma 의 UX. ## 📖 핵심 개념 - Local DB: 모든 read/write. - Sync queue: pending changes. - Optimistic UI: 즉시 반영. - Conflict resolution. ## 💻 코드 패턴 ### Architecture ``` [UI] ↕ (read/write) [Local DB] ←(sync)→ [Server] ↕ [Sync Queue] ``` ### Local DB 선택 ``` SQLite (RN, native): better-sqlite3 / op-sqlite WatermelonDB (RN): reactive, scalable Realm: cross-platform RxDB: client-side PouchDB: CouchDB sync SwiftData / Core Data (iOS) Room (Android) ``` ### WatermelonDB (RN, 가장 인기) ```ts import { Database, Model } from '@nozbe/watermelondb'; import { schemaMigrations, addColumns } from '@nozbe/watermelondb/Schema/migrations'; const schema = appSchema({ version: 1, tables: [ tableSchema({ name: 'tasks', columns: [ { name: 'title', type: 'string' }, { name: 'completed', type: 'boolean' }, { name: 'created_at', type: 'number' }, ], }), ], }); class Task extends Model { static table = 'tasks'; @field('title') title!: string; @field('completed') completed!: boolean; @date('created_at') createdAt!: Date; } const database = new Database({ adapter: new SQLiteAdapter({ schema }), modelClasses: [Task], }); ``` ### Reactive query ```tsx import { withObservables } from '@nozbe/watermelondb/react'; const TaskList = withObservables(['database'], ({ database }) => ({ tasks: database.collections.get('tasks').query().observe(), }))(({ tasks }) => ( } /> )); ``` → DB 변경 시 자동 re-render. ### Write (optimistic) ```ts async function addTask(title: string) { // 즉시 local DB await database.write(async () => { await database.collections.get('tasks').create((t) => { t.title = title; t.completed = false; t.createdAt = new Date(); }); }); // Background sync (다음 trigger 시) syncQueue.enqueue('tasks/create', { title }); } ``` → UI 즉시 반응 + server async sync. ### Sync queue ```ts class SyncQueue { private queue: PendingOp[] = []; enqueue(op: PendingOp) { this.queue.push({ ...op, id: uuid(), timestamp: Date.now() }); this.persistQueue(); this.tryRun(); } async tryRun() { if (!isOnline()) return; while (this.queue.length > 0) { const op = this.queue[0]; try { await this.executeOp(op); this.queue.shift(); this.persistQueue(); } catch (e) { if (isRetryable(e)) break; // 재시도 — 나중 else this.markFailed(op); break; } } } private persistQueue() { // localStorage / DB } } ``` ### Network state ```ts import NetInfo from '@react-native-community/netinfo'; NetInfo.addEventListener((state) => { if (state.isConnected) { syncQueue.tryRun(); } }); ``` ### Sync trigger ``` 1. App resume (foreground) 2. Network reconnect 3. User pull-to-refresh 4. Background fetch (15 min) 5. After mutation 6. Push notification ``` ### Pull (server → client) ```ts async function pull() { const lastSync = await db.getLastSync(); const response = await api.sync({ since: lastSync }); await database.write(async () => { // Apply changes for (const item of response.items) { await upsertTask(item); } for (const id of response.deleted) { await deleteTask(id); } }); await db.setLastSync(response.cursor); } ``` ### Push (client → server) ```ts async function push() { const pending = syncQueue.all(); if (pending.length === 0) return; const response = await api.sync({ operations: pending, }); // Server 가 결과 반환 for (const result of response.results) { if (result.ok) { syncQueue.remove(result.opId); } else if (result.conflict) { await handleConflict(result); } } } ``` ### Conflict resolution ```ts // 1. Last-write-wins (간단) async function resolve(local: Item, server: Item) { return server.updatedAt > local.updatedAt ? server : local; } // 2. Field-level merge async function merge(local: Item, server: Item) { return { ...server, customField: local.customField, // 일부 keep updatedAt: Math.max(local.updatedAt, server.updatedAt), }; } // 3. CRDT (자동 merge) // Yjs / Automerge // 4. 사용자 결정 (최후) showConflictDialog(local, server, (chosen) => apply(chosen)); ``` ### CRDT 통합 (Yjs) ```ts import * as Y from 'yjs'; const doc = new Y.Doc(); const tasks = doc.getArray('tasks'); tasks.push([{ id: '1', title: 'Buy milk', completed: false }]); // Sync (any 2 docs merge — same result) const update = Y.encodeStateAsUpdate(doc); // Send to server / peer const otherDoc = new Y.Doc(); Y.applyUpdate(otherDoc, update); ``` → 자동 conflict-free merge. ### Tombstone (delete) ```ts // 삭제도 sync 필요 — server 에 알림 { id: '...', deleted: true, deletedAt: Date.now(), } // Server 가 propagate. // 일정 시간 후 hard delete (GC). ``` ### Optimistic UI feedback ```tsx function Task({ task }: { task: Task }) { const isPending = task.syncStatus === 'pending'; return ( {task.title} {isPending && } ); } ``` → 사용자에 sync state 표시. ### Failed sync ```tsx if (task.syncStatus === 'failed') { return ( {task.title}