Angular에서 IndexedDB로 반응형 데이터 관리하기

Adam Kim·2025년 12월 15일

angular

목록 보기
89/102

데이터의 흐름을 스트림(Stream)으로 처리하는 RxJS Observables와, 상태 변화에 따라 UI를 자동으로 업데이트하는 Signals를 함께 사용하면 IndexedDB를 더욱 강력하고 선언적으로 다룰 수 있습니다.

이 글에서는 Promise 기반의 IndexedDB 서비스를 Angular의 반응형 생태계에 통합하는 완전한 CRUD 예제를 단계별로 알아보겠습니다.

계획

  1. 계층 분리: 순수 TypeScript로 작성된 저수준 DB 서비스와, 이를 Angular의 Observable로 감싸는 고수준 Angular 서비스를 분리합니다.
  2. Observable 변환: 저수준 서비스의 Promise 기반 메서드를 from 연산자를 사용하여 RxJS Observable로 변환합니다.
  3. 상태 관리 Signals: 컴포넌트 내에서는 Signal을 사용하여 데이터 상태를 관리하고, 데이터 변경 시 UI가 자동으로 업데이트되도록 합니다.
  4. 완전한 반응형 UI 구현: @for 문법을 사용하여 Signal에 담긴 데이터를 템플릿에 렌더링하고, CRUD 작업을 통해 데이터를 변경합니다.

DEMO 작성

1단계: 저수준 DB 서비스 (Promise 기반)

먼저, 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);
    });
  }
}

2단계: Angular 서비스 래퍼 (Observable 브릿지)

이제 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))));
  }
}

3단계: 반응형 컴포넌트 (Signals & UI)

컴포넌트에서 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),
    });
  }
}

4단계: 템플릿 작성 (Control Flow)

@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의 반응형 패러다임과 통합하면 매우 강력하고 선언적인 데이터 관리 아키텍처를 구축할 수 있습니다.

  • 저수준 Promise 서비스: IndexedDB의 복잡성을 캡슐화합니다.
  • 고수준 Observable 서비스: 비동기 데이터 흐름을 관리하고 Angular 생태계에 연결합니다.
  • 컴포넌트 내 Signal: UI 상태를 관리하고, 데이터 변경에 따라 뷰를 자동으로 업데이트합니다.

이러한 계층 분리 접근법은 코드의 재사용성과 테스트 용이성을 높이며, 복잡한 클라이언트 측 데이터 로직을 깔끔하게 유지하는 데 큰 도움이 됩니다.

profile
Angular2+ Developer

0개의 댓글