Local-First
Local-First는 IndexedDB + React 동기 스토어 + Zod 스키마로
“오프라인 내구성 있는 데이터 레이어”를 만드는 패키지입니다.
- IndexedDB를 단일 소스로 두고
- Zod 스키마로 타입·무결성을 보장하며
- TTL/버전/멀티 탭 동기화, 서버 동기화 훅, Suspense 연동까지 기본 내장합니다.
- 새로고침·재방문 시에도 목록/폼 상태를 그대로 유지하고 싶을 때
오프라인/불안정한 네트워크에서도 “마지막으로 보던 상태”만큼은 보장하고 싶을 때
- IndexedDB를 직접 다루고 싶지는 않지만, 로컬 내구성이 필요한 내부 도구/대시보드가 있을 때
1. 핵심 개념
모델 (Model)
Local-First의 모든 것은 모델 단위로 움직입니다.
// models/cart.ts
import { defineModel } from "@firsttx/local-first";
import { z } from "zod";
export const CartModel = defineModel("cart", {
schema: z.object({
items: z.array(
z.object({
id: z.string(),
name: z.string(),
qty: z.number(),
}),
),
}),
// 선택: 기본값은 5분 (5 * 60 * 1000 ms)
ttl: 5 * 60 * 1000,
// 선택: 데이터 구조가 깨지면 version을 올려 초기화
version: "1",
// version을 쓰는 경우 권장: 초기 디스크 값
initialData: {
items: [],
},
});
-
name- IndexedDB 스토어 내부의 키와 브로드캐스트 채널 이름(
firsttx:models)의 베이스가 됩니다.
- IndexedDB 스토어 내부의 키와 브로드캐스트 채널 이름(
-
schema- Zod 스키마. 저장/패치 전후로 항상 검증에 사용됩니다.
-
ttl- 캐시가 “stale(신선하지 않음)”으로 간주되기까지의 시간(ms). 기본 5분.
-
version- 스키마가 호환되지 않게 바뀌었을 때 올리는 버전 플래그입니다. 디스크의 버전과 다르면 기존 데이터를 버리고 초기화합니다.
-
initialData- 디스크에 아무 값도 없을 때 사용할 초기 데이터입니다.
version을 지정하면 사실상 필수에 가깝습니다.
- 디스크에 아무 값도 없을 때 사용할 초기 데이터입니다.
defineModel(name, options)
| Name | Type | Default | Description |
|---|---|---|---|
schema | z.ZodType<T> | - | 모델 구조를 정의하는 zod 스키마입니다. 저장/패치 전에 항상 검증되며, 실패 시 디스크에 저장된 데이터는 삭제되고 ValidationError가 발생합니다 (개발 모드에서는 오류를 그대로 throw하여 조기 발견을 돕습니다). |
ttl | number | 5 * 60 * 1000 | 캐시가 만료되기까지의 시간(ms)입니다. TTL을 넘기면 history.isStale가 true가 되며, 기존 데이터는 우선 그대로 표시하고, useSyncedModel 등에서 배경 동기화를 트리거할 수 있습니다. |
version | string | "1" | 스키마/데이터 구조가 깨질 수 있는 변경이 있을 때 올려주는 버전입니다. 디스크에 저장된 버전과 다르면 기존 데이터를 폐기하고 initialData 또는 빈 상태로 재설정합니다. version을 사용할 때는 초기 상태를 안정적으로 되살리기 위해 initialData를 함께 지정하는 것을 권장합니다. |
initialData | T | null | null | 디스크에 아무 데이터도 없을 때 사용할 초기 값입니다. initialData가 없는데 patch를 호출하면 “null에서 mutate”를 막기 위해 에러가 발생합니다. 대부분의 경우 initialData를 채워두는 것이 좋습니다. |
merge | (previous: T, next: T) => T | (prev, next) => next | replace 호출 시 사용할 머지 함수입니다. 기본값은 신규 값으로 완전히 교체하는 동작입니다. 주의: patch에는 적용되지 않으며, replace에만 사용됩니다. |
storageKey | string | name | IndexedDB에 저장될 실제 키입니다. 따로 지정하지 않으면 defineModel의 첫 번째 인자(name)가 사용됩니다. |
Zod
schema검증에 실패하면 해당 키는 IndexedDB에서 삭제되며, DevTools에는validation.error이벤트가 기록됩니다.이런 상황은 대개 스키마/버전 변경 실수이므로, 개발 환경에서는
ValidationError를 그대로 throw하여 조기 발견을 돕습니다. 프로덕션에서는 손상된 데이터를 조용히 제거하고null로 간주합니다.version을 올릴 때는initialData를 함께 정의해 두면 “깨진 데이터 → 새 초기 상태”로 자연스럽게 넘어갈 수 있습니다.
2. React 훅: useModel vs useSyncedModel
Local-First는 크게 두 가지 훅을 제공합니다.
useModel(model)- 로컬 모델 스냅샷만 구독 (서버 동기화 없음)useSyncedModel(model, fetcher, options?)- 로컬 모델 + 서버 동기화
두 훅 모두 내부적으로 useSyncExternalStore를 사용하므로, React 18/19 환경에서 안정적으로 작동합니다.
2-1. useModel: 로컬 데이터만 사용할 때
import { useModel } from "@firsttx/local-first";
import { CartModel } from "./models/cart";
export function CartSidebar() {
const { data: cart, patch, history, error, status } = useModel(CartModel);
if (status === "loading") {
return <div className="text-xs text-muted-foreground">로딩 중...</div>;
}
if (status === "error") {
return (
<div className="text-xs text-destructive">
카트 로드 실패: {error?.getUserMessage?.() ?? String(error)}
</div>
);
}
if (!cart) {
return <div className="text-xs text-muted-foreground">카트가 비어있습니다.</div>;
}
return (
<ul className="space-y-1 text-sm">
{cart.items.map((item) => (
<li key={item.id} className="flex items-center justify-between">
<span>{item.name}</span>
<span className="text-muted-foreground">x {item.qty}</span>
</li>
))}
{history && (
<p className="mt-2 text-[11px] text-muted-foreground">
Last updated {Math.round(history.age / 1000)}s ago
{history.isStale && " · stale"}
</p>
)}
</ul>
);
}
useModel은 동기적으로 현재 디스크 스냅샷을 반환합니다.- 서버와의 동기화는 전혀 하지 않고, 이미 모델에 있는 값만 읽어옵니다.
useModel(model) 반환값
| Name | Type | Default | Description |
|---|---|---|---|
data | T | null | - | 현재 모델 데이터입니다. 아직 아무 값도 없으면 <code>null</code>입니다. |
status | 'loading' | 'success' | 'error' | - | 현재 로딩 상태입니다. 로딩, 성공, 에러 상태를 구분하는 데 사용합니다. |
patch | (mutator: (draft: T) => void) => Promise<void> | - | 로컬 데이터를 변경하는 함수입니다. 내부에서 Zod 검증 후 IndexedDB에 저장하고, 브로드캐스트·DevTools 이벤트를 전파합니다. |
history | { updatedAt: number | null; age: number; isStale: boolean } | - | 마지막 업데이트 시각과 TTL 기준 신선도 정보입니다. 데이터가 없으면 <code>updatedAt = null</code>, <code>age = Infinity</code>, <code>isStale = true</code>입니다. |
error | FirstTxError | null | - | 스토리지 오류나 검증 오류가 발생했을 때의 에러 객체입니다. 없으면 <code>null</code>입니다. |
2-2. useSyncedModel: 서버 동기화 포함
import { useSyncedModel } from "@firsttx/local-first";
import { CartModel } from "./models/cart";
async function fetchCart(current: unknown) {
// current에는 현재 캐시된 값(또는 null)이 들어옵니다.
const res = await fetch("/api/cart");
if (!res.ok) throw new Error("Failed to fetch cart");
return res.json();
}
export function CartPage() {
const {
data: cart,
isSyncing,
error,
history,
patch,
sync,
} = useSyncedModel(CartModel, fetchCart, {
syncOnMount: "stale", // "always" | "stale" | "never"
});
if (error) {
return (
<div className="text-sm text-destructive">
Failed to sync: {error.getUserMessage?.() ?? error.message}
</div>
);
}
if (!cart) {
return <div>Loading cart…</div>;
}
return (
<div className="space-y-4">
{cart.items.map((item) => (
<div key={item.id} className="flex items-center justify-between">
<span>{item.name}</span>
<span className="text-sm text-muted-foreground">x {item.qty}</span>
</div>
))}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{isSyncing && <span>Syncing latest data…</span>}
{history && (
<span>
Last updated {Math.round(history.age / 1000)}s ago
{history.isStale && " (stale)"}
</span>
)}
<button
type="button"
onClick={() => sync("manual")}
className="rounded-full border border-border px-2 py-0.5 text-[11px]"
>
Refresh
</button>
</div>
</div>
);
}
useSyncedModel(model, fetcher, options)
| Name | Type | Default | Description |
|---|---|---|---|
fetcher | (current: T | null) => Promise<T> | - | 서버에서 최신 데이터를 가져오는 함수입니다. 현재 캐시된 값(없으면 null)이 첫 번째 인자로 전달됩니다. 성공 시 replace로 모델을 업데이트합니다. |
options.syncOnMount | "always" | "stale" | "never" | "always" | 컴포넌트 마운트 시 자동 동기화 전략입니다.
|
options.retry | RetryConfig | null | null | 동기화 요청 실패 시 재시도 전략입니다. @firsttx/tx의 재시도 설정과 동일한 구조(maxAttempts, delayMs, backoff)를 사용하며, 지정하지 않으면 재시도를 하지 않습니다. |
options.onSuccess | (data: T) => void | - | 동기화가 성공했을 때 호출되는 콜백입니다. |
options.onError | (error: FirstTxError) => void | - | 동기화 중 에러가 발생했을 때 호출되는 콜백입니다. DevTools에도 sync.error 이벤트가 기록됩니다. |
useSyncedModel(model, fetcher, options) 반환값
| Name | Type | Default | Description |
|---|---|---|---|
data | T | null | - | 현재 모델 데이터입니다. 아직 아무 값도 없으면 <code>null</code>입니다. |
status | 'loading' | 'success' | 'error' | - | 내부 모델의 현재 로딩 상태입니다. 로딩, 성공, 에러 상태를 구분하는 데 사용합니다. |
history | { updatedAt: number | null; age: number; isStale: boolean } | - | 마지막 업데이트 시각과 TTL 기준 신선도 정보입니다. |
error | FirstTxError | null | - | 마지막 동기화에서 발생한 에러입니다. 모델 자체 에러보다 동기화 에러가 우선합니다. |
isSyncing | boolean | - | 현재 서버 동기화가 진행 중인지 여부입니다. |
patch | typeof model.patch | - | 모델의 <code>patch</code> 메서드를 그대로 노출합니다. 로컬에서 낙관적 업데이트를 할 때 사용할 수 있습니다. |
sync | (trigger?: 'mount' | 'manual') => Promise<void> | - | 동기화를 직접 트리거하는 함수입니다. 내부적으로 중복 실행을 방지하고, DevTools에 <code>sync.start</code>/<code>sync.success</code>/<code>sync.error</code> 이벤트를 기록합니다. |
useSyncedModel내부에서는syncInProgressRef로 동기화가 이미 진행 중인지 추적합니다. 동일 모델에 대한 중복sync()호출이 들어와도, 한 번만 실행되도록 보장합니다.동시에 여러 컴포넌트에서
sync()를 호출하더라도, 실제 네트워크 요청은 1회로 합쳐집니다.
3. 멀티 탭 동기화 & BroadcastChannel
여러 탭에서 같은 앱을 열고 데이터를 수정하면, Local-First는
ModelBroadcaster를 통해 변경 사항을 전파합니다.
-
브라우저가
BroadcastChannel을 지원하면:firsttx:models채널을 통해model-patched,model-replaced,model-deleted메시지를 주고받습니다.- A 탭에서
CartModel.patch를 호출하면 B 탭의useModel(CartModel)스냅샷도 자동으로 갱신됩니다.
-
지원하지 않는 환경에서는:
- 폴백 브로드캐스터가 동작하여 실제 탭 간 동기화는 일어나지 않지만, DevTools에는
broadcast.fallback,broadcast.skipped이벤트가 기록됩니다. - 이런 환경에서는 새로고침이나 수동 sync로 최신 데이터를 맞추는 전략이 필요할 수 있습니다.
- 폴백 브로드캐스터가 동작하여 실제 탭 간 동기화는 일어나지 않지만, DevTools에는
현재 구현에서 모델에는
isConflicted라는 플래그가 준비되어 있지만, 항상false로 유지됩니다. 즉, 다중 탭 경합에 대한 충돌 감지는 아직 TODO 상태입니다.- 실시간 충돌 해결이 중요한 도메인이라면, “마지막 기록 우선”, “타임스탬프 기준 머지” 등 별도의 비즈니스 로직으로 최종 승자/머지 전략을 구현하는 것이 좋습니다.
4. Suspense 통합
Local-First는 React Suspense와도 맞물립니다.
가장 간편한 방법은 useSuspenseSyncedModel 훅을 사용하는 것입니다.
import { Suspense } from "react";
import { useSuspenseSyncedModel } from "@firsttx/local-first";
import { CartModel } from "./models/cart";
async function fetchCart() {
const res = await fetch("/api/cart");
if (!res.ok) throw new Error("Failed to fetch cart");
return res.json();
}
function CartInner() {
const { data: cart } = useSuspenseSyncedModel(CartModel, fetchCart);
return (
<ul className="space-y-2 text-sm">
{cart.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
export function SuspenseCart() {
return (
<Suspense fallback={<div>Loading cart…</div>}>
<CartInner />
</Suspense>
);
}
- 내부적으로는
model.getSyncPromise(fetcher)를 사용해, 데이터/에러가 아직 없는 경우 fetch를 트리거하고 Promise를 throw합니다. - 캐시에 데이터가 있으면 즉시 반환하고 Suspense로 빠지지 않습니다.
- 에러가 있다면 ErrorBoundary로 흘려보냅니다.
보다 저수준 제어가 필요하다면, 직접 getSyncPromise를 사용할 수도 있습니다.
function CartInnerLowLevel() {
CartModel.getSyncPromise(fetchCart); // 데이터가 없으면 이 Promise가 throw되며 Suspense fallback으로 이동
const { data: cart } = useModel(CartModel);
if (!cart) return null;
return (
<ul className="space-y-2 text-sm">
{cart.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
5. DevTools 연동
Local-First는 DevTools와의 연동을 통해 다음 정보를 전파합니다.
- 모델 초기화/로드:
init,load - 데이터 변경:
patch,replace - 재검증/배경 동기화:
revalidate,sync.start,sync.success,sync.error - 브로드캐스트:
broadcast.sent,broadcast.received,broadcast.fallback,broadcast.skipped - 검증/스토리지 오류:
validation.error,storage.error
모든 이벤트는:
category: "model"type: 위의 이벤트 타입 문자열timestamp: 발생 시각priority: 0~2 사이의 중요도 (예:sync.error/validation.error는 높은 우선순위)
로 구성되며, DevTools 패널에서 “model” 카테고리만 필터링해 보면:
- 어떤 모델이 얼마나 자주 동기화되는지
- 어느 시점에 ValidationError/StorageError가 났는지
- 브로드캐스트가 잘 작동하는지 (fallback·skipped 이벤트 여부)
를 한눈에 추적할 수 있습니다.
6. 권장 패턴 요약
마지막으로 Local-First를 설계할 때 도움이 되는 패턴들입니다.
-
데이터 상태 vs 뷰 상태 분리
- “뷰 상태 + 데이터 상태”를 모두 Local-First에 넣기보다는, 서버와 동기화되는 데이터 상태만 모델로 관리하는 것이 유지 보수에 좋습니다.
- 모달 열림/닫힘, hover 상태 등은 일반 React state로 두는 편이 쉽습니다.
-
TTL 설계
- 기본 5분 TTL에서 시작해서:
- 리드가 잦고 변경이 적은 데이터 → TTL을 늘려 네트워크 호출을 줄이고,
- 변경이 잦고 최신성이 중요한 데이터 → TTL을 줄여 자주 재동기화하도록 설정합니다.
- 기본 5분 TTL에서 시작해서:
-
Tx와 함께 사용할 때
- 낙관적 업데이트를
CartModel.patch등 Local-First 모델로 먼저 반영하고, @firsttx/tx트랜잭션의 다음 스텝으로 서버 동기화를 두면, 실패 시 보상(rollback)으로 Local-First 모델까지 깔끔하게 되돌릴 수 있습니다.
- 낙관적 업데이트를
-
merge의 역할
merge는replace에만 적용되고patch에는 적용되지 않습니다.- 부분 병합 전략이 필요하다면
patch내부에서 직접 구현하는 것이 명확합니다.
-
BroadcastChannel 미지원 환경 고려
- 오래된 브라우저/특수 환경에서는 탭 간 실시간 동기화가 되지 않습니다.
- 이 경우에는 “페이지 새로고침 / 수동 sync 버튼”으로 최신 데이터를 맞추는 UX를 함께 설계하는 것이 좋습니다.
-
대용량 데이터
- 대용량 모델의 경우 TTL, 서버 필터링, 부분 모델 분리 등을 함께 고민해 JSON 직렬화/비교 비용을 줄이는 것이 좋습니다.