feat: webforai.kr 방문 측정 (GTM·GA4) 연결과 처리방침 반영

- discovery 빌드에만 GTM(GTM-WQNTZVZH) 스니펫을 넣는다. GA4(G-013Z3BFY35)와 키 이벤트는 컨테이너 안에서 설정한다
- 문의 접수 성공 시 dataLayer 에 generate_lead(lead_source) 를 보낸다. 개인정보는 넣지 않는다
- GTM 가져오기 파일(docs/analytics/gtm_webforai_import.json): GA4 기본 태그, generate_lead·click_tel·click_email
- 개인정보처리방침 제1·5·8조에 Google 애널리틱스 분석 쿠키, 수집 항목, 국외 이전, 거부 방법을 반영한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-10-07 11:40:31 +09:00
parent f03c6a5b90
commit c90aef654f
5 changed files with 305 additions and 4 deletions

View File

@ -0,0 +1,268 @@
{
"exportFormatVersion": 2,
"exportTime": "2026-10-07 12:00:00",
"containerVersion": {
"accountId": "6381018301",
"containerId": "266335040",
"containerVersionId": "0",
"container": {
"accountId": "6381018301",
"containerId": "266335040",
"name": "webforai.kr",
"publicId": "GTM-WQNTZVZH",
"usageContext": [
"WEB"
]
},
"tag": [
{
"accountId": "6381018301",
"containerId": "266335040",
"tagId": "1",
"name": "GA4 - 기본 태그",
"type": "googtag",
"parameter": [
{
"type": "TEMPLATE",
"key": "tagId",
"value": "G-013Z3BFY35"
}
],
"firingTriggerId": [
"2147479553"
],
"tagFiringOption": "ONCE_PER_EVENT"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"tagId": "2",
"name": "GA4 이벤트 - generate_lead",
"type": "gaawe",
"parameter": [
{
"type": "TEMPLATE",
"key": "eventName",
"value": "generate_lead"
},
{
"type": "TEMPLATE",
"key": "measurementIdOverride",
"value": "G-013Z3BFY35"
},
{
"type": "LIST",
"key": "eventSettingsTable",
"list": [
{
"type": "MAP",
"map": [
{
"type": "TEMPLATE",
"key": "parameter",
"value": "lead_source"
},
{
"type": "TEMPLATE",
"key": "parameterValue",
"value": "{{DLV - lead_source}}"
}
]
}
]
}
],
"firingTriggerId": [
"10"
],
"tagFiringOption": "ONCE_PER_EVENT"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"tagId": "3",
"name": "GA4 이벤트 - click_tel",
"type": "gaawe",
"parameter": [
{
"type": "TEMPLATE",
"key": "eventName",
"value": "click_tel"
},
{
"type": "TEMPLATE",
"key": "measurementIdOverride",
"value": "G-013Z3BFY35"
}
],
"firingTriggerId": [
"11"
],
"tagFiringOption": "ONCE_PER_EVENT"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"tagId": "4",
"name": "GA4 이벤트 - click_email",
"type": "gaawe",
"parameter": [
{
"type": "TEMPLATE",
"key": "eventName",
"value": "click_email"
},
{
"type": "TEMPLATE",
"key": "measurementIdOverride",
"value": "G-013Z3BFY35"
}
],
"firingTriggerId": [
"12"
],
"tagFiringOption": "ONCE_PER_EVENT"
}
],
"trigger": [
{
"accountId": "6381018301",
"containerId": "266335040",
"triggerId": "10",
"name": "CE - generate_lead",
"type": "CUSTOM_EVENT",
"customEventFilter": [
{
"type": "EQUALS",
"parameter": [
{
"type": "TEMPLATE",
"key": "arg0",
"value": "{{_event}}"
},
{
"type": "TEMPLATE",
"key": "arg1",
"value": "generate_lead"
}
]
}
]
},
{
"accountId": "6381018301",
"containerId": "266335040",
"triggerId": "11",
"name": "링크 클릭 - tel",
"type": "LINK_CLICK",
"filter": [
{
"type": "STARTS_WITH",
"parameter": [
{
"type": "TEMPLATE",
"key": "arg0",
"value": "{{Click URL}}"
},
{
"type": "TEMPLATE",
"key": "arg1",
"value": "tel:"
}
]
}
],
"waitForTags": {
"type": "BOOLEAN",
"key": "waitForTags",
"value": "false"
},
"checkValidation": {
"type": "BOOLEAN",
"key": "checkValidation",
"value": "false"
}
},
{
"accountId": "6381018301",
"containerId": "266335040",
"triggerId": "12",
"name": "링크 클릭 - mailto",
"type": "LINK_CLICK",
"filter": [
{
"type": "STARTS_WITH",
"parameter": [
{
"type": "TEMPLATE",
"key": "arg0",
"value": "{{Click URL}}"
},
{
"type": "TEMPLATE",
"key": "arg1",
"value": "mailto:"
}
]
}
],
"waitForTags": {
"type": "BOOLEAN",
"key": "waitForTags",
"value": "false"
},
"checkValidation": {
"type": "BOOLEAN",
"key": "checkValidation",
"value": "false"
}
}
],
"variable": [
{
"accountId": "6381018301",
"containerId": "266335040",
"variableId": "20",
"name": "DLV - lead_source",
"type": "v",
"parameter": [
{
"type": "INTEGER",
"key": "dataLayerVersion",
"value": "2"
},
{
"type": "BOOLEAN",
"key": "setDefaultValue",
"value": "false"
},
{
"type": "TEMPLATE",
"key": "name",
"value": "lead_source"
}
]
}
],
"builtInVariable": [
{
"accountId": "6381018301",
"containerId": "266335040",
"type": "CLICK_URL",
"name": "Click URL"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"type": "PAGE_PATH",
"name": "Page Path"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"type": "EVENT",
"name": "Event"
}
]
}
}

View File

@ -12,6 +12,7 @@ import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { submitLead } from '../lib/submitLead'; import { submitLead } from '../lib/submitLead';
import { trackEvent } from '../lib/analytics';
import { CONTACT_EMAIL } from '../lib/contact'; import { CONTACT_EMAIL } from '../lib/contact';
interface ContactModalProps { interface ContactModalProps {
@ -86,6 +87,8 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp
source, source,
website, website,
}); });
// 접수에 성공한 경우만 리드로 센다. 어느 버튼에서 열었는지(source)만 보낸다.
if (result.ok) trackEvent('generate_lead', { lead_source: source });
setState(result.ok ? 'done' : 'error'); setState(result.ok ? 'done' : 'error');
}; };

11
src/lib/analytics.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* 방문 측정 이벤트. GTM 이 있는 배포(webforai.kr)에서만 dataLayer 로 넘기고, 없으면 아무것도 하지 않는다.
* 이벤트에는 개인정보(이름·연락처·문의 내용)를 넣지 않는다. 어느 버튼에서 왔는지 같은 화면 정보만 보낸다.
*/
type DataLayerWindow = Window & { dataLayer?: Record<string, unknown>[] };
export function trackEvent(event: string, params: Record<string, string> = {}): void {
const w = window as DataLayerWindow;
if (!Array.isArray(w.dataLayer)) return;
w.dataLayer.push({ event, ...params });
}

View File

@ -1,7 +1,8 @@
/** /**
* 개인정보처리방침 (/privacy). * 개인정보처리방침 (/privacy).
* 이 사이트가 실제로 처리하는 것만 적는다: 문의·진단 요청 서식(이름·연락처·회사·문의 내용) → /api/lead(Vercel) → * 이 사이트가 실제로 처리하는 것만 적는다: 문의·진단 요청 서식(이름·연락처·회사·문의 내용) → /api/lead(Vercel) →
* Google Apps Script → 구글 시트 저장 + 알림 메일. 방문 분석 쿠키는 아직 쓰지 않는다. GA4 등을 붙이면 제8조를 고친다. * Google Apps Script → 구글 시트 저장 + 알림 메일. 방문 분석은 GTM(GTM-WQNTZVZH) 안의 GA4(G-013Z3BFY35)로 한다.
* 광고 픽셀(Meta·네이버 등)을 GTM 에 추가하면 제1·5·8조를 함께 고친다.
* 구조는 의료 분야 SaaS 방침(메디팔·애프터닥)의 조항 구성을 참고했고, 문장은 이 서비스에 맞춰 새로 썼다. * 구조는 의료 분야 SaaS 방침(메디팔·애프터닥)의 조항 구성을 참고했고, 문장은 이 서비스에 맞춰 새로 썼다.
*/ */
import LegalLayout, { type LegalSection } from '../components/LegalLayout'; import LegalLayout, { type LegalSection } from '../components/LegalLayout';
@ -17,6 +18,7 @@ const SECTIONS: LegalSection[] = [
'- 필수: 성함, 연락처(전화번호 또는 이메일 중 하나 이상)', '- 필수: 성함, 연락처(전화번호 또는 이메일 중 하나 이상)',
'- 선택: 회사(병원)명, 문의 내용', '- 선택: 회사(병원)명, 문의 내용',
'서비스 이용 과정에서 접속 IP 주소, 브라우저 정보, 접속 일시가 웹 호스팅 서버의 로그로 자동 생성될 수 있습니다.', '서비스 이용 과정에서 접속 IP 주소, 브라우저 정보, 접속 일시가 웹 호스팅 서버의 로그로 자동 생성될 수 있습니다.',
'방문 분석을 위해 쿠키를 통해 방문 페이지, 체류 시간, 유입 경로, 기기·브라우저 종류, 대략적인 접속 지역(도시 단위)이 수집됩니다. 이 정보로 개인을 직접 식별하지 않습니다.',
'회사는 만 14세 미만 아동의 개인정보를 수집하지 않으며, 환자의 건강 정보 등 민감정보를 서식으로 받지 않습니다. 문의 내용에 환자 개인정보를 적지 말아 주십시오.', '회사는 만 14세 미만 아동의 개인정보를 수집하지 않으며, 환자의 건강 정보 등 민감정보를 서식으로 받지 않습니다. 문의 내용에 환자 개인정보를 적지 말아 주십시오.',
], ],
}, },
@ -49,6 +51,7 @@ const SECTIONS: LegalSection[] = [
body: [ body: [
'회사는 서비스 운영을 위해 아래와 같이 개인정보 처리업무를 위탁하며, 수탁자의 서버가 국외에 있어 개인정보가 국외로 이전됩니다.', '회사는 서비스 운영을 위해 아래와 같이 개인정보 처리업무를 위탁하며, 수탁자의 서버가 국외에 있어 개인정보가 국외로 이전됩니다.',
'- Google LLC (미국): 문의 내용 저장(Google 스프레드시트) 및 접수 알림 메일 발송. 이전 항목은 제1조의 서식 입력 항목이며, 서식을 제출하는 시점에 네트워크로 전송됩니다. 보유 기간은 제3조와 같습니다.', '- Google LLC (미국): 문의 내용 저장(Google 스프레드시트) 및 접수 알림 메일 발송. 이전 항목은 제1조의 서식 입력 항목이며, 서식을 제출하는 시점에 네트워크로 전송됩니다. 보유 기간은 제3조와 같습니다.',
'- Google LLC (미국): 웹사이트 방문 분석(Google 애널리틱스, Google 태그 관리자). 이전 항목은 제1조의 방문 분석 정보이며, 웹사이트를 이용하는 시점에 네트워크로 전송됩니다. 보유 기간은 수집일부터 최대 14개월입니다.',
'- Vercel Inc. (미국): 웹사이트 호스팅 및 서식 전송 처리. 이전 항목은 서식 입력 항목과 접속 기록이며, 웹사이트 이용 및 서식 제출 시 네트워크로 전송됩니다. 보유 기간은 위탁 계약 종료 시까지입니다.', '- Vercel Inc. (미국): 웹사이트 호스팅 및 서식 전송 처리. 이전 항목은 서식 입력 항목과 접속 기록이며, 웹사이트 이용 및 서식 제출 시 네트워크로 전송됩니다. 보유 기간은 위탁 계약 종료 시까지입니다.',
'국외 이전을 원하지 않으시면 서식 대신 이메일 또는 전화로 문의하실 수 있습니다. 다만 이 경우에도 회신 메일은 회사의 메일 서비스를 통해 처리됩니다.', '국외 이전을 원하지 않으시면 서식 대신 이메일 또는 전화로 문의하실 수 있습니다. 다만 이 경우에도 회신 메일은 회사의 메일 서비스를 통해 처리됩니다.',
], ],
@ -72,8 +75,12 @@ const SECTIONS: LegalSection[] = [
{ {
title: '쿠키 등 자동 수집 장치', title: '쿠키 등 자동 수집 장치',
body: [ body: [
'회사는 현재 이 웹사이트에서 광고 또는 방문 분석을 위한 쿠키를 사용하지 않습니다.', '회사는 웹사이트 이용 현황을 파악하고 서비스를 개선하기 위해 Google 애널리틱스의 분석 쿠키를 사용합니다. 광고 목적의 쿠키는 사용하지 않습니다.',
'방문 분석 도구를 도입하는 경우 이 방침을 개정해 수집 항목, 목적, 거부 방법을 미리 알려 드립니다.', '- 수집 항목: 제1조의 방문 분석 정보',
'- 이용 목적: 방문자 수, 많이 본 페이지, 유입 경로, 문의 전환 등 이용 현황 분석',
'- 거부 방법: 브라우저의 개인정보 보호 또는 쿠키 및 사이트 데이터 설정에서 이 사이트(webforai.kr)의 쿠키를 차단하거나 삭제할 수 있습니다. Google 애널리틱스 차단 브라우저 부가기능(tools.google.com/dlpage/gaoptout)으로도 수집을 거부할 수 있습니다.',
'쿠키 저장을 거부해도 웹사이트 이용과 문의에는 제한이 없습니다.',
'광고 목적의 쿠키나 추적 도구를 도입하는 경우 이 방침을 개정해 미리 알려 드립니다.',
], ],
}, },
{ {

View File

@ -41,6 +41,15 @@ export default defineConfig(({mode}) => {
'<link rel="apple-touch-icon" href="/discovery-apple-touch-icon.png" />', '<link rel="apple-touch-icon" href="/discovery-apple-touch-icon.png" />',
] ]
: []; : [];
// 방문 측정: Google 태그 관리자(GTM) 컨테이너. GA4(G-013Z3BFY35)와 키 이벤트는 컨테이너 안에서 설정한다.
// 공개 배포(webforai.kr)에만 넣는다. 컨테이너 ID 는 공개 값이다. 개인정보처리방침 제5·8조와 함께 관리한다.
const GTM_ID = 'GTM-WQNTZVZH';
const gtmHead = isDiscovery
? [`<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${GTM_ID}');</script>`]
: [];
const gtmBody = isDiscovery
? `<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${GTM_ID}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>`
: '';
const headHtml = [ const headHtml = [
`<title>${head.title}</title>`, `<title>${head.title}</title>`,
`<meta name="description" content="${head.description}" />`, `<meta name="description" content="${head.description}" />`,
@ -50,6 +59,7 @@ export default defineConfig(({mode}) => {
`<meta property="og:locale" content="ko_KR" />`, `<meta property="og:locale" content="ko_KR" />`,
...share, ...share,
...verify, ...verify,
...gtmHead,
].join('\n '); ].join('\n ');
return { return {
@ -78,7 +88,9 @@ export default defineConfig(({mode}) => {
{ {
name: 'infinith-head', name: 'infinith-head',
transformIndexHtml(html: string) { transformIndexHtml(html: string) {
return html.replace(/<title>[\s\S]*?<\/title>/, headHtml); return html
.replace(/<title>[\s\S]*?<\/title>/, headHtml)
.replace('<body>', gtmBody ? `<body>\n ${gtmBody}` : '<body>');
}, },
}, },
tailwindcss(), tailwindcss(),