자바스크립트 실전 #5 로컬 스토리지와 가벼운 상태 관리

#4 폼 다루기 에서 입력을 받아 보내는 흐름을 봤습니다. 이번 글에서는 데이터를 어디에 저장하고 여러 곳에서 어떻게 공유할지, 라이브러리 없이 갈 수 있는 데까지의 패턴들을 정리합니다.

Web Storage — localStorage / sessionStorage #

브라우저에 작은 데이터를 보관하는 표준 도구. 두 종류가 있습니다.

localStoragesessionStorage
수명영구 (직접 지우거나 사용자가 정리할 때까지)탭이 닫힐 때까지
공유같은 origin의 모든 탭같은 탭 안에서만
용량보통 5~10MB보통 5~10MB

기본 사용 #

localStorage 기본
localStorage.setItem('username', '커티스');
localStorage.getItem('username');        // '커티스'
localStorage.removeItem('username');
localStorage.clear();                     // 전체 삭제

// 키 개수 / 키 이름
localStorage.length;
localStorage.key(0);

sessionStorage도 정확히 같은 API. 차이는 수명뿐입니다.

항상 문자열 #

가장 큰 함정 — 값은 항상 문자열로 저장됩니다.

문자열 변환
localStorage.setItem('count', 42);
localStorage.getItem('count');      // '42'  (문자열!)
typeof localStorage.getItem('count');   // 'string'

객체나 배열을 저장하려면 중급 #7 JSON 에서 본 JSON 직렬화를 거쳐야 합니다.

객체 저장
const user = { id: 'u1', name: '커티스' };

localStorage.setItem('user', JSON.stringify(user));

const loaded = JSON.parse(localStorage.getItem('user'));
console.log(loaded.name);   // '커티스'

안전한 wrapper 함수 #

매번 JSON.parse/stringify를 적기 번거로워요. 작은 helper가 깔끔합니다.

storage wrapper
const storage = {
  get(key, fallback = null) {
    try {
      const raw = localStorage.getItem(key);
      return raw === null ? fallback : JSON.parse(raw);
    } catch {
      return fallback;
    }
  },
  set(key, value) {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch (err) {
      console.warn('storage set 실패:', err);
    }
  },
  remove(key) {
    localStorage.removeItem(key);
  },
};

// 사용처
storage.set('user', { id: 'u1', name: '커티스' });
const user = storage.get('user', { name: '익명' });
storage.remove('user');

이 정도만 있어도 JSON.parse 함정과 용량 초과 에러를 한 군데서 처리할 수 있습니다.

함정들 #

1) 용량 초과는 throw #

용량 함정
try {
  localStorage.setItem('big', new Array(10_000_000).join('x'));
} catch (err) {
  if (err.name === 'QuotaExceededError') {
    console.warn('저장소 가득 참');
  }
}

저장소 한도(보통 5~10MB)를 넘으면 throw 합니다. 작은 데이터에는 거의 안 만나지만, 캐시처럼 쌓이는 경우에는 의식해야 합니다.

2) 프라이빗/시크릿 모드 #

일부 브라우저는 시크릿 모드에서 localStorage를 막거나 용량을 0으로 만듭니다. setItem 자체가 throw 할 수 있습니다. wrapper가 그걸 흡수해 주는 게 좋습니다.

3) JSON으로 바꾸지 못하는 값 #

중급 #7 에서 본 JSON의 함정이 그대로 — undefined, 함수, Symbol, BigInt, 순환 참조. 저장하기 전에 변환 또는 직렬화 가능한 값만 다뤄야 합니다.

4) 다른 탭에서의 변경 — storage 이벤트 #

같은 origin의 다른 탭에서 storage가 바뀌면 이벤트가 발생합니다.

storage 이벤트
window.addEventListener('storage', (e) => {
  console.log(e.key, e.oldValue, e.newValue);
});

다른 탭에서 일어난 변경만 잡힙니다 — 자기 탭의 setItem은 이 이벤트를 발생시키지 않습니다. 멀티탭 동기화에서 표준 도구입니다.

더 큰 데이터 — IndexedDB #

localStorage가 5MB 한계가 있고 동기 API 라 큰 데이터에는 부적합. IndexedDB가 브라우저의 본격 데이터베이스 API 입니다.

IndexedDB 특징
- 용량: 보통 수백 MB ~ GB
- API: 비동기
- 구조: 키-값, 인덱스 가능
- 트랜잭션 지원

IndexedDB의 표준 API는 다소 복잡해서 직접 쓰면 코드가 깁니다. idb-keyval 같은 작은 wrapper가 흔한 선택입니다. 또는 더 풍부한 기능이 필요하면 dexie. 이 시리즈에서는 깊이 다루지 않지만, “localStorage가 모자랄 때 다음 단계가 IndexedDB” 라고 기억하세요.

Cookie — 옛 도구 #

쿠키도 데이터를 저장하지만 자동으로 모든 요청에 함께 전송되는 게 핵심 특징입니다.

localStorageCookie
자동 전송안 함매 요청에 자동
크기5~10MB4KB
자바스크립트 접근항상HttpOnly 쿠키는 불가
보안XSS 노출HttpOnly + Secure 권장

인증 토큰 같은 민감한 값은 HttpOnly 쿠키가 거의 정답입니다. 자바스크립트가 못 읽으니 XSS 공격이 토큰을 빼낼 수 없습니다.

자바스크립트가 직접 읽을 수 있는 쿠키도 있지만(document.cookie), API가 어색해서 보통 라이브러리(js-cookie)를 씁니다.

가벼운 상태 관리 — 라이브러리 없이 #

여러 곳에서 같은 데이터를 보고 변경할 때, 어떻게 동기화할지.

1) 단일 객체 + 이벤트 발행 #

간단한 store
function createStore(initialState) {
  let state = initialState;
  const listeners = new Set();

  return {
    get() {
      return state;
    },
    set(updater) {
      state = typeof updater === 'function' ? updater(state) : updater;
      listeners.forEach((fn) => fn(state));
    },
    subscribe(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
  };
}

// 사용처
const cart = createStore({ items: [] });

cart.subscribe((s) => {
  cartCount.textContent = s.items.length;
});

addBtn.addEventListener('click', () => {
  cart.set((prev) => ({ ...prev, items: [...prev.items, item] }));
});

고급 #1 클로저 의 패턴입니다. state 변수가 클로저로 살아남고, listeners 집합이 변경 알림을 보내요. Zustand 같은 라이브러리가 내부적으로 이런 모양을 더 정교화한 것입니다.

2) localStorage와 결합 — 영속 store #

영속 store
function createPersistedStore(key, initialState) {
  const stored = storage.get(key, initialState);
  const store = createStore(stored);

  store.subscribe((state) => {
    storage.set(key, state);
  });

  return store;
}

const settings = createPersistedStore('settings', {
  theme: 'light',
  lang: 'ko',
});

state가 바뀔 때마다 자동으로 localStorage에 저장. 다음 방문에도 그대로 복원됩니다.

디바운스 + 영속화 — 잦은 변경 다루기 #

state가 자주 바뀌면 매번 localStorage에 쓰는 비용이 커요. 디바운스로 합치는 게 좋습니다.

디바운스된 영속화
function createPersistedStore(key, initialState) {
  const stored = storage.get(key, initialState);
  const store = createStore(stored);

  const persist = debounce((state) => {
    storage.set(key, state);
  }, 200);

  store.subscribe(persist);

  return store;
}

200ms 동안 변경이 멈춘 후에야 실제로 저장. 폼 입력처럼 자주 바뀌는 경우에 적합합니다.

다른 탭과 동기화 #

같은 origin의 여러 탭이 한 store를 공유하려면 — storage 이벤트로 받아서 store 갱신.

멀티탭 동기화
function createSyncedStore(key, initialState) {
  const store = createPersistedStore(key, initialState);

  window.addEventListener('storage', (e) => {
    if (e.key !== key || e.newValue === null) return;
    try {
      const newState = JSON.parse(e.newValue);
      store.set(newState);
    } catch {
      // 잘못된 JSON 무시
    }
  });

  return store;
}

탭 A에서 카트에 항목을 추가하면 탭 B 에서도 즉시 반영됩니다. 작지만 인상적인 사용자 경험입니다.

브로드캐스트 채널 — 모던 탭 간 통신 #

storage 이벤트보다 명시적인 도구.

BroadcastChannel
const channel = new BroadcastChannel('app');

// 보내기
channel.postMessage({ type: 'cart-updated', items: [...] });

// 받기
channel.addEventListener('message', (e) => {
  console.log(e.data);
});

탭 간 메시징의 모던 정답입니다. localStorage에 의존하지 않고 직접 통신합니다.

Window에 직접 두지 마세요 #

안티패턴
window.appState = { ... };   // ✗ 어디서든 접근 가능
window.handleClick = () => { ... };

전역 객체에 직접 다는 건 옛 패턴입니다. 모듈 시스템(기초 #7) 이 등장한 뒤로 — export 한 store를 import 해 쓰는 게 표준입니다. 디버깅용 임시 노출 외에 글로벌은 피하세요.

마무리 #

이번 글에서 정리한 내용:

  • localStorage (영구) vs sessionStorage (탭 한정)
  • 값은 항상 문자열 — JSON 직렬화 필수
  • wrapper 함수로 parse/stringify와 에러를 한 군데서 처리
  • 용량 초과 / 시크릿 모드 / JSON 한계 / 다른 탭 변경 등의 함정
  • 큰 데이터는 IndexedDB
  • 토큰처럼 민감한 값은 HttpOnly 쿠키
  • 가벼운 store — 클로저 + 이벤트 listener
  • localStorage와 결합한 영속 store, 디바운스로 부담 줄이기
  • 멀티탭 동기화 — storage 이벤트 또는 BroadcastChannel

다음 글(#6 작은 앱 빌드)에서는 시리즈와 트랙 마지막으로, 이 모든 도구를 묶어 라이브러리 없이 작은 Todo 앱을 처음부터 끝까지 만듭니다.

X