Cookie · localStorage · sessionStorage — 질문 세 개면 고를 수 있다
같은 화면에서 폴더 트리는 Cookie, 스크롤 위치는 sessionStorage 로 갈린 이유
UI 상태를 어디에 둘지는 질문 세 개로 정한다. 서버가 읽나? → 첫 HTML 에 반영돼야 하나? → 탭을 닫아도 남아야 하나? 앞의 둘 중 하나라도 yes 면 Cookie, 셋째가 yes 면 localStorage, 다 no 면 sessionStorage 다. 이 글을 읽고 나면 「일단 localStorage」 대신 이유를 대고 고를 수 있다.
토큰 · 비밀값은 이 트리를 타지 않는다. 무조건 HttpOnly Cookie 다. 이유는 아래 보안 얘기에서.
같은 화면인데 둘로 갈렸다
최근 한 앱에서 사이드바 폴더 트리의 열림/닫힘과 목록 스크롤 위치를 둘 다 기억하게 만들었다. 처음엔 「둘 다 UI 상태니까 같은 데 넣으면 되겠지」 싶었는데, 따져 보니 답이 달랐다.
폴더 트리 → Cookie. 열림 상태는 <details open> 속성 하나로 표현된다. 서버가 그 값을 알면 처음부터 열린 채로 그린다. localStorage 에 두면 서버는 모르니까 전부 닫힌 채 그려졌다가 JS 가 뒤늦게 펼친다. 이 번쩍임이 FOUC(Flash of Unstyled Content, 스타일이 덜 입혀진 화면이 잠깐 보이는 현상)다.
스크롤 위치 → sessionStorage. scrollTop 은 HTML 속성이나 CSS 로 만들 수 있는 값이 아니다. 서버가 알아도 어차피 JS 가 넣어야 한다. 그러면 Cookie 로 얻는 게 하나도 없고, 이미지 · CSS · API 요청마다 그 값이 실려 나가는 비용만 남는다. 탭마다 다른 게 자연스럽고, 털려도 의미 없는 값이기도 하다.
갈림길은 결국 「CSS 나 HTML 속성으로 표현할 수 있는 상태인가」 였다. 표현할 수 있으면 서버가 미리 그릴 수 있으니 Cookie 가 이득이다.
여섯 가지 축을 표로
| 축 | Cookie | localStorage | sessionStorage |
|---|---|---|---|
| 서버로 전송 | 매 요청 자동 | 안 됨 | 안 됨 |
| 첫 렌더 반영 | 가능 | 불가 (JS 뒤) | 불가 (JS 뒤) |
| 수명 | Max-Age 로 지정 |
지울 때까지 | 탭을 닫을 때까지 |
| 공유 범위 | 도메인 + 경로, 서브도메인 공유 가능 | origin 의 모든 탭 | origin + 탭 하나 |
| 용량 | 한 개 4KB 안팎 | origin 당 5MB 안팎 | origin 당 5MB 안팎 |
| JS 에서 못 읽게 | HttpOnly 가능 |
불가 | 불가 |
용량은 브라우저마다 조금씩 다르다. 숫자는 MDN 기준의 대략값이다.
localStorage 의 「지울 때까지」 는 생각보다 길다. 몇 달 전 상태가 되살아나서 「이거 왜 이래?」 하는 버그가 여기서 나온다. 되살아나면 곤란한 값은 넣지 않는다.
보안 — JWT 를 localStorage 에 두면 안 되는 이유
Web Storage 는 페이지의 JS 라면 누구나 읽는다. XSS 가 한 번 뚫리면 거기 든 토큰은 그대로 나간다. Cookie 는 HttpOnly 를 붙이면 JS 가 아예 못 읽고, Secure · SameSite 까지 붙일 수 있다. 이건 Cookie 만 된다. 그래서 「localStorage 에 JWT」 는 피한다.
반대로 스크롤 위치처럼 털려도 아무 의미 없는 값이면 이 축은 신경 쓰지 않아도 된다.
쓰다가 밟은 것들
sessionStorage 는 예외를 던질 수 있다. 용량이 넘치거나 스토리지가 막힌 환경에서 setItem 이 throw 한다. 우리는 이걸 requestAnimationFrame 콜백 안에서 불러서, 스크롤할 때마다 예외가 쌓였다. 읽기 · 쓰기 둘 다 감싼다.
const key = `scroll:contracts:${listId}` // 화면 + 대상 ID 로 키를 쪼갠다
function readScroll() {
try { return Number(sessionStorage.getItem(key)) || 0 } catch { return 0 }
}
function saveScroll(y) {
try { sessionStorage.setItem(key, String(y)) } catch {}
}
키를 scroll 하나로 뭉치면 화면 A 의 값이 화면 B 로 샌다. 화면 이름과 대상 ID 를 키에 넣으면 그 사고가 구조적으로 막힌다.
「탭을 닫으면 사라진다」 도 완전히 맞는 말은 아니다. 닫은 탭을 복구(Cmd+Shift+T)하거나 탭을 복제하면 sessionStorage 도 같이 따라온다.
Cookie 쪽은 정적 파일 요청에도 실린다는 걸 잊기 쉽다. 페이지 하나에 요청이 50개면 50번 실린다. UI 잔상태를 Cookie 에 줄줄이 넣으면 4KB 한도도 금방 찬다.
값이 수 MB 거나 구조가 있는 데이터면 셋 다 아니고 IndexedDB 로 간다. Web Storage API 는 동기라서 큰 값을 읽고 쓰는 동안 메인 스레드가 멈춘다.
Rails · Hotwire 화면에서 이런 상태 저장을 정리하다 막혔다면 negabaro 에 편하게 물어봐도 된다. 우리도 이 기준에 오기까지 같은 화면을 몇 번 고쳤다.
핵심 포인트
서버가 그 값으로 분기하면 Cookie — 다른 선택지가 없다
HTML 속성 · CSS 로 표현되는 상태(테마, 열림/닫힘)는 Cookie 로 서버가 미리 그리게 한다
어차피 JS 로만 복원하는 값(스크롤 위치)은 Web Storage — 탭마다 다르면 sessionStorage
토큰은 HttpOnly Cookie. Web Storage 접근은 try/catch, 키는 화면 + ID 로 쪼갠다