487 lines
10 KiB
Markdown
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]]
|