데이터의 흐름을 스트림(Stream)으로 처리하는 RxJS Observables와, 상태 변화에 따라 UI를 자동으로 업데이트하는 Signals를 함께 사용하면 IndexedDB를 더욱 강력하고 선언적으로 다룰 수 있습니다.
이 글에서는 Promise 기반의 IndexedDB 서비스를 Angular의 반응형 생태계에 통합하는 완전한 CRUD 예제를 단계별로 알아보겠습니다.
먼저, Angular에 의존하지 않는 순수 TypeScript 기반의 DBService를 작성합니다. 모든 CRUD 작업을 Promise로 반환합니다.
// src/db.service.ts (프레임워크 비종속)
import { User } from './user.model';
const DB_NAME = 'MyUserDB';
const STORE_NAME = 'users';
const DB_VERSION = 1;
export class DBService {
private db: IDBDatabase | null = null;
public async openDB(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.db) return resolve();
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject('Error opening database');
request.onsuccess = () => { this.db = request.result; resolve(); };
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
}
};
});
}
private getStore(mode: IDBTransactionMode): IDBObjectStore {
if (!this.db) throw new Error('Database not initialized!');
return this.db.transaction(STORE_NAME, mode).objectStore(STORE_NAME);
}
public async getAllUsers(): Promise<User[]> {
const request = this.getStore('readonly').getAll();
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
public async addUser(user: Omit<User, 'id'>): Promise<User> {
const request = this.getStore('readwrite').add(user);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve({ ...user, id: request.result as number });
request.onerror = () => reject(request.error);
});
}
public async updateUser(user: User): Promise<User> {
const request = this.getStore('readwrite').put(user);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(user);
request.onerror = () => reject(request.error);
});
}
public async deleteUser(id: number): Promise<void> {
const request = this.getStore('readwrite').delete(id);
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
이제 Angular의 Injectable 서비스를 만들어, DBService의 Promise를 Observable로 변환합니다.
// src/app/user-db.service.ts (Angular 서비스)
import { Injectable } from '@angular/core';
import { from, Observable, switchMap, shareReplay } from 'rxjs';
import { DBService } from '../db.service';
import { User } from '../user.model';
@Injectable({ providedIn: 'root' })
export class UserDbService {
private dbService = new DBService();
private dbReady$ = from(this.dbService.openDB()).pipe(shareReplay(1));
getAllUsers(): Observable<User[]> {
return this.dbReady$.pipe(switchMap(() => from(this.dbService.getAllUsers())));
}
addUser(user: Omit<User, 'id'>): Observable<User> {
return this.dbReady$.pipe(switchMap(() => from(this.dbService.addUser(user))));
}
updateUser(user: User): Observable<User> {
return this.dbReady$.pipe(switchMap(() => from(this.dbService.updateUser(user))));
}
deleteUser(id: number): Observable<void> {
return this.dbReady$.pipe(switchMap(() => from(this.dbService.deleteUser(id))));
}
}
컴포넌트에서 UserDbService를 주입받아 데이터를 관리합니다. 데이터의 상태는 Signal에 저장하여 UI와 동기화합니다.
// src/app/app.component.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { UserDbService } from './user-db.service';
import { User } from '../user.model';
@Component({
selector: 'app-root',
standalone: true,
imports: [],
templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
private userDbService = inject(UserDbService);
users = signal<User[]>([]);
ngOnInit(): void {
this.loadUsers();
}
loadUsers(): void {
this.userDbService.getAllUsers().subscribe({
next: (users) => this.users.set(users),
error: (err) => console.error('Failed to load users', err),
});
}
addUser(): void {
const name = prompt("Enter user name:");
const email = prompt("Enter user email:");
if (!name || !email) return;
this.userDbService.addUser({ name, email }).subscribe({
next: (addedUser) => {
this.users.update((currentUsers) => [...currentUsers, addedUser]);
},
error: (err) => console.error('Failed to add user', err),
});
}
updateUser(user: User): void {
const newName = prompt("Enter new name:", user.name);
if (!newName) return;
const updatedUser = { ...user, name: newName };
this.userDbService.updateUser(updatedUser).subscribe({
next: (returnedUser) => {
this.users.update((users) =>
users.map((u) => (u.id === returnedUser.id ? returnedUser : u))
);
},
error: (err) => console.error('Failed to update user', err),
});
}
deleteUser(id: number): void {
if (!confirm('Are you sure you want to delete this user?')) return;
this.userDbService.deleteUser(id).subscribe({
next: () => {
this.users.update((users) => users.filter((u) => u.id !== id));
},
error: (err) => console.error('Failed to delete user', err),
});
}
}
@for를 사용하여 users Signal을 순회하고 UI를 그립니다.
<!-- src/app/app.component.html -->
<main>
<h1>Angular IndexedDB with Signals & Observables</h1>
<button (click)="addUser()">Add User</button>
<ul>
@for (user of users(); track user.id) {
<li>
<span>ID: {{ user.id }} | Name: {{ user.name }} | Email: {{ user.email }}</span>
<div>
<button (click)="updateUser(user)">Update Name</button>
<button (click)="deleteUser(user.id)">Delete</button>
</div>
</li>
} @empty {
<p>No users found in IndexedDB. Add one!</p>
}
</ul>
</main>
IndexedDB를 Angular의 반응형 패러다임과 통합하면 매우 강력하고 선언적인 데이터 관리 아키텍처를 구축할 수 있습니다.
이러한 계층 분리 접근법은 코드의 재사용성과 테스트 용이성을 높이며, 복잡한 클라이언트 측 데이터 로직을 깔끔하게 유지하는 데 큰 도움이 됩니다.