본문 바로가기
배움 기록/ETC

[TS] IndexedDB 공통 유틸: 구현과 사용법 (+ 기본 개념)

by dygreen 2024. 7. 28.

 

회사 프로젝트에서 Web Storage 로는 Local Storage 만을 사용하고 있었다.

그러나 추후 Web Storage 에 저장해야 할 정보들이 많이 생겨날 것을 대비해,

(Local Storage 보다) 더 많은 정보를 저장할 수 있는 IndexedDB 를 사용해보기로 했다.

 

모든 프로젝트에서 사용할 수 있게 공통화된 유틸을 작성하는 것이 필요했다.

개인적으로 유틸을 완성하기까지 고군분투한 과정을 기록해보려고 한다🤣


 

목차

  • IndexedDB 의 장점 및 특징
  • CRUD 적용(TypeScript ver.) 예시와 주요 개념
  • 트러블 슈팅 경험

 

📌 IndexedDB ?

IndexedDB 는 브라우저 내에서 구조화된 데이터 저장을 위한 API 이다.

Local Storage 와 비교할 때 IndexedDB 가 가지는 주요 장점은 아래와 같다.

 

  1. 데이터 저장 용량
    - Local Storage : 보통 5MB 로 제한됨
    - IndexedDB : GB 단위의 대용량 데이터 저장 가능
  2. 데이터 저장 구조
    - Local Storage : 문자열만 저장할 수 있어 복잡한 구조의 데이터(ex. 중첩된 구조의 객체/배열 등)를 저장하려면 JSON 문자열로 변환해야 함
    - IndexedDB : Key-Value 저장소. 객체 저장소를 지원하여 복잡한 구조의 데이터를 저장하고 관리할 수 있음
  3. 성능 (동기 / 비동기)
    - 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 의 이름을 넣고,  두 번째 인자로 KeyPathautoIncrement 를 넣을 수 있다.
    * 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 에 대한 또 하나의 개념을 알게된 뿌듯한 경험이었다!

 

 

728x90

댓글