@plott/webkit-analytics0.4.0
이벤트 추적 — 타입이 있는 이벤트 목록, 동의(Consent Mode v2), 로그인·로그아웃에 맞춘 식별자 수명, 벤더 스크립트 전 대기열, 전환 중복 제거, 화면을 숨길 때 sendBeacon, data-track 클릭. 벤더 SDK 를 싣지 않고 어댑터(GTM·GA4·Kakao·Naver)로 잇는다. 추적 오류가 화면을 죽이지 않는다
설치
pnpm add @plott/webkit-analytics@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-analytics | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-analytics/gtm | ./dist/gtm.d.ts | ./dist/gtm.js |
@plott/webkit-analytics/ga4 | ./dist/ga4.d.ts | ./dist/ga4.js |
@plott/webkit-analytics/kr | ./dist/kr.d.ts | ./dist/kr.js |
@plott/webkit-analytics/react | ./dist/react/index.d.ts | ./dist/react/index.js |
화면 이벤트를 GTM·GA4·광고 픽셀로 보내는 패키지입니다. 읽는 사람은 예약·결제 같은 화면에 이벤트 추적을 붙이는 프론트엔드 개발자입니다.
벤더 SDK 를 싣지 않습니다. 어댑터(adapter, 벤더 하나와 이어 주는 작은 객체)가 벤더가 만든 전역(window.dataLayer, window.gtag, window.kakaoPixel, window.wcs)에 말을 겁니다. 런타임 의존성이 없습니다.
진입점
| 진입점 | 어디서 도는가 | 내보내는 것 |
|---|---|---|
@plott/webkit-analytics | 어디서나(서버 컴포넌트에서 import 해도 됩니다. 서버에서는 호출이 아무 일도 하지 않습니다) | createAnalytics, defineEvents, keepQuery, defaultRedactPath, beaconAdapter, sendBeacon, 타입 |
…/gtm | 어디서나(consentDefaultScript, toConsentMode). 어댑터는 브라우저 | gtmAdapter, pushEvent, toConsentMode, consentDefaultScript |
…/ga4 | 브라우저 | ga4Adapter |
…/kr | 브라우저 | kakaoPixelAdapter, naverWcsAdapter (한국 광고 프리셋) |
…/react | 클라이언트 컴포넌트 | AnalyticsProvider, useAnalytics, useTrackOnce, useIdentity, installDataTrack |
붙이기
analytics.ts 에서 이벤트를 선언하고 인스턴스를 만듭니다. defineEvents 는 이벤트마다 보낼 키를 선언하며, 선언하지 않은 키는 어디로도 나가지 않습니다.
import { createAnalytics, defineEvents, keepQuery } from "@plott/webkit-analytics";import { gtmAdapter } from "@plott/webkit-analytics/gtm";
type Events = { page_error: { error_source?: string }; purchase: { order_id: string; value: number }; logout: void;};
const events = defineEvents<Events>({ page_error: ["error_source"], purchase: ["order_id", "value"], logout: [],});
export const analytics = createAnalytics<Events>({ events, adapters: [gtmAdapter()], enabled: process.env.NEXT_PUBLIC_ANALYTICS === "on", consent: false, dedupe: { key: (_event, p) => (typeof p.order_id === "string" ? p.order_id : undefined) }, redactPage: keepQuery(["utm_source", "utm_medium"]),});| 옵션 | 동작 |
|---|---|
adapters | 스크립트 태그는 앱이 넣습니다(@next/third-parties 등). 어댑터가 넣게 하려면 { loadScript: true, containerId } 를 줍니다. |
enabled | 켜지 않으면 아무것도 보내지 않습니다. |
redactPage | 화면 주소의 쿼리는 keepQuery 목록의 키만 남깁니다. |
page: { redactPath, title } | 경로의 식별 값은 기본으로 :id 가 되고, 화면 제목은 기본으로 보내지 않습니다. |
layout.tsx 의 클라이언트 쪽에서는 로그인한 사용자 id(string), 로그아웃 상태(null), 아직 모르는 상태(undefined)를 useIdentity 에 넘기고, 마크업이 일으킬 수 있는 이벤트를 installDataTrack 에 직접 적습니다(목록에 없는 이름은 버립니다). useTrackOnce 는 완료 화면에서 서버가 준 주문 번호로 이벤트를 한 번만 보냅니다. data-track 속성은 클릭 순간에 읽힙니다.
"use client";import { useEffect, type ReactNode } from "react";import type { Analytics } from "@plott/webkit-analytics";import { AnalyticsProvider, installDataTrack, useIdentity, useTrackOnce } from "@plott/webkit-analytics/react";
type Events = { page_error: { error_source?: string }; purchase: { order_id: string; value: number }; logout: void;};
declare const analytics: Analytics<Events>;declare function useSession(): { userId: string | null | undefined };
export function Tracking({ children }: { children: ReactNode }) { const session = useSession(); useIdentity(session.userId); useEffect(() => installDataTrack(analytics, { events: ["page_error"] }), []); return <AnalyticsProvider analytics={analytics}>{children}</AnalyticsProvider>;}
export function Done({ order }: { order: { id: string; total: number } }) { useTrackOnce<Events, "purchase">("purchase", { order_id: order.id, value: order.total }, order.id); return null;}마크업만으로는 다음 속성을 씁니다.
<button data-track="page_error" data-track-params='{"error_source":"ui"}'>동의(Consent Mode v2)를 받는 경우:
import { createAnalytics, defineEvents } from "@plott/webkit-analytics";import { gtmAdapter } from "@plott/webkit-analytics/gtm";
const events = defineEvents<{ logout: void }>({ logout: [] });
const analytics = createAnalytics({ events, adapters: [gtmAdapter()], consent: { default: { analytics: false, ads: false, personalization: false, functional: false } },});analytics.setConsent({ analytics: true });배너에서 허용하면 setConsent 가 대기열을 비웁니다.
GTM 을 쓰면 Consent Mode 의 default 가 컨테이너 스니펫보다 먼저 올라가야 합니다. 어댑터가 올리는 default 는 컨테이너 뒤라서 늦습니다. 서버 컴포넌트에서 <head> 맨 앞에 이 스크립트를 넣으세요.
import { consentDefaultScript } from "@plott/webkit-analytics/gtm";
declare function GtmContainerSnippet(): React.ReactNode;
export function Head() { return ( <head> <script dangerouslySetInnerHTML={{ __html: consentDefaultScript( { analytics: false, ads: false, personalization: false, functional: false }, { waitForUpdateMs: 500, region: undefined }, ), }} /> <GtmContainerSnippet /> </head> );}region 은 적용할 지역 코드 목록이며 없으면 모든 지역에 적용됩니다. GtmContainerSnippet 자리에는 GTM 컨테이너 스니펫(@next/third-parties 등)이 consentDefaultScript 뒤에 들어갑니다.
GA4·GTM 의 자동 수집은 이 패키지를 거치지 않습니다. GA4 «향상된 측정» 의 «페이지 변경 시 페이지 조회» 는 끄고, GTM 의 내장 Page URL·Page Path·Referrer 변수 대신 dataLayer 의 page_location·page_referrer 를 쓰세요. 켜 두면 실제 주소(쿼리·식별 값 포함)가 그대로 나갑니다.
우리 서버로도 보내려면 beaconAdapter({ url: "/collect" }) 를 어댑터에 더합니다. 화면을 숨길 때(pagehide) 모아 둔 것을 sendBeacon 으로 한 번에 보냅니다.
옵션과 기본값
| 옵션 | 뜻 | 기본값 |
|---|---|---|
events | defineEvents 의 결과 | 필수 |
adapters | 벤더 어댑터 목록 | 필수 |
enabled | true 또는 호출 때마다 읽는 함수 | 꺼짐 |
consent | 필수. false 는 동의를 따지지 않고 모든 목적을 허용으로 취급합니다. 객체는 아래 세 항목을 가집니다. JavaScript 에서 빠뜨리면(undefined) 던지지 않고 모든 목적을 거절·waitForUpdate: true 로 두어 setConsent 전까지 아무것도 보내지 않으며, onError(error, "consent") 로 한 번 알립니다 | 필수 |
consent.default | 시작할 때의 동의 | 객체를 쓸 때 필수 |
consent.waitForUpdate | false 면 거절 상태의 이벤트를 바로 버림 | true |
consent.purpose | 이벤트가 따르는 동의 목적 | 모두 analytics |
queue.max | 대기열 최대 개수, 넘으면 오래된 것부터 버림 | 100 |
loadTimeoutMs | 어댑터 load 를 기다리는 시간, 넘으면 그 어댑터만 건너뜀 | 10000 |
dedupe.key | 같은 키면 한 번만 보냄 | 중복 제거 없음 |
dedupe.ttlMs | 중복 표시 유지 시간 | 24시간 |
dedupe.storage | "session"(탭마다) · "local"(탭을 넘어) · "memory"(이 페이지 동안) | "session" |
page.redactPath | 경로 다듬기, false 면 끔 | defaultRedactPath |
page.title | true 그대로, 함수면 다듬어서(빈 값이면 보내지 않음) | 보내지 않음 |
redact | 보내기 직전에 값을 다듬음, 선언하지 않은 키를 더해도 버림 | 없음 |
redactPage | 화면 조회 주소를 다듬음, 던지면 그 조회를 보내지 않음 | 없음 |
normalizeId | 식별자를 한 가지 모양으로 맞춤, 빈 값이면 식별하지 않음 | 문자열·유한한 숫자를 trim |
onError | (error, where) 오류 처리기 | where 마다 한 번 console.warn |
analytics 객체는 track·trackByName(이름이 실행 중에 정해질 때)·trackOnce·page·identify·reset·setConsent·flush(대기열을 지금 비워 봄)·ready(모든 어댑터의 load 가 끝나거나 포기된 뒤 이행하고 거절하지 않는 Promise)를 가집니다. identify 는 로그인이 확인된 뒤에만 부릅니다. beaconAdapter 의 mode 는 "pagehide"(기본, 모았다가 화면을 숨길 때 한 번)와 "immediate"(이벤트마다)이고 maxBuffer 기본값은 50 입니다. sendBeacon(url, payload, { contentType }) 은 보냈다고 믿을 수 있으면 true 를 돌려주고 던지지 않습니다. GA4 어댑터는 measurementId·sendPageView·dataLayerName·loadScript 를, GTM 어댑터는 containerId·dataLayerName·scriptUrl·loadScript·eventKey·userIdKey 를 받습니다. 모두 기본은 앱이 스크립트 태그를 넣는 쪽(loadScript: false)이고 dataLayerName 은 "dataLayer" 입니다. 카카오·네이버 어댑터의 timeoutMs 기본값은 10000 입니다.
기본으로 지키는 것
- 선언한 키는 빠짐없이 보냅니다(없으면
undefined). GTM 데이터 모델은 앞서 넣은 값을 들고 있어서, 키를 빼고 넣으면 직전 이벤트의 값(오류 뒤에error_source가 남는 일)이 다음 이벤트에 묻습니다.defineEvents가 키를 채우고pushEvent가 모두 넣습니다. - 선언하지 않은 키와 객체 값은 나가지 않습니다(허용 목록). 예약 번호·파트너 번호가 이벤트에 실려 나가는 길을 막습니다. 값은 문자열·유한한 숫자·불리언·null 만 받습니다. 전체 위에
redact(event, params)가 보내기 직전의 마지막 손질입니다.redact가 던지거나 객체가 아닌 값을 주면 원본을 보내지 않고 그 이벤트를 버립니다. - 화면 주소의 쿼리는
redactPage로 거릅니다.keepQuery(["utm_source"])는 그 키만 남기고 해시를 뺍니다. 주소에 예약 번호가 실려도 새지 않게 앱이 목록을 정합니다. - 경로·제목에서도 개인정보를 덜어냅니다.
path·location·referrer(첫 화면의document.referrer포함)의 경로에서 이메일·UUID·4자리 이상 숫자·16자 이상 16진수 구간은:id가 됩니다(/orders/12345/hong@x.com→/orders/:id/:id). 바꾸려면page: { redactPath: (path) => … }, 끄려면false. 화면 제목은 기본으로 보내지 않습니다(홍길동님 주문 확인같은 제목이 새는 길).page: { title: true }로 그대로,title: (t) => …로 다듬어 켭니다. GA4 어댑터는page_location을 항상 명시해 GA4 가 실제 주소로 되돌아가지 않게 합니다(GA4 는page_title이 없으면document.title을 쓸 수 있으니, 제목에 개인정보가 담기는 화면은document.title자체를 안전하게 두세요). - 동의 목적은 어댑터마다 따집니다. 어댑터는
purposes를 가지며, 그 목적이 모두 허용이고 연산 자신의 목적(analytics, 또는consent.purpose가 정한 것)도 허용일 때만 받습니다. GA4·GTM 은["analytics"], 카카오·네이버는["ads"]라서{ analytics: true, ads: false }에서 광고 픽셀은 화면 조회도 전환도 받지 않습니다. 광고 픽셀이 화면 조회를 받으려면 두 목적이 모두 허용이어야 합니다. 허용 전에 건너뛴 연산은 그 어댑터에 나중에 가지 않습니다. 로그아웃(identify(null))은 목적과 상관없이 항상 갑니다. GTM 은 동의 상태도gtag('consent', …)로 받아, 컨테이너의 태그가 스스로도 걸러집니다. - 추적 코드는 던지지 않습니다. 어댑터의
load·track·page·identify·consent(동기·비동기),redact,dedupe.key,enabled가 던져도 호출한 쪽에는 닿지 않고onError(error, where)로 갑니다. 기본은where마다 한 번console.warn. 벤더 스크립트 오류가 화면 렌더나 사용자 동작을 막지 않습니다.useAnalytics는 Provider 밖에서도 던지지 않습니다. enabled는true일 때만 켜집니다. 기본은 꺼짐이라 개발·미리보기 환경이 운영 컨테이너로 보내지 않습니다. 켜지 않으면 어댑터를 불러오지도 않습니다. 서버(window없음)에서는 켜져 있어도 아무 일도 하지 않아, 요청 사이에 식별자가 섞이지 않습니다.- 벤더 스크립트가 뜨기 전 이벤트는 대기열에 두었다가 순서대로 보냅니다. 최대 100개(
queue.max), 넘으면 오래된 것부터 버립니다.load가 10초 안에 끝나지 않는 어댑터(광고 차단기)는 건너뛰어 다른 어댑터를 막지 않습니다. 네이버wcs_do는wcs가 뜬 뒤에만 부릅니다. - 동의가 없으면 보내지 않습니다.
consent는 필수입니다.consent: false는 동의를 따지지 않아 바로 보내고,consent를 빠뜨리면(JavaScript 에서undefined) 모든 목적을 거절로 두고setConsent가 허용하기 전까지 아무것도 보내지 않으며onError(error, "consent")로 한 번 알립니다.consent.default가 거절이면 이벤트는 대기열에 머물다가setConsent로 허용된 뒤에 나갑니다. 사용자가 그 목적을 거절로 정하면 쌓인 것을 버립니다. 어댑터에는default/update로 알리며, GTM·GA4 는gtag('consent', …)모양으로 넣습니다. 한 페이지에 GTM 과 GA4 어댑터를 둘 다 두면 동의가 두 번 들어가니 하나만 둡니다. - 식별자는 인증이 확인된 뒤에만 걸고 로그아웃에 지웁니다.
useIdentity(id):undefined(아직 모름)와 빈 문자열은 아무것도 하지 않고, id 문자열에identify,null(로그아웃)에reset. 계정이 바뀌면reset뒤에identify합니다. id 는 문자열·숫자만 받아trim한 한 가지 모양으로 맞추고(normalizeId로 바꿉니다), 같은 id 를 되풀이해도 한 번만 보냅니다. 준비 전에identify→reset하면identify는 나가지 않습니다. 동의 전에 식별 요청이 버려져도 «보낸 식별자» 와 따로 기억하므로, 허용된 뒤 같은 id 로identify하면 나갑니다. 이미 보낸 식별자는 통계 동의를 거두는 순간identify(null)로 지웁니다(GA4 는user_id: null, GTM 은undefined로 덮어 씁니다). - 전환은 서버가 준 id 로 한 번만 셉니다.
dedupe.key나trackOnce/useTrackOnce(…, key). StrictMode·다시 마운트·새로 고침(sessionStorage, 24시간)에도 한 번입니다. 표시는 어댑터에 «보낸 뒤» 에 남깁니다 — 어댑터가 뜨기 전이나 동의를 기다리는 동안 새로 고쳐도 전환이 사라지지 않고, 아무 어댑터도 받지 않은 전환은 표시하지 않아 나중에 허용되면 보낼 수 있습니다.sessionStorage는 탭마다 따로라서 다른 탭에서는 다시 셉니다. 탭을 넘겨 막으려면storage: "local"(같은 ttl)을 고릅니다.storage: "memory"는 새로 고침까지는 기억하지 않습니다. 저장소 키 앞머리는webkit-analytics:dedupe:(keyPrefix로 바꿉니다). - 화면 조회는 같은 주소를 두 번 세지 않습니다.
AnalyticsProvider가usePathname·useSearchParams로 이동을 보고, 같은 주소(경로+쿼리)는 건너뜁니다. A→B→B 는 2, StrictMode 두 번 마운트에도 한 번. 주소·제목이 바뀐 다음 읽도록 타이머 한 틱 뒤에 보냅니다. data-track은 클릭 순간에 읽습니다.document의 capture 단계에서 동기로 읽으므로 핸들러가 속성을 바꾸거나 노드를 지운 뒤에도 원래 값을 셉니다. 깨진 JSON 은 이름만 보내고, 선언하지 않은 이름은 버립니다.data-track은 허용한 이벤트만 일으킵니다.installDataTrack(analytics, { events: ["signup_click"] })—events(이름 배열 또는 판정 함수)가 필수입니다. 빠뜨리면 아무것도 보내지 않고 한 번 경고합니다. 사용자가 올린 글(HTML)이data-track="purchase"와 꾸민 값으로 전환을 쏘는 길을 막습니다. 전환 이벤트는conversions: ["purchase"]로 알려 두면 판정 함수가 true 를 줘도 막히고, 배열에 직접 적었을 때만 나갑니다.- 화면을 떠날 때
sendBeacon.beaconAdapter는pagehide·visibilitychange(hidden)에 한 번에 보내고,navigator.sendBeacon이 없거나 거절하면fetch(keepalive)로 보냅니다. 본문은 JSON 이지만Content-Type은 기본text/plain;charset=UTF-8입니다(다른 출처 주소에application/json을 쓰면 사전 요청이 필요해 화면을 떠나는 순간 실패할 수 있습니다). 받는 서버는 본문을 JSON 으로 파싱하세요.contentType으로 바꿀 수 있고, 같은 출처 주소를 권합니다. - 키는 입력을 믿지 않습니다. 값 읽기는
Object.hasOwn, 결과 객체는 prototype 이 없습니다.__proto__키 선언은 만들 때 거절하고, 선언하지 않은 이벤트 이름(__proto__·constructor)은 버립니다.
그 밖
useSearchParams와 Suspense.AnalyticsProvider는 화면 조회 컴포넌트를<Suspense fallback={null}>안에 둡니다. 덕분에useSearchParams가 정적 렌더를 클라이언트로 돌리는 영향이 이 컴포넌트 하나에 갇히고 나머지 트리는 그대로입니다. 화면 조회를 직접 부르려면pageViews={false}로 끄고analytics.page()를 부릅니다.- GA4 의
sendPageView. 기본 false:config가 첫 화면을 자동으로 보내지 않고analytics.page()가page_view를 보냅니다(켜 두면 첫 화면이 두 번 셉니다). - 카카오 픽셀은 공식 메서드 14개만 부릅니다.
pageView·completeRegistration·search·viewContent·addToCart·addToWishList·viewCart·purchase·participation·signUp·login·preparation·tutorial·missionComplete이고KAKAO_PIXEL_METHODS로 읽습니다. 그 밖의 이름(constructor같은 상속 키 포함)은 픽셀에 있어도 부르지 않고kakaoPixel has no method …로 던집니다. 스크립트는//t1.daumcdn.net/kas/static/kp.js이고kakaoPixel('<Track ID>').<메서드>(…)형태이며purchase의 필드는total_quantity·total_price·currency·products입니다. 출처: 카카오 비즈니스 픽셀 & SDK 가이드, 2026-11-04 확인. - 네이버 어댑터는 wcs.trans 가이드의 순서를 따릅니다.
wcs_add["wa"] = accountId→wcs.inflow(cookieDomain)→wcs_do()(화면 조회, 인자 없음) →wcs.trans({ type, value })입니다.cookieDomain을 주면inflow에 넘기고 없으면 인자 없이 부릅니다.value는 문자열로 보내며purchase가 아닌 전환(lead·sign_up등)은value없이type만 보냅니다. 출처: 네이버 광고 웹 전환 추적 Script 설치 가이드(wcs.trans), cnv → trans 전환 가이드, 2026-11-04 확인. /kr프리셋은 얇습니다. 스크립트 태그는 앱이 넣고, 전역이 뜨길 기다렸다 부릅니다. 카카오는map(event, params) → { method, payload }(purchase·signUp등), 네이버는map → { type, value }. 네이버 전환은wcs.trans가 있으면 그것으로 보내 화면 조회를 다시 세지 않습니다. 없는 스크립트면wcs.cnv로 만든 값을wcs_do({ cnv })로 보내는데 이 호출은 화면 조회도 함께 세므로, 같은 화면에서page()뒤에 전환이 오면 한 번 더 셉니다(전환이 먼저면 그 호출이 화면 조회를 겸하고 뒤따르는page()는 건너뜁니다).map이 비워 주는 이벤트는 보내지 않습니다.- GTM 의 이벤트 키.
eventKey(기본event)와 같은 이름의 선언 키는 이벤트 이름이 이깁니다.scriptUrl로 서버 쪽 GTM 주소를 쓸 수 있습니다. - 직접 만든 어댑터.
{ name, purposes?, load?, track?, page?, identify?(id | null), consent?(c, "default" | "update") }.track은 선언한 키가 모두 든 값과ctx.declared를 받습니다. - 테스트.
pnpm test(happy-dom, 서울이 아닌 시간대에서 돕니다).