GA4 쿠키 없이 쓰기: client_id를 직접 넘기는 구성
방문 분석은 하고 싶은데 쿠키 동의 팝업은 만들기 싫었습니다. 배너를 하나 붙이면 화면에 층이 하나 더 생기고, 동의 상태를 저장하고 갱신하고 철회까지 받는 코드가 따라옵니다. 방문자가 하루 수십 명인 회사 사이트에 그만한 장치를 두는 것은 과합니다.
GA4는 기본적으로 _ga 쿠키를 심습니다. 그래서 그것부터 끌 수 있는지 찾아봤습니다.
쿠키만 끄면 되는 줄 알았습니다
gtag 설정에 client_storage: 'none'을 주면 GA4는 쿠키를 만들지 않습니다. 여기까지는 문서에 그대로 있습니다.
gtag('config', 'G-XXXXXXXXXX', { client_storage: 'none' });문제는 그다음이었습니다. GA가 방문자를 알아보는 수단이 그 쿠키였습니다. 저장을 끄면 client_id를 둘 곳이 사라지고, 화면을 옮길 때마다 새 사람이 됩니다. 사용자 수가 페이지뷰와 비슷해지고 세션은 의미를 잃습니다. 쿠키를 없앴더니 보려던 것도 같이 없어졌습니다.
이미 갖고 있던 값으로 메웠습니다
이 사이트에는 자체 방문 분석이 따로 돌고 있습니다. 전환 이벤트를 우리 DB에 쌓는 쪽인데, 거기서 쓰려고 브라우저 로컬스토리지에 임의 문자열 하나를 넣어 두고 있었습니다. 유니크 방문을 세려고 만든 익명 ID입니다.
그 값을 client_id로 넘겼습니다.
var id = localStorage.getItem('dl_vid');
gtag('config', 'G-XXXXXXXXXX', { client_storage: 'none', client_id: id });새로 저장하는 것은 없습니다. 쿠키는 0이고, 이미 있던 키 하나를 GA도 같이 볼 뿐입니다. 그런데 사용자와 세션 지표는 원래대로 남습니다.
인라인 스크립트로 짠 이유가 있습니다. client_id는 로컬스토리지에서 읽어야 하는데 서버는 그 값을 모릅니다. useEffect로 미루면 그 사이에 첫 페이지뷰가 나가고, 그 한 건은 client_id 없이 집계됩니다. 스크립트 안에서 읽어 그 자리에서 넘기면 그 틈이 없습니다.
CSP를 뒤늦게 확인했습니다
코드를 다 짜고 나서야 응답 헤더를 봤습니다.
script-src 'self' 'unsafe-inline'
connect-src 'self'그대로 배포했으면 googletagmanager 스크립트부터 차단됩니다. 그건 그나마 콘솔에 위반이 찍히니 금방 압니다.
고약한 쪽은 connect-src입니다. script-src만 열어 두면 스크립트는 정상으로 뜨고 gtag 함수도 만들어집니다. 눈으로 보기에 다 된 것 같은데 수집 요청만 조용히 죽습니다. 실시간 보고서에 아무것도 안 잡히는데 페이지에는 아무 이상이 없습니다. 둘을 같이 열어야 합니다.
script-src ... https://www.googletagmanager.com
connect-src ... https://www.google-analytics.com https://*.google-analytics.com
https://*.analytics.google.com https://www.googletagmanager.com제 클릭을 빼는 데 손이 한 번 더 갔습니다
개발하면서 누르는 것까지 통계에 섞이면 방문자 수십 명 규모에서는 숫자가 눈에 띄게 흔들립니다. 그래서 운영 배포에만 스크립트가 실리게 했습니다.
여기서 걸린 것이 NODE_ENV로는 가를 수 없다는 점입니다. Vercel은 프리뷰도 프로덕션도 NODE_ENV=production으로 빌드합니다. 둘을 가르는 값은 VERCEL_ENV 하나인데, 이건 서버 전용이라 클라이언트 번들에서는 보이지 않습니다.
next.config.ts의 env로 옮겨 심었습니다.
env: {
NEXT_PUBLIC_VERCEL_ENV: process.env.VERCEL_ENV ?? "development",
},빌드 타임에 문자열로 인라인되므로 프리뷰 빌드에서는 조건문이 상수 false가 되고 컴포넌트가 통째로 트리셰이킹으로 빠집니다. 번들에 측정 ID조차 남지 않습니다.
본진에만 붙였더니 블로그가 0이었습니다
배포하고 경로별로 확인해 봤습니다.
GA:1 duolabs.co.kr/
GA:0 duolabs.co.kr/blog
GA:0 duolabs.co.kr/docs
GA:0 duolabs.co.kr/faq/blog와 /docs는 각각 다른 프로젝트가 렌더합니다. 한 도메인 아래 세 앱을 rewrites로 조합해 두었기 때문에, 회사 랜딩 레이아웃에 스크립트를 넣어도 그쪽에는 닿지 않습니다.
이게 왜 문제냐면, 검색으로 들어오는 길이 대부분 블로그이기 때문입니다. 글을 읽고 홈으로 넘어와 문의를 남긴 사람이 GA에는 "홈에 그냥 들어온 사람"으로 보입니다. 어떤 글이 일을 물어왔는지 알 방법이 없습니다. 정작 알고 싶은 것이 그것인데요.
그래서 세 프로젝트에 같은 측정 ID로 붙였습니다.
이게 되는 것은 도메인 구조 덕분이었습니다
여기가 이 작업에서 제일 오래 생각한 부분입니다.
client_id를 로컬스토리지에서 읽는다는 것은 오리진에 묶인다는 뜻입니다. 로컬스토리지는 스킴과 호스트와 포트가 같아야 공유됩니다.
/blog는 리다이렉트가 아니라 rewrites입니다. 서버가 프록시로 가져와 응답하므로 브라우저 주소창은 계속 duolabs.co.kr입니다. 브라우저 입장에서 세 앱은 한 오리진이고, 로컬스토리지도 하나입니다. 그래서 블로그 쪽 코드가 회사 랜딩이 만들어 둔 dl_vid를 그대로 읽습니다. 같은 사람이 GA에도 한 사람으로 남습니다.
만약 블로그가 blog.duolabs.co.kr이었다면 이 구성은 성립하지 않습니다. 저장소가 갈려 각자 다른 ID를 만들고, 한 사람이 셋으로 세어집니다. 쿠키를 쓰는 구성이라면 도메인을 .duolabs.co.kr로 지정해 서브도메인끼리 넘길 수 있는데, 쿠키를 안 쓰기로 한 순간 그 수단이 없어집니다.
쿠키를 버리면 도메인 구조가 곧 측정 구조가 됩니다. 저는 이 사이트를 경로로 합쳐 둔 이유가 검색 신호를 한곳에 모으기 위해서라고만 생각하고 있었는데, 그 결정이 측정까지 결정하고 있었습니다.
서브도메인 네 개에는 붙이지 않았습니다
같은 이유로, 서브도메인으로 떠 있는 위성 사이트들에는 GA를 넣지 않았습니다. 넣으면 사용자 수가 부풀려집니다. 그중 몇 개는 회사 랜딩에서 iframe으로 뜨기 때문에 페이지뷰가 따로 잡혀 세션까지 쪼개집니다.
알고 싶은 것이 "방문자가 어느 시스템에 반응하는가"라면 굳이 그 안까지 들어갈 필요가 없습니다. 랜딩에서 어떤 카드를 눌렀는지는 자체 이벤트로 이미 남고, 그 이벤트를 GA에도 같이 보내도록 해 두었습니다. 이벤트 목록을 두 곳으로 갈라 두면 한쪽만 고치는 날이 옵니다.
한 가지 주의할 점은 page_view입니다. GA4의 향상된 측정이 브라우저 기록 변경을 보고 화면 이동마다 페이지뷰를 이미 보냅니다. 자체 이벤트를 미러링하면서 page_view까지 같이 보내면 한 번 이동에 두 건이 잡힙니다. 그 이름만 빼야 합니다.
남은 것
쿠키는 0입니다. 개발자도구 Application 탭에 _ga가 없습니다. 그런데 로컬스토리지는 씁니다.
그래서 처리방침에도 "추적하지 않습니다"가 아니라 "쿠키를 사용하지 않습니다"라고 적었습니다. 그 둘은 다른 말인데, 쿠키를 없앴다는 사실이 기분 좋아서 앞엣말로 쓰고 싶어지는 순간이 있었습니다.
세 프로젝트에 측정 ID와 로컬스토리지 키가 각각 하드코딩되어 있는 것도 그대로 남았습니다. 값 두 개 때문에 공유 패키지를 만들고 리포 사이에 의존을 거는 것은 과해 보여서, 서로를 가리키는 주석만 달아 두었습니다. 측정 ID를 바꿀 일이 생기면 세 군데를 고쳐야 합니다. 지금은 그게 더 싸다고 보는데, 네 번째 앱이 붙으면 생각이 달라질 것 같습니다.
동의 팝업을 피하는 것이 목적이었다면 그 목적은 이걸로 달성됩니다. 다만 쿠키를 없앤다고 고지 의무까지 없어지지는 않습니다. 처리방침을 열었다가 정작 그쪽에서 한참 더 걸렸는데, 그 이야기는 따로 적겠습니다.
함께 읽기
- Next.js 웹앱을 Electron 데스크톱 앱으로 만들기 전에 따져볼 것사내 웹 도구를 쓰다 보면 어느 순간 "이거 그냥 데스크톱 앱으로 만들면 안 되나"라는 말이 나옵니다.
- Leaflet 지도가 느려지는 3가지 원인: React key, SVG 렌더러, 가드 위치지도 위에 H3 육각 격자를 얹은 화면을 만들었습니다. 셀을 누르면 인접한 여섯 셀이 함께 칠해지는, 영업 구역을 나눠 보는 화면입니다.
- Next.js OG 이미지 완전 가이드: SEO부터 로고·이미지·폰트 적용까지웹사이트 주소를 카카오톡이나 슬랙에 공유하면 제목, 설명과 함께 대표 이미지가 표시됩니다. 이 이미지를 흔히 OG 이미지라고 부릅니다.
- Google 번역을 켰더니 React 앱이 오류 화면으로 바뀐 이유한국어, 영어, 일본어, 중국어를 직접 제공하는 React 랜딩 페이지에서 예상하지 못한 문제가 생겼습니다. Chrome이 띄운 "이 페이지를 번역하시겠습니까?" 제안을 수락하면 번역이 시작되는 듯하다가, 잠시 뒤 사이트의 "일시적인 오류가 발생했습니다" 화면으로 바뀌었습니다.
- 클릭했는데 화면이 늦게 바뀌는 느낌을 줄이는 방법사용자가 버튼이나 링크를 눌렀는데 아무 반응이 없는 것처럼 보이면, 실제 로딩 시간이 길지 않아도 서비스가 느리다고 느낍니다. 이 문제는 단순한 성능 문제가 아니라 체감 성능과 피드백의 문제입니다.