브라우저 저장소 localStorage sessionStorage IndexedDB 비교

브라우저 저장소 localStorage sessionStorage IndexedDB 비교

한눈에 보기

localStorage는 origin 단위로 오래 유지되고 동기 API다. sessionStorage는 탭 세션에 묶인다. IndexedDB는 비동기 트랜잭션과 구조화된 큰 데이터를 지원한다.

목차

왜 이 문제가 생기는가

테마 설정, 작성 중인 글, 오프라인 상품 목록을 저장하려 할 때 모두 “브라우저에 저장하면 된다”고 생각하기 쉽다. 그러나 데이터의 수명과 크기, 읽는 빈도, 트랜잭션 필요 여부가 전혀 다르다. 익숙하다는 이유로 모든 상태를 localStorage에 넣으면 큰 JSON 직렬화가 메인 스레드를 막고, 오래된 캐시를 최신 데이터처럼 사용하는 문제가 생긴다.

저장소 선택은 API 취향이 아니라 다음 질문으로 시작해야 한다.

이 글의 예제에 관하여

메모 편집기와 상품 캐시는 저장소 특성을 설명하기 위한 가상의 예제다. 실제 서비스 데이터나 저장 코드를 복사하지 않았다.

세 저장소를 먼저 비교하기

기준 localStorage sessionStorage IndexedDB
기본 수명 명시적으로 지울 때까지 해당 탭의 페이지 세션 동안 명시적으로 지울 때까지
공유 범위 같은 origin의 문서 같은 origin이면서 같은 탭 세션 같은 origin의 문서
API 형태 동기 동기 비동기·이벤트 기반
문자열 문자열 구조화 복제 가능한 값
질의·인덱스 없음 없음 object store와 index 지원
트랜잭션 없음 없음 지원
적합한 예 테마, 작은 사용자 설정 다단계 입력의 탭별 진행 상태 오프라인 문서, 대량 캐시

브라우저나 사용자 설정에 따라 사용 가능한 용량과 제거 정책이 달라질 수 있으므로 “정확히 몇 MB까지” 같은 숫자를 설계의 고정 전제로 삼으면 안 된다. 쓰기 실패를 처리하고, 중요한 원본은 서버나 별도 영속 계층에 두어야 한다.

localStorage: 간단하지만 동기식이다

localStorage는 문자열 키와 값을 동기적으로 읽고 쓴다. 작은 설정에는 편리하다.

const THEME_KEY = "preferences.theme";

export function saveTheme(theme) {
  if (!new Set(["light", "dark", "system"]).has(theme)) {
    throw new Error("지원하지 않는 테마입니다.");
  }

  localStorage.setItem(THEME_KEY, theme);
}

export function loadTheme() {
  return localStorage.getItem(THEME_KEY) ?? "system";
}

문제는 API가 동기식이라는 점이다. 큰 객체를 직렬화하고 저장하는 동안 메인 스레드의 UI 작업이 지연될 수 있다.

// 좋지 않은 예: 입력할 때마다 전체 문서 목록을 직렬화한다.
editor.addEventListener("input", () => {
  const snapshot = collectEveryDocument();
  localStorage.setItem("documents", JSON.stringify(snapshot));
});

작은 초안 하나를 저장하더라도 입력마다 실행하지 말고 지연 저장하고, 실패를 처리해야 한다.

let saveTimer;

editor.addEventListener("input", () => {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(() => {
    try {
      const draft = {
        schemaVersion: 2,
        body: editor.value,
        savedAt: new Date().toISOString(),
      };

      localStorage.setItem("editor.draft", JSON.stringify(draft));
    } catch (error) {
      showSaveWarning("브라우저에 임시 저장하지 못했습니다.");
    }
  }, 500);
});

같은 origin의 다른 문서에서 값이 바뀌면 storage 이벤트로 변경을 감지할 수 있다. 변경을 일으킨 현재 문서 자체에는 같은 방식으로 이벤트가 오지 않는다는 점을 고려해, 현재 탭의 상태 갱신과 다른 탭 동기화를 별도로 설계한다.

window.addEventListener("storage", (event) => {
  if (event.key !== THEME_KEY) return;
  applyTheme(event.newValue ?? "system");
});

sessionStorage: 탭 단위의 임시 상태

sessionStorage의 API는 localStorage와 같지만 페이지 세션에 묶인다. 같은 사이트를 두 탭에서 열어도 각 탭이 별도의 진행 상태를 가져야 하는 다단계 폼 등에 어울린다.

const CHECKOUT_STEP_KEY = "checkout.current-step";

function moveToStep(step) {
  sessionStorage.setItem(CHECKOUT_STEP_KEY, String(step));
  renderStep(step);
}

function restoreStep() {
  const raw = sessionStorage.getItem(CHECKOUT_STEP_KEY);
  const step = Number(raw ?? 1);
  return Number.isInteger(step) && step > 0 ? step : 1;
}

다만 “탭을 닫으면 반드시 즉시 안전하게 삭제된다”는 보안 장치로 해석하면 안 된다. 세션 복원과 브라우저 동작에 따라 사용자가 예상한 것과 다른 수명이 보일 수 있고, JavaScript가 읽을 수 있다는 보안 특성은 localStorage와 같다.

IndexedDB: 구조화된 데이터를 비동기로 다루기

IndexedDB는 객체 저장소, 키, 인덱스, 트랜잭션을 제공한다. API가 장황하지만 많은 구조화 데이터와 오프라인 기능에 맞다. 아래는 메모 저장소를 여는 최소한의 예다.

function openNotesDatabase() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("notes-example", 2);

    request.onupgradeneeded = () => {
      const db = request.result;

      if (!db.objectStoreNames.contains("notes")) {
        const store = db.createObjectStore("notes", { keyPath: "id" });
        store.createIndex("by-updated-at", "updatedAt");
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

데이터 변경은 트랜잭션 범위 안에서 수행한다.

function putNote(db, note) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction("notes", "readwrite");
    const store = tx.objectStore("notes");

    store.put({
      id: note.id,
      title: note.title,
      body: note.body,
      updatedAt: new Date().toISOString(),
    });

    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error ?? new Error("저장이 취소되었습니다."));
  });
}
요청 성공과 트랜잭션 완료를 구분한다

개별 put 요청이 성공했더라도 전체 트랜잭션이 나중에 중단될 수 있다. 여러 쓰기의 완료를 보장하려면 트랜잭션의 complete를 기준으로 처리한다.

스키마 버전이 올라가면 onupgradeneeded에서 마이그레이션한다. 운영 데이터가 이미 존재하는 상황을 고려해 새 object store나 index를 조건부로 만들고, 업그레이드 실패 시 복구 전략도 정해야 한다.

어떤 저장소를 선택할까

flowchart TD
    A[브라우저에 저장할 데이터] --> B{구조화된 대량 데이터인가?}
    B -->|예| IDB[IndexedDB]
    B -->|아니오| C{탭마다 분리되어야 하는가?}
    C -->|예| SS[sessionStorage]
    C -->|아니오| D{작은 문자열 설정인가?}
    D -->|예| LS[localStorage]
    D -->|아니오| R[서버 또는 다른 저장 계층 재검토]

이 흐름도는 출발점일 뿐이다. 예를 들어 테마 설정은 localStorage에 어울리지만 서버 렌더링에서 첫 화면 깜빡임을 막으려면 쿠키나 서버 사용자 설정과 동기화할 수도 있다. 저장소는 데이터가 사용되는 전체 흐름 안에서 선택해야 한다.

클라이언트 저장소는 신뢰 경계가 아니다

브라우저 사용자는 개발자 도구로 저장 값을 바꿀 수 있고, 페이지에 XSS 취약점이 있으면 실행된 스크립트가 JavaScript 접근 가능한 저장소를 읽을 수 있다. 따라서 다음 원칙이 필요하다.

// UI 힌트일 뿐, 서버 권한 검사를 대체하지 않는다.
const preferredLayout = localStorage.getItem("dashboard.layout");

// 다음 값으로 관리자 API 접근을 허용하면 안 된다.
const claimedRole = localStorage.getItem("user.role");

캐시로 사용할 때 필요한 버전과 무효화

서버 데이터를 IndexedDB에 저장하면 그것은 원본이 아니라 캐시다. 캐시에 “무엇을 저장할까”만 있고 “언제 버릴까”가 없으면 오래된 데이터가 정상 데이터처럼 남는다.

const cachedCatalog = {
  schemaVersion: 3,
  fetchedAt: "2026-08-01T12:00:00.000Z",
  expiresAt: "2026-08-01T12:10:00.000Z",
  etag: '"catalog-v42"',
  items: [],
};

버전은 두 종류로 나눠 생각하면 좋다.

  1. 스키마 버전: 저장한 객체 구조가 코드와 호환되는가?
  2. 데이터 신선도: 서버 원본과 비교해 아직 사용할 수 있는가?

네트워크가 없을 때는 만료된 캐시를 “오프라인 데이터”라고 표시해 보여 줄 수 있다. 네트워크가 돌아오면 조건부 요청이나 동기화를 수행한다. 무조건 최신이라고 가장하는 것보다 데이터의 상태를 UI에 드러내는 편이 안전하다.

운영 전에 확인할 것

저장소 설계 점검

  • 쓰기 실패와 저장 공간 부족을 처리하는가?
  • JSON 파싱 실패나 구버전 데이터가 있어도 앱이 시작되는가?
  • 로그아웃할 때 제거해야 할 범위가 정해져 있는가?
  • 캐시 만료와 스키마 마이그레이션 정책이 있는가?
  • 브라우저 저장 값을 서버 권한 판단에 사용하지 않는가?
  • 다른 탭 및 오프라인 상태에서 동작을 시험했는가?

localStorage는 origin 단위로 오래 유지되고 동기 API다. sessionStorage는 탭 세션에 묶인다. IndexedDB는 비동기 트랜잭션과 구조화된 큰 데이터를 지원한다.

결론

작은 문자열 설정은 localStorage, 탭별 임시 상태는 sessionStorage, 대량의 구조화된 오프라인 데이터는 IndexedDB가 기본 출발점이다. 그러나 저장소 이름보다 중요한 것은 수명, 동기 처리 비용, 트랜잭션, 무효화, 보안 경계를 함께 정의하는 일이다. 브라우저 저장소는 편리한 저장 위치일 뿐 서버의 권한 검사나 중요한 원본을 대신하지 않는다.

관련 노트