본문 바로가기

React

React에서 IndexedDB를 이용한 오프라인 데이터 저장

네트워크가 불안정한 환경에서도 사용자 경험을 지키려면 클라이언트 로컬에 데이터를 안전하게 저장해야 합니다. 브라우저 표준 로컬 DB인 IndexedDB는 대용량, 트랜잭션, 인덱스를 지원하며 React 앱의 오프라인 퍼스트(Offline-first) 전략에 적합합니다. 이 글은 실무에서 바로 쓸 수 있는 최소 구현부터 동기화 전략까지 간결하게 정리했습니다.

1. 언제 IndexedDB를 써야 하나요?

- API 읽기/쓰기 지연을 숨기는 캐시 레이어가 필요할 때

- 네트워크 오프라인 상태에서도 생성/수정 작업을 허용하고 나중에 서버와 동기화할 때

- 로컬스토리지로는 부족한 대용량, 구조화된 데이터가 있을 때

2. 빠른 시작: idb로 안전하게 다루기

IndexedDB의 네이티브 API는 콜백과 이벤트 기반으로 번거롭습니다. 실무에서는 가볍고 타입 친화적인 idb를 많이 사용합니다.

// 설치
// npm i idb

import { openDB } from 'idb';

// DB 초기화 (버전 업그레이드 포함)
export const dbPromise = openDB('app-db', 2, {
  upgrade(db, oldVersion, newVersion, transaction) {
    if (oldVersion < 1) {
      const todos = db.createObjectStore('todos', { keyPath: 'id' });
      todos.createIndex('by-done', 'done');
      todos.createIndex('by-updatedAt', 'updatedAt');
      db.createObjectStore('outbox', { keyPath: 'clientId', autoIncrement: true }); // 오프라인 큐
    }
    if (oldVersion < 2) {
      // 마이그레이션 예시: 인덱스 추가
      const todos = transaction.objectStore('todos');
      if (!todos.indexNames.contains('by-updatedAt')) {
        todos.createIndex('by-updatedAt', 'updatedAt');
      }
    }
  },
});

// CRUD 유틸
export async function getTodos() {
  const db = await dbPromise;
  // 최신 업데이트 순으로
  const idx = db.transaction('todos').store.index('by-updatedAt');
  return idx.getAll(null, 1000).then(list => list.sort((a,b) => b.updatedAt - a.updatedAt));
}

export async function saveTodo(todo) {
  const db = await dbPromise;
  const withMeta = { ...todo, updatedAt: Date.now() };
  await db.put('todos', withMeta);
  return withMeta;
}

export async function deleteTodo(id) {
  const db = await dbPromise;
  await db.delete('todos', id);
}

3. React 훅으로 캡슐화하기

컴포넌트에서 비동기 DB 접근을 매번 쓰지 않도록 커스텀 훅으로 묶습니다. 오프라인일 때는 outbox에 작업을 쌓고, 온라인이 되면 서버로 밀어올립니다.

import { useState, useEffect, useCallback } from 'react';
import { dbPromise, getTodos, saveTodo, deleteTodo } from './db';

async function syncOutbox() {
  const db = await dbPromise;
  const tx = db.transaction(['outbox', 'todos'], 'readwrite');
  const outbox = tx.objectStore('outbox');
  const todos = tx.objectStore('todos');

  let cursor = await outbox.openCursor();
  while (cursor) {
    const job = cursor.value; // { type, payload, ts, clientId }
    try {
      // 서버 API 예시: 충돌은 updatedAt 기준으로 서버가 병합 처리한다고 가정
      if (job.type === 'CREATE_TODO') {
        await fetch('/api/todos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(job.payload) });
      } else if (job.type === 'UPDATE_TODO') {
        await fetch(`/api/todos/${job.payload.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(job.payload) });
      } else if (job.type === 'DELETE_TODO') {
        await fetch(`/api/todos/${job.payload.id}`, { method: 'DELETE' });
      }
      await cursor.delete();
    } catch (e) {
      // 네트워크 오류면 중단하여 재시도
      console.warn('sync failed, will retry later', e);
      break;
    }
    cursor = await cursor.continue();
  }

  await tx.done;
}

export function useTodos() {
  const [todos, setTodos] = useState([]);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    let active = true;
    (async () => {
      await dbPromise; // Safari 등 초기화 안정화
      const list = await getTodos();
      if (active) {
        setTodos(list);
        setReady(true);
      }
    })();
    return () => { active = false; };
  }, []);

  const add = useCallback(async (text) => {
    const todo = { id: crypto.randomUUID(), text, done: false };
    const saved = await saveTodo(todo);
    setTodos(prev => [saved, ...prev]);

    const db = await dbPromise;
    if (!navigator.onLine) {
      await db.add('outbox', { type: 'CREATE_TODO', payload: saved, ts: Date.now() });
    } else {
      await syncOutbox();
    }
  }, []);

  const toggle = useCallback(async (id) => {
    const next = todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
    setTodos(next);
    const changed = next.find(t => t.id === id);
    await saveTodo(changed);

    const db = await dbPromise;
    if (!navigator.onLine) {
      await db.add('outbox', { type: 'UPDATE_TODO', payload: changed, ts: Date.now() });
    } else {
      await syncOutbox();
    }
  }, [todos]);

  const remove = useCallback(async (id) => {
    setTodos(prev => prev.filter(t => t.id !== id));
    await deleteTodo(id);

    const db = await dbPromise;
    if (!navigator.onLine) {
      await db.add('outbox', { type: 'DELETE_TODO', payload: { id }, ts: Date.now() });
    } else {
      await syncOutbox();
    }
  }, []);

  useEffect(() => {
    const onOnline = () => { syncOutbox(); };
    window.addEventListener('online', onOnline);
    return () => window.removeEventListener('online', onOnline);
  }, []);

  return { ready, todos, add, toggle, remove };
}

4. 컴포넌트 사용 예시

import React, { useState } from 'react';
import { useTodos } from './useTodos';

export default function TodoPage() {
  const { ready, todos, add, toggle, remove } = useTodos();
  const [text, setText] = useState('');

  if (!ready) return <p>로딩 중...</p>;

  return (
    <div>
      <h2>오프라인 지원 Todo</h2>
      <form onSubmit={e => { e.preventDefault(); if (text.trim()) { add(text.trim()); setText(''); } }}>
        <input value={text} onChange={e => setText(e.target.value)} placeholder="할 일" />
        <button type="submit">추가</button>
      </form>
      <ul>
        {todos.map(t => (
          <li key={t.id}>
            <label>
              <input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} />
              {t.text}
            </label>
            <button onClick={() => remove(t.id)}>삭제</button>
          </li>
        ))}
      </ul>
      <p>상태: {navigator.onLine ? '온라인' : '오프라인'}</p>
    </div>
  );
}

5. 동기화 전략(실무 체크리스트)

- 충돌 해결 기준: updatedAt(최신 우선), 버전 필드(version), 사용자 우선 규칙 중 하나를 명확히 합의합니다.

- 배치 처리: outbox를 하나의 트랜잭션/배치 API로 묶어 왕복 횟수를 줄입니다.

- 재시도 정책: 지수 백오프, 최대 재시도 횟수, 네트워크 복구 이벤트(ononline) 활용을 정의합니다.

- 초기 싱크: 앱 최초 진입 시 서버→로컬 풀싱크 후 diff 적용을 권장합니다.

6. 성능 최적화 팁

- 인덱스 설계: 조회 패턴 별로 인덱스를 만들고 불필요한 전량 스캔을 피합니다.

- 트랜잭션 활용: 여러 put/delete를 하나의 readwrite 트랜잭션으로 묶습니다.

const db = await dbPromise;
const tx = db.transaction(['todos'], 'readwrite');
const store = tx.objectStore('todos');
await Promise.all(batch.map(item => store.put(item)));
await tx.done;

- 렌더링 최소화: React에서는 쓰기 완료 후 setState를 배치하고, 리스트는 key와 memoization을 적극 사용합니다.

7. 저장 용량과 제약

- 브라우저마다 용량이 다릅니다. StorageManager로 추정치를 구해 사용자에게 안내합니다.

if (navigator.storage && navigator.storage.estimate) {
  const { usage, quota } = await navigator.storage.estimate();
  console.log(`사용: ${(usage/1024/1024).toFixed(1)}MB / 할당: ${(quota/1024/1024).toFixed(1)}MB`);
}

- 프라이빗 모드(iOS Safari 등)에서는 IndexedDB가 제한되거나 실패할 수 있어 try/catch로 초기화를 감싸고, 실패 시 읽기 전용 모드로 폴백합니다.

8. 디버깅과 테스트

- Chrome DevTools: Application → IndexedDB에서 스토어/레코드를 확인합니다.

- 네트워크 탭/오프라인 토글: 오프라인 모드에서 UI 동작과 outbox 적재를 검증합니다.

- E2E: 첫 진입(오프라인), 수정(오프라인), 온라인 전환 후 동기화까지 사용자 플로우를 자동화합니다.

9. 보안과 유의사항

- IndexedDB는 평문 저장입니다. 민감정보(토큰, 개인정보 원문)는 저장하지 않거나, 필요한 경우 앱 레벨 암호화(예: SubtleCrypto) 후 저장합니다.

- 키 설계는 충돌이 적은 uuid를 권장합니다. 서버와 키를 공유할 경우 서버 생성 키를 동기화합니다.

10. Service Worker와 시너지

- Background Sync/Periodic Sync를 쓰면 앱이 포그라운드가 아니어도 동기화를 시도할 수 있습니다. Workbox로 라우팅 캐시 + 백그라운드 동기화를 구성하면 PWA 완성도가 올라갑니다.

요약: idb로 IndexedDB를 단순화하고, React 훅으로 상태/동기화를 캡슐화하면 오프라인에서도 매끄러운 UX를 만들 수 있습니다. 위 스니펫을 기반으로 스키마, 동기화 정책만 합의하면 바로 프로덕션에 적용 가능합니다.