웹 스토리지 완전 가이드: IndexedDB, Cache API, localStorage 전략

프론트엔드

IndexedDBCache APIService Worker오프라인웹 스토리지

이 글은 누구를 위한 것인가

  • 오프라인 지원이 필요한 웹 앱을 구현하려는 팀
  • IndexedDB를 직접 쓰기 복잡해서 Dexie.js를 고려하는 개발자
  • Cache API와 Service Worker로 네트워크 전략을 최적화하려는 팀

들어가며

"오프라인에서도 동작해야 한다"는 요구사항은 PWA의 핵심이다. localStorage는 5MB 제한, IndexedDB는 복잡한 API, Cache API는 SW와 결합해야 한다. 각 상황에 맞는 스토리지 전략을 알아보자.

이 글은 bluefoxdev.kr의 웹 스토리지 전략 가이드 를 참고하여 작성했습니다.


1. 스토리지 유형별 비교

[웹 스토리지 비교]

localStorage:
  용량: ~5-10MB
  타입: 문자열만 (JSON.stringify/parse 필요)
  동기: 블로킹 (메인 스레드)
  영속성: 영구 (명시적 삭제까지)
  접근: 메인 스레드만
  적합: 설정, 테마, 작은 데이터, 세션 토큰

sessionStorage:
  용량: ~5-10MB
  영속성: 탭/세션 닫으면 삭제
  적합: 임시 UI 상태, 폼 초안

IndexedDB:
  용량: 기기 저장공간의 최대 50%
  타입: 객체 (Binary, File, Blob 포함)
  비동기: 논블로킹
  영속성: 영구
  접근: 메인 스레드 + Service Worker + Web Worker
  적합: 오프라인 데이터, 캐시, 대용량 JSON

Cache API:
  용량: IndexedDB와 유사
  타입: HTTP 요청/응답 (Request/Response)
  비동기: 논블로킹
  접근: 메인 스레드 + Service Worker
  적합: 네트워크 응답 캐싱, 정적 에셋

OPFS (Origin Private File System):
  용량: 기기 저장공간 기반
  타입: 파일 시스템 (File, Directory)
  동기 가능: Web Worker에서 sync 접근
  적합: 대용량 파일, SQLite WASM, 비디오 처리

[선택 가이드]
  설정/토큰 (< 1MB): localStorage
  임시 UI 상태: sessionStorage
  앱 데이터 오프라인: IndexedDB (Dexie.js)
  API 응답 캐싱: Cache API
  대용량 파일: OPFS
  SQLite WASM: OPFS (블로킹 파일 I/O)

2. Dexie.js와 Cache API 구현

// Dexie.js - IndexedDB를 편하게
import Dexie, { type EntityTable } from 'dexie';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
  isFavorite: boolean;
  updatedAt: Date;
  syncedAt?: Date;
}

interface SyncQueue {
  id?: number;
  operation: 'create' | 'update' | 'delete';
  entityType: string;
  entityId: string;
  payload: object;
  createdAt: Date;
}

// 타입 안전한 DB 정의
class AppDatabase extends Dexie {
  products!: EntityTable<Product, 'id'>;
  syncQueue!: EntityTable<SyncQueue, 'id'>;
  
  constructor() {
    super('AppDatabase');
    
    this.version(1).stores({
      products: 'id, name, category, isFavorite, updatedAt',
      syncQueue: '++id, operation, entityType, createdAt',
    });
    
    // 버전 마이그레이션
    this.version(2).stores({
      products: 'id, name, category, isFavorite, updatedAt, syncedAt',
    }).upgrade(tx => {
      return tx.table('products').toCollection().modify(p => {
        p.syncedAt = new Date();
      });
    });
  }
}

export const db = new AppDatabase();

// 오프라인 우선 데이터 레이어
export class ProductRepository {
  // 목록 조회 (오프라인 우선)
  async getAll(filters?: { category?: string; isFavorite?: boolean }) {
    let query = db.products.toCollection();
    
    if (filters?.category) {
      query = db.products.where('category').equals(filters.category);
    }
    
    if (filters?.isFavorite !== undefined) {
      const filtered = await query.toArray();
      return filtered.filter(p => p.isFavorite === filters.isFavorite);
    }
    
    return query.sortBy('name');
  }
  
  // 검색
  async search(query: string) {
    return db.products
      .filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
      .toArray();
  }
  
  // 로컬 업데이트 + 동기화 큐
  async toggleFavorite(id: string) {
    await db.transaction('rw', [db.products, db.syncQueue], async () => {
      const product = await db.products.get(id);
      if (!product) return;
      
      await db.products.update(id, {
        isFavorite: !product.isFavorite,
        updatedAt: new Date(),
      });
      
      // 온라인 복구 시 서버와 동기화
      await db.syncQueue.add({
        operation: 'update',
        entityType: 'product',
        entityId: id,
        payload: { isFavorite: !product.isFavorite },
        createdAt: new Date(),
      });
    });
  }
  
  // 서버 데이터로 bulk 동기화
  async bulkSync(products: Product[]) {
    await db.products.bulkPut(products);
  }
  
  // 동기화 큐 처리
  async processSyncQueue() {
    if (!navigator.onLine) return;
    
    const queue = await db.syncQueue.orderBy('createdAt').toArray();
    
    for (const item of queue) {
      try {
        await fetch(`/api/${item.entityType}s/${item.entityId}`, {
          method: item.operation === 'delete' ? 'DELETE' : 'PATCH',
          body: JSON.stringify(item.payload),
          headers: { 'Content-Type': 'application/json' },
        });
        
        await db.syncQueue.delete(item.id!);
      } catch {
        break;  // 실패 시 중단 (순서 보장)
      }
    }
  }
}
// Cache API + Service Worker 전략
// public/sw.ts (Service Worker)

const CACHE_VERSION = 'v2';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;

// 정적 에셋 목록 (빌드 시 주입)
const STATIC_ASSETS = [
  '/',
  '/offline.html',
  '/manifest.json',
];

// 설치 시 정적 에셋 캐싱
self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then(cache => cache.addAll(STATIC_ASSETS))
      .then(() => (self as any).skipWaiting())
  );
});

// 활성화 시 구버전 캐시 삭제
self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(
        keys
          .filter(key => key !== STATIC_CACHE && key !== API_CACHE)
          .map(key => caches.delete(key))
      )
    ).then(() => (self as any).clients.claim())
  );
});

// 패치 전략
self.addEventListener('fetch', (event: FetchEvent) => {
  const { request } = event;
  const url = new URL(request.url);
  
  // API 요청: Network First (네트워크 우선, 실패 시 캐시)
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request, API_CACHE));
    return;
  }
  
  // 정적 에셋: Cache First (캐시 우선, 없으면 네트워크)
  if (request.destination === 'image' || request.destination === 'script') {
    event.respondWith(cacheFirst(request, STATIC_CACHE));
    return;
  }
  
  // 페이지: Network First
  event.respondWith(networkFirst(request, STATIC_CACHE));
});

async function networkFirst(request: Request, cacheName: string): Promise<Response> {
  try {
    const response = await fetch(request);
    
    if (response.ok) {
      const cache = await caches.open(cacheName);
      cache.put(request, response.clone());
    }
    
    return response;
  } catch {
    const cached = await caches.match(request);
    return cached ?? new Response('오프라인 상태입니다', { status: 503 });
  }
}

async function cacheFirst(request: Request, cacheName: string): Promise<Response> {
  const cached = await caches.match(request);
  if (cached) return cached;
  
  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(cacheName);
    cache.put(request, response.clone());
  }
  
  return response;
}

마무리

오프라인 우선 앱의 핵심은 "로컬 DB가 진실의 근원, 서버와 동기화"다. Dexie.js는 IndexedDB의 복잡한 API를 Promise 기반으로 단순화하고, 트랜잭션과 인덱스도 쉽게 다룬다. Cache API는 HTTP 요청/응답을 캐싱해서 오프라인에서 앱이 계속 동작하게 한다. 동기화 큐 패턴은 오프라인 변경을 큐에 쌓고 온라인 복구 시 처리해서 데이터 손실을 방지한다.