From e4d6db15ce2fd85e0d1e9b1490d0c3357a9c3bb5 Mon Sep 17 00:00:00 2001 From: jyt6640 Date: Sat, 22 Aug 2026 12:20:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[Test]=20#417=20-=20GA4=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EC=A0=81=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Measurement ID 파싱과 script/dataLayer 동작 검증\n- SPA 라우트 이동별 page_view 중복 방지 검증 --- .../model/__tests__/googleAnalytics.test.ts | 79 +++++++++++++++++++ .../ui/__tests__/GoogleAnalytics.test.tsx | 32 ++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/features/analytics/model/__tests__/googleAnalytics.test.ts create mode 100644 src/features/analytics/ui/__tests__/GoogleAnalytics.test.tsx diff --git a/src/features/analytics/model/__tests__/googleAnalytics.test.ts b/src/features/analytics/model/__tests__/googleAnalytics.test.ts new file mode 100644 index 0000000..3200d07 --- /dev/null +++ b/src/features/analytics/model/__tests__/googleAnalytics.test.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createGoogleAnalyticsClient, + getGoogleAnalyticsMeasurementId, + type GoogleAnalyticsClient, +} from '../googleAnalytics' + +const environment = { window, document } as const + +afterEach(() => { + document.querySelectorAll('script[data-yanus-ga4]').forEach((script) => script.remove()) + window.dataLayer = undefined + window.gtag = undefined +}) + +describe('getGoogleAnalyticsMeasurementId', () => { + it('Given a trimmed GA4 measurement ID, When parsed, Then it returns the normalized ID', () => { + expect(getGoogleAnalyticsMeasurementId(' G-JGZ8JFGKPW ')).toBe('G-JGZ8JFGKPW') + }) + + it('Given a missing or non-GA4 ID, When parsed, Then it disables analytics', () => { + expect(getGoogleAnalyticsMeasurementId(undefined)).toBeNull() + expect(getGoogleAnalyticsMeasurementId('UA-123456')).toBeNull() + }) +}) + +describe('createGoogleAnalyticsClient', () => { + it('Given a valid ID, When initialized, Then it queues the GA4 config without an automatic page view', () => { + const client = createGoogleAnalyticsClient('G-TEST123', environment) + + client.initialize() + + expect(document.querySelector('script[data-yanus-ga4]')).not.toBeNull() + expect(document.querySelector('script[data-yanus-ga4]')?.src).toContain( + 'id=G-TEST123', + ) + expect(window.dataLayer).toHaveLength(2) + expect(Array.from(window.dataLayer?.[1] ?? [])).toEqual([ + 'config', + 'G-TEST123', + { send_page_view: false }, + ]) + }) + + it('Given a configured client, When the same route is reported twice, Then it sends one page view per route', () => { + const client = createGoogleAnalyticsClient('G-TEST123', environment) + + client.pageView({ path: '/calendar', title: '캘린더', location: 'https://yanus.test/calendar' }) + client.pageView({ path: '/calendar', title: '캘린더', location: 'https://yanus.test/calendar' }) + client.pageView({ path: '/chat', title: '채팅', location: 'https://yanus.test/chat' }) + + expect(window.dataLayer?.filter((command) => String(command[0]) === 'event')).toHaveLength(2) + }) + + it('Given no measurement ID, When analytics is initialized, Then it does not add a script or data layer', () => { + const client = createGoogleAnalyticsClient(null, environment) + + client.initialize() + client.pageView({ path: '/', title: '홈', location: 'https://yanus.test/' }) + + expect(document.querySelector('script[data-yanus-ga4]')).toBeNull() + expect(window.dataLayer).toBeUndefined() + }) +}) + +describe('GoogleAnalyticsClient contract', () => { + it('describes the client methods used by the route tracker', () => { + const client: GoogleAnalyticsClient = { + initialize: vi.fn(), + pageView: vi.fn(), + } + + client.initialize() + client.pageView({ path: '/', title: '홈', location: 'https://yanus.test/' }) + + expect(client.initialize).toHaveBeenCalledOnce() + expect(client.pageView).toHaveBeenCalledOnce() + }) +}) diff --git a/src/features/analytics/ui/__tests__/GoogleAnalytics.test.tsx b/src/features/analytics/ui/__tests__/GoogleAnalytics.test.tsx new file mode 100644 index 0000000..a6e71d4 --- /dev/null +++ b/src/features/analytics/ui/__tests__/GoogleAnalytics.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes, Link } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { GoogleAnalytics } from '../GoogleAnalytics' +import type { GoogleAnalyticsClient } from '../../model/googleAnalytics' + +describe('GoogleAnalytics route tracker', () => { + it('tracks the initial route and each SPA navigation', async () => { + const user = userEvent.setup() + const client: GoogleAnalyticsClient = { + initialize: vi.fn(), + pageView: vi.fn(), + } + + render( + + + + 캘린더} /> + + , + ) + + expect(client.initialize).toHaveBeenCalledOnce() + expect(client.pageView).toHaveBeenCalledWith(expect.objectContaining({ path: '/' })) + + await user.click(screen.getByRole('link', { name: '캘린더' })) + + expect(client.pageView).toHaveBeenLastCalledWith(expect.objectContaining({ path: '/calendar' })) + }) +}) From 36da6e406e5fd919cc8914ee3943135a08a2fb40 Mon Sep 17 00:00:00 2001 From: jyt6640 Date: Sat, 22 Aug 2026 12:21:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[Feat]=20#417=20-=20GA4=20SPA=20page=5Fview?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 환경변수 기반으로 Google tag를 지연 로드하고 초기 자동 page_view를 비활성화\n- React Router 경로 변경마다 개인정보 없이 page_view를 전송 --- src/app/router.tsx | 2 + .../analytics/model/googleAnalytics.ts | 98 +++++++++++++++++++ src/features/analytics/ui/GoogleAnalytics.tsx | 34 +++++++ 3 files changed, 134 insertions(+) create mode 100644 src/features/analytics/model/googleAnalytics.ts create mode 100644 src/features/analytics/ui/GoogleAnalytics.tsx diff --git a/src/app/router.tsx b/src/app/router.tsx index 597bea7..3ee6f76 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,4 +1,5 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { GoogleAnalytics } from '../features/analytics/ui/GoogleAnalytics' import { Layout } from '../widgets/Layout' import { PrivateRoute } from '../shared/ui/PrivateRoute' import { AdminRoute } from '../shared/ui/AdminRoute' @@ -21,6 +22,7 @@ import { TeamManagement } from '../pages/team-management' export function AppRouter() { return ( + } /> } /> diff --git a/src/features/analytics/model/googleAnalytics.ts b/src/features/analytics/model/googleAnalytics.ts new file mode 100644 index 0000000..6d0d4e0 --- /dev/null +++ b/src/features/analytics/model/googleAnalytics.ts @@ -0,0 +1,98 @@ +export type GoogleAnalyticsPageView = Readonly<{ + path: string + title: string + location: string +}> + +export type GoogleAnalyticsClient = Readonly<{ + initialize: () => void + pageView: (page: GoogleAnalyticsPageView) => void +}> + +type GoogleAnalyticsConfig = Readonly<{ + send_page_view: false +}> + +type GoogleAnalyticsPageViewParameters = Readonly<{ + page_title: string + page_location: string + page_path: string +}> + +type GoogleAnalyticsCommand = + | readonly ['js', Date] + | readonly ['config', string, GoogleAnalyticsConfig] + | readonly ['event', 'page_view', GoogleAnalyticsPageViewParameters] + +type GoogleAnalyticsEnvironment = Readonly<{ + window: Window + document: Document +}> + +declare global { + interface Window { + dataLayer?: GoogleAnalyticsCommand[] + gtag?: (...command: GoogleAnalyticsCommand) => void + } +} + +const GA4_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]+$/i +const GOOGLE_ANALYTICS_SCRIPT_SELECTOR = 'script[data-yanus-ga4]' +const GOOGLE_ANALYTICS_SCRIPT_ATTRIBUTE = 'data-yanus-ga4' + +export function getGoogleAnalyticsMeasurementId(rawMeasurementId: unknown): string | null { + if (typeof rawMeasurementId !== 'string') { + return null + } + + const measurementId = rawMeasurementId.trim() + return GA4_MEASUREMENT_ID_PATTERN.test(measurementId) ? measurementId : null +} + +export function createGoogleAnalyticsClient( + measurementId: string | null, + environment: GoogleAnalyticsEnvironment, +): GoogleAnalyticsClient { + let isInitialized = false + let lastPagePath: string | null = null + + const initialize = (): void => { + if (!measurementId || isInitialized) { + return + } + + environment.window.dataLayer ??= [] + environment.window.gtag ??= (...command: GoogleAnalyticsCommand): void => { + environment.window.dataLayer?.push(command) + } + + if (!environment.document.querySelector(GOOGLE_ANALYTICS_SCRIPT_SELECTOR)) { + const script = environment.document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}` + script.setAttribute(GOOGLE_ANALYTICS_SCRIPT_ATTRIBUTE, measurementId) + environment.document.head.appendChild(script) + } + + environment.window.gtag?.('js', new Date()) + environment.window.gtag?.('config', measurementId, { send_page_view: false }) + isInitialized = true + } + + const pageView = (page: GoogleAnalyticsPageView): void => { + initialize() + + if (!measurementId || lastPagePath === page.path) { + return + } + + environment.window.gtag?.('event', 'page_view', { + page_title: page.title, + page_location: page.location, + page_path: page.path, + }) + lastPagePath = page.path + } + + return { initialize, pageView } +} diff --git a/src/features/analytics/ui/GoogleAnalytics.tsx b/src/features/analytics/ui/GoogleAnalytics.tsx new file mode 100644 index 0000000..c8cf634 --- /dev/null +++ b/src/features/analytics/ui/GoogleAnalytics.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' +import { + createGoogleAnalyticsClient, + getGoogleAnalyticsMeasurementId, + type GoogleAnalyticsClient, +} from '../model/googleAnalytics' + +const measurementId = getGoogleAnalyticsMeasurementId(import.meta.env.VITE_GA_MEASUREMENT_ID) +const defaultClient = createGoogleAnalyticsClient(measurementId, { window, document }) + +type GoogleAnalyticsProps = Readonly<{ + client?: GoogleAnalyticsClient +}> + +export function GoogleAnalytics({ client = defaultClient }: GoogleAnalyticsProps) { + const location = useLocation() + const pagePath = location.pathname + const pageLocation = `${window.location.origin}${pagePath}` + + useEffect(() => { + client.initialize() + }, [client]) + + useEffect(() => { + client.pageView({ + path: pagePath, + title: document.title, + location: pageLocation, + }) + }, [client, pageLocation, pagePath]) + + return null +} From 922f7c3e9fc6bfc5dbe79508834bcd9269a2aad4 Mon Sep 17 00:00:00 2001 From: jyt6640 Date: Sat, 22 Aug 2026 12:21:07 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[Docs]=20#417=20-=20GA4=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vercel과 로컬 환경에서 사용할 Measurement ID 변수 추가\n- Google Analytics 4 설정 위치를 README에 기록 --- .env.example | 3 +++ README.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 1a65352..b03d376 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,6 @@ VITE_API_BASE_URL=http://localhost:8080 # MSW Mock 활성화 (true: mock 사용, false 또는 미설정: 실제 API 사용) VITE_USE_MOCK=true + +# Google Analytics 4 Measurement ID +VITE_GA_MEASUREMENT_ID=G-JGZ8JFGKPW diff --git a/README.md b/README.md index e6f88cd..4cc254b 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,9 @@ VITE_AI_API_URL= # 사용할 AI 모델명 VITE_AI_API_MODEL=llama3.1 + +# Google Analytics 4 Measurement ID +VITE_GA_MEASUREMENT_ID=G-JGZ8JFGKPW ``` > **백엔드 API 연동** From ca221061b34202ed185b5159d726daf5bde47d90 Mon Sep 17 00:00:00 2001 From: jyt6640 Date: Sat, 22 Aug 2026 12:25:24 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[Fix]=20#417=20-=20GA4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20strict=20build=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GoogleAnalyticsCommand union을 tuple index assertion으로 검증\n- CI의 fresh TypeScript build에서도 테스트 타입을 통과하도록 정리 --- .../analytics/model/__tests__/googleAnalytics.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/features/analytics/model/__tests__/googleAnalytics.test.ts b/src/features/analytics/model/__tests__/googleAnalytics.test.ts index 3200d07..e7c6b08 100644 --- a/src/features/analytics/model/__tests__/googleAnalytics.test.ts +++ b/src/features/analytics/model/__tests__/googleAnalytics.test.ts @@ -35,11 +35,10 @@ describe('createGoogleAnalyticsClient', () => { 'id=G-TEST123', ) expect(window.dataLayer).toHaveLength(2) - expect(Array.from(window.dataLayer?.[1] ?? [])).toEqual([ - 'config', - 'G-TEST123', - { send_page_view: false }, - ]) + const configCommand = window.dataLayer?.[1] + expect(configCommand?.[0]).toBe('config') + expect(configCommand?.[1]).toBe('G-TEST123') + expect(configCommand?.[2]).toEqual({ send_page_view: false }) }) it('Given a configured client, When the same route is reported twice, Then it sends one page view per route', () => {