즐겨찾기를 Cookie로 저장했다가 localStorage로 옮긴 이유
며칠 만에 사라진 즐겨찾기를 계기로 Cookie와 localStorage의 차이를 정리했다. 탭 사이 동기화 문제와, 코드를 다시 열어보고 확인한 실제 저장 방식까지 적었다.
#프론트엔드 #localStorage #브라우저
며칠 만에 코인 사이트에 들어갔더니 즐겨찾기 해둔 코인들이 싹 사라져 있었다. 분명 별표를 눌러놨는데 목록이 텅 비어 있는 거다. 내가 만든 사이트라 원인을 찾을 수 있었지, 사용자였으면 "내가 저장한 게 왜 없어졌지?" 하고 그냥 나갔을 거다. 이 사이트는 로그인 없이도 쓰게 만드는 게 목표라, 로그인 안 한 사용자의 즐겨찾기를 서버가 아니라 브라우저에 저장해야 했다. 처음에 고른 게 Cookie였고, 그게 문제였다. 이 글에서는 Cookie가 왜 안 맞았는지, localStorage는 뭐가 다른지, 그리고 옮기고 나서 새로 생긴 문제까지 정리해본다. Cookie는 결국 사라진다 Cookie에는 만료 시간이 있다. expires나 max-age를 지정하면 그때 사라지고, 아무것도 안 지정하면 브라우저를 닫을 때 사라진다. 처음 짠 코드가 만료를 따로 안 줬으니 세션 쿠키였던 셈이고, 브라우저를 완전히 닫았다 열면 즐겨찾기가 비는 게 당연했다. 만료를 길게 잡으면 되긴 한다. 근데 그래도 결국 "언젠가는 사라지는" 데이터라는 게 찜찜했다. 즐겨찾기는 사용자가 지우기 전까지 계속 남아 있어야 하는 정보인데, 저장소 쪽에서 유통기한을 갖고 있는 게 성격에 안 맞았다. 만료 말고도 안 맞는 게 더 있었다 제일 아까운 건 요청마다 서버로 따라간다는 점이다. Cookie는 모든 HTTP 요청 헤더에 자동으로 붙는다. 서버는 즐겨찾기 정보가 전혀 필요 없는데도 시세를 받아올 때마다, 이미지를 받을 때마다 같이 전송된다. 이 사이트는 시세 요청이 잦아서 더 그렇다. 용량도 작다. Cookie는 보통 4KB가 한계라 마켓 코드를 JSON 배열로 넣어두면 즐겨찾기가 백 개쯤만 돼도 아슬아슬하다. 그리고 다루기가 번거롭다. 문자열을 파싱하고, 인코딩하고, 만료를 계산해서 붙이는 코드가 저장 하나에 줄줄이 붙는다. 실제로 지금 쿠키 저장 코드는 이렇다. // utils/cookies.ts const FAVORITES_COOKIE_NAME = 'coin_favorites'; export const saveFavoritesToCookie = (favorites: string[]): void = { const encoded = encodeURIComponent(JSON.stringify(favorites)); const expires = new Date(); expires.setFullYear(expires.getFullYear() + 1); // 1년 후 만료 document.cookie = `${FAVORITES_COOKIE_NAME}=${encoded}; expires=${expires.toUTCString()}; path=/`; }; export const getFavoritesFromCookie = (): string[] = { const cookies = document.cookie.split(';'); const favoriteCookie = cookies.find(cookie = cookie.trim().startsWith(`${FAVORITES_COOKIE_NAME}=`)); if (!favoriteCookie) return []; return JSON.parse(decodeURIComponent(favoriteCookie.split('=')[1])) as string[]; }; 읽는 것만 해도 document.cookie 전체를 세미콜론으로 쪼개서 내 것만 찾아내야 한다. 값 하나 꺼내는 데 할 일이 너무 많다. localStorage로 옮기면 이렇게 된다 localStorage는 위 문제들이 다 없다. 만료가 없어서 지우기 전까지 그대로 남고, 용량도 브라우저마다 5MB에서 10MB 정도로 훨씬 넉넉하고, 서버 요청에 딸려가지도 않는다. 그리고 API가 단순하다. // 저장 localStorage.setItem('favorites', JSON.stringify(favorites)); // 읽기 const favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); // 삭제 localStorage.removeItem('favorites'); Cookie 만료를 신경 쓰던 코드가 다 사라지고 이 세 줄로 끝난다. 브라우저를 닫았다 열어도, 며칠이 지나도 그대로 남는다. 그럼 Cookie는 언제 쓰나 Cookie가 나쁜 건 아니다. 서버가 읽어야 하는 정보, 그러니까 로그인 세션이나 인증 토큰은 Cookie가 맞다. 요청마다 자동으로 서버에 따라가는 그 특성이 거기서는 오히려 장점이고, httpOnly를 걸면 스크립트에서 못 읽게 막을 수도 있다. 반대로 클라이언트만 쓰는 정보는 굳이 서버로 보낼 이유가 없으니 localStorage가 맞다. 즐겨찾기, 화면 설정, 테마 같은 것들이다. 결국 "이 데이터를 서버가 알아야 하나?"라는 질문 하나로 갈린다. 옮기고 나서 생긴 문제 localStorage로 옮기고 나니 새 문제가 생겼다. 탭을 두 개 열어두면 서로 모른다. 한쪽에서 값을 바꿔도 다른 탭은 예전 값을 그대로 들고 있었다. storage 이벤트를 붙이면 된다고 해서 붙였는데, 이번엔 반대가 됐다. 다른 탭은 갱신되는데 정작 값을 바꾼 내 탭이 갱신이 안 됐다. storage 이벤트는 설계상 값을 바꾼 탭 자신에게는 안 오게 되어 있다. 바꾼 쪽은 이미 아는 값이니 알릴 필요가 없다는 논리인데, 컴포넌트가 여러 개면 그 "바꾼 쪽"이 화면의 다른 부분일 수 있다. 그래서 커스텀 이벤트를 하나 더 만들어서 둘을 같이 듣는 걸로 정리했다. useEffect(() = { refresh(); const handler = () = refresh(); window.addEventListener('storage', handler); // 다른 탭이 바꿨을 때 window.addEventListener('mockTradingUpdate', handler); // 내 탭이 바꿨을 때 return () = { window.removeEventListener('storage', handler); window.removeEventListener('mockTradingUpdate', handler); }; }, []); 값을 바꾸는 쪽에서 dispatchEvent로 mockTradingUpdate를 쏘게 해뒀다. 이벤트 두 개를 같은 핸들러에 물려두니 어느 쪽에서 바뀌든 화면은 한 가지 방법으로 갱신된다. 저장소를 바꾼 것 자체는 간단했는데, 바뀐 걸 누가 어떻게 아는가가 그 뒤로 며칠 더 걸렸다. 그런데 코드를 다시 열어보니 이 글을 다시 정리하면서 실제 코드를 열어봤다. 위의 이벤트 코드에 mockTradingUpdate라는 이름이 붙어 있는 게 힌트였는데, localStorage로 옮긴 건 즐겨찾기가 아니라 모의투자 잔고와 보유 내역이었다. 즐겨찾기는 지금도 coin_favorites라는 이름의 Cookie에 들어 있다. 대신 만료를 1년으로 늘려둔 상태다. 그러니까 며칠 만에 사라지던 문제는 만료를 붙이는 걸로 막았고, 이 글에서 정리한 "서버가 몰라도 되는 건 localStorage"라는 기준은 그 뒤에 만든 모의투자 쪽에 적용된 거였다. 글에는 옮겼다고 써놓고 정작 즐겨찾기는 안 옮긴 셈이다. 잘 돌고 있어서 손댈 이유를 못 찾았던 것 같은데, 요청마다 쿠키가 따라가는 건 지금도 그대로다. 지금 화면에서 별을 누르면 이렇게 목록 맨 위로 올라온다. 이 정보가 아직 쿠키에 있다. 브라우저에 뭔가를 저장할 일이 있으면 "서버가 이걸 알아야 하나"부터 한 번 물어보길 추천한다. 그리고 그 기준을 정했으면 옛날 코드에도 실제로 적용했는지 한 번씩 열어보는 게 좋다. 나처럼 새 코드에만 적용하고 기준을 세웠다고 착각하기 쉽다.