logo
Firsttx

빠른 시작

이 가이드는 Vite + React 기반 CSR 앱을 예시로,

  1. Prepaint만 먼저 붙여서 재방문 부트 구간에 마지막 visual snapshot을 replay하고,
  2. 필요하다면 Local-First로 데이터 내구성을 추가하고,
  3. 마지막으로 Tx로 낙관적 UI를 트랜잭션으로 감싸는 순서를 다룹니다.
어디까지 따라가야 할까요?
  • Prepaint만 써보고 싶다면 1~3단계까지만 진행해도 충분합니다.

  • 로컬 데이터 내구성까지 필요하다면 4~5단계를 함께 보세요.

  • 낙관적 트랜잭션까지 체험해 보고 싶다면 6단계까지 진행하면 됩니다.


0. 사전 준비

  • React 18.2+
  • Vite 기반 CSR 앱 (Next.js 16 / App Router에서의 사용법은 별도 문서에서 다룹니다)
  • 타입스크립트 사용 가정

1. 패키지 설치

우선 세 레이어를 모두 설치해둡니다.
(당장은 Prepaint만 쓸 예정이지만, 나중에 확장하기 쉬우면서 설치 과정도 한 번에 끝냅니다.)

패키지 설치
pnpm add @firsttx/prepaint @firsttx/local-first @firsttx/tx zod
Tx 단독 사용에 대해

@firsttx/tx는 런타임 외부 의존성이 없어, 단독으로도 사용할 수 있습니다. 다만 이 가이드에서는 Local-First 모델을 낙관적 업데이트 대상으로 사용하는 예제를 중심으로 설명합니다.


2. Vite 플러그인 설정 (Prepaint 부트 스크립트)

Prepaint는 Vite 플러그인으로 부트 스크립트를 HTML에 주입합니다. 현재 Prepaint 플러그인은 Vite만 지원합니다.

ts
// 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(),
  ],
});

이 플러그인은 빌드 시 HTML에 작은 부트 스크립트를 주입합니다. 이 스크립트는,

  1. 페이지 진입 시 IndexedDB에서 마지막 DOM 스냅샷을 읽어오고
  2. 유효한 스냅샷이 있다면 React보다 먼저 복원한 뒤
  3. 그 다음에 실제 React 앱이 마운트되도록 연결합니다.
Visual overlay가 기본입니다

스냅샷은 항상 React root 밖의 비상호작용 overlay에 표시됩니다. React는 빈 root에 마운트되고 첫 commit 뒤 overlay가 제거됩니다.


3. 엔트리 포인트 교체 (createFirstTxRoot) – ⭐ 필수

이제 기존 ReactDOM.createRoot 대신 createFirstTxRoot를 사용합니다.

tsx
// 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는 다음을 처리합니다.

  1. 페이지를 떠날 때 현재 화면을 IndexedDB에 캡처
  2. 재방문 시 메인 React 번들이 시작되기 전에 visual snapshot replay
  3. ViewTransition API가 가능하면 복원을 크로스페이드로 감싸고
  4. 마지막으로 React 앱을 마운트하고 핸드오프 과정에서 임시 visual cache 제거
여기까지 확인해 보세요
  1. 앱에서 아무 페이지나 연 뒤, 스크롤을 살짝 내려 둡니다.
  2. 새로고침 또는 뒤로가기를 여러 번 반복해 봅니다.
  3. React 앱 마운트 전에 마지막 visual snapshot이 표시되면 Prepaint가 동작하는 것입니다. 목표 기기와 네트워크 조건에서 시간을 측정하세요.

지금 상태에서 이미 Prepaint만 “도입 완료” 상태입니다. 다음 단계부터는 Local-First / Tx로 기능을 확장합니다.


4. Local-First 모델 정의 (defineModel)

이제 IndexedDB에 저장될 모델을 정의해봅니다. 예시로 CartModel을 하나 만들어보겠습니다.

ts
// 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 훅입니다.

tsx
// 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 상태라면, 백그라운드에서 서버 동기화를 진행합니다.
  • 재방문 시

    • 네트워크 상태와 상관없이 “마지막으로 저장된 장바구니”를 바로 보여줄 수 있습니다.
여기까지 확인해 보세요
  1. 페이지에 들어가 장바구니를 채운 뒤, 새로고침을 여러 번 해 봅니다.
  2. 네트워크 탭을 잠깐 Offline으로 전환한 뒤 다시 접속해 보세요.
  3. 마지막 장바구니 상태가 그대로 보인다면 Local-First가 잘 동작하는 것입니다.


6. Tx로 낙관적 업데이트를 트랜잭션으로 감싸기 (선택)

이제 장바구니에 아이템을 추가하는 예시로 Tx를 사용해 봅니다.

ts
// 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();
}

이 흐름은 다음 동작을 의도합니다.

  1. 사용자는 즉시 UI에서 아이템이 추가된 것을 보고,
  2. 서버 요청이 실패하면 compensate가 실행되어 UI를 원래 상태로 되돌리려고 시도하며,
  3. 전체 과정을 DevTools에서 하나의 트랜잭션 타임라인으로 추적할 수 있습니다.
  4. 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단계까지 따라왔다면, 현재 앱은 이미 다음을 제공합니다.

  1. 재방문 빈 화면 시간을 줄이는 부트 구간 visual snapshot replay (Prepaint)
  2. IndexedDB 기반 로컬 데이터 레이어 + TTL/멀티탭 동기화 (Local-First)
  3. saga 형태의 낙관적 단계 + 재시도/보상/타임아웃 (Tx)

각 레이어의 동작 원리/세부 옵션이 궁금하다면,

문서에서 이어서 살펴보시면 됩니다.