Local-First를 위한 여정

Keonwoo Kim·2025년 6월 6일

Needs

만들려고 하는 앱에 data-fetching을 최소화하면 좋겠다는 막연한 생각에 local-first 앱으로 구현하고자 했다.

Options

일단 이것저것 하려고 해봤다. 최대한 프로젝트를 간결하게 유지하기 위해 데이터베이스는 Cloudflare D1, 스토리지는 Cloudflare R2, 백엔드는 Cloudflare Worker에 올려서 관리하는 게 목표이다.

LiveStore

LiveStore: SQLite-based data layer for local-first apps

헉 너무 좋아보인다. 그런데 몇 시간의 삽질을 한 결과 구현하기 어렵다는 생각이 들었다.

  • 일단 제공해주는 worker adapter에서 직접 DB로 데이터를 넣을 수가 없다...
  • 예시 프로젝트에 잔버그가 너무 많았다.
  • 결정적으로 앱 기획에 적합하지 않았다.

    Reasons when not to use LiveStore

    • You have an existing database which is the source of truth of your data. (Better use Zero or ElectricSQL for this.)

그렇다고 Zero or ElectricSQL을 쓰기에는 추가 서비스가 필요한 상황이라 적절치 못한 것 같았다.

DIM (Do-it-myself)

그렇다면 직접 만드는 수밖에 없지...

클라이언트 데이터베이스는 D1이 SQLite 기반이므로 expo-sqlite를 쓰는 게 좋겠다.

전체적인 구상은 대충 이렇다.

  • 모든 테이블은 id: serial, createdAt/updatedAt/deletedAt: Date를 포함한다.
  • 서버 테이블과 클라이언트 테이블은 1:1로 대응되고, lastQueriedAt: DatelastStatus: DataStatus를 추가로 가진다. DataStatus는 예를 들어 권한 부족인지, 데이터가 삭제됐는지, 서버 에러가 발생해서 못 가져온 건지 등을 저장하면 좋지 않을까 한다.
  • 필터 없이 모든 row를 가져오는 경우 캐싱을 용이하게 하기 위해 테이블을 하나 추가한다. 이렇게 불러오는 테이블의 경우 언제(queriedAt) 어디(minId)부터 어디(maxId)까지 불러왔는지를 저장해 놓고, 불러올 때마다 업데이트한다. Infinite queries를 염두에 두고 만든 테이블이다.
    SELECT * FROM tableId
        WHERE tableId.id < minId            -- 아직 불러오지 못한 행들 (ORDER BY id DESC의 경우) 
           OR tableId.id > maxId            -- 마지막 쿼리 이후로 새로 추가된 행들
           OR tableId.updatedAt > queriedAt -- 마지막 쿼리 이후로 추가/변경된 행들
  • 주의할 점은 행 자체를 지워버리면 쿼리가 되지 않기에 클라이언트 측에서 지워졌다는 것을 알 수 없다. 그래서 데이터를 지울 때 행 자체는 남겨두고 iddeletedAt만 남기는 것이 좋겠다.

구현

Drizzle

양쪽 모두 SQLite이므로, 칼럼 정의를 한 곳에만 할 수 있으면 정말 좋을 것이다. 그런데 이렇게 하려면 서버와 클라이언트 테이블이 완전히 동일해져야 한다. 따라서 위 다이어그램에서 lastQueriedAtlastStatus를 별도의 테이블로 빼는 게 좋겠다.

import { sql } from "drizzle-orm";
import {
  type AnySQLiteColumn,
  int,
  sqliteTable,
  text,
} from "drizzle-orm/sqlite-core";
import type { WordConjugation, WordMeaning } from "../types/word";

export const words = sqliteTable("words", {
  id: int().primaryKey({ autoIncrement: true }),
  createdAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
  updatedAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
  deletedAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
  text: text().notNull(),
  pronunciation: text().notNull(),
  audioUrl: text(),
  meanings: text({ mode: "json" })
    .$type<WordMeaning[]>()
    .notNull()
    .default(sql`'[]'`),
  conjugations: text({ mode: "json" })
    .$type<WordConjugation[]>()
    .notNull()
    .default(sql`'[]'`),
});

export const verbs = sqliteTable("verbs", {
  id: int().primaryKey({ autoIncrement: true }),
  createdAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
  updatedAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
  deletedAt: int({ mode: "timestamp" }).default(sql`(strftime('%s', 'now'))`),
  wordId: int()
    .notNull()
    .references((): AnySQLiteColumn => words.id, { onDelete: "cascade" })
    .unique(),
});

Backend (CF Worker)

백엔드 디자인을 해보자. 최대한 가볍게 하기 위해 API 서버로만 사용하기로 하고, 프레임워크는 Hono + TRPC를 사용할 것이다. (프레임워크 설정은 생략)

import { type Schema, verbs } from "@acme/database";
import { asc, gt, lt, or } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { ListInputSchema } from "../../validators";
import { publicProcedure, router } from "../trpc";

export const verbsRouter = router({
  list: publicProcedure.input(ListInputSchema).query(async ({ ctx, input }) => {
    const db = drizzle<Schema>(ctx.env.DB);

    const [$count, rows] = await Promise.all([
      db.$count(verbs),
      db
        .select()
        .from(verbs)
        .orderBy(asc(verbs.id))
        .where(
          or(
            input.minId === undefined ? undefined : lt(verbs.id, input.minId),
            input.maxId === undefined ? undefined : gt(verbs.id, input.maxId),
            input.lastQueriedAt === undefined
              ? undefined
              : gt(verbs.updatedAt, input.lastQueriedAt),
            input.lastQueriedAt === undefined
              ? undefined
              : gt(verbs.deletedAt, input.lastQueriedAt),
          ),
        )
        .limit(input.limit ?? 50),
    ]);

    return { $count, rows };
  }),
});

일단 이런 느낌으로 결정. 위 다이어그램대로 구현되었다.

expo-sqlite

Expo + TRPC + drizzle + expo-sqlite 설정부터 하기.

// @acme/mobile/drizzle.config.ts
import type { Config } from "drizzle-kit";

export default {
  schema: ["./lib/schema.ts", "../../packages/database/src/schema/*"],
  out: "./drizzle",
  dialect: "sqlite",
  driver: "expo",
} satisfies Config;
// @acme/mobile/lib/schema.ts
import { int, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";

export const queryHistories = sqliteTable("query_histories", {
  tableId: text().primaryKey(),
  minId: int().notNull(),
  maxId: int().notNull(),
  queriedAt: int().notNull(),
});

export type QueryStatus =
  | "success"
  | "permission_denied"
  | "server_error"
  | "not_found";

export const localQueryInfos = sqliteTable(
  "local_query_infos",
  {
    id: int().primaryKey({ autoIncrement: true }),
    tableName: text().notNull(),
    tid: int().notNull(),
    lastQueriedAt: int().notNull(),
    lastStatus: text().$type<QueryStatus>().notNull(),
  },
  (table) => [uniqueIndex("table_idx").on(table.tableName, table.tid)],
);
// @acme/mobile/lib/db.ts
import type { Schema } from "@acme/database";
import { drizzle } from "drizzle-orm/expo-sqlite";
import { openDatabaseSync } from "expo-sqlite";
import type * as LocalSchema from "./schema";

export const expoDb = openDatabaseSync("db.db", { enableChangeListener: true });
export const db = drizzle<Schema & typeof LocalSchema>(expoDb);
// @acme/mobile/App.tsx
import { QueryClientProvider } from "@tanstack/react-query";
import { useMigrations } from "drizzle-orm/expo-sqlite/migrator";
import { Text, View } from "react-native";
import { queryClient } from "~/lib/trpc";
import migrations from "./drizzle/migrations";
import { db } from "./lib/db";

export default function () {
  return (
    <Layout>
      <App />
    </Layout>
  );
}

function Layout({ children }: { children: React.ReactNode }) {
  const { success, error } = useMigrations(db, migrations);

  console.log({ success, error });

  if (error) {
    return (
      <View>
        <Text>Migration error: {error.message}</Text>
      </View>
    );
  }

  if (!success) {
    return (
      <View>
        <Text>Migration is in progress...</Text>
      </View>
    );
  }

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

이제 크게 두 가지 use-case에 대해 간단한 훅을 만들어서 사용할 수 있게 했다.

모든 행 받아오기

순차적으로 필요할 때마다 받아올 수 있도록, infinite query 스타일의 함수 fetchNextData를 반환하도록 했다.

// hooks/useListQuery.ts
function useListQuery<T extends TableConfig>(
  table: SQLiteTableWithColumns<T>,
  opts?: { limit?: number; order?: "asc" | "desc" },
) {
  const tableName = useMemo(
    () => getTableName(table) as string & keyof typeof trpcClient,
    [table],
  );
  const tableColumns = useMemo(() => getTableColumns(table), [table]);

  const queryHistoriesQuery = useLiveQuery(
    db
      .select()
      .from(queryHistories)
      .where(eq(queryHistories.tableId, tableName)),
  );
  const queryHistory = queryHistoriesQuery.data.at(0);

  const dataQuery = useLiveQuery(db.select().from(table));

  const fetchNextData = useCallback(async () => {
    if (!queryHistoriesQuery.updatedAt) return;

    const response = (await trpcClient[tableName].list.query({
      limit: opts?.limit ?? 100,
      minId: queryHistory?.minId,
      maxId: queryHistory?.maxId,
      lastQueriedAt: queryHistory?.queriedAt,
      order: opts?.order ?? "asc",
    })) as unknown as {
      $count: number;
      $now: number;
      rows: SQLiteTableWithColumns<T>["$inferSelect"][];
    };

    const newMinId = Math.min(
      ...response.rows.map((word) => word.id!),
      queryHistory?.minId ?? Number.POSITIVE_INFINITY,
    );
    const newMaxId = Math.max(
      ...response.rows.map((word) => word.id!),
      queryHistory?.maxId ?? Number.NEGATIVE_INFINITY,
    );

    const set = Object.entries(tableColumns).reduce(
      (acc, [columnName, column]) => {
        acc[columnName as keyof SQLiteTableWithColumns<T>["$inferInsert"]] =
          sql.raw(`excluded.${column.name}`);
        return acc;
      },
      {} as SQLiteUpdateSetSource<SQLiteTableWithColumns<T>>,
    );

    db.transaction(async (tx) => {
      await Promise.allSettled([
        // Update query history
        tx
          .insert(queryHistories)
          .values({
            tableId: tableName,
            minId: newMinId,
            maxId: newMaxId,
            queriedAt: response.$now,
          })
          .onConflictDoUpdate({
            target: queryHistories.tableId,
            set: {
              minId: newMinId,
              maxId: newMaxId,
              queriedAt: response.$now,
            },
          }),

        // Upsert values to the table
        response.rows.length
          ? tx
              .insert(table)
              .values(response.rows as any[])
              .onConflictDoUpdate({
                target: table.id,
                set: set,
              })
          : undefined,

        // Insert local query info
        response.rows.length
          ? tx
              .insert(localQueryInfos)
              .values(
                response.rows.map((row) => ({
                  tableName,
                  tid: row.id as number,
                  lastQueriedAt: response.$now,
                  lastStatus: "success" as QueryStatus,
                })),
              )
              .onConflictDoUpdate({
                target: [localQueryInfos.tableName, localQueryInfos.tid],
                set: {
                  lastQueriedAt: sql.raw(
                    `excluded.${localQueryInfos.lastQueriedAt.name}`,
                  ),
                  lastStatus: sql.raw(
                    `excluded.${localQueryInfos.lastStatus.name}`,
                  ),
                },
              })
          : undefined,
      ]);
    });
  }, [
    opts?.limit,
    opts?.order,
    table.id,
    tableName,
    table,
    tableColumns,
    queryHistoriesQuery.updatedAt,
    queryHistory?.minId,
    queryHistory?.maxId,
    queryHistory?.queriedAt,
  ]);

  return { dataQuery, fetchNextData, queryHistory };
}
// App.tsx

function App() {
  const {
    dataQuery: wordsQuery,
    fetchNextData: fetchWords,
    queryHistory: wordsHistory,
  } = useListQuery(words);

  return (
    <View
      style={[
        StyleSheet.absoluteFill,
        {
          justifyContent: "center",
          alignItems: "center",
        },
      ]}
    >
      <Text>minId: {wordsHistory?.minId ?? "no minId"}</Text>
      <Text>maxId: {wordsHistory?.maxId ?? "no maxId"}</Text>
      <Text>
        wordsQuery:{" "}
        {wordsQuery.data
          .filter((x) => !x.deletedAt)
          .map((x) => `${x.id}: ${x.text}`)
          .join(", ")}
      </Text>

      <Button title="Load more" onPress={() => fetchWords()} />

      <Button
        title="Delete all"
        onPress={() => {
          expoDb.closeSync();
          deleteDatabaseSync("db.db");
        }}
      />
    </View>
  );
}


일부 행의 deletedAt을 설정했을 때 (160과 167이 삭제됨)

0개의 댓글