프로그램이 끝나면 메모리에 있던 변수는 전부 사라진다. 브라우저도 똑같아서 탭을 닫거나 새로고침을 하면 자바스크립트가 들고 있던 값이 날아간다. 그런데 로그인 상태나 사용자가 켜둔 다크 모드(어두운 화면 테마) 같은 건 창을 닫았다 다시 열어도 그대로여야 한다. 이런 걸 유지하려면 값을 브라우저 어딘가에 저장해야 한다. 브라우저가 내주는 저장 공간이 쿠키(cookie), 로컬스토리지(localStorage), 세션스토리지(sessionStorage) 이렇게 셋이다.
나는 다크 모드 토글을 처음 만들었을 때, 켜고 끄는 것까진 되는데 새로고침만 하면 도로 밝은 화면으로 돌아가는 걸로 항의를 잔뜩 받았다. 상태를 변수에만 담아뒀으니 리셋된 거다. 이 세 저장소가 각각 뭘 잘하고, 어디에 뭘 넣으면 큰일 나는지를 정리한다.
쿠키는 서버와 함께 다니는 쪽지다. 쿠키에 담은 값은 그 사이트로 요청을 보낼 때마다 자동으로 서버에 딸려 간다. 서버가 "이 사람 누구지"를 매번 알아보게 하려고 만든 물건이라 그렇다. 그래서 로그인 세션을 식별하는 값을 담는다. 자바스크립트에서는 document.cookie로 읽고 쓴다.
document.cookie = "theme=dark; max-age=31536000; path=/; SameSite=Lax";
이 한 줄이 쿠키 하나를 심는다. max-age는 유효 기간을 초 단위로 준 거고(31536000초는 1년이다), 이 값이 없으면 브라우저를 닫을 때 사라지는 세션 쿠키가 된다. SameSite=Lax는 다른 사이트발 요청에 쿠키를 함부로 안 붙이는 보안 설정, Secure는 HTTPS에서만 전송하라는 설정이다.
쿠키에는 치명적인 제약이 둘 있다. 크기가 하나에 대략 4KB(약 4천 글자)까지고, 그 값이 매 요청마다 서버로 날아간다는 거다. 이것저것 잔뜩 넣어두면 이미지 하나 받을 때도 데이터가 통째로 따라가 통신이 무거워진다. 그래서 쿠키에는 서버가 꼭 알아야 하는 작은 값만 담는 게 맞다.
쿠키 읽기는 문자열 파싱 노가다다. document.cookie를 읽으면 쿠키 하나가 예쁘게 오는 게 아니라, 모든 쿠키가 세미콜론으로 이어 붙은 한 덩어리 문자열("theme=dark; lang=ko; id=abc")이 온다. 원하는 값 하나를 꺼내려면 직접 잘라 써야 한다.
function getCookie(name) {
return document.cookie
.split("; ")
.find(row => row.startsWith(name + "="))
?.split("=")[1];
}
세미콜론과 공백으로 쪼개고, 내가 찾는 이름으로 시작하는 조각을 고르고, 등호 뒤를 떼어낸다. 찾는 쿠키가 없으면 뒤의 ?.(값이 없으면 멈추는 안전한 접근)가 undefined를 돌려준다. 쿠키 API가 이렇게 불친절해 실무에선 대개 이런 도우미 함수를 끼워 쓴다.
로컬스토리지는 브라우저에 남는 큰 서랍이다. 이건 순수하게 브라우저 안에만 남는 저장소라 서버로 안 날아가고, 용량도 사이트당 대략 5MB(약 5백만 글자)로 쿠키보다 천 배쯤 넉넉하다. 사용자가 직접 지우기 전까진 영구히 남는다. 다크 모드 설정, 최근 본 항목처럼 화면 쪽에서만 필요한 값에 제격이다. API도 사람답다.
localStorage.setItem("theme", "dark");
const theme = localStorage.getItem("theme"); // "dark"
localStorage.removeItem("theme");
localStorage.clear(); // 이 사이트 것 전부 삭제
setItem으로 넣고 getItem으로 꺼낸다. 하나 지울 땐 removeItem, 싹 비울 땐 clear다. 저장한 값은 같은 출처(도메인이 같은 곳)에서만 보여서 다른 사이트가 훔쳐볼 수 없다.
로컬스토리지에는 문자열만 들어간다. 여기가 첫 함정이다. 로컬스토리지는 값을 무조건 문자열로만 저장한다. 숫자를 넣어도 문자열로 바뀌고, 객체를 넣으면 더 황당한 일이 벌어진다.
const user = { id: 7, nick: "hong" };
localStorage.setItem("user", user);
localStorage.getItem("user"); // "[object Object]"
객체를 그냥 넣으면 문자열로 억지 변환되면서 [object Object]라는 쓸모없는 글자만 남는다. 나는 이걸 모르고 사용자 정보를 저장했다가 꺼냈더니 저 괴상한 문자열이 나와 한참 헤맸다. 객체나 배열은 JSON.stringify로 문자열로 바꿔 넣고, 꺼낼 땐 JSON.parse로 되살린다.
localStorage.setItem("user", JSON.stringify(user));
const saved = JSON.parse(localStorage.getItem("user"));
console.log(saved.nick); // "hong"
없는 키를 getItem하면 null이 온다. 저장된 값이 깨진 문자열이면 JSON.parse가 에러를 던지니, 파싱하는 부분은 try로 감싸 두는 편이 낫다.
세션스토리지는 탭 하나짜리 수명이다. 로컬스토리지와 API가 똑같은데 수명만 다른 게 세션스토리지다. 이 값은 이 탭 안에서만 살아 있다가 탭을 닫으면 사라지고, 같은 사이트를 새 탭으로 또 열면 그 탭은 못 본다. 여러 단계짜리 입력 폼처럼 그 탭에서만 잠깐 들고 있으면 되는 값에 좋다. 나는 결제 과정의 임시 값을 여기 뒀더니, 창을 닫는 순간 알아서 정리돼 따로 지울 코드를 안 짜도 됐다.
저장은 동기라 크면 화면이 멈춘다. 로컬스토리지와 세션스토리지는 동기(작업이 끝날 때까지 다음 줄로 못 넘어감) 방식이라, setItem이 끝날 때까지 브라우저의 다른 일이 멈춘다. 수 메가바이트짜리 데이터를 한 방에 넣으면 그동안 화면이 굳는다. 게다가 용량 한도를 넘겨 저장하려 하면 QuotaExceededError를 던지며 실패한다.
try {
localStorage.setItem("cache", bigString);
} catch (e) {
// QuotaExceededError: 용량 초과, 오래된 항목부터 비우기
}
그래서 큰 값은 try로 감싸고 초과하면 오래된 것부터 지운다. 로컬스토리지는 넉넉해 보여도 무한이 아니다.
탭 사이 동기화는 storage 이벤트로 한다. 어떤 탭에서 로컬스토리지 값이 바뀌면, 열려 있는 다른 탭들에서 storage 이벤트가 발생한다. 이걸로 여러 탭의 상태를 맞출 수 있다.
window.addEventListener("storage", (e) => {
if (e.key === "theme") applyTheme(e.newValue);
});
이 이벤트는 값을 바꾼 그 탭에는 안 오고 다른 탭들에만 온다. 나는 로그아웃을 이걸로 처리했다. 한 탭에서 로그아웃하면 다른 탭들도 이 신호를 받고 같이 로그아웃된다.
민감한 값은 로컬스토리지에 절대 넣지 마라. 이건 함정이 아니라 사고다. 로컬스토리지는 그 사이트에서 도는 모든 자바스크립트가 제한 없이 읽는다. 사이트에 악성 스크립트가 한 줄이라도 끼어들면(이런 공격을 XSS라 한다), 그 스크립트는 로컬스토리지의 로그인 토큰을 통째로 훔쳐 간다. 나도 인증 토큰을 여기 저장했다가 보안 점검에서 대차게 지적당했다.
그래서 로그인 토큰 같은 값은 HttpOnly 옵션을 건 쿠키에 담는다. 이 쿠키는 자바스크립트가 document.cookie로도 못 읽어서 악성 스크립트가 끼어들어도 못 훔친다. 로컬스토리지에는 훔쳐가도 별일 없는 화면 설정 정도만 둔다.
그래서 뭘 어디에 넣나. 서버가 알아야 하고 크기가 작은 값, 특히 로그인 세션은 쿠키(민감하면 HttpOnly)에 넣는다. 서버는 몰라도 되고 오래 남아야 하는 화면용 값은 로컬스토리지, 그 탭에서 잠깐 쓰다 버릴 값은 세션스토리지에 넣는다. 크고 복잡한 데이터는 IndexedDB(브라우저 내장 데이터베이스)를 봐야 하지만, 웬만한 상황은 이 셋이면 해결된다.
저장소는 종류를 고르는 것보다 무엇을 넣지 않을지가 더 중요하다. 서버로 갈 것과 안 갈 것, 오래 남길 것과 잠깐 쓸 것, 훔쳐가도 되는 것과 안 되는 것. 이 세 질문만 던지면 어디 넣을지는 저절로 정해진다.