네트워크가 불안정한 환경에서도 사용자 경험을 지키려면 클라이언트 로컬에 데이터를 안전하게 저장해야 합니다. 브라우저 표준 로컬 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를 만들 수 있습니다. 위 스니펫을 기반으로 스키마, 동기화 정책만 합의하면 바로 프로덕션에 적용 가능합니다.
'React' 카테고리의 다른 글
| React로 마이크로 프론트엔드 구현하기 (0) | 2026.05.14 |
|---|---|
| React 앱에서 Atomic Design 패턴 적용하기 (0) | 2026.05.14 |
| React에서 컴포넌트 성능 측정 및 분석하기 (1) | 2026.05.13 |
| React 앱 CI/CD 파이프라인 구축하기 (0) | 2026.05.12 |
| React 앱에서 사용자 권한(Role-Based Access Control) 처리하기 (0) | 2026.05.12 |