브라우저가 내 데이터를 버릴 때: 초록 체크가 거짓말을 한다
모바일 localStorage는 용량이 차면 데이터를 조용히 버린다. 그런데 '저장됨' 플래그는 남아 완료 체크가 뜬다. 저장소 계층별 용도, 큰 본문을 IndexedDB에 두는 법, 열 때 정합성 검사, storage.persist()로 eviction 줄이기까지.
브라우저 저장소는 durable하지 않다 — 데이터를 버리고도 “저장됨” 플래그는 남긴다. 모바일 localStorage에 사진을 넣으면, 용량이 찰 때 브라우저가 본문은 조용히 버리고 filled: true만 남긴다. 그럼 앱은 깨진 이미지 위에 초록 체크를 그려 사용자한테 거짓말을 한다.

왜 중요한가
사용자는 완료된 줄 알고 다음으로 넘어간다. 실제 데이터는 없는데. 현장에서 사진을 다시 찍을 기회를 그 순간 놓친다. 그리고 이건 에러가 아니라 “성공처럼 보이는 실패”라 리포트도 늦게 온다.
어디에 무엇을 두나
브라우저 저장소는 하나가 아니다. 크기·특성이 달라서, 잘못 고르면 조용히 사라진다.
| 저장소 | 용량 | 특성 | 용도 |
|---|---|---|---|
| localStorage | ~5MB | 동기, 문자열만 | 작은 플래그·설정 |
| sessionStorage | ~5MB | 탭 한정, 닫으면 소멸 | 일회성 임시 |
| IndexedDB | 수백MB+ | 비동기, Blob 저장 | 사진·파일·초안 본문 |
| Cache API | 대용량 | 비동기 | 네트워크 응답 캐시 |
핵심: 큰 본문(사진·바이너리)은 IndexedDB, 작은 플래그만 localStorage.
잘못된 방식: 본문을 localStorage에
// 큰 dataURL을 localStorage에 → 한도 초과 시 던지거나 다른 항목을 evict
try {
localStorage.setItem("photo:" + id, bigDataUrl); // 1~3MB
} catch (e) {
// QuotaExceededError — 근데 이미 다른 사진이 밀려났을 수도 있다
}
localStorage.setItem("photo:" + id + ":filled", "true"); // 본문 없이 플래그만 남는 사고
filled=true는 성공했는데 본문 저장은 실패 — 이 두 줄이 따로 노는 순간 거짓 체크가 된다.
정답: 큰 본문은 IndexedDB, 상태는 본문 존재로 판정
본문은 IndexedDB에 넣고(비동기), 상태는 플래그가 아니라 본문이 실제로 있는지로 계산한다.
function openDb_() {
return new Promise((resolve, reject) => {
const open = indexedDB.open("drafts", 1);
open.onupgradeneeded = () => open.result.createObjectStore("photos");
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
}
function idbPut_(key, blob) { // 본문 저장
return openDb_().then((db) => new Promise((res, rej) => {
const tx = db.transaction("photos", "readwrite");
tx.objectStore("photos").put(blob, key);
tx.oncomplete = () => res(true);
tx.onerror = () => rej(tx.error);
}));
}
function idbGet_(key) { // 본문 조회 (없으면 undefined)
return openDb_().then((db) => new Promise((res, rej) => {
const req = db.transaction("photos").objectStore("photos").get(key);
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
}));
}
// 상태는 '플래그'가 아니라 '본문이 실제로 있는가'로 판정한다
async function photoState_(key, meta) {
const body = await idbGet_(key);
if (body) return "filled";
if (meta && meta.filled) return "missing-body"; // 플래그만 남고 본문은 사라짐
return "empty";
}
열 때 정합성 검사 — 사라진 걸 사용자에게 알려라
앱을 열 때 플래그와 본문을 대조해서, 본문이 없는 항목을 missing-body로 뒤집는다.
// 앱 시작 시: 플래그는 있는데 본문이 없는 항목을 찾아 '다시 첨부'로 표시
async function reconcileDrafts_(metas) {
const missing = [];
for (const m of metas) {
if (m.filled && !(await idbGet_(m.key))) {
m.state = "missing-body";
missing.push(m.key);
}
}
if (missing.length) notifyUser_(missing.length + "개 사진을 다시 첨부해야 합니다");
return metas;
}
가능하면 용량을 미리 보고, 영구 저장을 요청해 eviction 확률을 낮춘다.
// 여유 확인 + 영구 저장 요청 (자동 삭제 대상에서 빠질 확률↑, 보장은 아님)
const { usage, quota } = await navigator.storage.estimate();
if (navigator.storage.persist) await navigator.storage.persist();
놓치기 쉬운 것
- 플래그와 데이터를 따로 믿지 마라. “저장됨”은 의도지 사실이 아니다.
missing-body같은 3번째 상태를 두고 본문 실재로 판정하라. - localStorage는 동기·소용량(~5MB). 큰 값을 넣으면 던지거나 조용히 실패한다. 사진·바이너리는 IndexedDB로.
- 거짓 성공을 UI에 그리지 마라. 본문 없는 “완료”는 초록 체크가 아니라 빨간 “다시 첨부”로.
- iOS Safari는 미방문 시 지운다. ITP상 일정 기간 안 열면 스크립트 저장소를 비운다. PWA일수록
persist()+ 열 때 정합성 검사가 필수다.
깊이: 초안은 두 저장소로 나눠라
오프라인 초안은 메타데이터·커서를 localStorage, 사진·바이너리 본문을 IndexedDB에 나눠 담는다. 서버 저장이 끝난 뒤에만 로컬 본문을 지우고, 그 전엔 본문 실재로 상태를 판정한다. 핵심 한 줄: “저장됨” 플래그가 아니라 데이터의 실재로 상태를 판정하라.
자주 묻는 질문
- localStorage에 사진을 저장했는데 왜 사라지나요?
- localStorage는 대략 5MB로 작습니다. 압축한 사진 한 장이 한도를 넘으면 브라우저가 QuotaExceededError를 던지거나 조용히 저장에 실패합니다. 사진·파일 같은 큰 본문은 IndexedDB에 저장하세요.
- PWA 초안을 다시 열면 왜 비어 있나요?
- 브라우저는 저장 공간이 부족하면 오리진 저장소를 통째로 비울 수 있습니다. iOS Safari는 일정 기간 미방문 시 데이터를 지우기도 합니다. 본문은 IndexedDB에 두고, 열 때마다 정합성을 검사해 사라진 항목을 사용자에게 알리세요.
- 데이터가 없는데 완료 체크가 뜨는 이유는?
- '저장됨' 플래그만 남고 실제 본문은 evict된 상태입니다. 플래그와 데이터를 따로 믿으면 이렇게 거짓 성공을 그립니다. 상태를 본문의 실재로 판정하세요.
- 브라우저가 내 데이터를 안 지우게 할 수 있나요?
- navigator.storage.persist()로 영구 저장을 요청하면 용량 압박 시 자동 삭제 대상에서 빠질 확률이 높아집니다. 다만 보장은 아니므로, 그래도 열 때 정합성 검사는 필요합니다.