localStorage란?
localStorage는 브라우저가 제공하는 key-value 저장소다. 네트워크도 없고 서버도 없다. 그냥 브라우저 안에 데이터를 넣어두는 서랍이다.
localStorage.setItem('theme', 'dark')
localStorage.getItem('theme') // 'dark'
localStorage.removeItem('theme')
localStorage는 어디에 저장되나
JavaScript 코드에서 setItem을 호출하면 브라우저는 이 순서로 처리한다.
JS 코드
→ 브라우저 스토리지 엔진 (key-value 테이블 유지)
→ OS 파일 시스템 (디스크에 영구 기록)
디스크까지 내려가기 때문에 탭을 닫아도, 창을 닫아도, 컴퓨터를 재시작해도 데이터가 남아 있다. 이것이 탭을 닫으면 사라지는 sessionStorage와 가장 큰 차이다. 브라우저별 실제 저장 위치는 다음과 같다.
| 브라우저 | 저장 엔진 | 경로 (macOS) |
|---|---|---|
| Chrome / Edge | LevelDB | ~/Library/Application Support/Google/Chrome/Default/Local Storage/leveldb/ |
| Firefox | SQLite | ~/Library/Application Support/Firefox/Profiles/xxx/webappsstore.sqlite |
| Safari | SQLite | ~/Library/WebKit/WebsiteData/LocalStorage/ |
Chrome이 LevelDB를 쓰는 이유는 쓰기 성능 때문이다.
LevelDB는 append-only 로그 구조라 setItem 호출이 잦아도 디스크 쓰기가 빠르다.
일반 저장 방식 vs append-only
일반 저장 방식 : 기존 내용을 찾아서 덮어쓰기
"cart" = "[{id:1}]" 저장돼 있음
setItem('cart', '[{id:1},{id:2}]') 호출
→ 디스크에서 "cart" 위치 찾기
→ 그 자리에 새 값 덮어쓰기
싹 다 지우고 다시 쓰는 과정을 거치기 때문에 느리다.
append-only 로그 구조: 무조건 뒤에 추가
"cart" = "[{id:1}]" 저장돼 있음
setItem('cart', '[{id:1},{id:2}]') 호출
→ 기존 내용 건드리지 않음
→ 그냥 맨 뒤에 "cart가 바뀜" 이라고 추가
기존 내용을 찾거나 지울 필요없이 뒤에 덧붙이기만 해서 빠르다.
오래된 값은 주기적으로 compaction 작업을 통해 쌓인 로그를 훑어보면서 최신 값만 남기고 정리한다.
출처(Origin) 격리
localStorage의 핵심 설계 원칙은 출처 격리다.
출처는 프로토콜 + 도메인 + 포트 세 가지를 합친 식별자다.
https://shop.example.com
프로토콜: https
도메인: shop.example.com
포트: 443 (https 기본값, 생략)
`https://shop.example.com` 의 JS가 `localStorage.getItem('cart')`를 호출하면,
`https://evil.com`의 저장 공간에는 절대 접근하지 못한다. 이것을 `Same-Origin Policy`라고 부른다.
쉽게 말해 다른 URL의 로컬 스토리지에는 접근 못한다는 뜻이다.
LevelDB 내부에서 격리가 구현되는 방식
Chrome은 모든 출처의 데이터를 하나의 LevelDB 인스턴스 안에 저장한다. 대신 key 자체에 출처를 prefix로 박아둔다.
_chrome.localstorage_ | https://shop.example.com/ | cart → "[{id:1}]"
_chrome.localstorage_ | https://evil.com/ | cart → "stolen?"
getItem('cart')를 호출하면 브라우저는 현재 탭의 출처를 앞에 붙여서 조회한다.
evil.com이 shop.example.com의 prefix를 직접 지정하는 것은 JS 레이어에서 원천 차단된다.
즉 별도의 메타테이블이 따로 있는 게 아니라, key prefix 자체가 출처 식별자 역할을 한다.
같은 출처의 탭끼리는 공유
같은 출처 안에서는 탭 1과 탭 2가 동일한 저장 공간을 공유한다. 탭 1에서 setItem하면 탭 2에서 즉시 getItem으로 읽을 수 있다.
한 탭이 데이터를 바꾸면 다른 탭에 storage 이벤트가 발생한다.
window.addEventListener('storage', e => {
console.log(e.key) // 변경된 key
console.log(e.newValue) // 새 값
console.log(e.oldValue) // 이전 값
})
쉽게 말해 같은 주소라면 탭이 여러개여도 같은 저장공간을 사용한다는 뜻이다.
실제 사이트들은 localStorage를 어떻게 쓰나
1. UI 설정 유지
가장 흔한 용도다. 새로고침해도 다크모드가 유지되는 게 이 덕분이다.
localStorage.setItem('theme', 'dark')
2. 장바구니 / 임시 상태
로그인 없이도 장바구니가 유지된다. 로그인하면 서버 DB와 병합하는 구조가 많다.
localStorage.setItem('cart', JSON.stringify([
{ id: 1, name: '상품A', qty: 2 }
]))
3. 최근 검색어
서버 요청 없이 브라우저에서 바로 보여주는 자동완성이 이 방식이다.
localStorage.setItem('recentSearch', JSON.stringify(['typescript', 'react']))
4. API 캐시
API 응답을 저장해뒀다가 다음 방문 때 서버 요청 전에 먼저 보여준다. 체감 속도가 빨라지는 효과가 있다.
localStorage.setItem('api_cache_user', JSON.stringify({
data: { name: '찰리' },
cachedAt: Date.now()
}))
인스타그램이나 유튜브 들어갈 때 피드가 일단 이전 내용으로 먼저 뜨고 잠깐 후에 새 내용으로 바뀌는 경험을 해보았을 것이다.
그게 바로 이 패턴이다. 빈 화면 대신 이전 데이터라도 먼저 보여주니까 체감 속도가 빨라진다. (like Skeleton UI)
5. A/B 테스트
서비스를 운영하다 보면 이런 고민이 생긴다.
"버튼을 파란색으로 바꾸면 구매율이 올라갈까?"
확신이 없으니까 전체 배포는 못 하겠고, 그렇다고 감으로 결정하기도 애매하다.
그래서 쓰는 방법이 A/B 테스트다.
사용자를 두 그룹으로 나눠서 각각 다른 UI를 보여주고, 실제 데이터로 비교하는 거다.
여기서 중요한 건 한 사람이 항상 같은 그룹에 있어야 한다는 거다.
새로고침할 때마다 파란 버튼이 됐다 빨간 버튼이 됐다 하면 측정 자체가 의미없어진다.
localStorage 없이 짜면 이렇게 된다.
const variant = Math.random() < 0.5 ? 'A' : 'B'
1번 방문 → B그룹 → 빨간 버튼
새로고침 → A그룹 → 파란 버튼 ← 바뀜
새로고침 → B그룹 → 빨간 버튼 ← 또 바뀜
같은 사람인데 매번 다른 그룹이다. 데이터가 오염된다.
localStorage에 그룹을 저장해두면 해결된다.
if (!localStorage.getItem('ab_variant')) {
const variant = Math.random() < 0.5 ? 'A' : 'B'
localStorage.setItem('ab_variant', variant)
}
const variant = localStorage.getItem('ab_variant')
if (variant === 'B') {
showNewButton()
}
1번 방문 → B그룹 배정 → localStorage에 'B' 저장
새로고침 → localStorage에서 'B' 읽음 → 빨간 버튼 (유지)
새로고침 → localStorage에서 'B' 읽음 → 빨간 버튼 (유지)
처음 방문 시 딱 한 번만 그룹을 배정하고, 이후에는 localStorage에서 꺼내 쓴다.
덕분에 "이 사람은 빨간 버튼을 쓰는 동안 구매했다"는 데이터가 정확해진다.
6. 비로그인 사용자 추적 ID
로그인하지 않은 사용자도 "이 브라우저에서 온 사람"으로 식별하기 위해 임의 ID를 발급해 저장한다. 광고 타겟팅이나 행동 분석에 쓰인다.
localStorage.setItem('web_id', '7603737740824610305')
공통점이 하나 있다. 서버에 굳이 저장할 필요 없고, 이 브라우저에서만 기억하면 되는 것들이다.
localStorage의 한계
동기(synchronous) API
localStorage는 비동기가 아니다. await 없이 바로 값을 반환한다.
편리한 대신 대용량 데이터를 읽고 쓰면 메인 스레드가 멈춰버린다.
MB 단위의 데이터나 자주 읽고 써야 하는 경우엔 비동기로 동작하는 IndexedDB를 써야 한다.
보안
localStorage 파일은 평문이다. 물리적으로 파일에 접근할 수 있으면 저장된 데이터를 그대로 읽을 수 있다.
# 터미널에서 실제로 읽히는 수준
strings 010802.ldb | grep "tistory"
→ theme
→ dark
브라우저 JS 입장에서는 출처별로 완전히 격리되지만, OS 파일 입장에서는 물리적으로 한 파일에 전부 모여 있다.
그래서 JWT 토큰 같은 인증 정보를 localStorage에 저장하는 것은 보안상 좋지 않다.
localStorage는 js로 접근이 가능하므로, 파일 하나가 노출되면 모든 사이트의 토큰이 한 번에 노출된다.
인증 토큰은 httpOnly 쿠키에 저장하는 것이 권장된다.
용량 제한
출처당 약 5MB다. 초과하면 QuotaExceededError(버퍼링 할당량 초과)가 발생한다.
localStorage vs 다른 저장소 비교
| localStorage | sessionStorage | 쿠키 | IndexedDB | |
|---|---|---|---|---|
| 수명 | 영구 | 탭 닫으면 삭제 | 만료일 설정 가능 | 영구 |
| 용량 | ~5MB | ~5MB | ~4KB | 수백 MB |
| 서버 전송 | 없음 | 없음 | 요청마다 자동 전송 | 없음 |
| API 방식 | 동기 | 동기 | 동기 | 비동기 |
| 탭 간 공유 | 같은 출처끼리 공유 | 탭마다 독립 | 같은 출처끼리 공유 | 같은 출처끼리 공유 |
정리
localStorage는 단순하다. `key-value` 한 쌍을 브라우저 디스크에 영구 저장하는 것이 전부다.
그 단순함 덕분에 UI 상태 유지부터 캐싱까지 광범위하게 쓰인다.
다만 동기 API라는 점, 평문 저장이라는 점, 5MB 용량 제한이라는 점은 언제나 염두에 둬야 한다.
정말 작은 단위의 데이터를 다룰 때는 localStorage만한게 없는 듯 하다.
'프론트엔드' 카테고리의 다른 글
| 내 손으로 만든 Toss Frontend Fundamentals 코치 (2) | 2026.05.25 |
|---|---|
| XHR부터 Ky까지 HTTP 클라이언트 라이브러리 파헤치기 (2) | 2026.04.24 |
| [Web API] Intersection Observer API (0) | 2026.04.11 |
| Network 탭으로 읽는 HTTP (0) | 2026.03.30 |
| 반응형 웹 (0) | 2025.08.14 |