About

Cookie · localStorage · sessionStorage — 질문 세 개면 고를 수 있다

같은 화면에서 폴더 트리는 Cookie, 스크롤 위치는 sessionStorage 로 갈린 이유

UI 상태를 어디에 둘지는 질문 세 개로 정한다. 서버가 읽나? → 첫 HTML 에 반영돼야 하나? → 탭을 닫아도 남아야 하나? 앞의 둘 중 하나라도 yes 면 Cookie, 셋째가 yes 면 localStorage, 다 no 면 sessionStorage 다. 이 글을 읽고 나면 「일단 localStorage」 대신 이유를 대고 고를 수 있다.

1. Does the server read it? sent with every request 2. Must the first HTML show it? otherwise it flickers (FOUC) 3. Keep it after the tab closes? survive a restart yesyesyesnonono Cookielogin session Cookiedark theme, open folders localStoragepreferences on this device sessionStoragelist scroll position Tokens, secretsHttpOnly Cookie. No tree.

토큰 · 비밀값은 이 트리를 타지 않는다. 무조건 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 에 편하게 물어봐도 된다. 우리도 이 기준에 오기까지 같은 화면을 몇 번 고쳤다.

핵심 포인트

1

서버가 그 값으로 분기하면 Cookie — 다른 선택지가 없다

2

HTML 속성 · CSS 로 표현되는 상태(테마, 열림/닫힘)는 Cookie 로 서버가 미리 그리게 한다

3

어차피 JS 로만 복원하는 값(스크롤 위치)은 Web Storage — 탭마다 다르면 sessionStorage

4

토큰은 HttpOnly Cookie. Web Storage 접근은 try/catch, 키는 화면 + ID 로 쪼갠다

사용 사례

다크모드 · 사이드바 접힘처럼 첫 화면에서 번쩍이면 안 되는 설정 목록 → 상세 → 뒤로가기에서 스크롤 위치 되돌리기
← Pretext — DOM 없이 텍스트 높이를 측정하는 순수 JS 레이아웃 라이브러리 event.target vs currentTarget — 아이콘 선 위를 누를 때만 터지던 버그 →