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

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
mobile
offline
sync
vibe-coding
language applicable_to
TS / Swift / Kotlin
iOS
Android
React Native
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, 가장 인기)

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 명시.

🔗 관련 문서