RSS듀오랩스
프론트엔드

GA4 쿠키 없이 쓰기: client_id를 직접 넘기는 구성

작성자
듀오랩스 대표·8분 읽기

방문 분석은 하고 싶은데 쿠키 동의 팝업은 만들기 싫었습니다. 배너를 하나 붙이면 화면에 층이 하나 더 생기고, 동의 상태를 저장하고 갱신하고 철회까지 받는 코드가 따라옵니다. 방문자가 하루 수십 명인 회사 사이트에 그만한 장치를 두는 것은 과합니다.

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.tsenv로 옮겨 심었습니다.

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를 바꿀 일이 생기면 세 군데를 고쳐야 합니다. 지금은 그게 더 싸다고 보는데, 네 번째 앱이 붙으면 생각이 달라질 것 같습니다.

동의 팝업을 피하는 것이 목적이었다면 그 목적은 이걸로 달성됩니다. 다만 쿠키를 없앤다고 고지 의무까지 없어지지는 않습니다. 처리방침을 열었다가 정작 그쪽에서 한참 더 걸렸는데, 그 이야기는 따로 적겠습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.