Tips & Tricks (업데이트: 2026. 6. 2.)

Claude Code로 IndexedDB 구현하기: 로컬 데이터 입문

IndexedDB schema, migration, transaction, quota, offline queue, test를 Claude Code로 안전하게 구현합니다.

Claude Code로 IndexedDB 구현하기: 로컬 데이터 입문

IndexedDB는 브라우저 안에 구조화된 데이터를 저장하기 위한 비동기 데이터베이스입니다. localStorage처럼 짧은 문자열만 저장하는 방식이 아니라 객체, 인덱스, Blob, 트랜잭션, 버전 업그레이드를 다룰 수 있습니다. 초안 자동 저장, 검색 가능한 캐시, PWA 오프라인 큐, AI 도구의 중간 결과 저장처럼 데이터가 커지거나 검색 조건이 생기는 순간 후보가 됩니다.

하지만 IndexedDB는 초보자에게 친절한 API가 아닙니다. onupgradeneeded, object store, index, transaction, quota, 다른 탭 때문에 막히는 upgrade 등 운영에서 터지는 지점이 많습니다. Claude Code에 “IndexedDB 붙여줘”라고만 요청하면 데모는 동작해도 schema migration, 용량 오류, 동기화 충돌, 테스트가 빠질 수 있습니다.

이 글은 idb wrapper를 이용한 TypeScript 예제로 IndexedDB를 처음 설계하는 방법을 설명합니다. 기준 문서는 MDN Using IndexedDB, MDN storage quotas and eviction criteria, web.dev Storage for the web, idb README, Dexie.js docs입니다. Claude Code 프롬프트 습관은 Claude Code 생산성 팁Claude Code 테스트 전략도 함께 보면 좋습니다.

localStorage 대신 IndexedDB를 쓰는 기준

localStorage는 테마, 언어, 마지막 탭, 짧은 설정값처럼 작은 문자열을 저장할 때 좋습니다. API가 단순하지만 동기식이라 메인 스레드를 막고, 검색용 index가 없으며, 큰 JSON 배열을 매번 통째로 다시 쓰는 구조가 되기 쉽습니다.

IndexedDB는 브라우저 안의 작은 데이터베이스에 가깝습니다. object store는 record를 담는 공간, key는 record의 식별자, index는 다른 필드로 찾는 경로, transaction은 여러 read/write를 하나의 단위로 묶는 장치입니다.

데이터 또는 작업localStorageIndexedDB
테마, UI 모드, 짧은 설정적합보통 과함
긴 초안, 폼 복구, 큰 JSON위험하고 blocking 가능적합
검색 가능한 글/상품 캐시큰 JSON blob이 됨index로 조회
첨부 파일 메타데이터, Blob부적합가능
오프라인 작업 큐순서와 retry 관리가 어려움transaction과 잘 맞음

실무 use case는 최소 네 가지가 있습니다. 첫째, CMS나 블로그 editor가 초안을 updatedAtsyncStatus로 저장해 새로고침 후에도 입력을 복구합니다. 둘째, SaaS dashboard가 상품, 요금제, help center article을 캐시하고 category나 freshness로 조회합니다. 셋째, PWA가 댓글, 문의 폼, 재고 변경을 offline queue에 넣고 online 이벤트에서 전송합니다. 넷째, Claude Code 분석 결과처럼 큰 중간 데이터를 expiry와 함께 저장해 같은 API 호출을 반복하지 않습니다.

schema, version, index를 먼저 정한다

IndexedDB 설계의 첫 질문은 “무엇을 저장할까?”가 아니라 “나중에 어떤 조건으로 읽을까?”입니다. id로 한 건만 읽으면 단순하지만, 실제 앱은 “dirty draft만”, “가장 오래된 cache부터”, “createdAt 순서로 queue 처리”, “attempts가 낮은 작업” 같은 조회가 필요합니다. 이 조건들은 index가 필요하고, index를 나중에 추가하려면 DB version을 올리고 upgrade 로직을 써야 합니다.

흔한 실패는 notes store만 만든 뒤 syncStatus index가 없어서 모든 note를 가져와 JavaScript에서 filter하는 것입니다. 50건에서는 괜찮아 보여도 수천 건에서는 첫 로딩이 느려집니다. 또 하나의 실패는 1.1 같은 소수 version을 쓰는 것입니다. IndexedDB version은 정수로 관리하는 것이 안전하므로 1, 2, 3처럼 올립니다.

npm i idb

아래 코드는 src/lib/local-db.ts 같은 client module로 붙여 넣을 수 있습니다. idb는 IndexedDB의 개념은 유지하면서 request/event 코드를 Promise 기반으로 줄여줍니다. 더 풍부한 query helper, live query, 높은 수준의 data layer가 필요하면 Dexie도 검토할 수 있지만, 이 글은 초보자가 경계를 이해하기 쉬운 idb를 기준으로 합니다.

import {
  openDB,
  type DBSchema,
  type IDBPDatabase,
} from "idb";

type SyncStatus = "clean" | "dirty" | "syncing" | "failed";

export interface DraftNote {
  id: string;
  title: string;
  body: string;
  updatedAt: number;
  baseVersion: number;
  syncStatus: SyncStatus;
}

export interface SyncJob {
  id: string;
  noteId: string;
  operation: "upsert-note" | "delete-note";
  payload: unknown;
  createdAt: number;
  attempts: number;
  lastError?: string;
}

interface AppDB extends DBSchema {
  notes: {
    key: string;
    value: DraftNote;
    indexes: {
      "by-updated-at": number;
      "by-sync-status": SyncStatus;
    };
  };
  syncQueue: {
    key: string;
    value: SyncJob;
    indexes: {
      "by-created-at": number;
      "by-attempts": number;
    };
  };
}

const DB_NAME = "claude-code-indexeddb-demo";
const DB_VERSION = 2;

let dbPromise: Promise<IDBPDatabase<AppDB>> | undefined;

export function getDb() {
  dbPromise ??= openDB<AppDB>(DB_NAME, DB_VERSION, {
    upgrade(db, oldVersion, _newVersion, tx) {
      if (oldVersion < 1) {
        const notes = db.createObjectStore("notes", {
          keyPath: "id",
        });
        notes.createIndex("by-updated-at", "updatedAt");

        const queue = db.createObjectStore("syncQueue", {
          keyPath: "id",
        });
        queue.createIndex("by-created-at", "createdAt");
        queue.createIndex("by-attempts", "attempts");
      }

      if (oldVersion < 2) {
        const notes = tx.objectStore("notes");
        if (!notes.indexNames.contains("by-sync-status")) {
          notes.createIndex("by-sync-status", "syncStatus");
        }
      }
    },
    blocked() {
      console.warn("Close other tabs to finish the DB upgrade.");
    },
    blocking() {
      dbPromise?.then((db) => db.close()).catch(() => {});
    },
  });

  return dbPromise;
}

store 생성과 index 추가는 upgrade 안에 둡니다. 다른 탭이 예전 version DB를 열고 있으면 새 version open이 막힐 수 있습니다. blocked에서는 사용자가 다른 탭을 닫도록 안내하고, blocking에서는 현재 연결이 다음 migration을 막지 않도록 닫습니다.

transaction으로 초안과 큐를 함께 저장한다

초안 저장과 sync queue 추가는 하나의 업무 동작입니다. note만 저장되고 queue insert가 실패하면 UI는 저장된 것처럼 보이지만 서버에는 가지 않습니다. 두 store를 하나의 readwrite transaction으로 묶습니다.

const createId = () =>
  globalThis.crypto?.randomUUID?.() ??
  Math.random().toString(36).slice(2);

export async function saveDraft(input: {
  id?: string;
  title: string;
  body: string;
  baseVersion?: number;
}) {
  const db = await getDb();
  const now = Date.now();
  const noteId = input.id ?? createId();

  const note: DraftNote = {
    id: noteId,
    title: input.title,
    body: input.body,
    updatedAt: now,
    baseVersion: input.baseVersion ?? 0,
    syncStatus: "dirty",
  };

  const job: SyncJob = {
    id: createId(),
    noteId,
    operation: "upsert-note",
    payload: note,
    createdAt: now,
    attempts: 0,
  };

  const tx = db.transaction(["notes", "syncQueue"], "readwrite");
  await tx.objectStore("notes").put(note);
  await tx.objectStore("syncQueue").put(job);
  await tx.done;

  return note;
}

export async function getDirtyNotes() {
  const db = await getDb();
  return db.getAllFromIndex("notes", "by-sync-status", "dirty");
}

주의할 점은 transaction 안에서 fetch를 기다리지 않는 것입니다. 브라우저는 더 이상 pending DB 작업이 없다고 판단하면 transaction을 닫을 수 있습니다. DB 쓰기를 끝낸 뒤 네트워크 요청을 보내고, 성공 결과는 새 transaction에서 반영하는 편이 안전합니다.

quota와 eviction을 오류 흐름으로 다룬다

IndexedDB는 무제한 저장소가 아닙니다. 브라우저는 origin별 quota, 기기 여유 공간, private mode, storage pressure에 따라 write를 거부하거나 best effort 데이터를 지울 수 있습니다. 따라서 QuotaExceededError를 잡고, 오래된 cache를 지우며, 중요한 데이터는 서버 동기화를 전제로 설계해야 합니다.

현실적인 실패 예시는 두 가지입니다. 첫째, 저장 실패가 console에만 남고 UI는 계속 “저장됨”을 보여줍니다. 둘째, 결제, 계약, 재고처럼 중요한 데이터를 IndexedDB에만 두고 백업이나 서버 version을 두지 않습니다. IndexedDB는 오프라인 작업 공간으로 매우 좋지만 최종 원본은 서버에 두는 것이 안전합니다.

export async function estimateStorage() {
  if (!navigator.storage?.estimate) {
    return { usage: undefined, quota: undefined };
  }

  const { usage, quota } = await navigator.storage.estimate();
  return { usage, quota };
}

export async function requestPersistentStorage() {
  if (!navigator.storage?.persist) {
    return false;
  }

  return navigator.storage.persist();
}

export function isQuotaError(error: unknown) {
  return (
    error instanceof DOMException &&
    error.name === "QuotaExceededError"
  );
}

export async function saveDraftWithErrorMessage(
  input: Parameters<typeof saveDraft>[0],
) {
  try {
    return await saveDraft(input);
  } catch (error) {
    if (isQuotaError(error)) {
      throw new Error(
        "브라우저 저장 공간이 부족합니다. 오래된 초안을 삭제해 주세요.",
      );
    }

    throw error;
  }
}

persist()는 보장을 뜻하지 않습니다. 브라우저와 사용자의 판단이 들어갑니다. Claude Code에는 quota 실패 시 UI 문구, 오래된 cache 정리, 테스트를 같이 요구해야 합니다.

offline queue와 sync conflict

오프라인 큐는 “나중에 보내기”만으로 끝나지 않습니다. 순서, retry 횟수, 중복 요청, 서버 데이터와의 충돌 규칙이 필요합니다. 같은 note를 한 기기에서는 offline으로 수정하고 다른 기기에서는 online으로 수정했다면, offline browser가 돌아왔을 때 오래된 작업이 최신 서버 데이터를 덮어쓸 수 있습니다.

type SendJob = (job: SyncJob) => Promise<void>;

export async function flushSyncQueue(sendJob: SendJob) {
  if (!navigator.onLine) return;

  const db = await getDb();
  const jobs = await db.getAllFromIndex(
    "syncQueue",
    "by-created-at",
  );

  for (const job of jobs) {
    try {
      await sendJob(job);
      await db.delete("syncQueue", job.id);
    } catch (error) {
      const message =
        error instanceof Error ? error.message : "Unknown sync error";

      await db.put("syncQueue", {
        ...job,
        attempts: job.attempts + 1,
        lastError: message,
      });
    }
  }
}

window.addEventListener("online", () => {
  void flushSyncQueue(async (job) => {
    const response = await fetch("/api/sync", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(job),
    });

    if (!response.ok) {
      throw new Error(`Sync failed: ${response.status}`);
    }
  });
});

개인 메모는 local/remote 버전을 보여주고 사용자가 선택하게 해도 됩니다. 하지만 재고, 예약, invoice, billing event는 서버에서 version, ETag, idempotency key로 오래된 write를 거부해야 합니다.

export function detectConflict(input: {
  local: DraftNote;
  remoteVersion: number;
}) {
  const { local, remoteVersion } = input;

  return (
    local.syncStatus === "dirty" &&
    remoteVersion > local.baseVersion
  );
}

테스트와 mock의 한계

fake-indexeddb는 Node에서 IndexedDB 비슷한 환경을 제공하므로 Vitest 단위 테스트에 유용합니다. 저장 함수, index 조회, queue insert, migration 회귀 테스트에 쓰세요. 그러나 quota, eviction, private mode, 여러 탭의 blocked 동작, 모바일 저장소 압박은 실제 브라우저에서 확인해야 합니다.

npm i -D vitest fake-indexeddb
import "fake-indexeddb/auto";
import { beforeEach, expect, test } from "vitest";
import { deleteDB } from "idb";
import {
  getDirtyNotes,
  saveDraft,
} from "./local-db";

beforeEach(async () => {
  await deleteDB("claude-code-indexeddb-demo");
});

test("saves a draft and finds it by sync status", async () => {
  await saveDraft({
    title: "IndexedDB note",
    body: "Saved while offline",
  });

  const dirtyNotes = await getDirtyNotes();

  expect(dirtyNotes).toHaveLength(1);
  expect(dirtyNotes[0]?.syncStatus).toBe("dirty");
});

릴리스 전에는 첫 실행 DB 생성, version 2 index 추가, 다른 탭으로 인한 upgrade 안내, offline 저장 후 reload 복구, online 복귀 시 queue 전송, quota 실패 메시지를 확인합니다. 수익화 페이지라면 CTA, 상품 링크, 상담 링크, analytics event가 그대로 동작하는지도 함께 봐야 합니다.

Claude Code에 안전하게 요청하는 프롬프트

IndexedDB 작업은 기능 설명보다 계약이 중요합니다. 수정 범위, DB 이름, version, store, index, transaction, quota, test를 명시하면 Claude Code의 결과를 review하기 쉬워집니다.

claude <<'PROMPT'
Scope:
- Edit only src/lib/local-db.ts and src/lib/local-db.test.ts.
- Do not change API routes, pricing copy, analytics, or CTA links.

Build:
- Use IndexedDB through the idb package.
- Database name: claude-code-indexeddb-demo.
- Version 2 must create notes and syncQueue stores.
- Add indexes for updatedAt, syncStatus, createdAt, and attempts.

Reliability:
- Use readwrite transactions for note + queue writes.
- Do not await fetch inside an active IndexedDB transaction.
- Handle QuotaExceededError with a user-facing message.
- Explain blocked upgrades from another open tab.

Testing:
- Use vitest and fake-indexeddb.
- Test draft save, dirty-note lookup, and queue insertion.
- Return findings, patch summary, commands, and residual risk.
PROMPT

이 프롬프트는 Claude Code가 단순 저장 코드를 만드는 대신 review 가능한 local data layer를 만들게 합니다. 수익화된 사이트에서는 offline draft, cache, queue가 CTA, 상품 링크, 상담 폼, analytics를 깨지 않는지도 품질입니다. 팀 도입은 Claude Code 교육 및 상담에서 점검할 수 있고, 개인 개발자는 무료 cheatsheetClaude Code 템플릿으로 체크리스트를 시작할 수 있습니다.

직접 검증한 결과

Masa가 작은 note editor에서 이 패턴을 시험했을 때 가장 효과가 컸던 것은 syncStatusupdatedAt index를 처음에 정한 점이었습니다. 첫 버전은 모든 note를 읽고 JavaScript에서 filter했는데, 데이터가 늘자 초기 화면이 느려졌습니다. idb로 옮기고 note 저장과 queue 추가를 하나의 transaction으로 묶으니 실패 상태를 설명하기 쉬워졌습니다. fake-indexeddb는 저장과 조회 테스트에는 충분했지만 quota와 tab blocking은 증명하지 못했습니다. 마지막에는 Chrome DevTools에서 storage를 지우고 network를 Offline으로 바꾼 뒤 저장, reload, Online 복귀, queue 전송까지 직접 확인했습니다.

#Claude Code #IndexedDB #로컬 스토리지 #오프라인 #TypeScript
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.