GA4 Measurement Protocol: gtag.js 없이 서버에서 이벤트 보내기
랜딩 페이지의 전송량을 파일 단위로 쪼개 보다가, 가장 큰 파일이 우리 코드가 아니라는 것을 알았습니다. Google Analytics 의 gtag.js 였고, 방문 한 번에 176KB 를 받고 있었습니다. 페이지 전체가 1,094KB 였으니 16% 가 분석 스크립트 하나였습니다.
이 글은 그 스크립트를 브라우저에서 빼고, 같은 이벤트를 서버가 GA4 로 보내게 바꾼 기록입니다. GA 관리 화면에서 「측정 프로토콜 API 비밀번호」를 하나 만들면 할 수 있는 일인데, 무엇이 그대로 남고 무엇이 사라지는지는 직접 정리해야 했습니다.
페이지에서 가장 큰 파일, gtag.js
처음에는 gtag.js 를 이미 해결한 문제로 여겼습니다. 예전에 모바일 LCP 를 줄이려고 next/script 의 lazyOnload 로 돌려 두었기 때문입니다. 페이지가 다 뜬 뒤에 받으니 첫 화면 속도에는 영향이 없습니다.
그런데 이번에 본 것은 속도가 아니라 용량이었습니다. 페이지를 끝까지 스크롤하면서 실제 전송량을 모아 보니, 늦게 받든 일찍 받든 176KB 는 그대로 나가고 있었습니다. 모바일 데이터로 들어온 방문자에게는 같은 비용입니다. 늦게 싣는 것은 순서를 바꿨을 뿐 양을 줄이지는 않았습니다.
선택지는 셋이었습니다. 그대로 두거나, GA 를 아예 빼거나, 서버에서 보내는 것입니다. 사이트에는 이미 자체 이벤트 수집과 Vercel Analytics 가 따로 돌고 있었지만, GA 는 구글 서치 콘솔 연동이나 기존 보고서 때문에 끊고 싶지 않았습니다. 그래서 셋째를 골랐습니다.
이미 서버에 모이고 있던 이벤트
서버 전송이 간단했던 것은 이벤트가 이미 서버를 지나가고 있었기 때문입니다. 페이지뷰, 문의 폼 열기와 제출, 연락 버튼 클릭 같은 이벤트는 전부 자체 수집 API 로 들어와 DB 에 한 줄씩 쌓이고 있었습니다. 같은 이벤트를 브라우저가 gtag 로 한 번, 수집 API 로 한 번, 두 번 보내던 구조였습니다.
그래서 할 일은 수집 API 가 DB 에 저장한 다음 같은 내용을 GA4 Measurement Protocol 로 넘기는 것뿐이었습니다. 방문자를 기다리게 하지 않도록 응답을 보낸 뒤에 보냅니다. Next.js 의 after() 가 정확히 이 용도입니다.
await prisma.analyticsEvent.create({ data });
// 응답을 먼저 보내고, GA 전송은 그 뒤에
after(() => sendToGa(data, headers));
return NextResponse.json({ ok: true });sendToGa 는 비밀값 환경변수가 없거나 운영 배포가 아니면 바로 돌아갑니다. 전송에 실패해도 삼킵니다. 분석 때문에 문의 기록이 늦어지거나 깨지는 일은 없어야 하기 때문입니다.
쿠키 없이 사용자를 잇는 client_id
GA 는 client_id 로 사람을 구분합니다. gtag 를 쓸 때는 보통 _ga 쿠키에 이 값이 들어갑니다. 저는 쿠키 배너를 만들고 싶지 않아서, 예전부터 client_storage: 'none' 으로 쿠키 저장을 끄고 자체 수집이 쓰는 익명 방문자 ID(localStorage)를 client_id 로 넘기고 있었습니다. 그 구성은 GA4 쿠키 없이 쓰기에 따로 적어 두었습니다.
서버 전송에서도 이 값을 그대로 씁니다. 수집 API 가 받는 이벤트에 이미 같은 방문자 ID 가 실려 오기 때문입니다. 덕분에 전환 전후로 GA 의 사용자가 끊기지 않습니다. 같은 사람이 어제는 gtag 로, 오늘은 서버로 잡혀도 GA 입장에서는 한 사용자입니다.
이 연속성은 키 하나에 걸려 있습니다. localStorage 키 이름을 바꾸면 모든 방문자가 그날 새 사용자가 됩니다. 그래서 코드 주석에 그 사실을 적어 두었습니다.
세션과 체류 시간이 끊기지 않게 하는 두 값
Measurement Protocol 로 이벤트만 보내면 GA 는 사용자는 세지만 세션은 제대로 세지 못합니다. gtag 가 알아서 붙이던 두 값을 서버가 직접 붙여야 합니다.
하나는 session_id 입니다. GA 는 이 값을 숫자로 받는데, 우리 세션 ID 는 문자열입니다. 그래서 문자열을 해시로 접어 양의 정수로 만듭니다(FNV-1a 32비트). 같은 세션은 늘 같은 숫자가 나오니 GA 에서도 한 세션으로 묶입니다.
다른 하나는 engagement_time_msec 입니다. 이 값이 없으면 이벤트가 「활성 사용자」 집계에서 빠집니다. 서버는 방문자가 실제로 몇 초 머물렀는지 모르므로 100ms 를 고정으로 넣습니다. 그래서 GA 의 평균 참여 시간 같은 지표는 이제 의미가 없습니다. 이 부분은 뒤에서 다시 적었습니다.
IP 대신 Vercel 지역 헤더
gtag 를 쓰면 지역 정보는 Google 이 방문자의 IP 로 추정합니다. Measurement Protocol 에는 IP 를 넘기는 필드(ip_override)도 있지만 쓰지 않았습니다. 방문자 IP 를 우리 서버가 굳이 Google 로 보낼 이유가 없었습니다.
Vercel 은 요청마다 국가, 지역, 도시를 계산해 x-vercel-ip-country, x-vercel-ip-country-region, x-vercel-ip-city 헤더로 넣어 줍니다. 이 값을 user_location 으로 보냅니다. 기기 종류는 User-Agent 로 mobile, tablet, desktop 만 가려 device.category 에 넣습니다.
{
"client_id": "익명 방문자 ID",
"events": [{
"name": "page_view",
"params": { "session_id": "123456789", "engagement_time_msec": 100, "page_location": "https://duolabs.co.kr/", "language": "ko" }
}],
"user_location": { "country_id": "KR", "region_id": "KR-41", "city": "Yongin" },
"device": { "category": "mobile", "language": "ko" }
}UTM 이 붙은 주소로 들어온 방문은 첫 페이지뷰 앞에 campaign_details 이벤트를 하나 더 붙입니다. gtag 는 주소의 UTM 을 알아서 읽었지만, 서버 전송에서는 우리가 넘겨야 GA 가 유입 경로를 압니다.
검증 엔드포인트로 먼저 확인한 페이로드
Measurement Protocol 은 잘못된 이벤트를 보내도 성공 응답을 돌려줍니다. 데이터가 버려졌는지는 보고서를 봐야 압니다. 그래서 배포 전에 검증용 주소 /debug/mp/collect 로 같은 모양의 페이로드를 보냈습니다. 이 주소는 저장하지 않고 형식만 검사해서 validationMessages 로 돌려줍니다. 빈 배열이 왔고, 필드 이름과 값 형식은 통과했습니다.
검증에서 실수도 하나 했습니다. 배포 직후 수집 API 가 받는지 보려고 운영 주소에 시험 이벤트를 직접 보냈습니다. 그 이벤트는 우리 DB 와 GA 양쪽에 진짜 페이지뷰로 남았습니다. 한 건이라 통계에 영향은 없지만, 운영 데이터로 가는 길에 시험을 보내기 전에 한 번 멈췄어야 했습니다. 확인은 검증 엔드포인트나 프리뷰 배포에서 끝내는 것이 맞습니다.
실시간 보고서로 확인하기 직전에 빠진 것을 하나 더 찾았습니다. 페이지 제목입니다. gtag 는 document.title 을 알아서 보냈지만, 서버는 경로만 알고 제목을 모릅니다. 이대로면 GA 의 「페이지 제목」 보고서가 전부 (not set) 이 됩니다. 브라우저가 수집 API 로 이벤트를 보낼 때 제목을 함께 싣고, 서버가 그것을 page_title 로 넘기게 고쳐서 다시 배포했습니다.
그다음 GA 의 실시간 보고서를 열어 두고, 새 브라우저로 운영 사이트를 세 번 방문했습니다. 매번 utm_source=claude-check 를 붙여 랜딩으로 들어간 뒤 가격 페이지로 옮겼습니다. 1분쯤 뒤 분당 활성 사용자 막대가 솟았고, 「페이지 제목 및 화면 이름」 카드에 가격 페이지가 정확히 3회로 찍혔습니다. 세 번 방문한 그 숫자입니다. 지도에도 위치가 찍혀 Vercel 헤더로 보낸 지역이 들어간 것을 확인했습니다.
유입 소스는 확인하지 못했습니다. 실시간 보고서의 소스 카드는 「첫 사용자 소스」만 보여 주는데, 여기에 claude-check 가 나오지 않았습니다. 서버에서 보낸 campaign_details 가 세션 단위 소스만 채우고 첫 사용자 소스는 채우지 않는 것인지, 아예 반영되지 않은 것인지는 실시간 화면으로는 가를 수 없습니다. 하루 뒤 트래픽 획득 보고서에서 세션 소스를 봐야 알 수 있고, 거기서도 비어 있으면 유입 경로를 넘기는 방식을 다시 고쳐야 합니다.
서버 전송으로 잃는 것
무엇이 그대로이고 무엇이 달라지는지는 표로 정리하는 편이 정확합니다.
| 항목 | gtag.js | 서버 전송 |
|---|---|---|
| 브라우저 다운로드 | 방문마다 176KB | 0 |
| 사용자 수 | client_id 기준 | 같은 client_id, 이어짐 |
| 세션 | 자동 | session_id 를 직접 붙여 유지 |
| 지역 | Google 이 IP 로 추정 | Vercel 헤더로 우리가 전달, IP 는 안 보냄 |
| 기기 | 브라우저·OS·해상도까지 | mobile·tablet·desktop 만 |
| 유입 경로 | 주소의 UTM 을 자동으로 읽음 | 랜딩 첫 페이지뷰에 campaign_details 로 전달(반영 확인 중) |
| 참여 시간·스크롤·이탈 | 향상된 측정이 자동 수집 | 없음(100ms 고정) |
표보다 먼저 알아 둘 것이 있습니다. Google 은 이 방식을 정식 사용법으로 두지 않습니다. Measurement Protocol 개요에는 이렇게 적혀 있습니다.
You must use tagging (gTag, Tag Manager, or Google Analytics for Firebase) to use this protocol. While it's possible to send events to Google Analytics solely with the Measurement Protocol, only partial reporting may be available.
Measurement Protocol 은 gtag 가 모으는 데이터에 서버 쪽 이벤트(결제 완료, 오프라인 전환 같은 것)를 덧붙이라고 만든 것이고, 저처럼 이것만 쓰면 보고서 일부가 빈다는 뜻입니다. 위 표의 빈칸들이 그 「일부」입니다. 유입 소스가 아직 확인되지 않은 것도 같은 맥락일 수 있습니다. campaign_details 설명은 이 이벤트 이후의 이벤트에 캠페인 값을 적용한다고만 할 뿐, 세션 소스와 첫 사용자 소스 중 무엇을 채우는지는 말하지 않습니다.
저는 이 한계를 알고 골랐습니다. 처음 GA 를 붙일 때 코드 주석에 적어 둔 용도는 「검색어(구글 서치 콘솔 연동), 지역, 기기 분포처럼 자체 집계로는 안 나오는 것」이었습니다. 서치 콘솔 데이터는 검색 쪽에서 오므로 gtag 와 상관없고, 지역은 확인한 대로 들어옵니다. 기기는 mobile·tablet·desktop 세 칸으로 거칠어졌습니다. 체류 시간이나 스크롤 깊이를 보고 판단해야 하는 사이트라면 gtag 를 남기는 것이 맞습니다.
가장 큰 손실은 마지막 줄입니다. GA 의 향상된 측정이 알아서 모으던 스크롤 깊이, 체류 시간, 바깥 링크 클릭 같은 것은 서버가 알 수 없습니다. 필요해지면 브라우저에서 우리 수집 API 로 그런 이벤트를 보내게 하면 되지만, 이번에는 만들지 않았습니다.
반대로 얻은 것도 숫자로 남았습니다. 이 작업을 같은 날 한 몇 가지 정리와 함께 배포했고, 랜딩 페이지 전송량은 데스크톱 1,094KB 에서 633KB 로, 모바일 993KB 에서 566KB 로 줄었습니다. 그중 176KB 가 gtag.js 였습니다. 광고 차단기가 막던 gtag 요청도 이제 없으니, GA 에 잡히는 방문은 오히려 늘 수 있습니다. 이 부분도 며칠 뒤 숫자로 확인할 생각입니다.
함께 읽기
- GA4 쿠키 없이 쓰기: client_id를 직접 넘기는 구성방문 분석은 하고 싶은데 쿠키 동의 팝업은 만들기 싫었습니다. 배너를 하나 붙이면 화면에 층이 하나 더 생기고, 동의 상태를 저장하고 갱신하고 철회까지 받는 코드가 따라옵니다. 방문자가 하루 수십 명인 회사 사이트에 그만한 장치를 두는 것은 과합니다.
- Next.js 랜딩 페이지의 Lighthouse 점수가 폰트 하나로 해결되지 않는 이유성능 최적화에서 가장 당황스러운 순간은 개발 환경에서는 빠른데 배포 후 측정값만 크게 느릴 때입니다. 이번 랜딩 페이지가 그랬습니다. 개발 환경에서 관찰한 LCP는 약 1.68초였지만 PageSpeed Insights의 첫 측정은 5.5초까지 늘어났습니다. 이후 배포본을 다시 측정했을 때도 성능 점수 72점, LCP 4…
- 웹사이트 공개 전 무료 점검 도구: 속도·보안·접근성·SEO 한 번에 확인하기웹사이트는 화면과 기능이 완성됐다고 바로 공개할 수 있는 상태가 되는 것은 아닙니다. 실제 사용자가 느끼는 속도, HTTP 보안 헤더, HTTPS 설정, 키보드 접근성, 구조화 데이터, DNS 설정은 서로 다른 문제입니다.
- TTFB: 속도가 아니라 지연 시간을 재는 지표회선을 두 배로 넓혀도 TTFB 는 거의 그대로입니다. 서버를 한 대 더 붙여도 마찬가지입니다. 그런데 같은 서버를 사용자와 가까운 리전으로 옮기면 절반으로 떨어집니다.
- 소프트웨어 아키텍처 핵심 개념 지도: 모듈 경계에서 이벤트 기반 시스템까지같은 기능을 만드는데 어떤 팀은 이틀에 끝내고 어떤 팀은 두 달을 씁니다. 실력 차이보다는 앞선 결정들이 무엇을 허용하고 무엇을 막아 놓았는지의 차이인 경우가 많습니다. 아키텍처는 화려한 다이어그램이 아니라, 나중에 바꾸기 어려운 결정들의 집합입니다.