browser
브라우저 저장소와 캐시: Cookie, LocalStorage, IndexedDB, Service Worker, HTTP 캐시
저장소 선택은 보안, 성능, 오프라인 지원을 모두 결정합니다. "토큰을 어디에 저장하나요?"는 면접 단골 질문이며, HTTP 캐시와 Service Worker Cache를 이해해야 PWA를 제대로 구현할 수 있습니다.
학습 개요
탄생 배경
해결하려 했던 문제
HTTP는 무상태 프로토콜이라 서버는 이전 요청을 기억하지 못합니다. 사용자 상태(로그인, 설정, 장바구니)를 유지하려면 클라이언트 측 저장이 필요합니다. 또한 네트워크 요청을 캐싱하여 성능을 향상시키고, 오프라인에서도 동작하는 앱을 만들기 위해 다양한 저장소 메커니즘이 필요했습니다.
역사적 맥락
1994년 Cookie 등장 → 2009년 localStorage/sessionStorage (Web Storage API) → 2010년 IndexedDB 표준화 → 2014년 Service Worker 명세 → 2015년 Cache API (Service Worker와 함께) → 2018년 Chrome에서 Storage API 통합 → 2019년 SameSite Cookie 광범위 지원 → 2022년 CHIPS(Cookies Having Independent Partitioned State) 제안
이전에는 어떻게 했나
Flash의 Local Shared Objects (Flash Cookies), userData (IE 전용), sessionStorage 이전의 URL hash 기반 상태 저장 등이 초기 대안이었습니다.
멘탈 모델
동작 원리
[Cookie 동작 원리] 서버가 Set-Cookie 헤더로 설정, 브라우저가 이후 요청에 자동 포함. 주요 속성: - Domain: 어떤 도메인에 쿠키를 전송할지 - Path: 어떤 경로에 쿠키를 전송할지 - Secure: HTTPS에서만 전송 - HttpOnly: JavaScript에서 접근 불가 (document.cookie 차단) - SameSite: 크로스 사이트 요청 시 쿠키 전송 제어 Strict: 동일 사이트 요청에만 전송 Lax: 최상위 레벨 탐색의 안전한 메서드(GET)에는 허용 (기본값) None: 항상 전송 (Secure 필수) - Expires/Max-Age: 만료 시간. 없으면 세션 쿠키 (브라우저 종료 시 삭제) - Partitioned (CHIPS): 서드파티 쿠키의 대안. 사이트별 파티션으로 격리 [LocalStorage vs SessionStorage] LocalStorage: - 탭/창을 닫아도 유지 (영구 저장, 명시적 삭제 전까지) - 같은 Origin의 모든 탭/창이 공유 - 용량: 5~10MB - 동기적 API (메인 스레드 블로킹) - 사용: 사용자 설정, 다크모드, 언어 설정 SessionStorage: - 탭/창을 닫으면 삭제 - 같은 탭 내에서만 공유 (다른 탭과 격리) - 용량: 5~10MB - 사용: 폼 임시 저장, 탭 단위 상태 주의: 둘 다 동기적이고 메인 스레드에서 실행. 대용량 데이터 저장 시 성능 문제. [IndexedDB] 대용량 구조화 데이터를 저장하는 NoSQL 데이터베이스. 특징: - 비동기 API (메인 스레드 블로킹 없음) - 트랜잭션 지원 - 인덱스로 빠른 조회 - 수백 MB 저장 가능 - 객체(Object Store) 단위로 저장 (테이블 유사) - Web Worker에서도 사용 가능 사용 사례: 오프라인 앱의 대용량 데이터, 복잡한 쿼리가 필요한 캐시. Dexie.js 같은 래퍼 라이브러리로 복잡한 API를 단순화. [HTTP 캐시] Cache-Control 지시자: - max-age: 리소스가 신선한 상태로 유지되는 시간(초) - no-cache: 캐시를 사용하되, 매번 서버에 유효성 검증 (304 Not Modified 가능) - no-store: 캐시 자체를 하지 않음 - immutable: 만료 전까지 재검증 없이 사용 (정적 자산에 적합) - stale-while-revalidate: stale 캐시를 즉시 사용하면서 백그라운드에서 갱신 - public: 공유 캐시(CDN)에 저장 허용 - private: 브라우저 캐시만 허용 (CDN 저장 불가) 조건부 요청 (Conditional Request): ETag: 서버가 리소스의 버전을 나타내는 해시값. If-None-Match: ETag 값을 포함하여 재요청. 서버가 변경 없으면 304. Last-Modified: 서버가 최종 수정 시간을 반환. If-Modified-Since: 수정 시간 이후 변경 여부 확인. [Memory Cache vs Disk Cache] Memory Cache: 브라우저 메모리(RAM)에 저장. 매우 빠름. 브라우저 종료 시 삭제. 현재 탭에서 방금 로드된 리소스가 주로 여기에 저장. Disk Cache: 디스크에 저장. 브라우저 종료 후에도 유지. Memory Cache보다 느림. 설정 가능한 크기 제한 내에서 LRU(Least Recently Used) 방식으로 관리. DevTools Network 탭에서 "from memory cache", "from disk cache" 표시로 확인. [Service Worker와 Cache API] Service Worker: 브라우저와 네트워크 사이의 프록시. 별도 스레드에서 실행. Cache API: Service Worker가 HTTP 요청/응답을 캐시하는 저장소. 캐싱 전략: 1. Cache-First: 캐시 있으면 반환, 없으면 네트워크 요청 후 캐시 저장. 적합: 정적 자산, 오프라인 우선 앱. 2. Network-First: 네트워크 우선, 실패 시 캐시 폴백. 적합: 자주 업데이트되는 콘텐츠. 3. Stale-While-Revalidate: 캐시를 즉시 반환하면서 백그라운드에서 업데이트. 적합: 약간의 stale이 허용되는 콘텐츠. 4. Cache-Only: 캐시만 사용 (오프라인 전용). 5. Network-Only: 캐시 없이 항상 네트워크.
핵심 구성 요소
HttpOnly Cookie
XSS로부터 민감한 데이터 보호. JavaScript 접근 차단
SameSite Cookie
CSRF 방어를 위한 크로스 사이트 요청 시 쿠키 전송 제어
Cache-Control
HTTP 캐시 동작을 제어하는 핵심 헤더
ETag
리소스 버전 식별자. 조건부 요청으로 불필요한 데이터 전송 방지
Service Worker
네트워크 요청 가로채기. 오프라인 지원과 고급 캐싱 전략 구현
IndexedDB
대용량 구조화 데이터의 비동기 클라이언트 저장소
흐름 설명
[브라우저 캐시 조회 순서]
1. Service Worker Cache (있는 경우)
2. Memory Cache (현재 세션에서 로드된 리소스)
3. Disk Cache (이전 세션에서 캐시된 리소스)
4. 네트워크 요청 → 서버 응답
- 304 Not Modified: 캐시를 계속 사용
- 200 OK: 새 리소스 수신, 캐시 업데이트
[정적 자산 캐싱 전략]
HTML: Cache-Control: no-cache (매번 서버 확인, ETag로 304 가능)
JS/CSS (해시 포함): Cache-Control: max-age=31536000, immutable
(1년 캐시, 변경 시 파일명 해시가 달라짐)
이미지: Cache-Control: max-age=86400 (1일)
API 응답: Cache-Control: no-store 또는 짧은 max-age
코드 예제
// Service Worker - Stale-While-Revalidate 전략
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('v1').then(async (cache) => {
const cachedResponse = await cache.match(event.request);
// 캐시가 있으면 즉시 반환하면서 백그라운드에서 업데이트
const networkFetch = fetch(event.request).then((response) => {
if (response.ok) {
cache.put(event.request, response.clone());
}
return response;
});
return cachedResponse || networkFetch;
})
);
});
// IndexedDB 사용 (Dexie.js 래퍼)
import Dexie, { Table } from 'dexie';
interface Todo {
id?: number;
title: string;
completed: boolean;
}
class TodoDB extends Dexie {
todos!: Table<Todo>;
constructor() {
super('TodoDB');
this.version(1).stores({
todos: '++id, completed', // id: 자동 증가, completed: 인덱스
});
}
}
const db = new TodoDB();
// 비동기 CRUD
async function addTodo(title: string) {
await db.todos.add({ title, completed: false });
}
async function getIncompleteTodos() {
return await db.todos.where('completed').equals(0).toArray();
}
// HTTP 캐시 최적화 설정 예시
// Next.js에서 정적 자산 캐시 설정
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/_next/static/(.*)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable', // 1년, 불변
},
],
},
{
source: '/api/(.*)',
headers: [
{
key: 'Cache-Control',
value: 'no-store', // API는 캐시 없음
},
],
},
];
},
};
// 쿠키 vs localStorage 보안 비교
// 인증 토큰 저장 권장 방식:
// Access Token: 메모리 변수 (가장 안전, 새로고침 시 사라짐 → Refresh Token으로 복구)
let accessToken: string | null = null;
// Refresh Token: HttpOnly Cookie (XSS 불가, CSRF는 SameSite로 방어)
// 서버에서 Set-Cookie: refreshToken=...; HttpOnly; Secure; SameSite=Strict
// 절대 LocalStorage에 인증 토큰 저장 금지:
// localStorage.setItem('token', accessToken); // XSS에 취약
비교 분석
브라우저
vsLocalStorage
브라우저
vsIndexedDB
트레이드오프
이상적인 사용 사례