TypeScript와 IndexedDB

TypeScript와 IndexedDB

이 문서에서는 TypeScript와 IndexedDB에 대해 설명합니다.

실제 예제를 통해 TypeScript와 IndexedDB를 설명하겠습니다.

YouTube Video

TypeScript와 IndexedDB

IndexedDB는 브라우저에서 구조화된 데이터를 영구적으로 저장할 수 있는 저수준 NoSQL 저장소입니다. TypeScript를 사용하면 타입 안전한 방식으로 스키마를 표현할 수 있어 오류를 줄이고 유지보수성을 향상시킬 수 있습니다.

기본 용어와 워크플로우

IndexedDB는 브라우저 내부의 작은 데이터베이스입니다. 이것은 이름과 버전이 있는 데이터베이스, 객체 저장소, 트랜잭션, 인덱스, 커서와 같은 메커니즘을 사용하여 데이터를 관리합니다. 데이터베이스는 버전을 가지며, 버전이 업그레이드될 때 onupgradeneeded가 호출되어 테이블 생성 또는 수정과 같은 스키마 업데이트가 이루어집니다.

IndexedDB 열기(기본 패턴)

먼저, IndexedDB로 데이터베이스를 열고 필요하다면 onupgradeneeded에서 오브젝트 스토어를 생성하는 예제를 보여줍니다.

 1// Open an IndexedDB database and create an object store if needed.
 2// This code shows the classic callback-based IndexedDB API wrapped into a Promise.
 3function openDatabase(dbName: string, version: number): Promise<IDBDatabase> {
 4  return new Promise((resolve, reject) => {
 5    const request = indexedDB.open(dbName, version);
 6
 7    request.onerror = () => {
 8      reject(request.error);
 9    };
10
11    request.onupgradeneeded = (event) => {
12      const db = request.result;
13      if (!db.objectStoreNames.contains('todos')) {
14        // Create object store with keyPath 'id'
15        db.createObjectStore('todos', { keyPath: 'id' });
16      }
17    };
18
19    request.onsuccess = () => {
20      resolve(request.result);
21    };
22  });
23}
24
25// Usage example:
26openDatabase('my-db', 1)
27  .then(db => {
28    console.log('DB opened', db.name, db.version);
29    db.close();
30  })
31  .catch(err => console.error('Failed to open DB', err));
  • 이 코드는 데이터베이스를 열고 성공 또는 실패 여부를 기록합니다.
  • 필요하다면 onupgradeneeded에서 todos 스토어가 생성됩니다.

TypeScript에서 타입 정의(모델)

다음으로 TypeScript를 사용해 데이터 타입을 정의합니다. 이를 통해 이후의 CRUD 작업에서 타입 안전성이 보장됩니다.

1// Define the TypeScript interface for a Todo item.
2// This helps with type safety in the database operations.
3interface Todo {
4  id: string;          // primary key
5  title: string;
6  completed: boolean;
7  createdAt: number;
8}
  • 여기서 Todo 타입을 정의합니다.

간단한 CRUD 함수 구현 예제

다음으로 오브젝트 스토어에서 추가, 조회, 수정, 삭제 등 기본 CRUD 작업 예제를 보여줍니다. 각 함수는 IDBDatabase를 받아 Promise를 반환합니다.

 1// CRUD utilities for the 'todos' object store.
 2// Each operation uses a transaction and returns a Promise for easier async/await usage.
 3
 4function addTodo(db: IDBDatabase, todo: Todo): Promise<void> {
 5  return new Promise((resolve, reject) => {
 6    const tx = db.transaction('todos', 'readwrite');
 7    const store = tx.objectStore('todos');
 8    const req = store.add(todo);
 9
10    req.onsuccess = () => {
11      console.log('Todo added', todo.id);
12    };
13    req.onerror = () => reject(req.error);
14
15    tx.oncomplete = () => resolve();
16    tx.onerror = () => reject(tx.error);
17  });
18}
  • 이 함수는 IndexedDB의 todos 저장소에 새로운 Todo를 추가합니다. 처리가 완료되면 Promise가 resolve되는 비동기 처리를 반환합니다.
 1function getTodo(db: IDBDatabase, id: string): Promise<Todo | undefined> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readonly');
 4    const store = tx.objectStore('todos');
 5    const req = store.get(id);
 6
 7    req.onsuccess = () => resolve(req.result as Todo | undefined);
 8    req.onerror = () => reject(req.error);
 9  });
10}
  • 이 함수는 지정된 ID를 가진 Todo를 검색하고 발견 시 객체를 반환합니다. 일치하는 데이터가 없으면 undefined를 반환합니다.
 1function updateTodo(db: IDBDatabase, todo: Todo): Promise<void> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readwrite');
 4    const store = tx.objectStore('todos');
 5    const req = store.put(todo);
 6
 7    req.onsuccess = () => {
 8      console.log('Todo updated', todo.id);
 9    };
10    req.onerror = () => reject(req.error);
11
12    tx.oncomplete = () => resolve();
13    tx.onerror = () => reject(tx.error);
14  });
15}
  • 이 함수는 기존의 Todo 데이터를 업데이트합니다. 성공 시, 업데이트된 Todo의 ID가 기록됩니다.
 1function deleteTodo(db: IDBDatabase, id: string): Promise<void> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readwrite');
 4    const store = tx.objectStore('todos');
 5    const req = store.delete(id);
 6
 7    req.onsuccess = () => {
 8      console.log('Todo deleted', id);
 9    };
10    req.onerror = () => reject(req.error);
11
12    tx.oncomplete = () => resolve();
13    tx.onerror = () => reject(tx.error);
14  });
15}
  • 이 함수는 지정된 ID의 Todo를 삭제합니다. 처리가 성공하면 삭제된 ID가 로그로 출력됩니다.

  • 이 함수들은 트랜잭션 완료 또는 오류 발생에 따라 Promise를 resolve 또는 reject합니다. console.log를 통해 출력을 포함하면 실행 중에 어떤 일이 일어나는지 쉽게 추적할 수 있습니다.

인덱스 및 복합 쿼리

IndexedDB에서 인덱스를 사용하면 특정 필드에 대해 효율적으로 검색할 수 있습니다. 여기서는 createdAt 필드에 대한 인덱스를 만들고 범위 쿼리 예시를 보여줍니다.

 1// When opening DB, create an index for createdAt.
 2// Then demonstrate a range query using the index.
 3
 4function openDatabaseWithIndex(dbName: string, version: number): Promise<IDBDatabase> {
 5  return new Promise((resolve, reject) => {
 6    const request = indexedDB.open(dbName, version);
 7
 8    request.onupgradeneeded = () => {
 9      const db = request.result;
10      if (!db.objectStoreNames.contains('todos')) {
11        const store = db.createObjectStore('todos', { keyPath: 'id' });
12        // Create an index on createdAt for sorting/filtering
13        store.createIndex('by-createdAt', 'createdAt', { unique: false });
14      } else {
15        const store = request.transaction!.objectStore('todos');
16        if (!store.indexNames.contains('by-createdAt')) {
17          store.createIndex('by-createdAt', 'createdAt', { unique: false });
18        }
19      }
20    };
21
22    request.onerror = () => reject(request.error);
23    request.onsuccess = () => resolve(request.result);
24  });
25}
  • 이 함수는 데이터베이스를 열고 createdAt 필드에 대해 by-createdAt 인덱스를 생성 또는 확인합니다. 이를 통해 생성일로 효율적으로 검색 및 정렬할 수 있습니다.
 1async function getTodosCreatedAfter(db: IDBDatabase, timestamp: number): Promise<Todo[]> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readonly');
 4    const store = tx.objectStore('todos');
 5    const index = store.index('by-createdAt');
 6    const range = IDBKeyRange.lowerBound(timestamp, true); // exclusive
 7    const req = index.openCursor(range);
 8
 9    const results: Todo[] = [];
10    req.onsuccess = (event) => {
11      const cursor = (event.target as IDBRequest).result as IDBCursorWithValue | null;
12      if (cursor) {
13        results.push(cursor.value as Todo);
14        cursor.continue();
15      } else {
16        resolve(results);
17      }
18    };
19    req.onerror = () => reject(req.error);
20  });
21}
  • 이 함수는 지정된 타임스탬프 이후에 생성된 Todo만을 검색합니다. 인덱스를 사용해 생성일 순서로 데이터를 효율적으로 조회할 수 있습니다.

  • 이 예제에서는 데이터베이스 업그레이드 시 by-createdAt 인덱스가 생성되고, 지정된 시간 이후에 생성된 Todo 항목들을 커서로 나열합니다.

Promise 기반 경량 래퍼

저수준 IndexedDB API는 작성하기가 복잡하며, 유사한 작업의 반복은 중복과 버그로 이어질 수 있습니다. 따라서 작업을 추상화한 범용 TypeScript 래퍼 클래스를 준비함으로써 코드의 단순성과 유지 관리성을 높일 수 있습니다. 아래는 기본 기능에 중점을 둔 구현입니다.

1// A minimal TypeScript wrapper around IndexedDB to simplify common operations.
2// This class is generic over the store's value type and assumes 'keyPath' is 'id'.
3
4class IDBWrapper<T extends { id: string }> {
5  private dbPromise: Promise<IDBDatabase>;
6
7  constructor(private dbName: string, private version: number, private storeName: string) {
8    this.dbPromise = this.open();
9  }
  • 이 클래스는 IndexedDB 작업을 래핑하며, 타입 안전한 CRUD 메서드를 제공합니다. 키가 id인 오브젝트 스토어를 전제로 합니다.
 1  private open(): Promise<IDBDatabase> {
 2    return new Promise((resolve, reject) => {
 3      const req = indexedDB.open(this.dbName, this.version);
 4      req.onerror = () => reject(req.error);
 5      req.onupgradeneeded = () => {
 6        const db = req.result;
 7        if (!db.objectStoreNames.contains(this.storeName)) {
 8          db.createObjectStore(this.storeName, { keyPath: 'id' });
 9        }
10      };
11      req.onsuccess = () => resolve(req.result);
12    });
13  }
  • 데이터베이스를 열고 필요하다면 새로운 오브젝트 스토어를 생성합니다. 스토어 초기화는 업그레이드 이벤트를 통해 수행합니다.
 1  async add(item: T): Promise<void> {
 2    const db = await this.dbPromise;
 3    await new Promise<void>((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.add(item);
 7      req.onsuccess = () => {
 8        console.log('added', item.id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • IndexedDB 스토어에 데이터를 추가합니다. 추가 후, ID를 콘솔에 로그로 남깁니다.
 1  async get(id: string): Promise<T | undefined> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readonly');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.get(id);
 7      req.onsuccess = () => resolve(req.result as T | undefined);
 8      req.onerror = () => reject(req.error);
 9    });
10  }
  • 지정된 ID에 해당하는 데이터를 조회합니다. 데이터가 없으면 undefined를 반환합니다.
 1  async put(item: T): Promise<void> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.put(item);
 7      req.onsuccess = () => {
 8        console.log('put', item.id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • 기존 데이터를 수정하거나 새 데이터를 추가합니다. 처리 후, 수정된 ID를 로그로 출력합니다.
 1  async delete(id: string): Promise<void> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.delete(id);
 7      req.onsuccess = () => {
 8        console.log('deleted', id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • 지정된 ID의 데이터를 삭제합니다. 성공 시, 삭제된 ID를 로그로 남깁니다.
 1  async getAll(): Promise<T[]> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readonly');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.getAll();
 7      req.onsuccess = () => resolve(req.result as T[]);
 8      req.onerror = () => reject(req.error);
 9    });
10  }
11}
  • 스토어 내 모든 데이터를 조회합니다. 반환 값은 T 타입의 배열입니다.
 1// Example usage with Todo type:
 2interface Todo {
 3  id: string;
 4  title: string;
 5  completed: boolean;
 6  createdAt: number;
 7}
 8
 9const todoStore = new IDBWrapper<Todo>('my-db', 1, 'todos');
10
11(async () => {
12  const newTodo: Todo = { id: '1', title: 'Learn IndexedDB', completed: false, createdAt: Date.now() };
13  await todoStore.add(newTodo);
14  const fetched = await todoStore.get('1');
15  console.log('fetched', fetched);
16  newTodo.completed = true;
17  await todoStore.put(newTodo);
18  const all = await todoStore.getAll();
19  console.log('all todos', all);
20  await todoStore.delete('1');
21})();
  • 이 코드는 IDBWrapper 클래스의 실제 사용 예제입니다. 이것은 Todo 데이터를 추가, 검색, 업데이트, 나열, 삭제하는 흐름을 보여줍니다.

  • 이 래퍼를 사용하면 기본 CRUD 작업을 간단히 처리할 수 있습니다. 실제 환경에서는 오류 처리와 스키마 관리(인덱스)도 처리해야 합니다.

스키마 마이그레이션(버전 업그레이드)

indexedDB.open의 두 번째 인수(버전)를 증가시키고, onupgradeneeded에서 이를 업데이트하여 데이터베이스 스키마를 변경할 수 있습니다. 기존 트랜잭션이 완료되고 파괴적인 변경이 방지되도록 설계해야 합니다.

 1// Example of handling upgrade to version 2: add an index and perhaps migrate data.
 2// onupgradeneeded receives an event where oldVersion and newVersion are accessible.
 3
 4function upgradeToV2(dbName: string): Promise<IDBDatabase> {
 5  return new Promise((resolve, reject) => {
 6    const req = indexedDB.open(dbName, 2);
 7    req.onupgradeneeded = (ev) => {
 8      const db = req.result;
 9      const oldVersion = (ev as IDBVersionChangeEvent).oldVersion;
10      console.log('Upgrading DB from', oldVersion, 'to', db.version);
11      let store: IDBObjectStore;
12      if (!db.objectStoreNames.contains('todos')) {
13        store = db.createObjectStore('todos', { keyPath: 'id' });
14      } else {
15        store = req.transaction!.objectStore('todos');
16      }
17      // Add index if not present
18      if (!store.indexNames.contains('by-completed')) {
19        store.createIndex('by-completed', 'completed', { unique: false });
20      }
21
22      // Optional: data migration logic if necessary can go here,
23      // but heavy migrations often should be done lazily on read.
24    };
25    req.onsuccess = () => resolve(req.result);
26    req.onerror = () => reject(req.error);
27  });
28}
  • onupgradeneeded 내부에서의 무거운 처리는 UI를 차단할 수 있으니 최소화하고, 가능하다면 지연 마이그레이션(앱 시작 시 분할 처리)을 고려하세요.

트랜잭션 주의사항(생명주기 및 오류)

트랜잭션은 이를 생성한 스크립트 실행이 끝나기 전에 자동으로 커밋됩니다. 트랜잭션 내에서 await를 사용할 경우 예기치 않게 커밋될 수 있으므로, 같은 트랜잭션 내에서 여러 비동기 작업을 할 때는 주의해야 합니다.

 1// Bad pattern: awaiting outside transaction callbacks can cause tx to auto-commit.
 2// Good pattern is to chain requests and resolve on tx.oncomplete as shown earlier.
 3
 4// Example: Do multiple operations inside single tx, avoid awaiting inside.
 5function multiOperation(db: IDBDatabase, items: Todo[]): Promise<void> {
 6  return new Promise((resolve, reject) => {
 7    const tx = db.transaction('todos', 'readwrite');
 8    const store = tx.objectStore('todos');
 9
10    for (const item of items) {
11      const req = store.put(item);
12      req.onerror = () => console.error('put error', req.error);
13      // Do NOT await here; just schedule requests synchronously.
14    }
15
16    tx.oncomplete = () => {
17      console.log('All operations in transaction completed');
18      resolve();
19    };
20    tx.onerror = () => reject(tx.error);
21  });
22}
  • 트랜잭션의 생명주기에 유의하고, 필요하다면 별도의 트랜잭션을 사용하거나 한 트랜잭션 내에서 동기적으로 작업을 예약하세요.

커서 활용 및 페이지네이션

커서를 사용하면 대규모 데이터를 순차적으로 처리하거나 오프셋 없이 간단한 페이지네이션을 구현할 수 있습니다.

 1// Example: fetch first N items using a cursor (ascending by key).
 2function fetchFirstN(db: IDBDatabase, n: number): Promise<Todo[]> {
 3  return new Promise((resolve, reject) => {
 4    const tx = db.transaction('todos', 'readonly');
 5    const store = tx.objectStore('todos');
 6    const req = store.openCursor();
 7    const out: Todo[] = [];
 8    req.onsuccess = (ev) => {
 9      const cursor = (ev.target as IDBRequest).result as IDBCursorWithValue | null;
10      if (cursor && out.length < n) {
11        out.push(cursor.value as Todo);
12        cursor.continue();
13      } else {
14        resolve(out);
15      }
16    };
17    req.onerror = () => reject(req.error);
18  });
19}
  • 커서로 아이템을 하나씩 조회하면 메모리 사용량을 줄일 수 있습니다. 페이징 구현 시, 마지막으로 읽은 키를 기억하는 것이 일반적입니다.

오류 처리 및 대체 방안

1// Feature detection
2if (!('indexedDB' in window)) {
3  console.warn('IndexedDB is not supported. Falling back to localStorage.');
4  // implement fallback logic...
5}
  • IndexedDB는 브라우저 간 구현 차이 또는 사용자의 프라이버시 설정(예: 시크릿 모드) 때문에 사용할 수 없을 수 있습니다. 따라서 indexedDB가 존재하는지 확인하고, 존재하지 않을 경우 localStorage와 같은 대체 수단을 제공해야 합니다.

성능 및 모범 사례

IndexedDB는 빠르고 강력하지만, 설계와 데이터 처리 방법에 따라 성능이 크게 달라질 수 있습니다. 사용 사례에 따라 다음과 같은 방식으로 최적화할 수 있습니다:.

  • 실제 사용에 맞게 객체 저장소를 설계하세요. 예를 들어 읽기 작업이 많다면 인덱스를 제공하고, 쓰기 작업이 많다면 키 설계를 단순하게 하세요.
  • 이미지나 오디오 같은 대용량 바이너리 데이터는 Blob으로 저장하거나, 필요하다면 File API 또는 서비스 워커로 관리해야 합니다. 필요하다면 압축도 고려할 수 있습니다.
  • 트랜잭션을 가능한 한 짧게 유지하고, 무거운 처리는 트랜잭션 외부에서 수행하여 락 타임을 최소화하세요.
  • 인덱스는 검색을 빠르게 하지만 삽입 및 업데이트를 느리게 하므로 정말 필요한 것만 생성하세요.
  • 작은 데이터가 많을 때 getAll()로 모두 한 번에 가져오면 메모리가 고갈될 수 있습니다. 커서로 나누어 처리하면 메모리 사용량을 줄일 수 있습니다.

보안 및 개인정보 보호

IndexedDB 데이터는 동일 출처 정책에 따라 도메인 및 프로토콜별로 분리됩니다. 사용자가 브라우저 데이터를 삭제하거나 프라이빗 모드를 사용할 경우 데이터가 손실될 수 있다는 가정을 바탕으로 설계하세요.

요약 및 권장 설계 패턴

TypeScript와 함께 IndexedDB를 효과적으로 사용하려면 타입과 비동기 처리를 준비하고, 버전 관리와 트랜잭션 설계에 주의하며, 공통 처리를 래핑하여 유지보수성을 높이는 것이 중요합니다.

  • TypeScript에서 타입을 정의하고 Promise/async/await로 IndexedDB 작업을 래핑하면 안정성과 코드 가독성이 향상됩니다.
  • 스키마 변경 시에는 onupgradeneeded와 버전 관리를 사용하고, 무거운 처리는 가능한 한 지연 처리하세요.
  • 트랜잭션은 짧게 설계하고, 같은 트랜잭션 내부에서는 무거운 비동기 처리를 피하세요.
  • 래퍼 클래스를 만들면 오류 처리, 로깅, 타입 정의 등 중복되는 공통 처리를 줄일 수 있습니다.

위의 기사를 보면서 Visual Studio Code를 사용해 우리 유튜브 채널에서 함께 따라할 수 있습니다. 유튜브 채널도 확인해 주세요.

YouTube Video