GA4 적용을 통한 방문자 집계

7/9/2025

어떻게 보면 기본적인 내용인데, 나도 집접 직접 설정하는게 처음이라 기록 차원에서 남겨둔다. 왜냐하면 Gemini CLI 에서 제공해준 내용과 실제 Next.js 에서 지원해주는 내용이 달랐기 때문에 더더욱 남겨두는게 좋다 생각되었다.

프로젝트에 GA4 설정

google Analytics 설정은 직접 구성해보는 걸로 하고, 프로젝트에 적용 부분만 다룬다.

Gemini CLI 제공 코드

Gemini CLI 에서는 app/layout.tsx 다음과 같이 적용하라고 제공해주었다.

...
import Script from 'next/script'
...

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {

  const gaMeasurementId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
  
  return (
    <html lang="en">
      <head>
        {/* GA4 추적 스크립트 추가 */}
        {gaMeasurementId && (
          <>
            <Script
              strategy="afterInteractive"
              src={`https://www.googletagmanager.com/gtag/js?id=${gaMeasurementId}`}
            />
            <Script
              id="google-analytics-script"
              strategy="afterInteractive"
              dangerouslySetInnerHTML={{
                __html: `
                  window.dataLayer = window.dataLayer || [];
                  function gtag(){dataLayer.push(arguments);}
                  gtag('js', new Date());
                  gtag('config', '${gaMeasurementId}', {
                    page_path: window.location.pathname,
                  });
                `,
              }}
            />
          </>
        )}
      </head>
      ...
  )
}

그런데 이전에 Next.js 공식 문서 내용에 GA/GTM 설정 문서가 있던게 기억나서 위 내용으로 진행해보려다가 아래에 진행하는 Next.js 공식문서 의 서드파티 적용을 했었는데, 정확한 이유까진 못찾았지만, 실제 GA 에서 적용되지 않는 문제가 있었다. 그래서 안전하게 적용하려면 위 내용대로 진행하는걸 추천한다.

Next.js 공식문서 적용

이 부분은 이런게 있다 참고만 하는게 좋을 듯 하다. 실제 이 내용으로 적용했을 때 GA4 대시보드에서 적용되지 않아서, 여러 모로 삽질이 있었다.

먼저 다음 Next.js 제공의 서드파티 라이브러리를 받아주어야 한다.

npm install @next/third-parties@latest

Getting Started 내용을 확인해보면 아직 개발중이라고 나와있긴하다. 그래서 그런지 내가 적용할 때에는 잘 연동되진 않았던 것 같다. 일단 진행해보려면 해당 링크의 내용대로 적용하면 된다.

내용을 보고 진행하면 되긴하지만 굳이 적용하는 부분을 구현하면 다음과 같이 적용하면 된다.

...

import { GoogleAnalytics } from '@next/third-parties/google'

...

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {

  const gaMeasurementId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
  
  return (
    <html lang="en">
      <body className={inter.className}>
	      ...
      </body>
      
      { gaMeasurementId && <GoogleAnalytics gaId={gaMeasurementId} /> }
    </html>
  );
}

물론 공식문서와 완전 똑같은 구현은 아니고, .env.local 파일에 NEXT_PUBLIC_GA_MEASUREMENT_ID 값으로 GA4 ID 를 적용해두고, 나의 경우 로컬에서 특별한 경우가 아니라면 적용하지 않기 위해 위 코드와 같이 구현하였다.

이렇게만 적용되면 일단 GA4 기본 설정은 끝난다. 로컬에서 설정해보고 ‘왜 메트릭 지표에 아무것도 안나오지?’ 싶다면, GA_ID 를 가져왔다는건 GA4 에 등록할 때 스트림으로 적용을 했다는 의미인데, 아마 이 스트림 적용하는 도메인이 아니라서 수집되지 않는 것일 것이다.

GA 방문자 집계

이제 GA 설정을 통해 페이지 집계가 GA 로 처리되고 있다는 가정하에 진행한다. 이제 여기서 필요한 설정은 Google Cloud 에서 프로젝트의 API 를 통해 GA 데이터를 조회하기, 그리고 이렇게 설정한 API 를 통해 실제 GA 데이터에 접근하고 보여주는 부분이다.

Google Cloud 와 Google API 설정

이 부분이 프로젝트 설정과 조금 별개라고 느껴져 언급하는게 맞을까 싶다가, 이 설정들이 꽤 복잡하다 생각되는 부분이 있을것 같아 적어두려한다.

이 부분은 GA 의 설정과 별개의 또 다른 프로젝트가 필요하다고 보면 된다. 만약 이미 관련하여 사용하고 있는 프로젝트가 있다면 기존 프로젝트에서 시작해도 무관하다.

우리가 필요한 건 Google Analytics Data API API 이다. 이 API 대한 사용은 GA 설정과는 별개라서 또 다른 프로젝트가 필요하다고 한 것이다. 적용하고자 하는 프로젝트에서 새로 만들었거나 기존 사용하던 프로젝트가 아니었다면, 라이브러리에서 해당 API 를 추가해주면 된다.

Notion Image

(참고를 위한 Google Cloud 메뉴)

라이브러리에서 우리가 사용하고자 하는 API 까지 추가했다면, 이제 해당 API 사용을 하는 사용자 인증 정보가 필요하다. 사용자 인증 정보 메뉴에 들어가보면 ‘+ 사용자 인증 정보 만들기’ 가 있을텐데 해당 메뉴에서 우리는 서비스 계정 을 선택할 것이다. 이유는 GA 에서는 접근 권한을 서비스 계정으로 주게 되어있기 때문이다.(사실 다른 방법으로 API 키 등으로 되는지 추가 확인까진 해보지 않았다) 이 서비스 계정 만들기 과정으로 서비스 계정을 만들면 우리가 로그인한 계정으로 새로운 이메일 주소 계정이 하나 생성되었을 텐데, 이 이메일 주소를 복사해주자.

이 주소를 GA 의 관리로 들어가 엑세스 가능한 계정으로 등록해 주어야 한다.

Notion Image

(참고를 위한 GA 메뉴 구성)

여기서 계정 엑세스 관리 메뉴에서 우리가 방금 복사한 계정의 이메일 주소를 등록해주면 된다. (권한은 사용하고자 하는 관리자 편의대로)

여기까지 마치면 이제 Google Cloud 프로젝트를 통해 API 를 설정하고, API 사용을 위한 계정 설정, 계정을 실제 사용하는 GA 까지 연결하는 과정을 마친 것이다. 이제 저 서비스 계정을 이용하여 프로젝트에서 API 조회만 하면 된다.

프로젝트 적용하기

프로젝트 적용을 위한 필요 설정 가져오기

먼저 우리가 사용하는 Google Cloud 프로젝트에서 먼저 서비스 계정의 JSON 키를 받아야 한다. IAM 및 관리자 대메뉴를 통해 우리가 연결을 위해 만든 서비스 계정 에 접근하면 탭 분류에 라는 메뉴가 있을 것이다.

Notion Image

이 메뉴에서 키 추가 를 통해 JSON 파일을 받을 수 있다. 매우매우 중요한 파일이니까 절대 다른데에 노출되지 않도록 잘 관리하자.

다른 하나는 GA 에서 우리가 도메인 단위 설정한 데이터 스트림 에서 우리가 적용한 데이터 스트림의 스트림 ID 값을 가져오면 된다. 이 부분을 나는 GA 설정에 사용했던 GA_ID 값과 혼동했는데, 숫자값으로 설정된 다른 값이니 꼭 잘 확인하고 가져오자.

잘 진행했다면 여기까지 진행했을 때, Google Cloud 를 통해 서비스 계정의 JSON 값 + GA 데이터 스트림의 스트림 ID 값 이렇게 두개가 존재해야 한다.

실제 코드레벨 프로젝트 적용

이 두 값을 적용하는 방법이야 어떤 프로젝트에 적용하느냐에 따라 달라지겠지만, 나의 경우 Next.js 프로젝트이므로 이를 기준으로 적용 예시를 구성하려 한다.

  • GA 데이터 스트림의 스트림 ID 값 → GA4_PROPERTY_ID
  • Google Cloud 를 통해 서비스 계정의 JSON 값 → GOOGLE_APPLICATION_CREDENTIALS_JSON
  • 이렇게 환경변수로 등록하고 사용하려 한다. Next.js 이므로 .env.local 파일을 통해 적용하였다. (GOOGLE_APPLICATION_CREDENTIALS_JSON 의 경우 잘…문자열로 해두길 바란다)

    이제 실제 조회를 사용하는 전체 코드 구성이다.

    import { BetaAnalyticsDataClient } from '@google-analytics/data';
    
    // 환경 변수에서 GA4 속성 ID와 서비스 계정 자격 증명을 가져옵니다.
    // GA4_PROPERTY_ID는 숫자 형식의 속성 ID여야 합니다.
    const propertyId = process.env.GA4_PROPERTY_ID;
    // GOOGLE_APPLICATION_CREDENTIALS_JSON은 서비스 계정 키 JSON 문자열입니다.
    // 파싱 오류를 방지하기 위해 기본값으로 빈 객체를 제공합니다.
    const credentials = JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS_JSON || '{}');
    
    // Google Analytics Data API 클라이언트 초기화
    const analyticsDataClient = new BetaAnalyticsDataClient({
      credentials,
    });
    
    // 날짜를 'YYYY-MM-DD' 형식으로 포맷하는 헬퍼 함수
    function getFormattedDate(date: Date): string {
      const year = date.getFullYear();
      const month = (date.getMonth() + 1).toString().padStart(2, '0');
      const day = date.getDate().toString().padStart(2, '0');
      return `${year}-${month}-${day}`;
    }
    
    // 어제 날짜를 'YYYY-MM-DD' 형식으로 가져오는 헬퍼 함수
    function getYesterdayDate(): string {
      const today = new Date();
      const yesterday = new Date(today);
      yesterday.setDate(today.getDate() - 1); // 오늘 날짜에서 하루를 뺍니다.
      return getFormattedDate(yesterday);
    }
    
    // Analytics Data API 보고서를 실행하는 헬퍼 함수
    async function runAnalyticsReport(
      dateRanges: Array<{ startDate: string; endDate: string }>,
      metrics: Array<{ name: string }>,
      dimensions: Array<{ name: string }> = [] // dimensions는 선택 사항
    ) {
      if (!propertyId) {
        throw new Error('GA4_PROPERTY_ID 환경 변수가 설정되지 않았습니다.');
      }
      const [response] = await analyticsDataClient.runReport({
        property: `properties/${propertyId}`,
        dateRanges,
        metrics,
        dimensions,
      });
      return response;
    }
    async function runRealtimeAnalyticsReport(
       metrics: Array<{ name: string }>,
       dimensions: Array<{ name: string }> = [],
       minutesAgo: number = 29 // 기본값: 지난 30분
    ) {
       if (!propertyId) {
          throw new Error('GA4_PROPERTY_ID 환경 변수가 설정되지 않았습니다.');
       }
       const [response] = await analyticsDataClient.runRealtimeReport({
          property: `properties/${propertyId}`,
          metrics,
          dimensions,
          minuteRanges: [{ startMinutesAgo: minutesAgo, endMinutesAgo: 0 }], // 지난 'minutesAgo'분부터 현재까지
       });
       return response;
    }
    
    export async function getAnalyticsData() {
      try {
        const todayFormatted = getFormattedDate(new Date());
        const yesterdayFormatted = getYesterdayDate();
    
        // Promise.all을 사용하여 total, today, realtime, yesterday 데이터를 병렬로 조회합니다.
        const [totalResponse, todayResponse, realtimeResponse, yesterdayResponse] = await Promise.all([
          // 1. 전체 페이지 뷰 (Total) 조회: GA4 속성 생성 시점부터 오늘까지의 총 페이지 뷰
          runAnalyticsReport(
            [{ startDate: '2020-01-01', endDate: todayFormatted }], // GA4가 시작된 대략적인 시점부터
            [{ name: 'screenPageViews' }] // 페이지 뷰 측정항목
          ),
          // 2. 오늘 페이지 뷰 (Today) 조회: 오늘 하루의 페이지 뷰
          runAnalyticsReport(
            [{ startDate: todayFormatted, endDate: todayFormatted }],
            [{ name: 'screenPageViews' }]
          ),
          // 3. 현재 페이지 뷰 (Realtime) 조회: 현재(30분 기준) 페이지 뷰
          runRealtimeAnalyticsReport(
            [{ name: 'screenPageViews' }]
          ),
          // 4. 어제 페이지 뷰 (Yesterday) 조회: 어제 하루의 페이지 뷰
          runAnalyticsReport(
            [{ startDate: yesterdayFormatted, endDate: yesterdayFormatted }],
            [{ name: 'screenPageViews' }]
          ),
        ]);
    
        // 각 응답에서 페이지 뷰 값을 추출합니다.
        // 데이터가 없을 경우 '0'으로 기본값을 설정합니다.
        const totalViews = totalResponse.rows?.[0]?.metricValues?.[0]?.value || '0';
        const todayViews = todayResponse.rows?.[0]?.metricValues?.[0]?.value || '0';
        const realtimeViews = realtimeResponse.rows?.[0]?.metricValues?.[0]?.value || '0';
        const yesterdayViews = yesterdayResponse.rows?.[0]?.metricValues?.[0]?.value || '0';
    
        return {
          total: totalViews,
          today: todayViews,
          realtime: realtimeViews,
          yesterday: yesterdayViews,
        };
      } catch (error) {
        console.error('GA4 데이터 조회 중 오류 발생:', error);
        // 오류 발생 시 null을 반환하여 UI에서 처리할 수 있도록 합니다.
        return null;
      }
    }

    먼저 @google-analytics/data 라이브러리를 통해 조회하도록 하였고(install 해야 한다), 워터풀로 오래걸리는 부분이 있을 수도 있을 것 같아 Promise.all 을 통해 병렬로 내가 원하는 조회 조건들을 조회하여 반환하도록 하였다.

    원래는 total, today, yesterday 만 두려했는데, today 쪽 집계가 GA 실시간(30분 기준) 집계는 적용되지 않는 것 같아, 아쉬운대로 둘까 하다가 따로 realtime 이라는 기준을 따로 적용하였다. 또 today 랑 합치면 정확하지 않는 데이터가 되어 분리하였다. 그래도 지금 생각해보니 그렇게 중요하진 않을 것 같아, 나중에 방문자 집계 지표 부분은 캐싱처리를 적용하고, realtime 기준은 제외하는게 맞을 것 같다 생각된다.(그렇게 중요한 부분은 아니리라 생각된다)

    Notion Image

    회고

    연동하기는 했는데, 이 방문자 지표 부분을 GA 이용하여 하는게 정말 괜찮은 방식일까? 라는 의문이 들긴한다. 왜냐하면 일단 저 부분을 보여주는 곳이 루트에 layout.tsx 하위에 있는 app-sidebar.tsx 내부에 서버 컴포넌트로 들어가는데, 그렇게 되면 현재 layout.tsx 에 설정되어있는