[모던JS: 심화] 브라우저에 데이터 저장하기 (2)

KG·2021년 7월 3일
4

모던JS

목록 보기
44/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

IndexedDB

IndexedDB는 브라우저에 내장된 일종의 데이터베이스이다. 앞서 쿠키와 웹 스토리지 객체를 이용해서도 브라우저에 데이터를 저장할 수 있었지만, IndexedDB는 이들보다 더 강력한 기능을 제공한다.

  • 거의 모든 유형의 값을 키-값 또는 여러개의 키-값으로 저장 가능
  • 안정성을 위해 트랜잭션을 지원
  • 키 범위, 인덱스 관련 쿼리 지원
  • localStorage와 비교해 더 많은 양의 데이터 저장 가능

지원하는 내용을 보면 서버에서 사용하는 상용 DB와 흡사해 보인다. 이처럼 IndexedDB는 브라우저 내장 데이터베이스에 가깝다. 때문에 앞서 다룬 두 가지 방식 보다 조금 더 데이터베이스의 관점으로 데이터를 저장하고 관리할 수 있다.

대부분의 클라이언트-서버 모델 형식을 취하는 웹 어플리케이션에서는 IndexedDB의 기능까지 활용할 일은 많지 않다. 이미 서버에서 더 좋은 상용 DB를 사용하고 있는 경우가 많기 때문이다. 보통 IndexedDB는 오프라인 상태에서도 서비스워커(ServiceWorker)등의 다른 기술과 결합해 안정적으로 서비스를 제공하기 위한 의도로 많이 쓰인다. 이를 적용해서 만든 형태의 웹 어플리케이션을 보통 PWA(Progressive Web Application)이라고 부른다. PWA와 관련된 자세한 설명은 다음 포스트에서 확인할 수 있다.

명세에 따른 순수 IndexedDB의 사용은 이벤트 기반으로 작동한다. 따라서 데이터베이스를 생성하고, 연결 및 데이터 저장 및 조작은 모두 이벤트 핸들러를 통해 작성한다.

하지만 이 같은 방식이 때로는 너무 복잡할 수 있다. 때문에 async/await를 사용한 비동기 방식으로도 IndexedDB를 사용할 수 있는데, 이때는 별도의 프라미스 기반 래퍼를 사용해야 한다. 이는 자체 제공되는 기능은 아니고 라이브러리 형태로 사용하는 것인데, 조금 더 편리하게 IndexedDB를 다룰 수 있다. 다만 프라미스 래퍼가 완벽하지는 않기 때문에 모든 케이스를 커버할 수 없다.

따라서 이번 챕터에서는 IndexedDB에 대해 알아보고, 순수하게 IndexedDB를 다루는 방법과 프라미스 래퍼 라이브러리를 이용해 다루는 방법을 모두 알아보도록 하자.

1) open database

IndexedDB를 사용하기 위해서는 가장 먼저 데이터베이스를 오픈시켜주어야 한다. 이는 일종의 연결 작업이라고 볼 수 있다.

let openRequest = indexedDB.open(name, version);
  • name : 데이터베이스 이름, 문자열
  • version : 데이터베이서 브전, 양의 정수형으로 기본값은 1

각각 다른 이름으로 많은 데이터베이스를 사용할 수 있다. 그렇지만 모든 데이터베이스는 현재 오리진(도메인/프로토콜/포트)에 종속된다. 즉 서로 다른 도메인에서는 하나의 페이지에서 생성된 IndexedDB에 접근할 수 없다.

open 메서드를 이용해 openRequest 객체를 생성하면, 그 후 발생하는 이벤트를 통해 해당 객체를 사용해 다음 작업을 수행할 수 있다. 이때 감지할 수 있는 이벤트 목록은 다음과 같다.

  • success : 데이터베이스가 준비되어 데이터베이스 객체가 openRequest.result에 정상적으로 생성된 경우 발생
  • error : 연결 도중 생긴 에러를 마주할 때 발생
  • upgradeneeded : : 데이터베이스는 준비되어 있지만 버전 업데이트가 필요한 경우 발생

upgradeneeded 이벤트에 대해 조금 더 자세히 알아보자. IndexedDB는 서버에서 사용하는 대부분의 DB 서비스에 없는 스키마 벌져닝(schema versioning)이라는 기본으로 제공되는 메커니즘을 가지고 있다.

서버에서 사용하는 DB와 달리 IndexedDB는 클라이언트 사이드에서 사용하는 DB이다. 이는 곧 모든 데이터가 브라우저 자체에 저장되고 관리된다는 이야기고 따라서 개발자는 해당 데이터에 24시간 접근할 수 있는 권한이 없을 가능성이 많다. 브라우저는 사용자와 1:1 관계를 맺으며 사용자의 의도에 따라 언제든지 종료될 수 있는 프로그램이기 때문이다. 때문에 개발자가 새로운 버전의 애플리케이션을 게시한 이후, 사용자가 웹 페이지에 방문하면 브라우저에서 사용하는 데이터베이스를 업데이트 해야 할 수 있다.

만약 현재 유저 브라우저에서 사용하는 IndexedDB의 버전이 IndexedDB가 연결될 때 언급된 버전보다 낮은 경우엔 특별한 이벤트인 upgradeneeded가 트리거된다. 해당 이벤트 리스너를 통해 필요에 따라 버전을 비교하고 데이터 구조 역시 업그레이드 할 수 있다.

upgradeneeded 이벤트는 이 외에도 데이터베이스가 아직 존재하지 않는 경우에도 트리거된다. 데이터베이스가 아직 존재하지 않는 경우는 내부적으로 버전을 0으로 인식하기 때문이다. 때문에 해당 이벤트를 이용해 데이터베이스 초기화에도 관여할 수 있다.

다음과 같이 IndexedDB를 사용해 1버전으로 첫 번째 웹 어플리케이션을 게시한다고 가정해보자. upgradeneeded 이벤트 핸들러를 통해 초기화를 수행할 수 있다.

let openRequest = indexedDB.open("store", 1);

openRequest.onupgradeneeded = function() {
  // 클라이언트에 DB가 없는 경우 or 버전이 낮은 경우 발생
  // 초기화 수행...
};

openRequest.onerror = function() {
  console.error('ERROR', opneRequest.error);
};

openRequest.onsuccess = function() {
  let db = openRequest.result;
  // 관련 작업 수행...
};

그리고 시간이 흘러 2번째 버전의 웹 어플리케이션을 게시하게 되었다. 이때 IndexedDB의 버전 역시 2로 올렸다면 upgradeneeded에서 다음과 같이 기존 데이터베이스 버전을 관리할 수 있다.

let openRequest = indexedDB.open("store", 2);

openRequest.onupgradeneeded = function (event) {
  let db = openRequest.result;
  
  switch (event.oldVersion) {
    case 0:
      // version 0은 아직 DB가 없는 경우를 의미
      // 따라서 DB 초기화 관련 로직 수행
    case 1:
      // 브라우저가 아직 구버전 DB를 사용하는 경우
      // 따라서 업데이트를 진행
  }
};

중요한 것은 onupgradeneeded 핸들러가 아무런 에러 없이 작업을 끝내야지만, 이어서 openRequest.onsuccess 가 곧장 트리거된다는 점이다. 이는 사전에 유저의 브라우저에서 데이터베이스가 있는지, 그리고 버전은 최신화가 되어있는지 검증 후에 관련 작업을 수행할 수 있기 때문이다.

만약 데이터베이스를 제거하고 싶다면 deleteDatabase 메서드를 실행하자. 해당 메서드 역시 open 메서드와 유사하게 deleteRequest 객체를 리턴하는데, deleteRequest.onsuccess/onerror 핸들러를 통해 결과를 추적할 수 있다.

let deleteRequest = indexedDB.deleteDatabase(name);

deleteRequest.onsuccess = function() {
  // ...
};

deleteRequest.onerror = function() {
  // ...
};

구버전의 IndexedDB는 다시 연결할 수 없다. 만약 현재 openIndexedDB의 버전이 3이라고 할 때, 이 보다 낮은 버전의 IndexedDBopen(...2)와 같이 다시 연결할 수 없다. 이 경우엔 에러가 발생하고, openRequest.onerror가 트리거 될 것이다.

이는 흔치않은 일이긴 하지만 종종 사용자의 프록시 캐시(proxy cache)와 같은 사정으로 구버전의 자바스크립트가 로드되는 경우 이런 이슈가 발생할 수도 있다.

이러한 에러까지 모두 방어하고 싶다면 db.version을 체크하고 페이지를 새로고침하는 로직을 준비해야 한다. 그리고 뒷단에서는 적절한 HTTP 캐싱 헤더를 사용해서 오래된 코드가 로드될 수 있는 상황을 미연에 방지하는 것도 필요하다.

3) 병렬 업데이트 문제

버전 업데이트와 관련된 이슈를 하나 살펴보고 넘어가도록 하자. 만약 다음과 같은 상황에서 IndexedDB는 어떻게 처리되어야 할까?

  1. 사용자는 브라우저 탭에서 하나의 페이지를 방문. 이때 사용된 IndexedDB 버전은 1

  2. 그 사이 새로운 IndexedDB 버전 업데이트를 수행하고 이를 배포

  3. 동일한 사용자가 다른 탭에서 동일한 페이지를 다시 방문

이 역시 흔히 일어나는 상황은 아니지만, IndexedDB를 사용하면서 트래픽이 많은 일부 페이지가 무중단 배포를 통해 페이지를 업데이트 하는 경우 발생할 수 있는 시나리오이긴 하다.

하나의 탭에서는 1 버전의 IndexedDB가 열려있고, 다른 하나의 탭에서는 2 버전의 IndexedDB가 열려고 한다. 따라서 두 번째 탭에서는 upgradeneeded 이벤트가 발생할 것이다.

문제는 IndexedDB가 오리진을 공유하기 때문에 두 개의 탭이 서로 데이터베이스를 공유하고 있다는 것이다. 그리고 버전은 동시에 두 개 이상이 따로 존재하며 공유될 수 없다. 만약 2 버전으로 업데이트가 정상적으로 수행되려면 아직 1 버전을 사용하고 있는 모든 연결이 사전에 종료되어야 한다.

이를 위해 versionchange 이벤트를 이용해 관련 작업을 적절하게 수행할 수 있다. versionchange 이벤트는 구식의 IndexedDB 객체가 감지되었을 때 발생하는데, 이를 통해 오래된 IndexedDB 연결을 종료하고 업데이트를 위한 로직을 수행할 수 있다.

만약 오래된 IndexedDB을 닫지 않는다면 두 번째 탭에서 새로운 버전의 IndexedDB 연결은 계속 수행되지 않는다. 이때 두 번째 탭의 openRequest 객체는 success 이벤트 대신 blocked라는 이벤트를 트리거한다. 즉 작업을 진행하지 못하고 계속 중단되어 있는 상태를 유지하게 된다.

다음은 이 같은 상황에서 병렬적으로 업데이트를 수행할 수 있도록 도와주는 코드 예시이다. 새로운 IndexedDBonversionchange 핸들러에서 구버전의 연결을 해제한 이후 연결된다.

let openRequest = indexedDB.open('store', 2);

openRequest.onupgradeneeded = ...;
openRequest.onerror = ...;

openRequest.onsuccess = function() {
  let db = openRequest.result;
  
  db.onversionchange = function() {
    db.close();
    alert("구식 DB 사용중... 페이지 새로고침 하세요!");
  };
  
  // 새로운 버전의 DB가 준비되는 영역.. 관련 작업 수행
};

openRequest.onblocked = function() {
  // 구식 DB 연결이 해제되지 않을 경우 발생
  // 관련 작업을 처리해줄 수 있음
  // onversionchnage 핸들러가 성공적으로 처리되면 발생하지 않음
};
  1. db.onversionchange 핸들러는 연결이 성공적으로 수행된 이후에 추가한다. 이 시점 이후에 연결된 DB의 버전을 파악할 수 있기 때문이다.

  2. openRequest.onblocked 핸들러는 구버전에 대한 연결이 해제되지 않은 경우를 처리한다. 만약 db.onversionchange에서 close() 메서드를 통해 연결이 정상적으로 해제되는 경우 발생하지 않는다.

4) Object store

IndexedDB 를 생성 및 연결하고서 무언가를 저장하고 싶다면 object store가 필요하다. object stroeIndexedDB의 핵심 컨셉인데, 이는 다른 상용 DB의 테이블이나 컬렉션에 해당하는 개념으로 볼 수 있다. 곧 이는 데이터가 DB에 저장되는 단위이며, 여러 개의 store를 가질 수 있다.

이름은 object store이지만 원시타입의 데이터도 문제 없이 저장할 수 있다. 아주 복잡한 객체를 포함해 거의 모든 종류의 데이터를 문제 없이 저장할 수 있다.

IndexedDB는 어떤 객체를 저장하거나 복제하기 위해 표준 직렬화 알고리즘을 사용하는데, 이는 JSON.stringify 메서드와 유사하다고 보면 된다. 다만 JSON 타입이 허용할 수 없는 타입까지 포함하기 때문에 보다 더 강력하다. 다만 허용하지 못하는 타입도 있는데, 객체에 순환 참조가 있는 경우는 IndexedDB에 저장할 수 없다. 해당 객체는 직렬화가 불가능하기 때문이다. 이는 사실 JSON.stringify 역시 동일하다.

IndexedDB는 키-값 쌍으로 데이터를 저장하기 때문에 모든 값에는 이에 대응하는 키가 필요하다. 이때 키는 number / date / string / binary / array 중에 하나에 속하며 항상 고유한 값이어야 한다. 즉 키는 일종의 다른 DB에서의 인덱스 역할을 수행한다.

이처럼 키-값의 쌍으로 데이터를 저장하는 것은 이전 챕터에서 살펴본 localStorage와도 동일한 매커니즘이다. 그러나 객체를 저장할 때 IndexedDB는 객체 속성을 키로 설정할 수도 있고, 키를 자동으로 설정할 수 있는 옵션을 제공하기 때문에 보다 더 편리하다.

먼저 object store를 생성해보자. 관련 문법은 다음과 같다.

db.createObjectStore(name[, keyOptions]);

이때 해당 작업은 항상 동기적으로 수행되기에 await과 같은 키워드가 필요하지 않다는 것에 주의하자. 이러한 내부 매커니즘 때문에 아직까지 프라미스 래퍼를 통해 IndexedDB를 완전하게 사용하기 어렵다.

  • name : 스토어의 이름 (eg. books)
  • keyOptions : 다음 두 속성 중 하나를 가지는 선택값
    • keyPath : IndexedDB에서 키로 사용할 객체 프로퍼티 (eg. id)
    • autoIncrement : true인 경우 스토어에 저장되는 키는 자동으로 생성, 보통 증가하는 숫자값

keyOptions은 선택값이기 때문에, 만약 사용하지 않는다면 객체를 저장할 때 키 값을 명시적으로 제공해야 한다.

다음은 object store의 키 값으로 객체의 프로퍼티를 지정하는 예시이다.

db.createObjectStore('books', { keyPath: 'id' });

object store를 생성할 때 주의점이 있다. object store를 생성하고 수정하는 것은 오직 IndexedDB의 버전을 업데이트 하는 과정에서 수행되어야 한다. 즉 upgradeneeded 이벤트 핸들러 내에서 생성과 수정이 일어나야 한다.

이는 기술적인 제한으로 명시되어 있다. 데이터 자체를 추가/제거/변경하는 것은 upgradeneeded 이벤트 핸들러 외부에서도 가능하지만, object store를 생성/수정 하는 것은 오직 버전 업데이트 내부에서만 가능하다.

데이터베이스의 버전을 업그레이드 하는 방법으로는 크게 두 가지 접근법이 있다.

  1. 위에서 살펴본 바와 같이 버전별로 구분하여 업데이트를 진행한다.

  2. 또는 데이터베이스를 검사해 기존 개체 저장소 목록을 db.objectStoreNames로 가져올 수 있다. 해당 객체는 DOMStringList 타입이기 때문에 존재 여부를 검사하는 contains(name) 메서드를 사용할 수 있다. 이를 통해 어떤 것이 존재하고 하지 않는지를 통해 원하는 업데이트를 수행할 수 있다.

작은 규모의 데이터베이스라면 2번 접근방법이 더 간단할 수 있다. 다음은 2번 방식으로 object store를 생성하는 예시이다.

let openRequest = indexedDB.open('db', 2);

openRequest.onupgradeneeded = function() {
  let db = openRequest.result;
  if (!db.objectStoreNames.contains('books')) {
    db.createObjectStore('books', { keyPath: 'id' });
  }
};

만약 생성된 object store를 제거하고자 한다면 deleteObjectStore(name) 메서드를 사용하면 된다.

db.deleteObjectStore('books');

5) 트랜잭션

트랜잭션이라는 용어는 데이터베이스에서 어떤 작업이 일어나는 단위를 일컫는다. 이때 해당 작업은 항상 모두 성공하거나 모두 실패하는 두 가지 결과를 가진다. 예를 들어 어떤 사람이 물건을 사는 경우라면 다음 두 과정이 필요하다.

  1. 계좌로 부터 돈을 인출
  2. 선택한 상품을 유저 인벤토리에 추가

이때 첫 번째 작업을 수행하고 나서 어떤 사정에 의해 두 번째 작업을 정상적으로 수행하지 못 했다고 생각해보자. 그렇게 되면 유저는 돈만 지불하고 원하는 상품은 받아보지 못 하는 최악의 상황에 빠지게 된다. 만약 두 작업을 트랜잭션 단위로 묶었다면, 1번이 실패하면 2번 역시 실패하게 되고 두 작업이 모두 성공하는 경우에 모든 작업이 성공적으로 마무리 되었다고 판단할 수 있다. 때문에 데이터를 조작하고 관리하는 데이터베이스에서는 민감한 데이터는 모두 트랜잭션 단위로 처리한다. 이는 IndexedDB 역시 마찬가지로, 모든 데이터 조작은 트랜잭션 내부에서 처리된다.

트랜잭션을 시작하기 위한 문법은 아래와 같다.

db.transaction(store[, type]);
  • store : 스토어 이름을 뜻하며, 위에서 생성한 object store에 트랜잭션 단위로 작업을 시작하겠다는 것을 의미한다. 만약 여러 개의 스토어를 지정하고자 한다면 배열로 전달할 수 있다.
  • type : 트랜잭션 타입으로 다음 두 가지 중에 하나이다.
    • readonly : 기본값으로 읽기만 허용
    • readwrite : 읽기와 쓰기 모두 허용

versionchange 이벤트 역시 일종의 트랜잭션 유형에 속하는데, 이 경우에도 모든 작업이 가능하다. 그러나 개발자가 해당 타입을 직접 사용하지 않는다. versionchange는 위에서 살펴보았듯이 IndexedDB가 자동으로 생성하는 트랜잭션으로 주로 updateneeded 이벤트 핸들러에서 처리된다.

트랜잭션이 readonlyreadwrite 두 가지 타입으로 나뉘는 이유는 성능과 관련이 깊다. 대부분의 readonly 타입의 트랜잭션은 동일한 스토어에 동시 접근이 가능하다. 그러나 readwrite의 경우엔 어떤 변경사항이 발생하기 때문에 하나의 트랜잭션이 접근하는 경우 스토어에 lock이 걸린다. 때문에 자연스레 지연시간이 발생한다. 만약 내가 데이터베이스로부터 그저 값만 가져오는 경우엔 readonly 타입을 사용하는 것이 더 빠르다.

트랜잭션을 생성하고 난후, 트랜잭션을 이용해 데이터를 추가하고 제거하는 등의 작업을 수행할 수 있다.

let transaction = db.transaction('books', 'readwrite'); // (1)

let books = transaction.objectStore('books'); // (2)

let book = {
  id: 'js',
  price: 10,
  created: new Date(),
};

let request = books.add(book); // (3)

request.onsuccess = function() { // (4)
  console.log("book added to the store", request.result);
};

request.onerror = function() {
  console.error('Error', request.error);
};

(1) 원하는 스토어에 원하는 타입으로 트랜잭션 생성
(2) 트랜잭션이 적용된 스토어 객체를 가져온다.
(3) 스토어 객체에 원하는 데이터를 삽입
(4) 데이터가 정상적으로 삽입되면 success 이벤트 발생

object store는 데이터를 저장하기 위해 두 가지 메서드를 지원한다. keyobject storekeyPath 또는 autoIncrement를 가지고 있지 않는 경우 명시한다.

  1. put(value, [key]) : 스토어에 값을 삽입하되 이미 키가 존재하는 경우 값을 덮어씌운다.

  2. add(value, [key]) : 스토어에 값을 삽입하되 이미 키가 존재하는 경우엔 ContraintError를 발생시킨다.

6) 트랜잭션 자동반영(autocommit)

위의 예시에서 우리는 트랜잭션을 시작하고 삽입 요청을 만드는 것을 살펴보았다. 하지만 앞서 이야기 한 바와 같이 하나의 트랜잭션 단위에는 여러 개의 요청이 포함되어 있을 수 있다. 트랜잭션 단위로 묶인 이러한 요청은 모두 성공하거나 실패해야하는 것이 보장되어야 한다. 이때 더 이상 요청이 오지 않는 경우엔 트랜잭션이 완료된 것으로 판단할 수 있을까?

결론부터 말하자면 불가능하다. 정확히는 다음 3.0 버전에서는 수동으로 트랜잭션을 종료시킬 수 있는 기능이 명세에 있지만, 현재 2.0 버전에서는 관련 기능을 지원하고 있지 않다.

트랜잭션 내 모든 요청이 종료되고, 마이크로태스크 큐가 비워지는 순간에 트랜잭션은 자동으로 반영된다. 마이크로태스크 큐는 비동기 작업을 관리하는데, 비동기 작업이 언제 끝나는 지는 아무도 확실히 예측할 수가 없다. 때문에 트랜잭션을 스케쥴링하며 일일이 추적하는 것은 불가능에 가깝다.

이러한 이유로 fetch와 같은 비동기 작업이나 setTimeout 등의 스케줄링 함수를 트랜잭션 중간에 사용할 수 없다. IndexedDB는 이러한 작업이 모두 끝날 때까지 기다리며 트랜잭션을 계속 유지할 수 없다.

let request1 = books.add(book);

request1.onsuccess = function() {
  fetch('/').then(response => {
    let request2 = books.add(anotherBook);
    request2.onerror = function() {
      console.log(request2.error.name);
    };
  });
};

위 코드에서 request2는 실패한다. 왜냐하면 기존 트랜잭션은 이미 첫 요청 시점에서 이미 완료되고 자동으로 커밋되었기 때문이다. 트랜잭션이 완료된 이후 이에 접근하려 하고 있으므로 에러가 발생한다.

이를 해결하는 가장 간단한 방법은 새로운 요청을 날리기 전에 또 다른 db.transaction을 생성하는 것이다. 그렇지만 모든 작업을 하나의 트랜잭션에서 계속 유지하고 관리하고 싶다면 보다 나은 방법이 있다.

먼저 fetch 메서드 등을 이용해 필요한 데이터를 모두 준비하고나서 트랜잭션을 생성한다. 그리고 준비된 데이터를 이용해서 모든 데이터베이스 요청을 수행한다. 트랜잭션이 이를 성공적으로 수행했는지를 검사하기 위해서는 transaciton.oncomplete 이벤트 핸들러를 통해 파악할 수 있다.

let transaction = db.transaction('books', 'readwrite');

// ... 요청 작업 수행

transaction.oncomplete = function() {
  console.log('Transaction is complete');
};

complete 이벤트 만이 트랜잭션이 에러없이 모두 잘 처리되었는지를 보장할 수 있다. 개개의 요청은 모두 성공했지만, 마지막 쓰기 요청이 실패하는 경우엔 complete 이벤트가 발생하지 않는다.

만약 수동으로 트랜잭션을 중단하고자 한다면 abort() 메서드를 호출할 수 있다.

transaction.abort();

트랜잭션이 취소되면 이에 해당하는 모든 요청들이 취소된다. 이 순간은 transaction.onabort 이벤트 핸들러를 통해 감지할 수 있다.

7) 에러 핸들링

쓰기 요청은 때때로 실패할 수 있다. 이는 예측할 수 없는 이유로 개발자가 작성한 코드 측에서 문제가 있을 수 있고, 또는 외부 상황에 기인한 문제일 수도 있으며 트랜잭션 자체와 관련없는 이유일 수도 있다. 예를 들어 스토리지 할당량이 초과되는 경우가 그러하다. 따라서 우리는 이러한 에러를 처리할 수 있어야 한다.

모든 실패한 요청은 자동으로 트랜잭션을 중단시키며, 관련된 변경사항은 모두 반영되지 않는다. 이는 앞에서 설명한 트랜잭션 단위의 기본 작동 원리이다.

그러나 가끔은 어떤 요청이 실패했을 때 이미 발생한 변경사항을 모두 되돌리는 것이 아니라, 실패한 요청 시점부터 다시 트랜잭션을 이어나가고 싶을 수 있다. request.onerror 핸들러에서 트랜잭션 중단을 방지하는 것으로 이를 방지할 수 있다. 트랜잭션 중단은 브라우저 기본 동작이기 때문에 event.preventDefault()를 호출해서 막을 수 있다.

let transaction = db.transaction('books', 'readwrite');

// 이미 'js' 라는 id가 스토어에 존재하는 관계로
// ConstraintError가 발생한다고 가정
let book = { id: 'js', price: 10 };

let request = transaction.objectStore('books').add(book);

request.onerror = function(event) {
  // ConstraintError 발생
  if (request.error.name === 'ConstraintError') {
    console.log('alreay exsited ID');
    event.preventDefault();
    
    // ... 관련 작업 수행
  } else {
    // 예상치 못한 에러 발생 영역
    // 트랜잭션은 중단
  }
};

transaction.onabort = function() {
  console.log("ERROR", transaction.error);
};

이벤트 위임 (Event Delegation)

굳이 onerrer/onsuccess 이벤트를 모든 요청 단위에 등록해야 할 필요가 있을까? 앞서 살펴본 이벤트 위임 패턴을 사용한다면 보다 편리하게 핸들러 관리가 가능하다.

이는 IndexedDB의 이벤트들 역시 버블링 되기 때문이다. 버블링 순서는 다음과 같다.

  • requesttransactiondatabase

IndexedDB 이벤트는 모두 DOM 이벤트이다. 때문에 캡쳐링과 버블링 모두 가능하지만, 앞서 이야기한 바와 같이 보통 버블링을 많이 사용한다. 따라서 db.onerror를 통해 발생하는 모든 에러를 관리할 수 있다.

db.onerror = function(event) {
  // 에러를 유발한 request 타겟
  let request = event.target;
  
  console.log("ERROR", request.error);
};

그러나 만약 발생한 에러가 하위에서 이미 완전히 처리된 경우 위와 같이 리포팅 할 필요가 없을 수 있다. 이 경우에는 event.stopPropagation() 메서드를 이용해서 처리가 끝난 에러는 상위로 버블링 되지 않도록 차단해줄 수 있다.

request.onerror = function(event) {
  if (request.error.name === "ConstraintError") {
    console.log('ID already exist!');
    event.prevnetDefault();
    event.stopPropagation();
  } else {
    // 트랜잭션 중단
  }
};

8) 키를 통한 탐색

object store를 검색하기 위한 방법은 크게 두 가지가 있다.

  1. keykey range를 이용한 탐색 eg. book.id
  2. obejct field를 이용한 탐색 eg. book.price

먼저 키와 키 범위를 통해 탐색하는 방법부터 살펴보자. 기본적으로 탐색은 쿼리를 DB에 요청해야 하는 과정이 필요하다. 이때 IDBKeyRange 객체를 이용하면 키를 이용한 쿼리(query)를 만들 수 있다. IDBKeyRange를 이용하면 키뿐만 아니라 해당하는 범위 내의 키 값들에 관련된 쿼리 역시 만들 수 있다.

  • IDBKeyRange.lowerBound(lower, [open]) : lower 보다 크거나 같은 키값에 해당하는 범위 (opentrue이면 lower 값을 포함하지 않음)
  • IDBKeyRange.upperBound(upper, [open]) : upper 보다 작거나 같은 키값에 해당하는 범위 (opentrue이면 upper 값을 포함하지 않음)
  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen]) : lowerupper 사이에 위치한 범위 (opentrue이면 lowerupper값을 포함하지 않음)
  • IDBKeyRange.only(key) : 단 하나의 key 값으로 구성된 범위 (잘 사용되지 않음)

스토어는 query를 인수로 건네받아 탐색 결과를 리턴한다. 이때 쿼리는 위에서 만든 키 또는 키 범위에 해당한다.

  • store.get(query) : 주어진 쿼리에 해당하는 첫 번째 값을 탐색
  • store.getAll([query], [count]) : 주어진 쿼리에 해당하는 모든 값을 탐색, count에 원하는 양을 명시할 수 있음
  • store.getKey(query) : 주어진 쿼리를 만족하는 첫 번째 키를 탐색
  • store.getAllKeys([query], [count]) : 주어진 쿼리를 만족하는 모든 키를 탐색, count에 원하는 양을 명시할 수 있음
  • store.count([query]) : 쿼리를 만족하는 모든 키의 개수를 반환

예를 들어 위에서 만든 스토어의 엄청나게 많은 book 데이터를 가지고 있다해보자. 이때 id는 해당 객체의 프로퍼티이자 스토어의 키값이다.

// `js` 아이디를 가진 한 개의 스토어 객체 리턴
books.get('js');

// 'css' <= id <= 'html'에 해당하는 모든 스토어 객체 리턴
books.getAll(IDBKeyRange.bound('css', 'html'));

// id < 'html' 에 해당하는 모든 스토어 객체 리턴
books.getAll(IDBKeyRange.upperBound('html', true));

// 모든 스토어 객체 리턴
books.getAll();

// id > 'js'에 해당하는 모든 스토어 객체의 키를 리턴
books.getAllKeys(IDBKeyRange.lowerBound('js', true));

object store의 데이터는 항상 정렬된 상태를 유지한다. 이때 정렬 기준은 내부적으로 키 값을 기준으로 한다. 때문에 모든 요청에 대한 응답으로 키 순으로 정렬된 데이터를 받을 수 있다.

9) 인덱스 필드를 통한 탐색

키 값이 아닌 다른 필드값을 통해 데이터를 탐색하고 싶을 수 있다. 이러한 필드값을 보통 인덱스(index)라고 부른다. 정확히는 객체의 다른 프로퍼티를 인덱스로 지정하여, 스토어에서 해당 프로퍼티를 추적할 수 있다. 이를 위해서는 스토어에 관련된 프로퍼티를 인덱스로 등록해주어야 한다.

objectStore.cretaeIndex(name, keyPath, [options]);
  • name : 인덱스로 사용할 이름
  • keyPath : 저장할 객체의 프로퍼티, 해당 필드가 스토어의 인덱스로 지정됨
  • options : 선택값으로 다음의 프로퍼티로 구성
    • unique : true일 경우 인덱스로 지정된 필드는 단 하나만 존재할 수 있음. 만약 중복될 경우 에러 발생
    • multiEntry : 만약 keyPath의 값이 배열과 같이 여러 개로 구성된 경우 사용. 기본적으로는 해당 프로퍼티가 배열로 되어있어도 전체를 인덱스 키로 사용하나, multiEntrytrue로 설정하면 배열의 원소 각각을 인덱스 키로 사용 가능

위의 여러 예시에서 우리는 books 스토어의 키 값으로 id를 사용했다. 이번에는 book 객체가 가지고 있는 또 다른 프로퍼티인 price 값으로 탐색을 진행해보자. 먼저 인덱스를 스토어에 생성하려면 스토어 생성과 동일하게 upgradeneeded 이벤트 내에서 수행해야 한다.

openRequest.onupgradeneeded = function() {
  let books = db.createObjectStore('books', { keyPath: 'id' });
  let index = books.createIndex('price_idx', 'price');
};
  • index는 이제 price 프로퍼티(필드)를 추적한다.
  • price는 고유값이 아니기 때문에 unique 옵션을 사용하지 않았다.
  • price는 배열이 아니기 때문에 multiEntry 옵션을 사용하지 않았다.

만약 우리의 스토어가 4개의 책을 아래 이미지처럼 가지고 있었다면, 인덱스는 다음과 같이 생성된다.

이때 price에 해당하는 값은 여러 데이터가 동일하게 가지고 있을 수 있다. 이 경우에는 위 이미지와 같이 리스트의 형태로 키 값이 관리된다. 인덱스를 생성하면, 위와 같은 구조로 알아서 항상 최신상태로 관리해주기 때문에 따로 갱신에 대해 개발자가 신경 쓸 필요는 없다.

인덱스를 생성했기 때문에, 이 인덱스를 가지고 위에서 키를 기준으로 탐색을 진행한 것과 동일하게 원하는 값을 탐색할 수 있다.

let transaction = db.transaction('books');
let books = transaction.objectStore('books');
let priceIndex = books.index('price_idx');

// price = 10에 해당하는 모든 데이터 조회
let request = priceIndex.getAll(10);

request.onsuccess = function() {
  if (request.result !== undefined) {
    console.log("Books", request.result);
  } else {
    console.log("No such books");
  }
};

또한 위에서 살펴본 IDBKeyRange 객체를 활용해 인덱스 검색을 수행할 수 있다. 다음은 5보다 작거나 같은 price에 해당하는 데이터를 조회하는 예시이다.

let request = priceIndex.getAll(IDBKeyRange.upperBound(5));

인덱스도 키와 마찬가지로 내부적으로 자동 정렬된다. 여기서는 price 를 인덱스로 잡았기 때문에, price를 기준으로 정렬이 된다.

10) 스토어 데이터 삭제

delete 메서드를 이용하면 원하는 데이터를 제거할 수 있다. 이때 해당 메서드에 전달하는 인수도 일종의 쿼리이다.

// id='js'인 데이터 제거
books.delete('js');

만약 키가 아닌 다른 인덱스를 기준으로 데이터를 제거하고 싶다면 먼저 인덱스가 스토어에 형성되어 있어야 한다. 그리고 원하는 인덱스를 통해 키 값을 조회하고, 해당 키 값을 전달해 제거할 수 있다.

let request = priceIndex.getKey(5);

request.onsuccess = function() {
  let id = request.result;
  let deleteRequest = books.delete(id);
};

만약 한 번에 모든 데이터를 제거하고자 한다면 clear 메서드를 사용할 수 있다.

books.clear();

11) 커서 (Cursor)

getAll/getAllKeys와 같은 메서드는 강력하고 간편하지만, 만약 스토어에 엄청나게 많은 데이터가 저장되어 있을 경우엔 다소 비효율적이다. 선별적으로 데이터를 가져오는 것이 아니라 한 번에 쿼리에 해당하는 모든 데이터를 가져오기 때문이다. 또한 가져오고자 하는 데이터가 브라우저 메모리 용량보다 더 큰 경우에는 당연히 실패하게 될 것이다.

이를 위해 도입된 기능이 커서(cursor)이다. 커서는 비단 DB에서만 사용되는 것이 아니라, 조회 관련된 메커니즘에서 빈번히 사용되는 개념이다. 비슷한 개념으로는 페이지(page)도 있다.

IndexedDB에서 커서는 저장공간을 자유자재로 돌아다닐 수 있는 특별한 객체로, 쿼리가 주어지면 그에 해당하는 한 번에 하나의 키/값을 반환하여 메모리를 절약할 수 있다. 스토어의 데이터는 모두 키 또는 인덱스를 기준으로 정렬 상태를 유지하기 때문에, 커서는 스토어에 키를 기준으로 접근할 수 있다.

// getAll 메서드와 유사하나 커서와 함께 조회
let request = store.openCursor(query, [direction]);

// getAllKeys 메서드와 유사하나 커서와 함께 조회
let request = store.openKeyCursor(query, [direction]);
  • query : 키나 키 범위에 해당 (getAll과 동일)
  • direction : 선택값으로 어떤 순서를 사용할 것인지 명시
    • "next" : 기본값으로 오름차순 순서로 커서 조회
    • "prev" : 내림차순 순서로 커서 조회
    • "nextunique", "prevunique" : 순서는 위와 동일하지만 중복값은 건너뜀. 만약 price와 같은 인덱스가 기준이라면 5에 해당하는 여러 키가 있을때 첫 번째 키만 반환

다른 조회 메서드와의 가장 큰 차이점은 커서를 이용한 조회는 request.onsuccess를 여러번 호출한다는 점이다. 즉 조회되는 결과마다 한 번씩 호출된다.

let transaction = db.transaction('books');
let books = transaction.objectStore('books');

let request = books.openCursor();

request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let key = cursor.key;
    let value = cursor.value;
    console.log(key, value);
    cursor.continue();
  } else {
    console.log('No more books');
  }
};

커서 객체는 다음 두 가지의 메서드를 지원한다.

  • advance(count) : count에 명시된 개수만큼 커서 이동
  • continue([key]) : 바로 다음 값으로 커서 이동. 만약 key를 명시하면 해당하는 키 바로 다음으로 커서 이동

커서와 일치하는 값이 더 있는지 여부와 관계없이 onsuccess 핸들러는 호출되고 관련 데이터는 result에 담기게 된다.

위의 예시는 기본키인 id를 기준으로 커서 조회를 진행했다. 하지만 스토어에 다른 필드가 인덱스로 지정되어 있으면 이를 이용해서도 커서 조회를 진행할 수 있다. 이때 만약 스토어가 사용하는 기본키에 접근하고자 한다면 primaryKey 프로퍼티를 사용할 수 있다.

// index 처리된 price 필드를 기준으로 커서 생성
let request = priceIdx.openCursor(IDBKeyRange.upperBound(5));

request.onsuccess = function() {
  let cursor = request.result;
  if (cursor) {
    let primaryKey = cursor.primaryKey;	// id
    let value = cursor.value; // book object
    let key = cursor.key; // price
    console.log(key, value);
    cursor.continue();
  } else {
    console.log('No more books');
  }
};

12) 프라미스 래퍼 (Promise Wrapper)

IndexedDB는 위에서 살펴본 것과 같이 이벤트를 캐치한 콜백함수 형식으로 사용할 수 있다. 그런데 우리는 앞서 비동기 처리를 배우며, 콜백지옥에 빠질 수 있는 콜백함수 패턴을 간단하게 변환하는 방법을 살펴보았다. IndexedDB 역시 매번 모든 요청에 이벤트 핸들러를 등록하는 방법은 매우 번거로운 작업이다. 적절히 이벤트 위임 패턴을 활용하여 그 수고를 어느정도 덜 수 있지만, 그럼에도 불구하고 여전히 사용하기 까다로운 것은 마찬가지이다.

IndexedDB를 비동기 패턴과 유사하게 조금 더 편리하게 사용하기 위한 시도가 여럿있었다. 예를 들어 async/await 키워드를 적용하여 가독성과 편리함을 모두 챙기고자 했다. 다만 이는 자체적으로 제공되는 기능은 아니고, 라이브러리의 힘을 빌려야 한다. 이는 곧 IndexedDB를 프라미스화 하는 것과도 같기에 라이브러리는 프라미스 객체로 둘러싸는 역할을 한다. 우리는 깃허브에 있는 라이브러리를 사용하도록 하자. 해당 라이브러리를 이용하면 IndexedDB와 관련 메서드를 프라미스화한 idb 객체를 생성한다.

let db = await idb.openDB('store', 1, db => {
  if (db.oldVersion === 0) {
    // 초기화 수행
    db.createObjectStore('books', { keyPath: 'id' });
  }
});

let transaction = db.transaction('books', 'readwrite');
let books = transaction.objectStore('books');

try {
  await books.add(...);
  await books.add(...);
  
  await transaction.compelte;
  
  console.log('jsbook saved!');
} catch(err) {
  console.log('error', err.message);
}

이처럼 이벤트를 감지해 별도의 리스너에서 관련 처리를 해주는 수고스러운 필요없이 동기식으로 간단하게 관련 작업을 수행할 수 있다.

1) 에러 핸들링

만약 try...catch 블록으로 에러를 잡지 않는다면 발생한 에러는 전체 코드를 죽여버릴 것이다. 가장 간단한 방법은 위 예시처럼 try...catch를 통해 에러를 처리하는 것이고, 또 에러 핸들링 챕터에서 살펴본 것과 같이 unhandledrejection 이벤트를 활용할 수 있다.

window.addEventListener('unhandledrejection', event => {
  // IndexedDB 네이티브 요청 객체
  let request = event.target;
  // request.error 와 동일한 내용
  let error = event.reason;
  // ... 에러 리포팅 수행
});

2) 비활성 트랜잭션

이미 알고있는 바와 같이 트랜잭션은 브라우저가 현재 코드와 마이크로태크스 큐의 모든 작업을 완료하자 마자 자동으로 커밋을 실시한다. 때문에 우리가 fetch와 같은 비동기 작업을 트랜잭션 중간에 호출하게 되면, 해당 작업을 완료할 때까지 기다려주지 않는다.

이는 프라미스 객체를 통해 프라미스화를 진행하더라도 마찬가지이다. 다음 예시를 살펴보자.

let transaction = db.transaction('inventory', 'readwrite');
let invertory = transaction.objectStore('invertory');

await inventory.add(...);

await fetch(...);
            
await inventory.add(...);  // Error

fetch 이후에 요청된 await inventory.add는 에러를 발생시킨다. 이는 첫 요청에서 이미 트랜잭션이 완료되었기 때문에, 비활성화된 트랜잭션에 추가 요청을 날림으로 발생하는 에러이다. 해당 이슈는 프라미스 객체로 감싼 IndexedDB와 네이티브 IndexedDB 모두 공통으로 해당하기 때문에 프라미스화된 IndexedDB 역시 동일한 관점에서 이러한 이슈를 해결해야 한다.

3) 네이티브 객체

프라미스화 된 IndexedDB는 내부적으로 여전히 onsuccess/onerror 핸들러를 통해 작업을 처리한다. 단지 이에 대한 결과를 reject/resolve 가능한 프라미스 객체로 리턴하는 것이다. 이는 프라미스를 이용해 모든 IndexedDB의 기능을 완벽하게 커버할 수 없음을 의미한다.

때문에 가끔씩 몇몇 케이스에서는 네이티브 객체를 이용해서 작업을 처리해야 하는 경우가 있다. 그러나 이 경우 다시 네이티브 객체를 일일이 선언할 필요는 없다. 이런 상황을 대비해 프라미스 객체에서 관련 프로퍼티를 제공해주기 때문이다. promise.request를 사용하면 네이티브 요청에 접근할 수 있다.

let promise = books.add(book);

let request = promise.request;	// original request
let transaction = request.transaction;

// ... 네이티브 객체를 이용한 어떤 작업 수행 ...

let reulst = await promise;

References

  1. https://ko.javascript.info/data-storage
  2. https://developer.mozilla.org/ko/docs/orphaned/Web/API/IndexedDB_API/Basic_Concepts_Behind_IndexedDB
  3. https://developer.mozilla.org/ko/docs/Web/API/IndexedDB_API/Using_IndexedDB
profile
개발잘하고싶다

2개의 댓글

comment-user-thumbnail
2022년 4월 19일

정리를 정말 잘하셨네요 많이 배웠습니다!

답글 달기
comment-user-thumbnail
2024년 11월 22일

모던하네요

답글 달기