10 KiB
10 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| mobile-offline-first | Offline-first — Local-first / Sync / Conflict | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
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, 가장 인기)
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
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)
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
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
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)
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)
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
// 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)
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)
// 삭제도 sync 필요 — server 에 알림
{
id: '...',
deleted: true,
deletedAt: Date.now(),
}
// Server 가 propagate.
// 일정 시간 후 hard delete (GC).
Optimistic UI feedback
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
if (task.syncStatus === 'failed') {
return (
<View>
<Text>{task.title}</Text>
<Button title="Retry" onPress={() => retry(task.id)} />
</View>
);
}
Backend (sync endpoint)
// 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
-- 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;
// Pull
async function pullChanges(userId: string, since: Date) {
return db.tasks.findMany({
where: { userId, updatedAt: { gt: since } },
});
}
Replicache (open-source library)
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)
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)
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)
// 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 명시.