이벤트 로깅, 심지 않고 수집하기(트래킹 자동화)
data-attribute 기반 이벤트 수집을 자동화했던 경험을 공유해요.
이전 글에서 Data Attribute 기반의 선언적 이벤트 수집 방식을 소개했었어요.
이벤트 트래킹 코드가 비즈니스 로직 한가운데에 끼어 있는 게 문제였어요.
const handleSave = () => {
trackEvent('click_save_post') // 분석
savePost() // 비즈니스 로직
}
목적이 다른 두 코드가 한 함수에 섞여 있고, 이런 핸들러가 수십 개였어요.
그래서 이걸 무엇을 수집할지는 마크업에 선언하고, 실제로 보내는 일은 전역 리스너 하나가 맡는 구조로 바꿨어요.
<Button onClick={savePost} data-amplitude-event={TRACK_EVENTS.POST.SAVE.CLICK}>
저장
</Button>
document.addEventListener('click', (event) => {
const element = event.target.closest('[data-amplitude-event]')
if (element) {
trackEvent(element.dataset.amplitudeEvent)
}
})
핸들러에서 trackEvent()가 빠지면서 두 로직이 깔끔하게 분리됐어요.
그런데 뭔가... 간지러운 부분은 계속 남아있었어요..
"이벤트를 심는 일 자체를 없앨 수는 없을까?!"
분명 개선됐다고 생각은 되는데, 서비스에서 버튼 하나를 새로 추가할 때 아래와 같은 일들이 일어나요.
- 논의: 이 클릭을 수집할지 말지 정하고
- 작명: 뭐라고 부를지 합의하고 (
click_save_post?click_post_save?) - 상수 등록:
TRACK_EVENTS트리 어디에 넣을지 찾아서 추가하고 - 부착: 컴포넌트로 돌아와 속성을 붙이고
이 모든 것들이 병목이었어요. 그러다 보니 합의가 늦어지고, 이벤트를 아직 못심어서 배포가 딜레이되는 경우도 생겼어요.
더 큰 문제는 따로 있었어요. 누락돼도 아무도 모른다는거에요.
<Button onClick={handleShare}>공유하기</Button>
이 버튼은 이벤트가 안심겨있지만, 에러는 발생하지 않아요. 타입 체크도 통과하고, 빌드도 성공하고, 화면도 멀쩡하게 잘 나와요. 그냥 데이터만 안 쌓일뿐이에요.
이를 lint, CI 등으로 잡아내기에는 이벤트를 심어야하는 컴포넌트, 심지않아도 되는 컴포넌트는 기계적으로 구분할 방법이 없어요.
몇 주 뒤에 대시보드를 보다가 "어? 이 버튼 클릭률이 왜 없지?!"
이미 몇 주치 데이터가 날아간 뒤인데, 로그는 소급해서 만들 수 없어요...
마침 사내에서 Amplitude를 걷어내고 자체 이벤트 수집 서버를 만들기로 하면서, 이 구조를 처음부터 다시 설계하고자 했어요.
패턴을 보고 판단하려면 데이터가 온전해야 해요. 표본이 군데군데 빠져 있으면 그건 분석이 아니라 착시예요. "이 기능은 아무도 안 쓰네"가 사실은 "이 기능만 트래킹이 안됐네?"일 수 있거든요. 그리고 더 무서운 건, 빠졌다는 사실 자체를 데이터로는 알 수 없다는 점이에요.
불필요한 사용자 행위라는 것도 딱히 없어요. 눌렀다가 바로 뒤로 간 것도, 스크롤하다 멈춘 지점도 전부 신호예요. "이건 안 봐도 되는 클릭"이라고 판단하는 순간 그 신호를 우리 손으로 지우는 거고요.
처음엔 아무도 신경 안 쓰던 버튼이 나중에 핵심 지표가 되기도 하고, 중요할 거라 생각했던 클릭이 아무 의미 없기도 했어요. 심는 시점의 우리는 그걸 알 방법이 없어요.
그래서 요구사항이 이렇게 정리됐어요.
- 유실은 용납하지 않는다: 클릭은 전부 쌓는다
- 사람이 개입하지 않는다: 모든 이벤트가 자동으로 심기고,
이 두 줄을 만족하는 답은 하나뿐이었어요.
다 심고, 그리고 그걸 알 필요 없게 하자!
1) 라벨은 이미 DOM에 있어요
<Button onClick={handleNext}>다음</Button>
이벤트 이름으로 쓰고 싶은 다음이라는 글자가, 이미 화면에 있어요.
당연한 얘기예요. 사용자한테 보여줘야 하는 글자고, 버튼에 라벨이 없으면 그건 버튼이 아니잖아요. 그러니까 우리는 지금까지 이미 있는 정보를 옆에 한 번 더 적고 있었던 거예요.
<Button onClick={handleNext} data-amplitude-event="click_next">
다음 {/* ← 여기 이미 있는데 */}
</Button>
그러니까 굳이 심을 필요가 없어요. 클릭이 일어난 순간에 화면에서 읽어오면 되니까요.
2) "추적 대상"의 정의를 바꿨어요
문제는 "그럼 어떤 요소를 추적 대상으로 볼 거냐"였어요. 처음엔 "클릭 핸들러가 달린 요소" 라고 생각했는데, 이게 함정이었어요. onClick이 달렸는지는 소스 코드에만 보여요. 렌더링된 DOM에는 그 흔적이 없거든요.
onClick은 결국 자바스크립트에서 addEventListener로 붙이는 리스너예요. 리스너는 DOM 속성이 아니라 브라우저 메모리에만 있어서, 렌더링된 요소를 아무리 들여다봐도 "여기 핸들러가 있다"는 표시가 없어요.
<Button onClick={handleNext}>다음</Button>
그래서 이 정의를 쓰려면 빌드 타임에 소스를 뒤져 DOM으로 정보를 옮겨 적어야 해요.
그건 좀 아니다 싶어서, 정의를 바꿨어요.
| 추적 대상의 정의 | 그 정보가 있는 곳 | |
|---|---|---|
| 처음 발상 | onClick 핸들러가 있다 | 소스에만 |
| 최종 선택 | 시맨틱 태그다 | DOM에 이미 |
버튼은 <button>이고 링크는 <a>에요. 이건 렌더링된 DOM에 그대로 남아 있어요.
그랬더니 전체 수집 로직이 이렇게 됐어요.
const CLICKABLE_SELECTOR =
'button, a, input, select, textarea, label, [role="button"]'
const findClickable = (target: EventTarget | null) => {
if (!(target instanceof Element)) {
return null
}
return target.closest<HTMLElement>(CLICKABLE_SELECTOR)
}
빌드 도구 0개, 주입 0개예요. 클릭한 지점에서 가장 가까운 클릭 가능 요소를 찾아 올라가는 게 전부예요.
3) 그럼 이름은 누가 짓지?
마킹을 없앴으니 이제 진짜 병목이던 작명이 남았어요. 그런데 이것도 고민할 게 없었어요. 정답이 이미 화면에 다 있습니다!
사용자가 클릭하는 순간을 그대로 문장으로 옮겨보면 이래요.
"어떤 화면에서 어떤 요소를 어떻게 했다"
셋 다 런타임에 알 수 있어요. 지금 보고 있는 화면은 URL이 말해주고, 어떤 요소인지는 그 요소의 라벨이, 무엇을 했는지는 이벤트 종류가 말해주죠. 그래서 이 셋을 그대로 이름 문법으로 만들었어요.
((page))#((fragment)).((verb))
| 슬롯 | 질문 | 어디서 오나 |
|---|---|---|
page | 어떤 화면에서 | location.pathname |
fragment | 어떤 요소를 | DOM 텍스트 또는 aria-label |
verb | 어떻게 했나 | 이벤트 종류 (click·view·scroll…) |
실제로 조립되면 이렇게 나와요.
checkout#다음.click
history#이전-날짜.click
products/[id]#구매하기.click
다음 버튼이 100개여도 checkout#다음과 signup#다음은 다른 이름이에요. 이름만 보고도 어느 화면의 무슨 버튼인지 바로 읽히죠.
한 가지 손댈 부분은 page였어요. location.pathname을 그대로 쓰면 사용자 수만큼 화면 이름이 생기거든요. 그래서 값이 바뀌는 세그먼트는 [id]로 접었어요.
/history/2026-08-11 → history/[id]
/products/1024 → products/[id]
이제 새 버튼을 추가할 때 이벤트를 위해 쓰는 코드가 0줄이 됐어요.
<Button onClick={handleNext}>다음</Button>
// → checkout#다음.click 으로 자동 수집
4) Transpiler는 ?!
사실 여기까지 오기 전에 Transpiler로 푸는 방법을 먼저 검토했어요.
빌드 타임에 JSX를 순회하면서, onClick이 달린 요소에 마킹 속성을 자동으로 주입하는 거예요. 그럼 개발자는 아무것도 안 붙여도 되고, 런타임 로직은 기존 방식 그대로 쓸 수 있어요.
이 방식이 더 나은 점도 분명히 있어요.
<div onClick>도 잡아요. 시맨틱 태그가 아니어도 핸들러만 있으면 되니까요.- 컴포넌트 정체성을 알 수 있어요. "디자인 시스템의 Button 클릭률"같은 건 소스에만 있는 정보라, DOM만 봐서는 잴 수 없어요.
그런데 문제는 ...
첫째, 커스텀 컴포넌트에서 속성이 증발해요.
// 빌드 타임에 플러그인이 data-click-log 를 자동으로 주입해요
<Button onClick={handleNext} data-click-log>
다음
</Button>
주입 대상이 DOM 태그가 아니라 Button이라는 컴포넌트예요. 그럼 Button 내부에서 {...rest}를 실제 <button>까지 흘려보내야 속성이 살아남아요.
둘째, 이름은 빌드 타임에 구울 수가 없어요. 이게 더 결정적이었어요.
<Button onClick={onClick}>{label}</Button>
버튼 라벨의 상당수가 이렇게 {label}이나 {children}으로 넘어와요. 실제 글자가 뭔지는 런타임에야 정해지고요.
그러니까 Transpiler를 쓰더라도 이름은 결국 클릭하는 순간 DOM에서 읽어야 해요. 그럼 마킹만 자동화되고 작명은 그대로 남는 셈인데, 저희 병목은 마킹이 아니라 작명이었거든요. 빌드 파이프라인을 하나 늘리는 비용까지 생각하면 생산성이 크게 개선되지 않을거라고 생각했어요.
그래서 최종적으로 DOM을 직접 읽는 방식으로 진행했어요.
5) 읽으려면 클릭 "전"에 읽어야 해요
여기서 예상 못 한 함정이 하나 있어요. data-attribute 방식에서는 리스너를 이렇게 달고 있었어요.
document.addEventListener('click', handleClick) // 버블링
근데 이러면 결제 버튼이 이렇게 찍혀요.
결제-처리-중
버튼에 원래 쓰여 있던 글자는 9,900원 결제하기인데요. (pending 동안은 문구가 바뀌는 구조)
이유는 React의 렌더링 타이밍이었어요. 클릭하면 React가 핸들러를 실행하면서 setState를 동기적으로 반영하니까, 이벤트가 document까지 버블링돼서 올라올 쯤에는 버튼이 이미 로딩 상태로 다시 그려져 있는 거예요.
클릭 → React 핸들러 → setState 동기 반영 → 버튼이 "결제 처리 중…"으로 리렌더
↓
버블링 도착 → 여기서 읽으면 이미 늦음!
그래서 이벤트 캡처링 방식으로 변경했어요.
document.addEventListener('click', handleClick, { capture: true })
캡처링은 document에서 타깃으로 내려가는 단계라, React 핸들러가 실행되기 전에 먼저 읽을 수 있어요.
여기까지 오고 나서 꽤 만족했어요. 아무것도 안 심어도 다 쌓이니까요.
그러다 실제 이벤트 네임이 어떻게 찍히는지 봤더니..
#민하님-맞춤-추천받기.click 1
#덕배님-맞춤-추천받기.click 1
#철수님-맞춤-추천받기.click 1
#영희님-맞춤-추천받기.click 1
...
버튼은 하나인데 이벤트 이름은 유저 수만큼 생겨 있었어요.
<Button onClick={handleRecommend}>{userName}님 맞춤 추천받기</Button>
이름을 DOM 텍스트에서 읽으니까, 개인화된 문구가 그대로 이름이 되어버린 거예요.
문제가 두 개 동시에 터져요.
하나, 분석이 힘들어져요. 같은 버튼의 클릭률을 세려면 유저 이름을 전부 알아야 하고, 유저가 늘면 이벤트 이름도 같이 늘어나요.
둘, 개인정보가 이벤트 이름에 붙을 수 있어요. 대시보드에도, 로그에도, DB에도요... 😱
실제로 쌓인 데이터들을 계속 보니 비슷한 문제들이 발견됐어요.
| 증상 | 실제로 찍힌 이름 |
|---|---|
| 유저 이름 | 민하님-맞춤-추천받기 |
| 날짜 | 26년 8월 11일 (화) |
| 금액 | 2,800원 결제하기 |
1) 정규화로 거르기
먼저 이름을 만들 때 기계적으로 거를 수 있는 건 걸러내기로 했어요.
const normalizeLabel = (raw: string) =>
raw
.replace(/\d+/g, '') // 숫자 제거
.replace(/[^\p{L}\s_-]+/gu, '') // 이름에 쓸 수 없는 문자 제거
.trim()
.replace(/\s+/g, '-')
.slice(0, 24) // 길이 상한
날짜나 금액처럼 값이 계속 바뀌는 문구는 숫자만 지워도 하나로 모여요. 문장이 통째로 들어오는 경우는 길이 상한이 잘라주고요.
다만 여기까지는 폭발을 막는 안전장치일 뿐이에요. 유저 이름은 숫자도 특수문자도 아니라서 이 그물에 안 걸리거든요. 진짜 해결은 다음 단계였어요.
2) 예외만 선언하기
그래서 자동 추론이 틀리는 자리에만 사람이 개입하도록 했어요. 상황별로 네 가지 탈출구를 뒀어요.
라벨을 이름으로 쓰면 안 될 때: data-event
<Button onClick={handleRecommend} data-event="맞춤-추천">
{userName}님 맞춤 추천받기
</Button>
이러면 화면에는 민하님 맞춤 추천받기가 그대로 보이고, 이벤트만 #맞춤-추천으로 모여요.
항목 수만큼 이름이 늘어날 때: data-event-group
탭이나 목록처럼 항목마다 텍스트가 다른 경우예요. 조상 요소에 걸어두면 하위 클릭이 전부 한 이름으로 모여요.
<div data-event-group="카테고리-탭">
<Tab>전체</Tab>
<Tab>인기</Tab>
</div>
읽을 텍스트가 아예 없을 때: aria-label
아이콘만 있는 버튼들은 별도 속성을 만들지 않고 접근성 이름을 그대로 써요.
<button aria-label="알림">
<BellIcon />
</button>
스크린 리더가 읽어야 할 이름과 이벤트 이름이 자연스럽게 같은 경우거든요. 하나 붙이면 두 곳에서 값을 하게돼요. (원래도 붙어있어야한다! for 접근성)
아예 세면 안 되는 클릭: data-event-ignore
새 버튼을 추가할 때 이벤트를 위해 쓰는 코드는 이제 엄청나게 간단해졌어요.
// 1편 — 파일 3개를 오가며...
<Button onClick={handleNext} data-amplitude-event={TRACK_EVENTS.POST.NEXT.CLICK}>
다음
</Button>
// 지금 — 0줄
<Button onClick={handleNext}>다음</Button>
하지만 한계도 있어요. 렌더링된 DOM만 읽으면 컴포넌트 정체성은 영원히 못 봐요. <Button>이든 <CardButton>이든 렌더되고 나면 전부 그냥 <button>으로 읽어요.
그리고 product_id 같은 값은 화면에 안 보이니까 DOM에 있을 이유가 없어요. 그리고 소스 코드에도 "이 버튼의 분석 속성은 product.id다"라는 정보는 없어요. 그래서 필요한 속성은 여전히 선언해줘야해요.
아무튼, 이번 이벤트 트래킹 자동화를 설계하면서 "어떻게 하면 잘 심을까"를 계속 물었으면 lint 룰이나 플러그인을 만들고 있었을 거예요.
"왜 심어야 하지?"로 질문을 바꾸니까 심을 필요가 없는 지점이 보였고요.