본문으로 건너뛰기
언어

@plott/webkit-analytics0.4.0

이벤트 추적 — 타입이 있는 이벤트 목록, 동의(Consent Mode v2), 로그인·로그아웃에 맞춘 식별자 수명, 벤더 스크립트 전 대기열, 전환 중복 제거, 화면을 숨길 때 sendBeacon, data-track 클릭. 벤더 SDK 를 싣지 않고 어댑터(GTM·GA4·Kakao·Naver)로 잇는다. 추적 오류가 화면을 죽이지 않는다

설치

pnpm
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 는 이벤트마다 보낼 키를 선언하며, 선언하지 않은 키는 어디로도 나가지 않습니다.

ts
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 속성은 클릭 순간에 읽힙니다.

tsx
"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;}

마크업만으로는 다음 속성을 씁니다.

html
<button data-track="page_error" data-track-params='{"error_source":"ui"}'>

동의(Consent Mode v2)를 받는 경우:

ts
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> 맨 앞에 이 스크립트를 넣으세요.

tsx
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 으로 한 번에 보냅니다.

옵션과 기본값

옵션뜻기본값
eventsdefineEvents 의 결과필수
adapters벤더 어댑터 목록필수
enabledtrue 또는 호출 때마다 읽는 함수꺼짐
consent필수. false 는 동의를 따지지 않고 모든 목적을 허용으로 취급합니다. 객체는 아래 세 항목을 가집니다. JavaScript 에서 빠뜨리면(undefined) 던지지 않고 모든 목적을 거절·waitForUpdate: true 로 두어 setConsent 전까지 아무것도 보내지 않으며, onError(error, "consent") 로 한 번 알립니다필수
consent.default시작할 때의 동의객체를 쓸 때 필수
consent.waitForUpdatefalse 면 거절 상태의 이벤트를 바로 버림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.titletrue 그대로, 함수면 다듬어서(빈 값이면 보내지 않음)보내지 않음
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, 서울이 아닌 시간대에서 돕니다).