Skip to main content
Advertisement

Web Storage & Cookie

브라우저는 클라이언트 측 데이터 저장을 위한 여러 메커니즘을 제공합니다. 각 방법의 특성을 이해하고 적절히 활용해야 합니다.


localStorage vs sessionStorage 차이

두 API는 동일한 인터페이스를 공유하지만 수명(lifetime)이 다릅니다.

특성localStoragesessionStorage
수명명시적으로 삭제할 때까지탭/창 닫으면 삭제
범위같은 출처(origin)의 모든 탭현재 탭만
용량5~10MB5~10MB
서버 전송없음없음
접근동기 API동기 API
// localStorage: 브라우저를 닫아도 유지
localStorage.setItem('theme', 'dark');
localStorage.getItem('theme'); // 'dark'
localStorage.removeItem('theme');
localStorage.clear(); // 모든 항목 삭제
localStorage.length; // 저장된 항목 수
localStorage.key(0); // 인덱스로 키 접근

// sessionStorage: 탭을 닫으면 삭제
sessionStorage.setItem('tempData', 'value');
sessionStorage.getItem('tempData');

// 모든 항목 순회
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
console.log(key, value);
}

// Object.keys를 사용한 대안
Object.keys(localStorage).forEach(key => {
console.log(key, localStorage.getItem(key));
});

// storage 이벤트: 다른 탭에서 변경 감지 (localStorage만)
window.addEventListener('storage', (e) => {
console.log('키:', e.key);
console.log('이전 값:', e.oldValue);
console.log('새 값:', e.newValue);
console.log('출처:', e.url);
console.log('스토리지:', e.storageArea); // localStorage 객체
});

JSON 직렬화/역직렬화 패턴

localStorage는 문자열만 저장하므로 객체와 배열은 JSON으로 변환해야 합니다.

// 기본 패턴
const user = { name: 'Alice', age: 30, roles: ['admin', 'user'] };

// 저장
localStorage.setItem('user', JSON.stringify(user));

// 읽기
const stored = localStorage.getItem('user');
const restoredUser = stored ? JSON.parse(stored) : null;

// 안전한 읽기 헬퍼
function getStorageItem(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item !== null ? JSON.parse(item) : defaultValue;
} catch (err) {
console.error(`스토리지 읽기 실패 (${key}):`, err);
return defaultValue;
}
}

function setStorageItem(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (err) {
// QuotaExceededError 처리
if (err.name === 'QuotaExceededError') {
console.error('스토리지 용량 초과');
}
return false;
}
}

// 사용
setStorageItem('settings', { theme: 'dark', fontSize: 16 });
const settings = getStorageItem('settings', { theme: 'light', fontSize: 14 });

// 만료 시간 포함 저장
function setWithExpiry(key, value, ttl) {
const item = {
value,
expiry: Date.now() + ttl,
};
localStorage.setItem(key, JSON.stringify(item));
}

function getWithExpiry(key) {
const itemStr = localStorage.getItem(key);
if (!itemStr) return null;

const item = JSON.parse(itemStr);
if (Date.now() > item.expiry) {
localStorage.removeItem(key);
return null;
}
return item.value;
}

// 사용 (1시간 TTL)
setWithExpiry('accessToken', 'abc123', 60 * 60 * 1000);
const token = getWithExpiry('accessToken'); // 만료 시 null

IndexedDB 기초

IndexedDB는 브라우저 내장 NoSQL 데이터베이스입니다. 대용량 구조화 데이터를 저장할 수 있으며 비동기로 동작합니다.

특성localStorageIndexedDB
용량5~10MB수백MB~GB
데이터 타입문자열만거의 모든 타입
쿼리키만인덱스, 범위 쿼리
트랜잭션없음지원
API동기비동기
// IndexedDB Promise 래퍼 (기본 사용)
function openDB(name, version, { upgrade } = {}) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, version);

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

// 데이터베이스 구조 변경 시 실행
request.onupgradeneeded = (event) => {
const db = event.target.result;
upgrade?.(db, event.oldVersion, event.newVersion);
};
});
}

// DB 열기 및 스토어 생성
const db = await openDB('my-app', 1, {
upgrade(db) {
// ObjectStore 생성 (테이블과 유사)
if (!db.objectStoreNames.contains('users')) {
const store = db.createObjectStore('users', {
keyPath: 'id', // 고유 키 필드
autoIncrement: false // 자동 증가 여부
});

// 인덱스 생성 (빠른 검색용)
store.createIndex('email', 'email', { unique: true });
store.createIndex('name', 'name', { unique: false });
}

if (!db.objectStoreNames.contains('posts')) {
const postsStore = db.createObjectStore('posts', {
keyPath: 'id',
autoIncrement: true
});
postsStore.createIndex('userId', 'userId', { unique: false });
postsStore.createIndex('createdAt', 'createdAt', { unique: false });
}
}
});

// 데이터 조작 헬퍼
function transaction(db, storeNames, mode = 'readonly') {
const tx = db.transaction(storeNames, mode);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(new Error('트랜잭션 중단됨'));
});
}

function idbRequest(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}

// CRUD 작업
async function addUser(db, user) {
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
await idbRequest(store.add(user));
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}

async function getUser(db, id) {
const tx = db.transaction('users', 'readonly');
const store = tx.objectStore('users');
return idbRequest(store.get(id));
}

async function updateUser(db, user) {
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
await idbRequest(store.put(user)); // put: 있으면 업데이트, 없으면 추가
}

async function deleteUser(db, id) {
const tx = db.transaction('users', 'readwrite');
const store = tx.objectStore('users');
await idbRequest(store.delete(id));
}

// 인덱스로 검색
async function getUserByEmail(db, email) {
const tx = db.transaction('users', 'readonly');
const store = tx.objectStore('users');
const index = store.index('email');
return idbRequest(index.get(email));
}

// 커서로 전체 데이터 순회
async function getAllUsers(db) {
const tx = db.transaction('users', 'readonly');
const store = tx.objectStore('users');
return idbRequest(store.getAll());
}

// 사용 권장: idb 라이브러리 (Jake Archibald 개발)
// import { openDB } from 'idb';
// 더 깔끔한 API 제공

쿠키는 서버가 클라이언트에 저장하는 소량의 데이터입니다. HTTP 요청마다 서버로 자동 전송됩니다.

// 기본 쿠키 설정
document.cookie = 'username=Alice';

// 여러 속성 포함
document.cookie = 'sessionId=abc123; Path=/; Secure; SameSite=Strict; Max-Age=3600';

// 쿠키 속성 설명:
// Path : 쿠키가 전송될 경로 (기본: 현재 경로)
// Domain : 쿠키가 전송될 도메인 (기본: 현재 도메인)
// Max-Age : 만료까지 초 단위 (Max-Age=0 → 즉시 삭제)
// Expires : 만료 날짜/시간 (Max-Age 우선)
// Secure : HTTPS에서만 전송
// HttpOnly : JavaScript에서 접근 불가 (서버만 설정 가능)
// SameSite : CSRF 방지 (Strict, Lax, None)

// 쿠키 읽기 (모든 쿠키를 문자열로 반환)
console.log(document.cookie);
// 'username=Alice; theme=dark; language=ko'

// 쿠키 파싱 헬퍼
function parseCookies() {
return Object.fromEntries(
document.cookie
.split('; ')
.filter(Boolean)
.map(cookie => {
const [key, ...rest] = cookie.split('=');
return [key.trim(), decodeURIComponent(rest.join('='))];
})
);
}

function getCookie(name) {
const cookies = parseCookies();
return cookies[name] ?? null;
}

// 쿠키 설정 헬퍼
function setCookie(name, value, options = {}) {
const {
maxAge,
expires,
path = '/',
domain,
secure = true,
sameSite = 'Lax'
} = options;

let cookieStr = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;

if (maxAge !== undefined) cookieStr += `; Max-Age=${maxAge}`;
if (expires) cookieStr += `; Expires=${expires instanceof Date ? expires.toUTCString() : expires}`;
if (path) cookieStr += `; Path=${path}`;
if (domain) cookieStr += `; Domain=${domain}`;
if (secure) cookieStr += '; Secure';
cookieStr += `; SameSite=${sameSite}`;

document.cookie = cookieStr;
}

// 쿠키 삭제
function deleteCookie(name, path = '/') {
document.cookie = `${name}=; Max-Age=0; Path=${path}`;
}

// 사용
setCookie('theme', 'dark', { maxAge: 365 * 24 * 60 * 60 }); // 1년
getCookie('theme'); // 'dark'
deleteCookie('theme');

SameSite 속성 상세 설명

// SameSite=Strict: 같은 사이트 요청에서만 전송
// - 외부 사이트에서 링크 클릭 시에도 쿠키 전송 안 함
// - 가장 강력한 CSRF 보호
// - 인증 쿠키에 적합 (세션 하이재킹 방지)

// SameSite=Lax (기본값): 같은 사이트 + 외부에서의 GET 네비게이션
// - 외부 링크 클릭으로 이동 시 전송 가능
// - POST 요청 등은 차단
// - 대부분의 상황에서 균형 잡힌 선택

// SameSite=None: 모든 요청에서 전송
// - Secure 속성 필수
// - 크로스 사이트 인증에 필요 (OAuth, 결제 등)
// - CSRF 위험 있음 → CSRF 토큰 병행 사용 권장

setCookie('session', token, {
sameSite: 'Strict',
secure: true,
httpOnly: true // 서버에서만 설정 가능
});

HttpOnly와 Secure 속성

// HttpOnly: JavaScript에서 접근 불가
// - document.cookie로 읽을 수 없음
// - XSS 공격으로부터 쿠키 보호
// - 서버 측에서만 설정 가능 (Set-Cookie 헤더)

// 서버 응답 헤더:
// Set-Cookie: sessionId=abc123; HttpOnly; Secure; SameSite=Strict; Path=/

// Secure: HTTPS에서만 전송
// - HTTP로 전송 시 쿠키 포함 안 됨
// - 중간자 공격 방지
// - 개발 환경(localhost)에서는 없어도 동작

// 클라이언트에서 HttpOnly 쿠키는 없어 보임
console.log(document.cookie); // HttpOnly 쿠키는 여기 없음

스토리지 한계 및 보안 주의사항

용량 한계

// 스토리지 사용량 확인 (Storage API)
const estimate = await navigator.storage.estimate();
console.log(`사용 중: ${(estimate.usage / 1024 / 1024).toFixed(2)}MB`);
console.log(`할당량: ${(estimate.quota / 1024 / 1024).toFixed(2)}MB`);

// 영구 스토리지 요청
async function requestPersistentStorage() {
if (navigator.storage && navigator.storage.persist) {
const isPersisted = await navigator.storage.persist();
console.log(isPersisted ? '영구 저장소 획득' : '임시 저장소 사용 중');
}
}

// QuotaExceededError 처리
function safeSetItem(key, value) {
try {
localStorage.setItem(key, value);
} catch (e) {
if (e.name === 'QuotaExceededError') {
// 오래된 항목 삭제 또는 사용자에게 알림
clearOldEntries();
try {
localStorage.setItem(key, value);
} catch {
console.error('저장 실패: 스토리지가 가득 찼습니다');
}
}
}
}

보안 주의사항

// 1. 민감한 정보 저장 금지
// localStorage/sessionStorage에 저장하면 안 되는 것:
// - 비밀번호
// - 신용카드 정보
// - 개인식별정보 (주민번호 등)
// - 비공개 API 키

// 나쁜 예
localStorage.setItem('password', 'user_password'); // 절대 금지!
localStorage.setItem('creditCard', '1234-5678-...'); // 절대 금지!

// JWT 토큰 저장 위치 논쟁:
// localStorage: XSS 취약, CSRF 안전
// HttpOnly Cookie: XSS 안전, CSRF 취약 (SameSite로 완화)
// 권장: HttpOnly Cookie + SameSite=Strict/Lax + CSRF 토큰

// 2. XSS 방지
// localStorage 데이터를 DOM에 삽입 시 이스케이프 필수
const stored = localStorage.getItem('userContent');
element.textContent = stored; // 안전 (HTML로 파싱 안 됨)
// element.innerHTML = stored; // 위험! XSS 가능

// 3. 동일 출처 정책
// localStorage는 출처(프로토콜 + 호스트 + 포트)별로 격리됨
// https://example.com vs http://example.com → 다른 스토리지
// https://example.com vs https://sub.example.com → 다른 스토리지

// 4. 서드파티 접근
// iframe이나 다른 스크립트가 같은 출처면 접근 가능
// → 신뢰할 수 없는 스크립트 로드 주의

// 5. 프라이버시 모드
// 일부 브라우저에서 프라이버시 모드 시 스토리지 접근 제한 또는 세션 종료 시 삭제
function isStorageAvailable(type) {
let storage;
try {
storage = window[type];
const testKey = '__storage_test__';
storage.setItem(testKey, testKey);
storage.removeItem(testKey);
return true;
} catch (e) {
return e instanceof DOMException && (
e.code === 22 ||
e.code === 1014 ||
e.name === 'QuotaExceededError' ||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED'
) && storage?.length !== 0;
}
}

실전 예제: 상태 영속화

// 폼 데이터 자동 저장 (임시 저장)
class FormAutoSave {
#form;
#storageKey;
#saveTimer;
#debounceDelay;

constructor(form, { storageKey, debounceDelay = 500 } = {}) {
this.#form = form;
this.#storageKey = storageKey ?? `form-autosave-${form.id}`;
this.#debounceDelay = debounceDelay;
this.#restore();
this.#bindEvents();
}

#save() {
clearTimeout(this.#saveTimer);
this.#saveTimer = setTimeout(() => {
const data = Object.fromEntries(new FormData(this.#form));
sessionStorage.setItem(this.#storageKey, JSON.stringify(data));
}, this.#debounceDelay);
}

#restore() {
const saved = sessionStorage.getItem(this.#storageKey);
if (!saved) return;

const data = JSON.parse(saved);
Object.entries(data).forEach(([name, value]) => {
const field = this.#form.elements[name];
if (field) field.value = value;
});
}

#bindEvents() {
this.#form.addEventListener('input', () => this.#save());
this.#form.addEventListener('submit', () => {
sessionStorage.removeItem(this.#storageKey);
});
}

clear() {
sessionStorage.removeItem(this.#storageKey);
}
}

// 사용
const form = document.querySelector('#post-form');
const autoSave = new FormAutoSave(form, { storageKey: 'new-post-draft' });

// 사용자 설정 영속화
class UserSettings {
static #key = 'user-settings';
static #defaults = {
theme: 'system',
language: 'ko',
fontSize: 16,
notifications: true,
compactMode: false
};

static get() {
try {
const stored = localStorage.getItem(this.#key);
return stored
? { ...this.#defaults, ...JSON.parse(stored) }
: { ...this.#defaults };
} catch {
return { ...this.#defaults };
}
}

static set(updates) {
const current = this.get();
const updated = { ...current, ...updates };
localStorage.setItem(this.#key, JSON.stringify(updated));
window.dispatchEvent(new CustomEvent('settings:changed', { detail: updated }));
return updated;
}

static reset() {
localStorage.removeItem(this.#key);
window.dispatchEvent(new CustomEvent('settings:changed', { detail: this.#defaults }));
}
}

// 사용
UserSettings.set({ theme: 'dark' });
const { theme, language } = UserSettings.get();

고수 팁

1. 스토리지 추상화 레이어

// 환경에 따라 스토리지 전략 변경
class Storage {
static create(strategy = 'local') {
const strategies = {
local: new LocalStorageStrategy(),
session: new SessionStorageStrategy(),
memory: new MemoryStorageStrategy(), // SSR/테스트용
indexedDB: new IndexedDBStrategy() // 대용량
};
return strategies[strategy];
}
}

2. 스토리지 암호화 (민감하지 않은 데이터에 한해)

// SubtleCrypto로 AES-GCM 암호화
async function encryptData(data, key) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(JSON.stringify(data));
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);

return {
iv: Array.from(iv),
data: Array.from(new Uint8Array(encrypted))
};
}

3. 스토리지 이벤트로 탭 간 상태 동기화

// 한 탭에서 변경 → 다른 탭에서 감지 (localStorage만)
window.addEventListener('storage', (e) => {
if (e.key === 'cart') {
const cart = JSON.parse(e.newValue ?? '[]');
updateCartUI(cart);
}
if (e.key === 'logout-signal') {
// 모든 탭 로그아웃
window.location.href = '/login';
}
});

// 로그아웃 신호 전송
function logoutAllTabs() {
localStorage.setItem('logout-signal', Date.now().toString());
localStorage.removeItem('logout-signal');
}
Advertisement