빠른 시작
이 가이드는 Vite + React 19 기반 CSR 앱을 예시로,
- **Prepaint만 먼저 붙여서 “재방문 빈 화면 제거”**를 경험하고,
- 필요하다면 Local-First로 데이터 내구성을 추가하고,
- 마지막으로 Tx로 낙관적 UI를 트랜잭션으로 감싸는 순서를 다룹니다.
Prepaint만 써보고 싶다면 1~3단계까지만 진행해도 충분합니다.
로컬 데이터 내구성까지 필요하다면 4~5단계를 함께 보세요.
낙관적 트랜잭션까지 체험해 보고 싶다면 6단계까지 진행하면 됩니다.
0. 사전 준비
- React 19 (또는 React 19로 마이그레이션 예정)
- Vite 기반 CSR 앱 (Next.js 16 / App Router에서의 사용법은 별도 문서에서 다룹니다)
- 타입스크립트 사용 가정
1. 패키지 설치
우선 세 레이어를 모두 설치해둡니다.
(당장은 Prepaint만 쓸 예정이지만, 나중에 확장하기 쉬우면서 설치 과정도 한 번에 끝냅니다.)
@firsttx/tx는 런타임 외부 의존성이 없어, 단독으로도 사용할 수 있습니다. 다만 이 가이드에서는 Local-First 모델을 낙관적 업데이트 대상으로 사용하는 예제를 중심으로 설명합니다.
2. Vite 플러그인 설정 (Prepaint 부트 스크립트)
Prepaint는 Vite 플러그인으로 부트 스크립트를 HTML에 주입합니다.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { firstTx } from "@firsttx/prepaint/plugin/vite";
export default defineConfig({
plugins: [
react(),
firstTx(), // ✅ Prepaint 부트 스크립트 자동 주입
],
});
이 플러그인은 빌드 시 HTML에 작은 부트 스크립트를 주입합니다. 이 스크립트는,
- 페이지 진입 시 IndexedDB에서 마지막 DOM 스냅샷을 읽어오고
- 유효한 스냅샷이 있다면 React보다 먼저 복원한 뒤
- 그 다음에 React가 하이드레이션/렌더링을 진행하도록 도와줍니다.
3. 엔트리 포인트 교체 (createFirstTxRoot) – ⭐ 필수
이제 기존 ReactDOM.createRoot 대신 createFirstTxRoot를 사용합니다.
// main.tsx
import React from "react";
import { createFirstTxRoot } from "@firsttx/prepaint";
import { App } from "./App";
createFirstTxRoot(
document.getElementById("root")!,
<React.StrictMode>
<App />
</React.StrictMode>,
);
createFirstTxRoot는 다음을 처리합니다.
- 페이지를 떠날 때 현재 화면을 IndexedDB에 캡처
- 재방문 시 React가 로드되기 전에 스냅샷을 즉시 복원
- ViewTransition API가 가능하면 복원을 크로스페이드로 감싸고
- 마지막으로 React 앱을 하이드레이션 또는 클라이언트 렌더로 마운트
- 앱에서 아무 페이지나 연 뒤, 스크롤을 살짝 내려 둡니다.
- 새로고침 또는 뒤로가기를 여러 번 반복해 봅니다.
흰 화면 없이 바로 마지막 화면이 보였다면 Prepaint가 제대로 동작하고 있는 것입니다.
지금 상태에서 이미 Prepaint만 “도입 완료” 상태입니다. 다음 단계부터는 Local-First / Tx로 기능을 확장합니다.
4. Local-First 모델 정의 (defineModel)
이제 IndexedDB에 저장될 모델을 정의해봅니다.
예시로 CartModel을 하나 만들어보겠습니다.
// 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(),
}),
),
}),
// ttl은 선택사항입니다. 기본값은 5분(5 * 60 * 1000 ms)입니다.
ttl: 5 * 60 * 1000,
// 초기값을 지정하면 첫 방문 시에도 null 대신 이 값으로 시작합니다.
initialData: {
items: [],
},
});
-
schema: Zod 스키마로 모델 구조를 정의합니다.- Local-First는 이 스키마를 기준으로 저장 전후에 데이터를 검증합니다.
- 스키마에 맞지 않는 데이터는 IndexedDB에서 제거됩니다.
-
ttl: 데이터가 “stale”로 간주되기까지의 시간 (ms). 기본값은 5분입니다. -
initialData: 디스크에 아무 값도 없을 때 사용할 초기 값입니다.
5. 컴포넌트에서 동기화 훅 사용 (useSyncedModel)
useSyncedModel은 모델과 서버를 자동으로 동기화해 주는 React 훅입니다.
// CartPage.tsx
import { useSyncedModel } from "@firsttx/local-first";
import { CartModel } from "./models/cart";
async function fetchCart(current: unknown) {
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,
} = useSyncedModel(CartModel, fetchCart, {
// 기본값은 "always" 입니다.
// "stale"로 설정하면 TTL이 지난 경우에만 자동 동기화를 수행합니다.
syncOnMount: "stale",
});
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>
))}
{isSyncing && (
<p className="text-xs text-muted-foreground">
Syncing latest data…
</p>
)}
{error && (
<p className="text-xs text-destructive">
Failed to sync: {error.getUserMessage?.() ?? error.message}
</p>
)}
</div>
);
}
이렇게 하면,
-
최초 진입 시
- IndexedDB에 데이터가 있으면 그걸 먼저 동기적으로 읽어와 즉시 렌더합니다.
- TTL이 지나서
stale상태라면, 백그라운드에서 서버 동기화를 진행합니다.
-
재방문 시
- 네트워크 상태와 상관없이 “마지막으로 저장된 장바구니”를 바로 보여줄 수 있습니다.
- 페이지에 들어가 장바구니를 채운 뒤, 새로고침을 여러 번 해 봅니다.
- 네트워크 탭을 잠깐 Offline으로 전환한 뒤 다시 접속해 보세요.
마지막 장바구니 상태가 그대로 보인다면 Local-First가 잘 동작하는 것입니다.
6. Tx로 낙관적 업데이트를 트랜잭션으로 감싸기 (선택)
이제 장바구니에 아이템을 추가하는 예시로 Tx를 사용해 봅니다.
// cart-actions.ts
import { startTransaction } from "@firsttx/tx";
import { CartModel } from "./models/cart";
type CartItem = {
id: string;
name: string;
qty: number;
};
export async function addToCart(item: CartItem) {
const tx = startTransaction({ transition: true });
// 1단계: 로컬 모델 낙관적 업데이트
await tx.run(
() =>
CartModel.patch((draft) => {
draft.items.push(item);
}),
{
// 실패 시 보상(rollback)
compensate: () =>
CartModel.patch((draft) => {
draft.items.pop();
}),
},
);
// 2단계: 서버에 실제 반영
await tx.run(() =>
fetch("/api/cart", {
method: "POST",
body: JSON.stringify(item),
}),
);
// 모든 단계가 성공하면 커밋
await tx.commit();
}
이 코드는 다음을 보장합니다.
- 사용자는 즉시 UI에서 아이템이 추가된 것을 보고,
- 서버 요청이 실패하면
compensate가 실행되어 UI가 원래 상태로 되돌아가며, - 전체 과정을 DevTools에서 하나의 트랜잭션 타임라인으로 추적할 수 있습니다.
transition: true일 때 ViewTransition을 지원하는 브라우저라면, 롤백 경로도 자연스러운 전환 애니메이션으로 감쌉니다.
7. DevTools와 다음 단계
FirstTx는 Prepaint / Local-First / Tx의 모든 중요한 이벤트를 DevTools로 전송합니다.
- Chrome 확장 프로그램 FirstTx DevTools를 설치하고,
- DevTools → “FirstTx” 패널을 연 뒤,
prepaint,model,tx카테고리를 필터링해 보세요.
그러면,
- Prepaint가 언제 스냅샷을 캡처/복원했는지,
- Local-First가 언제 데이터를 동기화/검증/정리했는지,
- Tx가 어떤 순서로 단계를 실행/재시도/롤백했는지
를 타임라인으로 한눈에 확인할 수 있습니다.
정리: 지금까지 구현한 것
이 가이드의 1~6단계까지 따라왔다면, 현재 앱은 이미 다음을 제공합니다.
- 재방문 시 빈 화면 없이 마지막 화면 복원 (Prepaint)
- IndexedDB 기반 로컬 데이터 레이어 + TTL/멀티탭 동기화 (Local-First)
- 트랜잭션 기반 낙관적 업데이트 + 재시도/롤백/타임아웃 (Tx)
각 레이어의 동작 원리/세부 옵션이 궁금하다면,
문서에서 이어서 살펴보시면 됩니다.