블로그: 방문자가 글을 얼마나 읽었을까 — GA4 커스텀 훅
블로그: 방문자가 글을 얼마나 읽었을까에 대해 "왜?"라고 물으면 답할 수 있으신가요?
\n\n> 블로그: 방문자가 글을 얼마나 읽었을까에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n블로그를 만들면 자연스럽게 궁금해지는 게 있습니다. 사람들이 어떤 글을 읽는지, 얼마나 오래 읽는지, 어디서 나가는지.
GA4 기본 gtag.js는 페이지뷰만 추적합니다. 근데 페이지뷰만으로는 알 수 있는 게 별로 없습니다. 페이지를 열고 바로 닫은 건지, 끝까지 읽은 건지 구분이 안 되거든요.
그래서 네 가지를 추가로 추적하는 커스텀 훅을 만들었습니다.
- 스크롤 깊이 — 25%, 50%, 75%, 100% 도달 여부
- ** 글 읽기 시간** — 실제 체류 시간
- ** 외부 링크 클릭** — 어떤 링크를 타고 나가는지
- ** 검색어** — 뭘 검색하는지
조건부 로딩: 프로덕션에서만
개발 중에 GA 이벤트가 날아가면 데이터가 오염됩니다. 프로덕션에서만 로드되게 했습니다.
let isLoaded = false;
function init() {
if (isLoaded || import.meta.env.DEV) return;
if (!import.meta.env.VITE_GA_ID) return;
const script = document.createElement('script');
script.src = `https://www.googletagmanager.com/gtag/js?id=${import.meta.env.VITE_GA_ID}`;
script.async = true;
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
window.gtag = function() { dataLayer.push(arguments); };
gtag('js', new Date());
gtag('config', import.meta.env.VITE_GA_ID, { send_page_view: false });
isLoaded = true;
}
send_page_view: false로 기본 페이지뷰를 끄고, 커스텀 훅에서 직접 전송합니다. 이래야 SPA 라우트 변경을 정확하게 추적할 수 있습니다.
useGoogleAnalytics: 페이지뷰
SPA에서는 라우트가 바뀌어도 실제 페이지 로드는 발생하지 않습니다. useLocation()으로 라우트 변경을 감지해서 직접 페이지뷰를 전송합니다.
export default function useGoogleAnalytics() {
const location = useLocation();
useEffect(() => { init(); }, []);
useEffect(() => {
send('page_view', {
page_path: location.pathname + location.search,
page_title: document.title,
});
}, [location]);
}
useScrollDepth: 스크롤 깊이
사용자가 글을 25%, 50%, 75%, 100% 읽었는지 추적합니다.
export function useScrollDepth() {
useEffect(() => {
const thresholds = [25, 50, 75, 100];
const reached = new Set();
const handler = () => {
const scrolled = window.scrollY;
const height = document.documentElement.scrollHeight - window.innerHeight;
if (height <= 0) return;
const percent = Math.round((scrolled / height) * 100);
for (const t of thresholds) {
if (percent >= t && !reached.has(t)) {
reached.add(t);
event('scroll_depth', { depth: t });
}
}
};
window.addEventListener('scroll', handler, { passive: true });
return () => window.removeEventListener('scroll', handler);
}, []);
}
Set으로 이미 전송한 임계값을 관리합니다. 사용자가 위아래로 스크롤해도 25% 이벤트는 최초 1회만 발생합니다. 이거 안 하면 스크롤할 때마다 이벤트가 날아가서 데이터가 난장판이 됩니다.
{ passive: true }는 스크롤 성능 최적화 옵션입니다. 이 핸들러가 preventDefault()를 안 쓴다고 브라우저에게 알려주면 스크롤이 더 부드러워집니다.
useReadTracking: 읽기 시간
글 상세 페이지에서 실제 체류 시간을 측정합니다.
export function useReadTracking(title) {
useEffect(() => {
if (!title) return;
const start = Date.now();
return () => {
const seconds = Math.round((Date.now() - start) / 1000);
if (seconds >= 10) {
event('read_time', {
article_title: title,
time_seconds: seconds,
});
}
};
}, [title]);
}
마운트 시 타이머를 시작하고, 언마운트 시(페이지 이동) 경과 시간을 전송합니다.
10초 미만은 무시합니다. 실수로 클릭했다가 바로 나간 경우를 걸러내기 위해서입니다. 10초도 안 되는 체류는 "읽었다"고 보기 어렵습니다.
useOutboundTracking: 외부 링크
블로그에서 외부 사이트로 나가는 클릭을 추적합니다.
export function useOutboundTracking() {
useEffect(() => {
const handler = (e) => {
const link = e.target.closest('a[href]');
if (!link) return;
const url = link.href;
if (new URL(url).hostname === window.location.hostname) return;
event('outbound_click', {
url: url,
link_text: link.textContent?.trim().slice(0, 100),
});
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
}
이벤트 위임을 씁니다. 모든 a 태그에 개별 리스너를 달 필요 없이, document 레벨에서 클릭을 잡아서 외부 링크인지 확인합니다.
같은 도메인이면 무시하고, 외부 도메인일 때만 이벤트를 전송합니다.
trackSearch: 검색어
글 목록의 검색 바에서 검색어를 추적합니다. 디바운싱을 걸어서 타이핑 중에는 전송하지 않습니다.
export function trackSearch(term) {
if (!term?.trim()) return;
event('search', { search_term: term.trim() });
}
// PostsContent에서 사용
const handleSearch = useCallback((e) => {
const value = e.target.value;
setSearch(value);
clearTimeout(searchTimer.current);
searchTimer.current = setTimeout(() => trackSearch(value), 800);
}, []);
800ms 동안 입력이 없으면 검색어를 전송합니다. 자모 하나하나가 이벤트로 날아가면 의미가 없으니까요.
App.jsx에서 초기화
전역 추적 훅은 App에서 한 번만 호출합니다.
function App() {
useGoogleAnalytics();
useScrollDepth();
useOutboundTracking();
return (
<ErrorBoundary>
{/* ... */}
</ErrorBoundary>
);
}
useReadTracking은 글 상세 페이지에서만 쓰입니다.
const PostDetailPage = () => {
useReadTracking(post?.title);
// ...
};
GA4 대시보드에서 확인
이 커스텀 이벤트들은 GA4의 이벤트 리포트에서 확인할 수 있습니다.
| 이벤트 | 추적 대상 | 알 수 있는 것 |
|---|---|---|
scroll_depth | 25/50/75/100% | 글을 끝까지 읽는 비율 |
read_time | 체류 시간 | 어떤 글이 오래 읽히는지 |
outbound_click | 외부 링크 | 참고 자료 중 뭘 클릭하는지 |
search | 검색어 | 방문자가 뭘 찾는지 |
스크롤 깊이가 50%를 넘지 않는 글이 많으면 도입부가 지루하다는 뜻이고, 읽기 시간이 10초 미만인 글이 많으면 제목 낚시를 하고 있다는 뜻입니다. 이런 데이터를 보면서 글을 개선할 수 있습니다.