7일차 주요 구현 기능인 IndexedDB를 이용한 로컬 자동저장에 관한 정리 글
IndexedDB란?
브라우저에서 제공하는 key-value 형식의 저장소.
용량이 LocalStorage에 비해 크고, 문자열 뿐 아니라 객체 형식으로 저장되는 특징이 있다.
구분 | localStorage | sessionStorage | IndexedDB |
---|---|---|---|
용량 | 작음 (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 훅 만들기
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 연결이 완료되면
db
에IDBDatabase
객체를 저장. - 초기값:
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: true
→id
는 자동 증가.
-
createIndex
로 인덱스 추가:"postId"
: 글 단위로 필터링 가능하게 함."timestamp"
: 최신 항목 순 정렬 가능하게 함.
onsuccess
request.onsuccess = () => {
setDb(request.result);
};
- DB 연결이 성공하면
request.result
에IDBDatabase
객체가 들어있고, 이를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
: 검색 범위를 표현하는 객체
objectStore
나index
는 데이터를 조회할 때 범위를 명시해야 한다. 이것이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")
로 가장 최신 게시글을 조회한다.
작동 방식
openCursor(...)
로 커서 시작- 첫 번째 레코드를
.onsuccess
에서 받음 - 필요시
cursor.continue()
로 다음 레코드로 이동 - 반복
순회 방향
"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를 조작한다.
- isLocalDBChecked : 컨트롤러가 마운트되면 useEffect를 통해 indexedDB 내부를 조회하고, 가장 최근 게시글을 불러온 후 isLocalDBChecked를 true로 한다.
- isAutoSaving : 게시글 수정이 일어나면 isAutoSaving 플래그가 올라간다. isAutoSaving가 올라가면 현재 작성중인 게시글을 IndexedDB에 저장한 후 false로 한다.
- 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에 임시저장된 게시글들을 지운다.