Theme:

블로그: 방문자가 글을 얼마나 읽었을까에 대해 "왜?"라고 물으면 답할 수 있으신가요?

\n\n> 블로그: 방문자가 글을 얼마나 읽었을까에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n블로그를 만들면 자연스럽게 궁금해지는 게 있습니다. 사람들이 어떤 글을 읽는지, 얼마나 오래 읽는지, 어디서 나가는지.

GA4 기본 gtag.js는 페이지뷰만 추적합니다. 근데 페이지뷰만으로는 알 수 있는 게 별로 없습니다. 페이지를 열고 바로 닫은 건지, 끝까지 읽은 건지 구분이 안 되거든요.

그래서 네 가지를 추가로 추적하는 커스텀 훅을 만들었습니다.

  • 스크롤 깊이 — 25%, 50%, 75%, 100% 도달 여부
  • ** 글 읽기 시간** — 실제 체류 시간
  • ** 외부 링크 클릭** — 어떤 링크를 타고 나가는지
  • ** 검색어** — 뭘 검색하는지

조건부 로딩: 프로덕션에서만

개발 중에 GA 이벤트가 날아가면 데이터가 오염됩니다. 프로덕션에서만 로드되게 했습니다.

JAVASCRIPT
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()으로 라우트 변경을 감지해서 직접 페이지뷰를 전송합니다.

JAVASCRIPT
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% 읽었는지 추적합니다.

JAVASCRIPT
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: 읽기 시간

글 상세 페이지에서 실제 체류 시간을 측정합니다.

JAVASCRIPT
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: 외부 링크

블로그에서 외부 사이트로 나가는 클릭을 추적합니다.

JAVASCRIPT
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: 검색어

글 목록의 검색 바에서 검색어를 추적합니다. 디바운싱을 걸어서 타이핑 중에는 전송하지 않습니다.

JAVASCRIPT
export function trackSearch(term) {
    if (!term?.trim()) return;
    event('search', { search_term: term.trim() });
}
JAVASCRIPT
// 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에서 한 번만 호출합니다.

JSX
function App() {
    useGoogleAnalytics();
    useScrollDepth();
    useOutboundTracking();

    return (
        <ErrorBoundary>
            {/* ... */}
        </ErrorBoundary>
    );
}

useReadTracking은 글 상세 페이지에서만 쓰입니다.

JSX
const PostDetailPage = () => {
    useReadTracking(post?.title);
    // ...
};

GA4 대시보드에서 확인

이 커스텀 이벤트들은 GA4의 이벤트 리포트에서 확인할 수 있습니다.

이벤트추적 대상알 수 있는 것
scroll_depth25/50/75/100%글을 끝까지 읽는 비율
read_time체류 시간어떤 글이 오래 읽히는지
outbound_click외부 링크참고 자료 중 뭘 클릭하는지
search검색어방문자가 뭘 찾는지

스크롤 깊이가 50%를 넘지 않는 글이 많으면 도입부가 지루하다는 뜻이고, 읽기 시간이 10초 미만인 글이 많으면 제목 낚시를 하고 있다는 뜻입니다. 이런 데이터를 보면서 글을 개선할 수 있습니다.

댓글 로딩 중...