본문 바로가기
개발/자바스크립트

[자바스크립트] IndexedDB 실전 사용법 (idb)

by 핸디(Handy) 2022. 9. 30.

들어가며

예전에 localstorage 관련 글 마지막에 localstorage 보다는 indexedDB 사용을 권고한다는 글로 끝맺음했었습니다.

시간이 흘러 이번 프로젝트에서 IndexedDB를 이용해 네트워크 리소스를 최적화한 경험이 있어 공유드립니다.

개발 환경은 Next, React를 사용했지만 바닐라 자바스크립트 기반으로도 동작하는 코드이니 살펴보시면 좋겠습니다.

대상 독자

  • 프론트엔드 개발을 하다가 localstorage, sessionstorage를 넘어 indexedDB도 써보려는 개발자
  • 같은 파일을 여러 번 요청하는 게 안타까운 최적화에 특화된 개발자
 

[Next] localstorage를 사용하는 방법 #1

localStorage 란? 웹 스토리지 객체(web storage object)의 한 종류로 sessionStorage와 함께 브라우내 내에 key-vale 쌍을 저장할 수 있도록 해주는 Web API입니다. Window.localStorage - Web API | MDN localS..

all-dev-kang.tistory.com

 

문제 인식 및 설계

다짜고짜 IndexedDB에 대해 설명하기 전에 왜 IndexedDB를 사용해야 하는가에 대한 필요성에 대해 언급하고 가겠습니다.

오디오를 다루는 회사로 이직한 이후로 텍스트 기반 json 상하차를 하던 제가 오디오 상하차를 하게 되었습니다.

우리가 일반적으로 생각하는 mp3의 용량은 대략 3mb(3분짜리 노래 기준)

그리고 제공하는 플랫폼 상에서는 악기별로 각각의 오디오 파일이 필요합니다.

5종류의 오디오 파일 대략 15mb

그리고 뒤로가기, 앞으로 가기, 다운로드할 때마다 이미 같은 파일을 여러 번 요청하는 게 매우 매우 불편하게 느껴졌습니다.

(실은 aws 요금 폭탄 맞은 후로 최적화에 힘쓰고 있는 우리팀...)

그래서 처음 다운로드할 때 어디엔가 저장을 하고 이를 가져다 쓰는 것으로 개발 계획을 세우고 어떤 것을 쓰면 좋을까 고민을 시작했습니다.

IndexedDB를 사용한 이유

그리고 내부 스터디(라 하고 혼자스터디) 이후 IndexedDB를 사용하기로 하였는데, 이유는 다음과 같습니다.

다른 storage 보다 대용량을 지원한다.

오디오 파일의 경우 용량이 큽니다. 그리고 현재 서비스에서 30분까지의 오디오 파일을 지원하기 때문에 local, session storage 가지고는 용량이 금방 부족해질 것이라 판단했습니다. ( 일반적으로 10MB까지 지원 )

IndexedDB의 최대 크기에 대한 설명은 해외 형 누님들이 글로 대체합니다.

 

Maximum item size in IndexedDB

I'm working on a simple web utility that makes use of IndexedDB (similar to a key-value DB) feature of HTML5. I was looking for but I was unable to know: what is the maximum size I can store in an...

stackoverflow.com

신경쓰지말라는 Cool함

요약하자면 크로미움 기반 브라우저는 컴퓨터 용량의 80%까지 허용하고, 사파리는 1GB, 파이어폭스는 2GB라고 합니다.

사파리와 파이어폭스의 용량 단위는 전체 스토리지의 단위가 아닌 같은 origin 기준입니다. 

다른 storage와 달리 JS의 객체 저장을 지원한다.

오디오 파일의 경우 string이 아닌 arraybuffer 또는 blob으로 데이터를 관리합니다.

따라서 문자열만 저장 가능한 다른 storage는 우선적으로 사용할 만한 게 못됩니다.

간단 설계

설계의 목적은 단 하나 "최적화와 빠른 스피드~~~"

그래서 첫 다운로드 이후에 해당 오디오 파일을 IndexedDB에 저장하고 이후 가져다 쓴다가 목표입니다.

가져다 쓴다의 말은 2가지 목적을 가집니다.

  • 오디오 파일을 재생할 때 가져다 쓴다.
  • 다운로드할 때 가져다 쓴다.

유저가 로그인, 로그아웃할 때 IndexedDB를 clear 한다.

IndexedDB를 활용한 구현

그럼 이제 구현을 시작해보겠습니다.

이번 글에서는 IndexedDB의 상세한 사용법보다는 전체적인 로직, 프로젝트에서 활용을 위주로 진행합니다.

idb 라이브러리

우선 라이브러리를 하나 설명하고 가겠습니다.

 

idb

A small wrapper that makes IndexedDB usable. Latest version: 7.1.0, last published: 9 days ago. Start using idb in your project by running `npm i idb`. There are 315 other projects in the npm registry using idb.

www.npmjs.com

idb라는 라이브러리인데요.

This is a tiny (~1.06kB brotli'd) library that mostly mirrors the IndexedDB API, but with small improvements that make a big difference to usability.
- https://github.com/jakearchibald/idb#indexeddb-with-usability

설명을 보시다시피 간단한 IndexedDB 랩핑 라이브러리입니다. 직관적인 메소드명과 타입스크립트를 지원한다는 장점이 있어서 사용하게 되었습니다.

같은 역할의 라이브러리로는 Dexie.js가 있습니다.

Dexie.js is a wrapper library for indexedDB - the standard database in the browser.
- https://github.com/dexie/Dexie.js#dexiejs

다만 다운로드 수와 유지보수 측면에서 idb가 압도적임으로 idb를 사용하겠습니다.

 

IndexedDB 클래스 구현체

import { IDBPDatabase, openDB } from "idb";

class IndexedDb {
  private database: string;
  private db: any;

  constructor(database: string) {
    this.database = database;
  }

  public async createObjectStore(tableNames: string[]) {
    try {
      this.db = await openDB(this.database, 1, {
        upgrade(db: IDBPDatabase) {
          for (const tableName of tableNames) {
            if (db.objectStoreNames.contains(tableName)) {
              continue;
            }
            db.createObjectStore(tableName);
          }
        },
      });
    } catch (error) {
      return false;
    }
  }

  public async getValue(tableName: string, id: number | string) {
    const tx = this.db.transaction(tableName, "readonly");
    const store = tx.objectStore(tableName);
    const result = await store.get(id);
    return result;
  }

  public async getAllValue(tableName: string) {
    const tx = this.db.transaction(tableName, "readonly");
    const store = tx.objectStore(tableName);
    const result = await store.getAll();
    return result;
  }

  public async putValue(
    tableName: string,
    value: object,
    key: string | number
  ) {
    const tx = this.db.transaction(tableName, "readwrite");
    const store = tx.objectStore(tableName);
    const result = await store.put(value, key);
    return result;
  }

  public async deleteValue(tableName: string, id: number | string) {
    const tx = this.db.transaction(tableName, "readwrite");
    const store = tx.objectStore(tableName);
    const result = await store.get(id);
    if (!result) {
      return result;
    }
    await store.delete(id);
    return id;
  }

  public async deleteAllValue(tableName: string) {
    const tx = this.db.transaction(tableName, "readwrite");
    const store = tx.objectStore(tableName);
    if (store) {
      await store.clear();
    }
    return;
  }
}

export default IndexedDb;

세부 메소드를 설명해보겠습니다.

createObjectStore

tableNames를 이용해서 indexedDB에서 table를 생성해주는 로직입니다.

import { openDB } from "idb";

public async createObjectStore(tableNames: string[]) {
  try {
    this.db = await openDB(this.database, 1, {
      upgrade(db: IDBPDatabase) {
        for (const tableName of tableNames) {
          if (db.objectStoreNames.contains(tableName)) {
            continue;
          }
          db.createObjectStore(tableName);
        }
      },
    });
  } catch (error) {
    return false;
  }
}

idb의 메소드 openDB로 DB를 열어주고 db_version을 넣어줍니다. (여기선 1) 그리고 tableNames를 통해 넣어줍니다.

아래 사진 기준으로 test_key가 tableName입니다. store_test_key는 아래에서 다시 설명할게요.

참고 에러

createObjectStore를 만들고 데이터를 넣었을 때 아래와 같은 에러를 뱉는 경우가 있습니다.

Uncaught (in promise) DOMException: Failed to execute 'put' on 'IDBObjectStore': The object store uses in-line keys and the key parameter was provided.

이유는 바로 store를 생성할 때 keypath를 지정하고 넣었기 때문인데요.

// 선언부
db.createObjectStore(tableName, { keyPath: "id" });

// 저장부
await store.put(value, key); // <- 에러 발생

keypath를 지정하고 넣을 시에 이미 key를  inline-key를 넣을 수 없다고 합니다. 그러니 별도의 key를 사용하려면 keypath를 제거하세요. 

getValue

db를 readonly로 트랜잭션을 생성하고 tableName, keyf를 이용해서 데이터를 반환하는 로직입니다.

public async getValue(tableName: string, key: number | string) {
  const tx = this.db.transaction(tableName, "readonly");
  const store = tx.objectStore(tableName);
  const result = await store.get(key);
  return result;
}

 

 

putValue

tableName과 key를 통해 원하는 value를 넣어주는 로직입니다.

public async putValue(
  tableName: string,
  value: object,
  key: string | number
) {
  const tx = this.db.transaction(tableName, "readwrite");
  const store = tx.objectStore(tableName);
  const result = await store.put(value, key);
  return result;
}
 

deleteValue

tableName과 key를 통해 원하는 value를 제거하는 로직입니다.

public async deleteValue(tableName: string, key: number | string) {
  const tx = this.db.transaction(tableName, "readwrite");
  const store = tx.objectStore(tableName);
  const result = await store.get(key);
  if (!result) {
    return result;
  }
  await store.delete(key);
  return key;
}

await store.clear(); // <- 해당 store를 전체 제거하는 메소드
 

IndexedDB 사용 예시

사용 예시가 약간 애매하기 하지만요 실제 내부 코드를 간단히 들고 와서 간략화해보았습니다.

직접 IndexDB 클래스의 메소드를 사용하기보다는 별도의 함수로 만들어 사용하고 있습니다.

const INDEXEDDB_KEY = "내부 키";
const INDEXEDDB_STORE_KEY = "내부 스토어 키";

export const setCachedPlayListAudioFilesByKey = async (
  uniqueAudioKey: string,
  playListAudioFiles: Array<{ path: string }>
) => {
  const indexedDb = new IndexedDb(INDEXEDDB_KEY);
  await indexedDb.createObjectStore([INDEXEDDB_STORE_KEY]);
  await indexedDb.putValue(
    INDEXEDDB_STORE_KEY,
    playListAudioFiles.map((item) => {
      return { ...item, path: undefined };
    }),
    uniqueAudioKey
  );
};

export const getCachedPlayListAudioFilesByKey = async (
  uniqueAudioKey: string
) => {
  const indexedDb = new IndexedDb(INDEXEDDB_KEY);
  await indexedDb.createObjectStore([INDEXEDDB_STORE_KEY]);
  const cachedPlayListAudioFiles = await indexedDb.getValue(
    INDEXEDDB_STORE_KEY,
    uniqueAudioKey
  );
  return cachedPlayListAudioFiles;
};

export const clearPlaylistAudioDB = async () => {
  const indexedDb = new IndexedDb(INDEXEDDB_KEY);
  await indexedDb.createObjectStore([INDEXEDDB_STORE_KEY]);
  await indexedDb.deleteAllValue(INDEXEDDB_STORE_KEY);
};

예를 들어 로그아웃 시 IndexedDB를 날리도록 하고 있어요.

  const logOut = async () => {
    const {
      data: { path },
    } = await axios.get("/api/auth/logout");
    await signOut({ redirect: false });
    window.location.href = path;
    clearPlaylistAudioDB(); // <- DB 날려라!!!
  };

 

IndexedDB 주의사항

사용하면서 경험했던 에러사항 및 주의사항에 대해 언급하겠습니다.

시크릿 모드에서의 사용성에 대한 주의

mdn문서에 따르면 private browsing mode에서는 제공하지 않는다고 되어있습니다.

https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Browser_storage_limits_and_eviction_criteria

확인해본 결과 크롬은 시크릿 모드에서도 정상동작하지만 사파리는 동작하지 않습니다.

[크롬 시크릿 모드] 생성 O 입출력 O

[사파리 시크릿모드] 생성 O 입출력 X

 

 

결과 확인

로딩 경험 최적화

네트워크 속도 비교(fast 3g 기준)
네트워크 용량 비교 ( 24.4MB -> 33.4KB )

이전에는 모든 요청 시마다 오디오 파일을 가져왔었습니다. 

하지만 개선 이후에는 같은 데이터를 사용할 경우 indexedDB에서 가져와서 사용함으로 더 이상의 서버에 대한 리소스 요청이 사라졌습니다.

따라서 서버에 대한 요청없이 바로 데이터를 player에 로드함으로써 UX가 개선되었습니다.

다운로드 경험 최적화

위에서 보셨듯이 지구 어딘가 있는 원격의 데이터가 아닌 이미 저장하고 있는 데이터를 사용함으로 전반적인 로딩 속도가 올라갔습니다.

또한 현재 서비스에서는 음원을 다운로드하는 기능을 제공하고 있습니다.

이전에는 path를 기반으로 서버에 음원데이터를 다시 요청해서 다운로드 기능을 제공하고 있었습니다.

개선 이후에는 indexedDB에 있는 데이터를 이용해 다운로드 기능을 제공함으로써 다운로드를 위해 필요했던 시간을 제거하고 네트워크 요청도 절반으로 줄어들었습니다.

다운로드 로직 개선 전 후

참고 자료

마치며

이번 글에서는 IndexedDB의 코드부터 사용방법 그리고 성능 차이를 확인해봤습니다.

네트워크 사용량을 줄이는 것부터 내부적으로 로딩 속도가 빨라진 점까지 이번 최적화는 아주 성공적이었습니다.

다행스럽게도 이전에 사이드 프로젝트를 하면서 작성했던 IndexedDB 코드가 있어서 이번엔 빠르게 가져와서 적용해봤네요.

그러니 여러분도 사이드 프로젝트하세요.

끝.

댓글