목록 페이지에서 글 A, B, C를 Ctrl을 누른 채 연달아 클릭했다. 탭 세 개가 열렸는데 셋 다 C였다. 가끔은 A 탭만 제대로 나오고 나머지 두 탭은 데이터가 비어 있었다.
당시 투입된 프로젝트는 화면 사이에 값을 넘길 때 document.cookie를 썼다. 쓰임새는 세 가지였다.
다른 화면으로 파라미터를 넘긴다
뒤로가기로 돌아왔을 때 이전 검색조건을 되살린다
window.open으로 띄운 팝업 창에서 부모 창으로 값을 전달한다
쿠키는 같은 브라우저의 모든 탭이 함께 보는 저장소다. 탭마다 다른 값을 넘기려면 key도 탭마다 달라야 한다. 이 글에서는 화면 카테고리마다 key 하나를 공유하던 구조를, 쿠키마다 고유 key를 만들어 URL로 넘기는 구조로 바꾼 과정을 정리한다.
탭 세 개가 key 하나를 두고 경쟁했다
기존 코드는 화면 카테고리별로 같은 쿠키 key를 썼다. 목록에서 상세로 갈 때 그 key에 파라미터를 쓰고, 새로 열린 상세 탭이 같은 key를 읽은 뒤 지웠다.
클릭이 한 번이면 문제가 없다. 짧은 간격으로 세 번이면 이렇게 된다.
sequenceDiagram
participant L as 목록 탭
participant K as 쿠키 (공통 key)
participant A as 상세 탭 A
participant B as 상세 탭 B
L->>K: A 파라미터 쓰기
L->>K: B 파라미터로 덮어쓰기
L->>K: C 파라미터로 덮어쓰기
A->>K: 읽기 (C 파라미터가 나온다)
B->>K: 읽기 (C 파라미터가 나온다)탭 A가 열리고 쿠키를 읽기 전에 목록 탭이 이미 C의 파라미터로 덮어써 버린 것이다. 두 번째 증상도 같은 구조에서 나온다. 먼저 뜬 탭이 값을 읽고 쿠키를 지우면 뒤에 뜬 탭은 빈 쿠키를 읽는다. 쿠키의 “생성 > 조회 > 삭제"가 여러 탭에서 겹쳐 일어나면 어느 탭이 어떤 값을 볼지 보장할 수 없다.
처음에는 이 현상을 document.cookie가 비동기로 동작해서 생긴다고 봤다. 다시 보면 정확한 설명이 아니다. 한 탭 안에서 document.cookie에 값을 쓰고 바로 읽으면 방금 쓴 값이 나온다. 원인은 쓰기와 읽기의 타이밍보다 여러 탭이 key 하나를 나눠 쓴 데 있다. 쿠키 쓰기가 아무리 즉시 반영되더라도 같은 key를 쓰는 한 마지막 값만 남는다.
localStorage와 sessionStorage를 접은 이유
쿠키 대신 Web Storage로 옮기는 방법도 따져봤다.
localStorage는 데이터를 브라우저에 영구적으로 저장하므로 흐름에 따라 지워지지 않고 남을 수 있다. 방어 코드와 만료 시간을 직접 붙이면 막을 수는 있지만, 모든 경우를 막으려면 공수가 커진다.sessionStorage는 새로고침에는 유지되고 탭을 닫으면 지워진다. 대신 사용 범위가 그 탭 하나로 한정된다. 목록 탭에서 쓴 값을 새로 열린 상세 탭에서 읽어야 하는 이번 상황과 맞지 않았다.
쿠키를 편하게 다루는 라이브러리(js-cookie, react-cookie)를 들이는 것도 고민했지만 이미 많은 화면이 document.cookie로 짜여 있었다. 저장소를 바꾸기보다 기존 코드를 최대한 살리기로 하고 목표를 네 가지로 잡았다.
만료 시간을 지정할 수 있을 것
같은 글을 여러 탭으로 동시에 띄워도 파라미터가 탭마다 독립적으로 전달될 것
파라미터 전달용 데이터와 검색조건 복원용 데이터가 서로 충돌하거나 덮어쓰지 않을 것
공통 함수로 만들어 기존 로직을 최대한 그대로 쓸 것
key는 매번 새로 만들고 URL에 싣는다
바뀐 점을 표로 보면 이렇다.
| 구분 | 기존 | 변경 후 |
|---|---|---|
| 쿠키 key | 화면 카테고리별 고정 key | 쿠키마다 새로 만든 랜덤 key |
| key 전달 | 고정값이라 필요 없음 | URL QueryString의 dataKey |
| 쿠키 누적 | 고정 key라 누적 없음 | 생성할 때 총 크기를 보고, 기준을 넘으면 가장 오래된 쿠키 삭제 |
key가 고유하니 탭 A, B, C는 각자 다른 쿠키를 읽는다. 대신 상세 탭은 어떤 key를 읽어야 할지 알아야 하므로 key를 URL로 넘긴다. 화면에 들어올 때도, 뒤로가기로 돌아올 때도 URL이 key를 실어 나른다. 화면을 옮길 때마다 쿠키가 하나씩 새로 생기니 사용자가 탭을 계속 열면 쿠키가 쌓인다. 정리 로직이 따라붙는 이유다.
고유 key를 만들고 URL에 기록한다
common.js의 쿠키 생성 부분이다.
// 고유 key name 쿠키 생성 함수
// 여러 Tab 동시 오픈시 데이터 조회 에러 대응 > 탭끼리 같은 key를 덮어쓰던 현상
function setRareCookie(cookieValue, expireDate, uri) {
checkAndDeleteCookie(); // 총 크기 체크. 기준치 이상인 경우 가장 오래된 쿠키 삭제하는 함수
let cookieName = randomNum(); // 쿠키의 고유 key name 생성
changeBrowserLocation(cookieName); // key name을 URL에 설정
document.cookie = cookieName + "=" + escape(cookieValue) + ";expires=" + expireDate + ";path=" + uri;
return cookieName;
}
function randomNum(){
let randomNum = String(Math.floor(Math.random() * 10000));
let currDateTime = String(new Date().getTime()).substring(3);
return currDateTime + randomNum;
}
// 고유 key name을 URL에 설정
function changeBrowserLocation(dataKey){
let params = new URLSearchParams(location.search);
params.set('dataKey', dataKey);
let queryString = params.toString();
let url = location.pathname + '?' + queryString;
window.history.replaceState({}, document.title, url);
}
randomNum()은 밀리초 단위 현재 시각(13자리)에서 앞 세 자리를 잘라낸 10자리 뒤에 0~9999 난수를 붙인다. 앞 10자리가 시각이라서 뒤에서 볼 정리 로직이 이 부분으로 오래된 쿠키를 고른다.
changeBrowserLocation()은 history.replaceState로 현재 페이지 URL의 dataKey만 바꾼다. 페이지를 다시 불러오지 않고 히스토리 항목도 늘리지 않는다. 그래서 브라우저 뒤로가기로 목록에 돌아와도 목록 URL에 key가 남아 있다.
URL의 key로 조회하고 삭제한다
// URL에 있는 dataKey를 Key Name으로 갖는 쿠키 조회
function getRareCookie() {
let cookieName = getDataKey();
let cookie = document.cookie;
if (cookie.length > 0 && !isEmpty(cookieName)) {
let startIndex = cookie.indexOf(cookieName);
if (startIndex != -1) {
startIndex += cookieName.length;
let endIndex = cookie.indexOf(";", startIndex);
if (endIndex == -1) endIndex = cookie.length;
return unescape(cookie.substring(startIndex + 1, endIndex));
} else {
return '';
}
} else {
return '';
}
}
// 쿠키 삭제
function deleteRareCookie(uri) {
let expireDate = new Date();
expireDate.setDate(expireDate.getDate() - 1);
document.cookie = getDataKey() + "= " + ";expires=" + expireDate.toGMTString() + ";path=" + uri;
}
// URL의 QueryString 중 dataKey 유무 확인
function getDataKey(){
let dataKey = new URL(location.href).searchParams.get('dataKey') || '';
return dataKey;
}
// 화면 이동시 URL에 cookie key name 전달
function getDataKeyUrl(delimiter){
let dataKey = getDataKey();
return dataKey && `${delimiter}dataKey=${dataKey}`;
}
조회와 삭제 모두 getDataKey()로 URL의 dataKey를 먼저 꺼낸다. 탭마다 URL이 다르니 읽는 쿠키도 다르다. getDataKeyUrl()은 화면을 옮길 때 현재 key를 구분자(? 또는 &)와 함께 붙여 주고, key가 없으면 빈 문자열을 돌려준다.
쌓인 쿠키를 정리한다
// 쿠키의 총 크기 반환 함수
function getCookieSize() {
let totalSize = 0;
let cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i].trim();
totalSize += cookie.length * 2; // 쿠키의 길이 2배(유니코드 문자 고려)
}
return totalSize;
}
// 총 크기를 체크하여 기준치 이상인 경우 가장 오래된 쿠키 삭제
function checkAndDeleteCookie() {
let maxSize = 8500;
while (getCookieSize() > maxSize) {
let cookies = document.cookie.split('; ');
let oldestCookie = 0;
let firstOfAll = true;
let deleteTargetCookieName = 0;
for (let i = 0; i < cookies.length; i++) {
let keyName = cookies[i].substring(0, cookies[i].indexOf('='));
if(!isNumericString(keyName)) continue;
let keyName_dateTime = parseInt(keyName.substring(0,10));
if(firstOfAll) {
oldestCookie = keyName_dateTime;
deleteTargetCookieName = keyName;
firstOfAll = false;
continue;
}
if(oldestCookie > keyName_dateTime){
oldestCookie = keyName_dateTime;
deleteTargetCookieName = keyName;
}
}
if(firstOfAll) break; // 삭제할 쿠키 없는 경우 break
document.cookie = deleteTargetCookieName + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
}
}
function isNumericString(str) {
let pattern = /^\d+$/;
return pattern.test(str);
}
checkAndDeleteCookie()는 총 크기가 8500을 넘는 동안 가장 오래된 쿠키를 하나씩 지운다. key가 숫자로만 된 쿠키만 대상으로 삼으므로 다른 용도의 쿠키는 건드리지 않는다. 지울 대상이 없으면 break로 빠져나와 무한 루프를 피한다.
화면에서 부르는 쪽
목록 페이지는 진입할 때 쿠키가 있으면 검색조건을 되살리고 쿠키를 지운다. 상세로 갈 때는 넘길 데이터를 쿠키에 쓰고 key를 URL에 붙인다.
// 리스트페이지.js: 저장된 쿠키를 조회하고, 있으면 화면에 데이터 세팅
화면시작시실행되는초기화함수 : function(){
this.searchCondition = getRareCookie();
if(this.searchCondition != '') {
...
(화면 inputbox에 setting 변수 초기화)
...
deleteRareCookie("/");
}
}
...
// 상세페이지로 이동
화면이동함수 : function(){
...
let dataKey = setRareCookie({다음화면에 전달할 JSON 데이터}, expireDate, "/");
window.location.href = "{상세페이지URL}?dataKey=" + dataKey;
}
상세 페이지의 뒤로가기는 자기 URL의 key를 목록 URL에 그대로 붙인다. 같은 key가 상세로 갈 때 한 번, 목록으로 돌아올 때 한 번 쓰이는 셈이다.
// 상세페이지.js: 뒤로가기 시 파라미터 설정
뒤로가기함수 : function() {
window.location.href = "{목록페이지URL}" + getDataKeyUrl("?");
}
주의 사항
getRareCookie()는 key를 부분 문자열로 찾는다.cookie.indexOf(cookieName)은 다른 쿠키의 이름이나 값 안에 같은 숫자열이 있어도 그 위치를 돌려준다. key가 11~14자리 숫자라 겹칠 일은 드물지만,split('; ')로 나눈 뒤name=부분을 정확히 비교하는 편이 안전하다.escape/unescape와Date.prototype.toGMTString은 더 이상 권장되지 않는 API다. 새로 짠다면encodeURIComponent/decodeURIComponent,toUTCString을 쓴다.쿠키는 path가 맞는 모든 요청에 실려 서버로 간다.
path=/로 만든 파라미터 쿠키가 쌓일수록 요청 헤더도 그만큼 커진다.
정리
여러 탭이 같은 쿠키 key를 쓰면 마지막에 쓴 값만 남는다. 원인은 비동기가 아니라 key 공유였다.
localStorage는 정리 부담,sessionStorage는 탭 범위 때문에 대안이 되지 못했다.쿠키마다 랜덤 key를 만들고, key는 URL의
dataKey로 넘겼다.화면을 옮길 때마다 쿠키가 하나씩 쌓이므로 생성 시점에 총 크기를 보고 가장 오래된 것부터 지운다.
저장소는 그대로 두고 key를 정하는 방식만 바꿨다. 새 탭이 몇 개 열리든 각 탭은 자기 URL에 적힌 key만 본다.