THE DEVLOG

scribbly.

Next.js 블로그 프로젝트

2025.05.16 16:59:07

7일차 주요 구현 기능인 IndexedDB를 이용한 로컬 자동저장에 관한 정리 글

IndexedDB란?

브라우저에서 제공하는 key-value 형식의 저장소.

용량이 LocalStorage에 비해 크고, 문자열 뿐 아니라 객체 형식으로 저장되는 특징이 있다.

구분localStoragesessionStorageIndexedDB
용량작음 (5MB 내외)작음 (5MB 내외)큼 (수십~수백 MB)
데이터 형태문자열만 저장 가능문자열만 저장 가능객체(구조화된 데이터) 저장 가능
비동기 여부동기동기비동기 (await/Promise)
지속성탭/브라우저 닫아도 유지탭 닫으면 사라짐브라우저 닫아도 유지됨
검색/쿼리없음없음인덱스 검색 가능

 

IndexedDB 주요 개념

DB 이름과 버전

  • indexedDB.open("my-db", 1)
  • "my-db"는 데이터베이스 이름
  • 1은 버전 (업그레이드할 때 숫자 증가시킴)

Object Store (저장소)

  • 데이터가 저장되는 공간 (SQL의 테이블과 비슷)
  • 예: "posts", "users"
  • 생성 시 keyPath 지정 가능 (예: "id")
db.createObjectStore("posts", { keyPath: "id" });

기본 작업 흐름

1) 데이터베이스 열기

const req = indexedDB.open("my-db", 1);

2) 처음 열거나 버전이 바뀌면 onupgradeneeded

req.onupgradeneeded = (e) => {
  const db = e.target.result;
  db.createObjectStore("posts", { keyPath: "id" });
};

3) 데이터 저장 (쓰기)

const tx = db.transaction("posts", "readwrite");
tx.objectStore("posts").put({ id: "post-1", title: "Hello" });

4) 데이터 조회

const tx = db.transaction("posts", "readonly");
tx.objectStore("posts").get("post-1");

Cursor

  • 전체 데이터 반복 조회에 사용
  • 데이터베이스 안의 모든 항목을 하나씩 순회할 때 유용
const req = store.openCursor();

req.onsuccess = (e) => {
  const cursor = e.target.result;
  if (cursor) {
    console.log(cursor.value); // 현재 항목
    cursor.continue();         // 다음 항목으로 이동
  }
};

 

IndexedDB 훅 만들기

main/hooks/use-indexeddb.tsx

IndexedDB 초기화

useIndexedDB를 호출하면 아래와 같이 IndexedDB의 초기화를 시도한다.

  const [db, setDb] = useState<IDBDatabase | null>(null);

  /** @returns {DOMException} 데이터베이스가 초기화되지 않았을 때 발생하는 예외 */
  const notInitializedException = useCallback(
    () => new DOMException("DB is not initialized", "InvalidStateError"),
    []
  );

  /** @returns {DOMException} 알 수 없는 IndexedDB 오류 예외 */
  const unknownException = useCallback(
    () => new DOMException("Unknown IndexedDB error", "UnknownError"),
    []
  );

  useEffect(() => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = () => {
      const db = request.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        const store = db.createObjectStore(STORE_NAME, {
          keyPath: "id",
          autoIncrement: true,
        });

        store.createIndex("postId", "postId", { unique: false }); // postId 추가
        store.createIndex("timestamp", "timestamp", { unique: false }); // timestamp 추가
      }
    };

    request.onsuccess = () => {
      setDb(request.result);
    };

    request.onerror = () => {
      console.error("IndexedDB Error:", request.error ?? unknownException());
    };
  }, [unknownException]);

상태 선언

const [db, setDb] = useState<IDBDatabase | null>(null);

  • 역할: IndexedDB 연결이 완료되면 dbIDBDatabase 객체를 저장.
  • 초기값: null, 아직 데이터베이스가 초기화되지 않았음을 의미.
  • 이 상태는 이후 addData, deleteByPostId 등 내부 함수에서 DB 접근에 사용됨.

예외 생성기 함수

const notInitializedException = useCallback(
  () => new DOMException("DB is not initialized", "InvalidStateError"),
  []
);

  • db === null인 상태에서 DB 연산을 하려고 할 때 사용하는 예외 객체.
  • DOMException은 브라우저 API에서 오류를 표현할 때 사용하는 표준 예외 타입.
const unknownException = useCallback(
  () => new DOMException("Unknown IndexedDB error", "UnknownError"),
  []
);

  • IndexedDB 자체의 내부 오류나, 브라우저가 구체적인 오류를 제공하지 않을 때 fallback으로 사용.
  • 이 두 함수는 memoization 되어 의존성 없이 생성됨.

DB 연결 시도 (useEffect)

useEffect(() => {
  const request = indexedDB.open(DB_NAME, DB_VERSION);
  ...
}, [unknownException]);

  • 의도: 컴포넌트가 마운트되었을 때 한 번만 실행 ([]과 유사하나 unknownException을 의존성에 넣음. unknownException는 메모이제이션 되어 있기에 변화하지 않음.).
  • indexedDB.open(name, version)으로 DB 연결 시도.
  • 반환된 request 객체에 이벤트 핸들러를 등록해서 비동기 처리.

onupgradeneeded

request.onupgradeneeded = () => {
  const db = request.result;
  if (!db.objectStoreNames.contains(STORE_NAME)) {
    const store = db.createObjectStore(STORE_NAME, {
      keyPath: "id",
      autoIncrement: true,
    });

    store.createIndex("postId", "postId", { unique: false });
    store.createIndex("timestamp", "timestamp", { unique: false });
  }
};

  • DB 버전이 증가했거나 처음 생성될 경우 실행됨.

  • 기존에 같은 object store가 없으면 createObjectStore를 통해 생성:

    • keyPath: "id" → 각 객체는 id 필드를 고유 키로 가짐.
    • autoIncrement: trueid는 자동 증가.
  • createIndex로 인덱스 추가:

    • "postId": 글 단위로 필터링 가능하게 함.
    • "timestamp": 최신 항목 순 정렬 가능하게 함.

onsuccess

request.onsuccess = () => {
  setDb(request.result);
};

  • DB 연결이 성공하면 request.resultIDBDatabase 객체가 들어있고, 이를 db 상태에 저장.

onerror

request.onerror = () => {
  console.error("IndexedDB Error:", request.error ?? unknownException());
};

  • 연결이나 초기화 중 오류 발생 시 콘솔에 출력.
  • 오류 객체가 없으면 unknownException() 사용.

요약 흐름도

컴포넌트 마운트됨
  └── useEffect 실행
        ├── indexedDB.open(DB_NAME, DB_VERSION)
        │     ├── onupgradeneeded → store 및 인덱스 생성
        │     ├── onsuccess → db 상태에 저장
        │     └── onerror → 오류 로깅

 

IndexedDB CRUD

Create

  /**
   * 데이터를 IndexedDB에 추가
   * @param {T & { postId: string; timestamp: number }} data - 추가할 데이터
   * @returns {Promise<{ status: "ok"; id: IDBValidKey }>} 저장된 ID와 상태 반환
   */
  const addData = (
    data: T & { postId: string; timestamp: number }
  ): Promise<{ status: "ok"; id: IDBValidKey }> => {
    return new Promise((resolve, reject) => {
      if (!db) return reject(notInitializedException());
      const transaction = db.transaction(STORE_NAME, "readwrite");
      const store = transaction.objectStore(STORE_NAME);
      const request = store.add(data);
      request.onsuccess = () => resolve({ status: "ok", id: request.result });
      request.onerror = () => reject(request.error ?? unknownException());
    });
  };

Read

  /**
   * 특정 postId에 해당하는 최신 데이터를 가져옴 (timestamp 기준 최신)
   * @param {string} postId - 조회할 postId
   * @returns {Promise<{ status: "ok"; data?: T }>} 최신 데이터 반환
   */
  const getLatestDataByPostId = (
    postId: string
  ): Promise<{ status: "ok"; data?: T }> => {
    return new Promise((resolve, reject) => {
      if (!db) return reject(notInitializedException());
      const transaction = db.transaction(STORE_NAME, "readonly");
      const store = transaction.objectStore(STORE_NAME);
      const index = store.index("postId");
      const range = IDBKeyRange.only(postId);
      const request = index.openCursor(range, "prev"); // 최신 데이터를 먼저 가져옴

      request.onsuccess = (event) => {
        const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
        if (cursor) {
          resolve({ status: "ok", data: cursor.value as T });
        } else {
          resolve({ status: "ok", data: undefined });
        }
      };

      request.onerror = () => reject(request.error ?? unknownException());
    });
  };

objectStore.index(...): 보조 인덱스 (Secondary Index)
  • IndexedDB는 Key-Value 기반의 트랜잭션 DB다.
  • 기본 검색은 objectStore.get(키)처럼 keyPath 기준이다.
  • keyPath가 아닌 다른 필드로 검색할 때에 index(...) 필요.
  • index()는 object store에 존재하는 특정 필드 값들에 대해, 자동 정렬된 검색용 구조다.
  • postId 필드를 index로 지정했다.
IDBKeyRange: 검색 범위를 표현하는 객체
  • objectStoreindex는 데이터를 조회할 때 범위를 명시해야 한다. 이것이 IDBKeyRange 객체다.
  • postId가 파라미터와 같은 것만 조회하였다.
메서드의미
only(x)값이 정확히 x인 항목만 포함
lowerBound(x)x 이상인 모든 항목 포함
upperBound(x)x 이하인 모든 항목 포함
bound(x, y)x 이상 y 이하의 항목 포함 (양쪽 경계 포함/비포함 여부 조절 가능)
openCursor: 순차 탐색 커서(Cursor)
  • get()은 특정 키 1개만 가져온다.
  • openCursor()는 여러 키를 반복적으로 순차 탐색할 수 있게 한다.
  • 커서는 레코드 하나씩 while처럼 순회하며 다룬다.
  • timestamp가 가장 큰 것이 위로 오도록되어 있기에 index.openCursor(range, "prev")로 가장 최신 게시글을 조회한다.
작동 방식
  1. openCursor(...)로 커서 시작
  2. 첫 번째 레코드를 .onsuccess에서 받음
  3. 필요시 cursor.continue()로 다음 레코드로 이동
  4. 반복
순회 방향
  • "next": 정방향 (오름차순)
  • "prev": 역방향 (내림차순)
Delete
  /**
   * 특정 postId 값을 가진 모든 데이터를 삭제
   * @param {string} postId - 삭제할 데이터의 postId 값
   * @returns {Promise<DBResponse>} 삭제 성공 여부 반환
   */
  const deleteByPostId = (postId: string): Promise<DBResponse> => {
    return new Promise((resolve, reject) => {
      if (!db) return reject(notInitializedException());

      const transaction = db.transaction(STORE_NAME, "readwrite");
      const store = transaction.objectStore(STORE_NAME);
      const index = store.index("postId");

      const range = IDBKeyRange.only(postId);
      const request = index.openCursor(range);

      request.onsuccess = (event) => {
        const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result;
        if (cursor) {
          store.delete(cursor.primaryKey); // 해당 데이터 삭제
          cursor.continue(); // 다음 데이터로 이동
        } else {
          resolve({ status: "ok" }); // 모든 삭제 완료
        }
      };

      request.onerror = () => reject(request.error ?? unknownException());
    });
  };

request.onsuccess에 리스너를 달아준다.
cursor.continue()가 Success를 발생시키면서 postId가 같은 모든 임시저장 게시글들을 삭제한다.

IndexedDB 컨트롤러

main/components/post/autosave/autosave-wrapper.tsx

Zustand로 isLocalDBChecked, isAutoSaving isAutoSaved의 플래그 선언하고, 상태 변경에 따라 IndexedDB를 조작한다.

  1. isLocalDBChecked : 컨트롤러가 마운트되면 useEffect를 통해 indexedDB 내부를 조회하고, 가장 최근 게시글을 불러온 후 isLocalDBChecked를 true로 한다.
  2. isAutoSaving : 게시글 수정이 일어나면 isAutoSaving 플래그가 올라간다. isAutoSaving가 올라가면 현재 작성중인 게시글을 IndexedDB에 저장한 후 false로 한다.
  3. isAutoSaved : 현재 작성중인 게시글이 IndexedDB에 저장되면 isAutoSaved를 true로 한다.

게시글 편집기

main/components/markdown/milkdown-app/milkdown-wrapper.tsx

게시글을 편집하면 debounce로 게시글의 변경 사항을 추적한다.
debounce가 끝나면 isAutoSaving 플래그를 true로 변경하여 IndexedDB 컨트롤러가 작동하도록 한다.

업로더 컴포넌트

main/components/post/autosave/autosave-indicator.tsx

isAutoSaved가 true일 때에 업로드 가능 버튼을 활성화한다.
업로드가 완료되면 deleteByPostId로 indexedDB에 임시저장된 게시글들을 지운다.