회사 프로젝트에서 Web Storage 로는 Local Storage 만을 사용하고 있었다.
그러나 추후 Web Storage 에 저장해야 할 정보들이 많이 생겨날 것을 대비해,
(Local Storage 보다) 더 많은 정보를 저장할 수 있는 IndexedDB 를 사용해보기로 했다.
모든 프로젝트에서 사용할 수 있게 공통화된 유틸을 작성하는 것이 필요했다.
개인적으로 유틸을 완성하기까지 고군분투한 과정을 기록해보려고 한다🤣
목차
- IndexedDB 의 장점 및 특징
- CRUD 적용(TypeScript ver.) 예시와 주요 개념
- 트러블 슈팅 경험
📌 IndexedDB ?
IndexedDB 는 브라우저 내에서 구조화된 데이터 저장을 위한 API 이다.
Local Storage 와 비교할 때 IndexedDB 가 가지는 주요 장점은 아래와 같다.
- 데이터 저장 용량
- Local Storage : 보통 5MB 로 제한됨
- IndexedDB : GB 단위의 대용량 데이터 저장 가능 - 데이터 저장 구조
- Local Storage : 문자열만 저장할 수 있어 복잡한 구조의 데이터(ex. 중첩된 구조의 객체/배열 등)를 저장하려면 JSON 문자열로 변환해야 함
- IndexedDB : Key-Value 저장소. 객체 저장소를 지원하여 복잡한 구조의 데이터를 저장하고 관리할 수 있음 - 성능 (동기 / 비동기)
- Local Storage : 동기 API 사용. 대량의 데이터 접근 시 blocking 이 될 수 있으며, 이는 성능 저하를 초래할 수 있음
- IndexedDB : 비동기 API 사용. 비동기적으로 데이터 접근이 가능하여, 대용량 데이터를 처리할 때 성능 저하 최소화 가능
Chrome > 개발자 도구 > Application 탭에서 확인할 수 있는 IndexedDB의 형태는 아래 이미지와 같다.
IndexedDB 가 동작하는 패턴은 다음과 같다. 패턴을 알아두면 복잡한 개념들을 이해하기 수월하다.
- 데이터베이스 열기
- 데이터베이스에 Object Store 생성하기
- 트랜젝션(Transaction) 시작하기 (데이터 CRUD 등의 작업을 요청함)
- 이벤트리스너를 사용하여 요청이 완료될 때까지 기다리기
- 요청 결과를 가지고 동작 로직 짜기
📌 CRUD 적용(TS ver.) 예시와 주요 개념
• 데이터베이스 열기
IndexedDB 는 CRUD 를 적용하기 전에, 데이터베이스를 여는 과정을 무조건 겪어야 한다.
데이터베이스를 열기 전, 브라우저 지원 여부를 확인해야 한다. 오래된 브라우저는 IndexedDB 를 사용할 수 없는 경우가 있다.
if (!('indexedDB' in window)) {
return Promise.reject(
new Error("This browser doesn't support indexedDB"),
)
}
IndexedDB 는 이벤트 기반으로 동작하는 비동기 API 이다.
데이터베이스 작업이 완료되었을 때 이벤트를 통해 알림을 받는 구조로 되어 있다.
export const openDB = (
dbName: string, // 데이터베이스 이름
storeName: string, // Object Store 이름
newVersion?: number, // 새로운 Object Store 추가할 경우 사용
): Promise<IDBDatabase> => {
if (!('indexedDB' in window)) {
return Promise.reject(
new Error("This browser doesn't support indexedDB"),
)
}
return new Promise((resolve, reject) => {
const openRequest = indexedDB.open(dbName, newVersion ?? 1)
openRequest.onsuccess = (e) => {
resolve((e.target as IDBRequest).result)
}
openRequest.onerror = (e) => {
reject((e.target as IDBRequest).error)
}
openRequest.onupgradeneeded = (e) => {
const db = (e.target as IDBRequest).result
// 동일 이름의 Object Store 가 존재하는지 체크
// 이미 존재할 경우 onsuccess 호출
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName)
}
}
})
}
이벤트에 대한 설명은 다음과 같다.
onsuccess
: 요청이 성공적으로 완료되었을 때 호출onerror
: 요청이 실패했을 때 호출onupgradeneeded
: 데이터베이스 버전이 업그레이드(= 증가)될 때 호출 → 현재 버전이 동일하면 onsuccess 만 호출됨!
+ 새로운 Object Store 나 인덱스를 생성할 때 주로 사용됨createObjectStore
: Object Store 를 생성할 때, 첫 번째 인자로는 Object Store 의 이름을 넣고, 두 번째 인자로KeyPath
나autoIncrement
를 넣을 수 있다.
* KeyPath 를 지정할 경우, Object Store 또는 인덱스에서 브라우저가 어디로부터 key 를 추출해야 하는지 정의할 수 있다.
• CRUD
본격적인 CRUD 예시를 보여주기 전에,,,
transaction 과정은 CRUD 로직에 필수적으로 들어가므로, 따로 함수로 만들어 공통으로 사용할 수 있도록 하였다.
transaction 공통 함수
const transactionPromise = (
db: IDBDatabase, // open 한 데이터베이스 이름
storeName: string, // Object Store 이름
mode: IDBTransactionMode, // transaction 모드
callback: (store: IDBObjectStore) => IDBRequest, // CRUD 동작 적용
): Promise<IDBValidKey> => {
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, mode)
const store = transaction.objectStore(storeName)
const request = callback(store)
request.onsuccess = (e) => {
resolve((e.target as IDBRequest).result)
}
request.onerror = (e) => {
reject((e.target as IDBRequest).error)
}
transaction.oncomplete = () => {
db.close()
}
})
}
transaction
: Object Store 에 접근하거나 데이터 요청이 가능하다.- transaction 의 모드는 3가지가 있다.
readonly
: 데이터를 읽을 수 있지만 변경할 수 없음readwrite
: 기존 데이터 저장소의 데이터를 읽고 쓸 수 있음versionchange
: 객체 저장소와 인덱스를 삭제하고 만드는 작업을 포함한 모든 작업을 허용함
- 마지막으로
db.close()
를 통해, transaction 과정이 끝나면 열려있던 데이터베이스를 닫도록 하였다.
데이터 쓰기
export const set = async <T>(
dbName: string, // 데이터베이스 이름
storeName: string, // Object Store 이름
item: T, // 저장할 데이터
key: IDBValidKey, // 저장할 데이터의 Key 이름
): Promise<IDBValidKey> => {
const db = await openDB(dbName, storeName)
return transactionPromise(db, storeName, 'readwrite', (store) =>
store.put(item, key),
)
}
- (공통 유틸이기 때문에) 데이터베이스를 열 때
createObjectStore
에서KeyPath
를 지정하는 방식이 아니라put
을 할 때 직접 지정한 key 를 입력할 수 있도록 파라미터를 열어 놓았다.
'특정' 데이터 조회
export const getSpecific = async (
dbName: string,
storeName: string,
key: IDBValidKey,
): Promise<IDBValidKey> => {
const db = await openDB(dbName, storeName)
return transactionPromise(db, storeName, 'readonly', (store) =>
store.get(key),
)
}
- 입력한 key 값을 통해 특정 데이터를 조회할 수 있다.
'전체' 데이터 조회
export const getAll = async (
dbName: string,
storeName: string,
): Promise<IDBValidKey> => {
const db = await openDB(dbName, storeName)
return transactionPromise(db, storeName, 'readonly', (store) =>
store.getAll(),
)
}
getAll()
: Object Store 내의 전체 데이터를 배열 형태로 담아준다.
데이터 수정
export const update = async <T>(
dbName: string,
storeName: string,
item: T,
key: IDBValidKey,
): Promise<IDBValidKey> => {
const db = await openDB(dbName, storeName)
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
const objStoreRequest = store.get(key)
objStoreRequest.onsuccess = () => {
const setRequest = store.put(item, key)
setRequest.onsuccess = (e) => {
resolve((e.target as IDBRequest).result)
}
setRequest.onerror = (e) => {
reject((e.target as IDBRequest).error)
}
}
transaction.oncomplete = () => {
db.close()
}
})
}
- 데이터 수정의 경우, 수정할 Object Store 를 가져온 뒤 수정을 하는 로직이어야 한다.
즉,get
을 성공한 후에put
을 하는 식으로 동작한다. 따라서 transaction 공통함수를 사용하지 않았다.
'특정' 데이터 삭제
export const remove = async (
dbName: string,
storeName: string,
key: IDBValidKey,
): Promise<IDBValidKey> => {
const db = await openDB(dbName, storeName)
return transactionPromise(db, storeName, 'readwrite', (store) =>
store.delete(key),
)
}
- 입력한 key 값을 통해 특정 데이터를 삭제할 수 있다.
'전체' 데이터 삭제
export const clear = async (
dbName: string,
storeName: string,
): Promise<IDBValidKey> => {
const db = await openDB(dbName, storeName)
return transactionPromise(db, storeName, 'readwrite', (store) =>
store.clear(),
)
}
clear()
: Object Store 내 전체 데이터를 삭제한다.
위 내용은 Object Store 내부의 값에 대한 CRUD 예시였다면,
아래 내용은 데이터베이스를 삭제하는 예시와 Object Store 를 추가하는 예시에 대해 다루겠다.
• 데이터베이스 삭제
export const deleteDB = (dbName: string) => {
return new Promise((resolve, reject) => {
const deleteRequest = indexedDB.deleteDatabase(dbName)
deleteRequest.onsuccess = (e) => {
resolve((e.target as IDBRequest).result)
}
deleteRequest.onerror = (e) => {
reject((e.target as IDBRequest).error)
}
})
}
deleteDatabase()
를 통해 데이터베이스를 삭제한다.
• Object Store 추가
// 현재 데이터베이스 버전 가져오기
const getCurrentDBVersion = (dbName: string): Promise<number> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName)
request.onsuccess = (e) => {
const db = (e.target as IDBRequest).result
const { version } = db
db.close()
resolve(version)
}
request.onerror = (e) => {
reject((e.target as IDBRequest).error)
}
})
}
// Object Store 추가
// 기존 데이터베이스에 새로운 Object Store 를 추가하기 위해 사용 (버전 up)
export const addObjectStore = async <T>(
dbName: string,
newStoreName: string,
item: T,
key: IDBValidKey,
) => {
const currentVersion = (await getCurrentDBVersion(dbName)) as number
const newVersion = currentVersion + 1
const db = await openDB(dbName, newStoreName, newVersion)
return transactionPromise(db, newStoreName, 'readwrite', (store) =>
store.put(item, key),
)
}
- 기존 데이터베이스에 새로운 Object Store 를 추가하고 싶을 때 사용하면 된다.
- Object Store 를 추가하려면 현재 데이터베이스의 버전보다 높아야 한다. 따라서 버전을 높이는 과정이 필요하다.
- 추가하는 과정은 다음과 같다.
1.getCurrentDBVersion()
를 통해 현재 데이터베이스 버전을 가져온다. (→version
)
2.addObjectStore()
내부에서 버전을 +1 한다.
3. 데이터베이스를 여는 공통 함수에 높인 버전을 전달(newVersion
)하여, 해당 버전의 데이터베이스를 열도록 한다.
4. 새로운 Object Store 를 생성한다.
📌 트러블 슈팅
공통 유틸을 제작하면서 겪은 문제와 해결 과정에 대해 공유해 보려고 한다.
❗️Object Store 에 객체 외의 다른 타입의 데이터가 저장되지 않는 문제
TypeError
Failed to execute 'createObjectStore' on 'IDBDatabase': The provided value is not of type 'IDBObjectStoreParameters'.
: createObjectStore
를 할 때, KeyPath
를 임의로 'id' 라고 지정한 것이 원인이었다.
KeyPath 를 지정할 경우, Object Store 또는 인덱스에서 브라우저가 어디로부터 key 를 추출해야 하는지 정의해야 하기 때문에
객체 형태의 데이터를 저장할 수 밖에 없었던 것이다.
즉, 'id' 가 KeyPath 라면, 저장할 데이터는 'id'를 Key 값으로 무조건 가지고 있어야 하는 것이다.
// 'id' 를 key 값으로 가지고 있는 데이터 예시
{
id: '123'
name: 'dygreen'
}
따라서, 이 문제를 해결하기 위해 임의로 지정해 놓았던 KeyPath 를 제거하고,
데이터를 put
하는 시점에 직접 key 를 지정하는 방식으로 변경하였더니, 객체 외의 다른 타입의 데이터도 저장할 수 있게 되었다.
❗️기존 데이터베이스에 새로운 Object Store 를 저장하지 못하는 문제
NotFoundError
Failed to execute 'transaction' on 'IDBDatabase': One of the specified object stores was not found.
: 추가하려는 데이터베이스의 버전이 기존 데이터베이스의 버전보다 높지 않아서 발생하는 에러이다.
버전이 낮기 때문에 데이터베이스를 열지 못하고, 따라서 transaction 도 동작하지 않는다.
즉, 기존에 데이터베이스가 이미 존재한다면 기존 데이터베이스의 버전보다 높아야 새로운 Object Store 를 추가할 수 있다.
따라서 Object Store 를 추가하는 함수(addObjectStore
) 내부에서 현재 데이터베이스의 버전보다 +1 을 하였다.
이 값은 데이터베이스를 여는 공통 함수에 인자(newVersion
)로 전달하여, onupgradeneeded
이벤트가 호출될 수 있도록 하였다.
(* onupgradeneeded 는 데이터베이스 버전이 업그레이드(= 증가)될 때 호출된다 → 현재 버전이 동일하면 onsuccess 만 호출됨!)
IndexedDB 공통 유틸 작업을 진행하면서 (Local Storage 에 비해) 복잡하고 낯선 개념들로 인해 그냥 포기하고 Local Storage 를 쓰고 싶었다..
하지만 앞으로 방대해질 프로젝트를 위해서 필요한 작업이기 때문에 계속 문제를 해결하려고 노력했다
마지막으로 리팩토링을 한 뒤, 유틸 사용 가이드 md 파일과 테스트 할 수 있는 페이지를 작업하여 다른 개발자들에게 공유하는 시간을 가졌다.
물론 완성된 코드가 완벽한 코드는 아니지만, 브라우저 Web Storage 에 대한 또 하나의 개념을 알게된 뿌듯한 경험이었다!
'배움 기록 > ETC' 카테고리의 다른 글
React-Query 를 사용하는 이유(+ 개념, 컨셉, SSR Hydration, Redux ...) (0) | 2023.10.09 |
---|---|
[MongoDB] Next.js 에서 MongoDB 사용하기 (+ Dynamic Route 에서 DB 데이터 사용하는 법) (0) | 2023.09.12 |
[Axios] Axios Interceptors 사용법 (0) | 2023.07.29 |
[JWT] refresh token, access token 정리 (로그인 과정) (0) | 2023.04.04 |
댓글