사내 백오피스 웹 페이지를 개선하는 작업을 진행하던 중에, 서버에서 관리하기에는 비용이 많이 들고 임시로 관리하면 되는 데이터이면서 URI에는 노출되지 않아야 할 데이터를 클라이언트에서 관리하게 되었습니다. IndexedDB API를 활용하여 데이터를 관리하면서 고려했던 점과 예시 코드를 정리해보고자 합니다.
IndexedDB는 파일이나 blob 등의 데이터를 클라이언트에 저장할 수 있는 API로 JavaScript 기반의 객체지향 데이터베이스입니다. 인덱스 키를 통해 데이터를 저장하고 검색할 수 있기 때문에 빠르게 탐색할 수 있습니다.
보안에 취약하고 용량이 제한되어 있는 Web Storage API(Local Storage, Session Storage)에 비해 IndexedDB는 다음과 같은 장점을 가지고 있습니다.
- 동일 출처 정책에 의해 스크립트에서 직접 데이터베이스에 접근하지 못한다.
- 비동기 I/O로 다른 작업을 블로킹하지 않는다.
- Chrome 기준 브라우저에서 가용한 컴퓨터 저장용량의 50%까지 저장할 수 있다.
- 객체 형태의 구조화된 데이터를 저장할 수 있다.
특히 보안상 중요한 대규모 데이터를 클라이언트에서 관리해야 한다면 유용하게 활용해 볼 수 있을 것 같습니다.
비동기로 동작하기 때문에 아무래도 동기로 동작하는 Local Storage나 Session Storage보다 비동기 처리를 위해 추가로 핸들링해야 하는 로직이 많아집니다. 그리고 IndexedDB도 개발자 도구에서 임의로 DB를 초기화하거나 삭제할 수도 있기 때문에 그에 대한 예외 처리도 필요했습니다. 메모리 누수를 최소화하기 위해 IndexedDB가 필요하지 않은 시점에 메모리 해제 처리도 필요합니다.
그래서 IndexedDB를 좀 더 쉽게 사용할 수 있도록 도와주는 외부 라이브러리들이 다수 개발되어 있습니다. 예를 들어, localForage의 경우 IndexedDB를 지원하지 않는 브라우저에서 WebSQL로 폴백한 다음 localStorage로 폴백하는 방식으로 처리하고 있었습니다.
이번에는 외부 라이브러리를 사용하지 않고 직접 IndexedDB를 다루는 로직을 구현하였고, IndexedDB를 사용하지 못하는 경우 Session Storage를 사용하도록 예외 처리하였는데요.
저장하는 곳은 다르지만 클라이언트에서 데이터를 관리하는 메서드 이름이 같아야 사용하는 것이 편리할 것 같아서 IndexedDB와 Session Storage를 다루는 핸들러 클래스의 추상 클래스를 다음과 같이 선언했습니다.
Typescript를 사용하여 미리 정의한 DB 인덱스 키 값이 아닌 값으로 코드를 작성하면 에러가 발생하도록 처리했습니다.
export abstract class BaseHandler {
abstract set(key: DataKey, value: string);
abstract get(key: DataKey);
abstract delete(key: DataKey);
abstract clear();
}
이제 이 BaseHandler 클래스를 상속 받는 IndexedDBHandler와 StorageHandler 클래스를 정의해보겠습니다.
두 클래스 모두 한번 인스턴스가 생성되면 해당 인스턴스로만 접근할 수 있도록 제한하기 위해 싱글톤 클래스로 구현하였습니다(getInstance() 메서드 참고).
먼저 사용 방법이 비교적 간단한 Session Storage를 핸들링하는 StorageHandler 클래스입니다.
export default class StorageHandler extends BaseHandler {
static instance: StorageHandler;
storage: Storage;
constructor() {
super();
this.storage = sessionStorage;
}
// 싱글톤 클래스 구현
static getInstance(): StorageHandler {
if (!this.instance) {
this.instance = new StorageHandler();
}
return this.instance;
}
// 데이터 저장
set(key: DataKey, value: string): void {
this.storage.setItem(key, value);
}
// 데이터 조회
get(key: DataKey): string {
const value = this.storage.getItem(key);
if (null === value) {
throw new Error("Session storage retrieval failed.");
}
return value;
}
// 데이터 삭제
delete(key: DataKey): void {
this.storage.removeItem(key);
}
// DataKey 타입에 해당하는 데이터 초기화
clear() {
[DataKey 타입에 해당하는 데이터로만 구성된 배열].forEach((key): void => {
this.storage.removeItem(key);
});
}
}
IndexedDB의 경우 데이터베이스에 접속하여 트랜잭션을 만들고, 접근하려는 저장소(store)를 지정한 후에 접근을 할 수 있습니다.
데이터 접근, 삭제, 초기화를 할 때마다 트랜잭션을 만든 후 각 요청의 성공/실패 여부에 따라 성공 시 결과를 리턴하고, 실패 시 데이터를 임시로 저장하는 객체에 접근하여 예외 상황에 대비하였습니다.
export default class IndexedDBHandler extends BaseHandler {
static instance: IndexedDBHandler | null = null;
dbName = "데이터베이스_이름"; // DB 이름
dbVersion = 1; // DB 버전을 기준으로 업데이트 처리 가능(아래의 init 메서드 참고)
storeName = "detail_id"; // 상세페이지를 식별하는 ID
db: IDBDatabase | null = null; // DB 인스턴스
dbInitRetryMaxCount = 3; // DB 초기화 재시도 가능 최대 횟수
// 데이터 저장 실패 시에도 get, delete 요청을 처리하기 위한 임시 저장소
tempStorage: Record<DataKey, string> = {
// DataKey에 해당하는 데이터 초깃값
};
// 싱글톤 클래스 구현
static getInstance(): IndexedDBHandler {
if (!this.instance) {
this.instance = new IndexedDBHandler();
}
return this.instance;
}
// 데이터 저장
async set(key: DataKey, value: string): Promise<string> {
try {
const transaction = await this.createTransaction("readwrite");
return this.checkRequestSuccess(transaction.put(key, value));
} catch (error) {
console.error(error);
this.tempStorage[key] = value;
return value;
}
}
// 데이터 조회
async get(key: DataKey): Promise<string> {
try {
const transaction = await this.createTransaction("readonly");
return this.checkRequestSuccess(transaction.get(key));
} catch (error) {
return await this.useTempStorage(key);
}
}
// 데이터 삭제
async delete(key: DataKey): Promise<string> {
try {
const transaction = await this.createTransaction("readonly");
return this.checkRequestSuccess(transaction.get(key));
} catch (error) {
return await this.useTempStorage(key);
}
}
// DataKey에 해당하는 데이터 초기화
async clear(): Promise<void> {
const transaction = await this.createTransaction("readwrite");
await Promise.all(
[DataKey 타입에 해당하는 데이터로만 구성된 배열].map((keys) => {
this.checkRequestSuccess(transaction.delete(key));
this.tempStorage[key] = "";
})
);
this.cleanUp();
}
// DB 생성 및 열기
init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onsuccess = (event) => {
this.db = (event.target as IDBOpenDBRequest).result;
resolve();
};
request.onerror = () => {
reject(request.error);
};
request.onupgradeneeded = (event) => {
this.db = (event.target as IDBOpenDBRequest).result;
// 객체 저장소 생성
if (event.oldVersion < this.dbVersion) {
this.db.createObjectStore(this.storeName);
}
};
});
}
// indexedDB에 store가 있으면 transaction 생성 후 store return, 없으면 store create
async createTransaction(
mode: IDBTransactionMode,
retryCount = this.dbInitRetryMaxCount
): Promise<IDBObjectStore> {
if (this.db) {
return this.db
.transaction(this.storeName, mode)
.objectStore(this.storeName);
}
try {
await this.init(); // 새로고침 등으로 DB를 다시 열어야 할 때
} catch (error) {
if (retryCount > 0) {
return await this.createTransaction(mode, retryCount - 1); // 재시도
} else {
throw error;
}
}
return await this.createTransaction(mode);
}
// 트랜잭션 성공 시 결과를 리턴하고, 실패 시 에러를 리턴함
checkRequestSuccess(request: IDBRequest): Promise<string> {
return new Promise((resolve, reject) => {
request.onsuccess = (event) => {
resolve((event.target as IDBRequest<string>).result);
};
request.onerror = (event) => {
reject(new Error((event.target as IDBRequest<string>).error?.message));
};
});
}
// 데이터 조회 또는 삭제에 실패할 경우 임시로 저장해둔 데이터를 리턴함
async useTempStorage(key: DataKey): Promise<string> {
const value = this.tempStorage[key];
if (value) {
await this.set(key, value);
this.tempStorage[key] = "";
return value;
} else {
throw new Error("IndexedDB retrieval failed");
}
}
// 트랜잭션 종료 및 DB 연결 닫기
cleanUp(): void {
if (!this.db) return;
// 트랜잭션 종료
if (this.db.transaction) {
const transaction = this.db.transaction(this.storeName);
transaction.abort();
}
// DB 연결 닫기
this.db.close();
// 이벤트 리스너 제거
this.db.onclose = null;
this.db.onabort = null;
// 객체 참조 해제
this.db = null;
}
}
이제 위에서 구현한 IndexedDBHandler 클래스와 StorageHandler 클래스를 통해 최종적으로 데이터를 관리하는 DataManager 클래스를 다음과 같이 구현했습니다.
export default class DataManager {
storageHandler: StorageHandler;
indexedDBHandler: IndexedDBHandler | null;
constructor() {
this.indexedDBHandler = null;
this.storageHandler = StorageHandler.getInstance();
// IndexedDB를 지원하지 않는 브라우저인 경우 Session Storage를 사용하도록 처리
if ("indexedDB" in window) {
this.indexedDBHandler = IndexedDBHandler.getInstance();
}
}
// 데이터 저장
async set(key: DataKey, value: string) {
if (this.indexedDBHandler) {
await this.indexedDBHandler.set(key, value);
} else {
this.storageHandler.set(key, value);
}
}
// 데이터 조회
async get(key: DataKey) {
if (this.indexedDBHandler) {
return await this.indexedDBHandler.get(key);
} else {
return this.storageHandler.get(key);
}
}
// 데이터 삭제
async delete(key: DataKey) {
if (this.indexedDBHandler) {
await this.indexedDBHandler.delete(key);
} else {
this.storageHandler.delete(key);
}
}
// 데이터 초기화
async clear() {
if (this.indexedDBHandler) {
await this.indexedDBHandler.clear();
} else {
this.storageHandler.clear();
}
}
}
DataManager 클래스를 통해 IndexedDB를 다루는 로직을 추상화하여, IndexedDB로 데이터를 관리해야 할 때마다 복잡한 로직을 구현하지 않게 되어서 편리했습니다.
트랜잭션이 실패할 경우, 트랜잭션 요청 및 응답 처리를 위한 초기화 처리, 종료 처리 등 고려할 점이 생각보다 많아서 수많은 예외 케이스를 예측하고 대비해볼 수 있는 기회였습니다.
'Web' 카테고리의 다른 글
tailwind-merge로 클래스 충돌 해결하기 (0) | 2024.04.11 |
---|---|
requestAnimationFrame() 알아보기 (0) | 2024.03.17 |
가상 DOM 없이 효율적으로 DOM 요소 추가하기 (feat. DocumentFragment 노드) (0) | 2022.10.31 |