이벤트 분석 도구 SDK를 제작하며 생긴 일들
사내 이벤트 분석 도구 SDK 설계 과정과 트러블 슈팅을 공유해요.
지난 글에서 이벤트 이름을 사람이 짓지 않고 화면에서 읽어오는 수집기를 소개했어요.
Amplitude를 걷어내고 사내 자체 수집 서버로 옮기면서 만든 도구예요. 이번에는 이를 사내 SDK로 배포하며 고민했던 과정들을 공유하려고합니다.
"Amplitude를 떼어내면서, Amplitude만큼, 혹은 보다 쉽게 만들자!"
목표는 하나였어요. 익숙한 방식은 그대로, 심는 양은 더 적게. 많은 팀들이 옮겨야 하니 갈아타는 비용이 0에 가까워야 했어요.(최대한 동일하거나 개선된 DX를 제공하자!)
지금 새 앱에 수집기를 붙일 때 개발자가 쓰는 코드는 이게 전부예요.
export const eventCollectorConfig: EventCollectorConfig = {
service: 'my-app',
endpoint: process.env.NEXT_PUBLIC_EVENT_API_URL,
identity: () => (user ? { type: 'user', id: String(user.id) } : null),
}
<EventCollectorProvider config={eventCollectorConfig} />
이렇게 되기 전까지, 앱은 수집기를 붙이려고 이런 걸 알고 있어야 했어요.
// 기존 코드
<Suspense>
<EventCollectorProvider />
</Suspense>
수집기를 붙이는데 왜 <Suspense>가 나오는지부터 이상하다고 느낄거에요. 데이터를 기다리는 것도, 폴백을 보여줄 필요도 없는데?!
1) 이름을 지으려면 라우터가 필요해요
수집기는 이벤트 이름에 "지금 어떤 화면인가"를 넣어야 해서 URL을 봐야해요. ?tab=인기처럼 쿼리 값이 화면을 가르는 앱도 있어서 쿼리 스트링까지 반드시 필요합니다.
그런데 URL 문자열만으로는 부족해요. /mart/1024를 그대로 이름에 쓰면 상품 개수만큼 페이지 이름이 생기거든요. 2편에서 이벤트 이름이 터진 것과 같은 일이에요. mart/[id]로 접으려면 어느 세그먼트가 동적값인지를 알아야 하는데, 그건 URL에 없어요. 라우터만 알아요.
그래서 라우트는 라우터에서 읽어요. Next라면 next/navigation이 pathname·params·쿼리를 다 알려주니 앱은 Provider 한 줄이면 되고요.
2) Next는 알아서 알려주는데, 대가가 있어요
하지만... App Router에서 useSearchParams()를 쓰면 가장 가까운 Suspense 경계까지가 통째로 프리렌더에서 빠져요. 던지는 쪽은 Next예요. 서버엔 아직 줄 쿼리가 없으니 값 대신 예외를 던져요. 스트리밍 SSR에서 경계 안쪽이 던지면 React는 렌더 전체를 실패시키지 않고 그 경계까지만 포기해요. HTML에는 폴백을 넣고, 이 자리는 브라우저에서 다시 그리라고 표시해두고요.
잡아줄 경계가 없으면 라우트 최상단까지 올라가요. 수집기 하나 붙였다고 페이지 전체가 클라이언트 렌더가 되는 거예요. 그래서 Next 앱은 Provider를 <Suspense>로 감싸야 했어요. 아까 그 코드가 이거고요.
3) 경계를 SDK 안으로
그런데 프리렌더가 어떻고 경계가 어떻고는 수집기의 사정이지 앱의 사정이 아니에요. 그래서 경계를 SDK 안으로 들여놨어요.
import { EventCollectorProvider } from '@my-org/event-collector' // 앱이 route 를 넘긴다
import { EventCollectorProvider } from '@my-org/event-collector/next' // SDK 가 알아서 한다
/next 입구는 next/navigation으로 라우트를 읽고 <Suspense>도 안에 있어요. 앱은 컴포넌트 한 줄만 쓰면 되고요.
둘이 공유하는 건 RouteTracker 하나예요. 라우트 스냅샷을 받아 view/exit·클릭·스크롤을 수집하는 본체고, 어댑터는 그 위에 "라우트를 어디서 구해오는지"만 얹은 껍데기예요. 프레임워크가 하나 늘어도 본체는 안 건드려요.
여기까지 정리하고 나니 기준이 잡혔어요. 앱이 알아야 할 건 "이 앱은 누구인가"뿐이에요. 나머지는 전부 수집기가 자기 안에서 감당할 몫이고요.
이걸 다 걷어내고 남은 게 맨 앞에서 본 그 객체예요. 앱마다 다른 사정은 libs/event 파일 하나에 모아뒀고, 호출부는 그 파일만 바라봐요. 덕분에 워크스페이스 패키지를 SDK로 갈아탈 때 호출부 수정이 0건이었어요.
Amplitude를 써봤다면 초기화는 이 모양이 익숙할 거예요.
amplitude.init(API_KEY, options)
저희도 처음엔 같은 모양을 생각했어요. 그런데 이 방식엔 조건이 하나 숨어 있어요.
"모든 track()보다 init()이 먼저 실행된다"는게 보장될까?
호출 지점은 앱 전체에 흩어져 있고, 실행 순서는 import 순서와 코드 스플리팅에 따라 바뀌어요. 타입도 린터도 못 잡아요. 지키는 방법이 "개발자가 조심한다"뿐인 규칙이에요. 앞에서 본 부수효과 트릭이 이걸 지키려던 장치였고요.
1) Provider로 감싸면 되는 거 아닌가?
저도 그렇게 생각했어요. 흔한 패턴이 이거예요.
export const AnalyticsProvider = ({ children }: PropsWithChildren) => {
useEffect(() => {
initAnalytics() // 당연히 effect는 먼저 실행되겠지?
}, [])
return <>{children}</>
}
하지만!! React의 두 단계는 방향이 반대예요. 렌더는 부모부터, effect는 자식부터 돌아요.
렌더는 부모부터, effect는 자식부터라서 감싸기만으로는 순서가 보장되지 않아요.
감싸는 구조가 보장하는 건 "부모가 먼저 렌더된다"까지예요. init을 effect에 두면 자식의 마운트 effect에서 쏘는 첫 화면 view나 진입 트래킹이 전부 init보다 먼저 실행돼요.
2) 그런데도 Amplitude는 왜 멀쩡히 동작할까?
내부 큐가 받아주기 때문이에요. init 전에 들어온 track을 버퍼에 쌓아뒀다가 init 순간 재생해요.
본체가 도착하기 전의 호출을 배열이 받아뒀다가, 준비되면 순서대로 재생해요.
프레임워크를 모르는 범용 SDK 입장에선 이게 정답이에요. 순서를 강제할 수단이 없으니, 강제할 수 없는 규칙을 "나중에라도 맞춰지는" 것으로 바꾼 거예요. 대가는 있어요. 버퍼된 이벤트는 발생 시점이 아니라 재생 시점의 설정을 따라요.
3) 초기화를 렌더 단계로 올렸어요
저희 SDK의 소비자는 전부 React 위에 있어요. 그러면 버퍼 없이 풀려요. 초기화를 렌더 단계에 두면 돼요.
export const EventCollectorProvider = ({ config }: Props) => {
if (config) {
configureEventCollector(config) // effect가 아니라 렌더 중(동기 적용)
}
return <RouteObserver />
}
configure가 렌더 구역으로 올라오면 순서가 구조로 보장돼요.
track()이 불릴 수 있는 자리는 이벤트 핸들러 아니면 effect뿐이고, 둘 다 커밋 뒤에 실행돼요. 렌더는 커밋보다 앞서고요. 그러니까 어떤 호출 지점에서도 설정이 track보다 먼저인 게 React 실행 순서의 구조로 성립해요. 규칙에 기대지 않고요.
Vercel Analytics(<Analytics />)와 PostHog(<PostHogProvider apiKey={…}>)도 같은 모양인 걸 보면, React를 전제할 수 있는 SDK는 대체로 여기로 오는 것 같아요.
지금까지는 설계과정이었고, 여기서부터는 실제로 SDK로 떼어내 배포하고, 앱에 붙여 테스트를 진행해보며 만난 문제들입니다.
1) hydration missmatch
아마 Nextjs를 사용하는 유저들은 익숙한 많이 익숙한(?) 문장일거에요.
화면 하나를 다른 제품이 iframe으로 보여주는 기능이 있어요. 그 트래픽은 지표에서 빼야 해서 이런 게이트를 넣었어요.
// 기존 코드
export const EventCollectorProvider = () => {
if (isEmbedded()) {
return null // 임베드면 수집기 자체를 안 그린다
}
return (
<Suspense>
<CollectorProvider />
</Suspense>
)
}
그리고 어느 날부터 Sentry에 Hydration Error 알럿이 쌓이기 시작했어요.
hydration은 서버가 만든 HTML 위에 React가 얹히는 과정이에요. 서버가 그린 결과와 브라우저가 그린 결과가 같아야 얹을 수 있어요.
문제는 isEmbedded()가 window.self !== window.top으로 판정한다는 거예요. 서버에는 창이라는 개념이 없어요. 그래서 서버에선 무조건 false(=수집기를 그린다), iframe 속 브라우저에선 무조건 true(=안 그린다). 같은 컴포넌트가 두 곳에서 반대 답을 냅니다.
그런데 수집기는 화면에 아무것도 안 그리는데...? 어째서?
아까 봤던 <Suspense>가 어긋났어요. React는 경계를 서버 HTML에 주석 마커(<!--$--><!--/$-->)로 적어 보내는데, 자식이 아무것도 안 그려도 이 마커는 DOM에 실제로 존재하는 노드예요. 서버가 보낸 HTML엔 마커가 있고 브라우저가 계산한 결과엔 없으니, 첫 자리부터 안 맞습니다.
그러면 React는 서버 HTML을 통째로 버리고 처음부터 다시 그려요.(임베드로 열릴 때마다 계속)
처음에는 분기를 경계 안쪽으로 옮겨서 해결했어요. 경계 자체는 서버든 브라우저든 항상 그려지니까 마커가 어긋나지 않아요.
// 급한 불 끄기 — 분기를 <Suspense> 안으로
return <Suspense>{isEmbedded() ? null : <CollectorProvider />}</Suspense>
근데 사실 "임베드에서는 수집 안 함"은 그리기가 아니라 전송의 결정이에요. 무엇을 그릴지가 아니라 무엇을 보낼지의 문제인데, 그걸 렌더 단계에서 내리니 hydration이라는 그리기 규칙과 부딪힌 거예요.
근본적으로 해결하려면 이전송 쪽으로 옮기면 됩니다.
export const eventCollectorConfig: EventCollectorConfig = {
// 임베드 판정을 "보낼 곳이 있는가"로 번역한다 — 렌더 밖에서 실행되므로 hydration과 무관
endpoint: isEmbedded() ? undefined : process.env.NEXT_PUBLIC_EVENT_API_URL,
// ...
}
Provider는 항상 마운트되고 렌더 게이트는 통째로 삭제됐어요. SDK는 "임베드"라는 개념을 아예 몰라도 되고요. SDK가 아는 건 "보낼 곳이 있느냐"뿐이고, 임베드인지 아닌지를 판단하는 건 앱의 몫으로 남았어요.
2) 로그인 세션의 82%가 익명으로 찍히다..
수집되는 데이터를 보다가 이상한 걸 발견했어요. 로그인한 세션의 82%가 첫 화면 조회를 익명으로 남기고 있었어요.
원인은 시간이었어요. 첫 화면의 .view는 마운트 직후 동기로 발행되는데, 신원은 유저 정보 API 응답이 와야 setIdentity()로 도착해요. 그 사이에 낀 이벤트는 어떤 수집기를 써도 익명이에요.
SDK에 이런 경우를 위한 정책이있어요. 신원 없는 이벤트를 붙들었다 나중에 붙여 내보내고, 끝내 안 오면 버리는 정책이요. 그런데 이건 안전망이지 해결책이 아니에요.
그런데 신원이 정말 비동기인가?
로그인 상태는 페이지 로드 시점에 이미 확정돼 있어요. accessToken 쿠키가 있으니까요. 늦는 건 신원이 아니라 신원을 읽는 수단이었어요.
identity: () => resolveIdentityFromToken(getCookie('accessToken')),
게터가 토큰을 동기로 디코드하니 첫 이벤트부터 신원이 붙고 레이스가 소멸했어요. 로그아웃은 토큰 삭제라 자동으로 익명이 되고요. API 응답을 기다리던 setIdentity 호출은 지웠어요.
3) 서킷 브레이커
전송 계층에는 서킷 브레이커가 있어요. 실패가 일정 횟수 쌓이면 회로를 열어 한동안 전송을 끊는 장치에요.
그런데 서버 렌더 중에 track()이 불리는 경로가 있었어요. 페이로드를 만들려면 window가 필요한데 서버에는 없으니 생성이 실패해요. 여기까진 어쩔 수 없어요. 문제는 그 실패가 전송 실패와 같은 카운터에 세졌다는 거예요.
왼쪽 갈래는 몇 번을 다시 해도 실패해요. 그런데 오른쪽과 구분 없이 같은 카운터에 쌓였어요.
이 실패는 재시도해도 소용이 없어요. 서버에는 window가 앞으로도 없으니까요. 몇 번을 다시 하든 100% 실패하니 카운터는 순식간에 임계치를 넘어요.
// breaker.ts
const state = { failures: 0, openedAt: 0 }
브라우저에서는 이게 탭 하나를 뜻하니 문제가 없어요. 그런데 서버에서는 Node 프로세스 하나를 뜻하고, 그 프로세스는 들어오는 요청을 돌아가며 처리해요.
그래서 어떤 요청의 서버 렌더에서 난 실패가 카운터에 쌓이고, 그게 임계치를 넘으면 같은 프로세스가 처리하는 다른 요청들까지 60초 동안 전송이 막혀요. 수집 서버는 사실 멀쩡한데..
서버에서는 이벤트를 만들 수 없다는 게 확실하니까,
export const sendEvent = (input) => {
if (typeof window === 'undefined') return // 서버면 early return
// ...
}
이렇게 하면 실패로 카운팅하지 않게 돼요.
브레이커는 수집 서버가 건강한가?를 재는 장치인데, 그동안 종류를 안 가리고 다 카운팅했어요. 이제 서버가 안 받아준 실패만 카운팅합니다. 이벤트를 못 만들었거나 설정이 빠진 건 제 코드 문제라, 서버 상태와 아무 상관이 없으니까요.
돌아보면 이 글의 결정들은 대부분 "어디에 둘 것인가" 였어요. 초기화를 어느 단계에 둘지, 임베드 판정을 그리기에 둘지 보내기에 둘지, 실패를 어느 카운터에 셀지... 그리고 그 결정은 최대한 사용하는 개발자는 번거로운 로직을 몰랐으면 좋겠는 방향으로 설계했어요.(for DX)
지금 새 앱에 수집기를 붙이는 개발자는 프리렌더도, 경계도, 초기화 순서도 몰라도 돼요. config 객체 하나 선언하고 컴포넌트 한 줄 쓰면 끝이고, 나머지는 SDK가 자기 안에서 감당해요.