Files
2nd/10_Wiki/Topics/Coding/Mobile_Offline_First.md
T
2026-05-09 22:47:42 +09:00

487 lines
10 KiB
Markdown

---
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 }) => (
<FlatList data={tasks} renderItem={({ item }) => <Task task={item} />} />
));
```
→ 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 (
<View style={{ opacity: isPending ? 0.5 : 1 }}>
<Text>{task.title}</Text>
{isPending && <ActivityIndicator size="small" />}
</View>
);
}
```
→ 사용자에 sync state 표시.
### Failed sync
```tsx
if (task.syncStatus === 'failed') {
return (
<View>
<Text>{task.title}</Text>
<Button title="Retry" onPress={() => retry(task.id)} />
</View>
);
}
```
### Backend (sync endpoint)
```ts
// POST /sync
{
cursor: '...', // last sync
operations: [
{ type: 'create', table: 'tasks', data: {...} },
{ type: 'update', table: 'tasks', id: '...', changes: {...} },
{ type: 'delete', table: 'tasks', id: '...' },
],
}
// Response
{
results: [
{ opId: '...', ok: true, applied: {...} },
{ opId: '...', conflict: true, server: {...}, client: {...} },
],
changes: [...newServerSide],
cursor: 'new-cursor',
}
```
### Server-side
```sql
-- Sync 위 schema
ALTER TABLE tasks ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE tasks ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN sync_version BIGINT DEFAULT 0;
CREATE INDEX tasks_user_updated ON tasks(user_id, updated_at) WHERE deleted_at IS NULL;
```
```ts
// Pull
async function pullChanges(userId: string, since: Date) {
return db.tasks.findMany({
where: { userId, updatedAt: { gt: since } },
});
}
```
### Replicache (open-source library)
```ts
import { Replicache } from 'replicache';
const rep = new Replicache({
name: 'my-app',
pushURL: '/api/replicache/push',
pullURL: '/api/replicache/pull',
mutators: {
addTask: async (tx, args) => {
await tx.set(`task/${uuid()}`, args);
},
},
});
// UI
const tasks = useSubscribe(rep, async (tx) => {
const all = await tx.scan({ prefix: 'task/' }).entries().toArray();
return all.map(([_, t]) => t);
});
// Mutation
await rep.mutate.addTask({ title: 'Buy milk' });
```
→ Sync engine + conflict resolution + reactivity built-in.
### Tinybase (alternative)
```ts
import { createStore } from 'tinybase';
const store = createStore();
store.setRow('tasks', 'task1', { title: 'Buy milk', completed: false });
// Sync
import { createCustomPersister } from 'tinybase/persisters';
const persister = createCustomPersister(store, ...);
```
### Linear / Figma 패턴
```
Linear: Replicache (custom).
Figma: 자체 OT (operational transform).
Notion: 자체 sync engine.
Obsidian: file-based — sync = files.
→ 큰 회사 가 자체 build.
```
### Local-first vs Online-first
```
Online-first (legacy):
- Server 가 truth
- Offline = read-only (cached)
- Reconnect = simple refresh
Local-first (modern):
- Local 가 truth
- Offline write OK
- Sync 가 background
- 더 좋은 UX
```
### Use cases
```
✅ Notes (Notion)
✅ Tasks (Linear)
✅ Email (offline read)
✅ Maps (cached)
✅ Photos (cached + delete sync)
❌ Banking (transaction = strong consistency)
❌ Booking (resource race)
❌ Real-time game (server authoritative)
```
### CouchDB / PouchDB (sync OG)
```ts
import PouchDB from 'pouchdb';
const local = new PouchDB('local-db');
const remote = 'https://couchdb.example.com/db';
local.sync(remote, { live: true, retry: true })
.on('change', (info) => console.log(info))
.on('error', (err) => console.error(err));
```
→ Built-in sync. 옛 but 안정.
### Mobile-specific
```
React Native:
- WatermelonDB / Realm / op-sqlite
- React Query + offline persister
Native iOS:
- SwiftData + CloudKit (자동 sync)
- Core Data + CloudKit
Native Android:
- Room + 자체 sync
- Firebase Firestore (offline 자동)
Cross:
- Firebase Firestore (cross-platform offline)
```
### Test (offline)
```ts
// React Native
import NetInfo from '@react-native-community/netinfo';
jest.mock('@react-native-community/netinfo', () => ({
fetch: jest.fn(() => Promise.resolve({ isConnected: false })),
addEventListener: jest.fn(),
}));
// Test offline mutation queued
test('addTask queues when offline', async () => {
await addTask('Buy milk');
expect(syncQueue.size).toBe(1);
});
```
## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| Notes / tasks app | Local-first (WatermelonDB / Replicache) |
| Photo / video | Local cache + upload queue |
| Real-time collab | CRDT (Yjs / Automerge) |
| Strong consistency | Online-first |
| Cross-platform | Firestore / Replicache |
| Native iOS | SwiftData + CloudKit |
## ❌ 안티패턴
- **Sync 가정 + offline test 없음**: production fail.
- **Conflict resolution 없음**: data loss.
- **Optimistic UI + rollback 없음**: 잘못된 state.
- **Sync 무 throttle**: server 부담.
- **Tombstone 없음**: 삭제 안 sync.
- **모든 데이터 sync**: bandwidth. delta only.
- **사용자 sync state 모름**: 신뢰 X.
## 🤖 LLM 활용 힌트
- Local DB + sync queue + optimistic UI.
- CRDT (Yjs) 가 conflict 자동.
- Replicache / Linear-style = modern.
- Sync state UI 명시.
## 🔗 관련 문서
- [[Mobile_Background_Sync]]
- [[CS_CRDT_Patterns]]
- [[CS_Eventual_Consistency]]