이마트앱 프론트엔드에는 GTM, Airbridge, shinsegae.ai 검색 행동 로그, EAPP Tracker가 공존했습니다. 각 화면과 Store가 외부 시스템의 이벤트명과 payload를 직접 만들고 있었기 때문에 같은 사용자 행동이 서로 다른 이름으로 기록되고, 새로운 분석 도구를 추가할 때마다 호출부를 다시 수정해야 했습니다.
이 문제를 해결하기 위해 화면은 행동의 의미만 한 번 발행하고, 중앙 모듈이 각 벤더 규약으로 변환하는 Canonical Tracking 구조를 설계하고 구현했습니다.
문제 상황
기존 구조에는 세 가지 문제가 있었습니다.
첫째, 화면과 Store가 Airbridge와 검색 Tracker를 직접 호출했습니다. 이벤트 이름과 vendor 필드가 비즈니스 코드에 분산되어 변경 영향 범위를 파악하기 어려웠습니다.
둘째, click과 실제 업무 성공이 섞여 있었습니다. 구매나 취소 버튼을 누른 사실만으로 flow가 시작되거나 성공했다고 해석하면 미로그인, 검증 실패, 서버 거절까지 정상 전환으로 집계할 수 있습니다.
셋째, 범용 Record<string, unknown> attributes가 canonical 필드와 개인정보를 다시 넣을 수 있는 탈출구였습니다. TypeScript 타입만으로는 JavaScript 호출, 타입 단언, 저장 데이터에서 들어오는 잘못된 값을 막을 수 없었습니다.
설계 결정
이벤트 키를 domain.area.object.action으로 통일했습니다. domain은 이벤트를 소유하는 bounded context, area는 분석 목적, object는 행동 대상, action은 의미 있는 행동을 담당합니다.
사용자 조작 경로와 처리 결과는 별도 축으로 분리했습니다.
action = 취소, 완료, 선택 같은 의미
interaction = 버튼, 뒤로가기, 닫기, gesture 같은 입력 경로
outcome = attempted, accepted, succeeded, rejected 같은 처리 결과
Catalog가 이벤트 의미와 전송 정책의 단일 원천이 되도록 했습니다. 화면은 destination을 선택하지 않으며, Catalog와 adapter가 GTM, Airbridge, Search Tracker, EAPP Tracker로 전송할 payload를 결정합니다.
닫힌 attributes 계약
초기 설계에는 BaseTrackInput.attributes?: Record<string, unknown>가 있었습니다. 하지만 이 구조는 action, scope, APP_CID 같은 값을 attributes에 다시 넣을 수 있었습니다.
따라서 BaseTrackInput에서 attributes를 제거하고 이벤트별 닫힌 계약을 도입했습니다.
interface OrderCompleteAttributes {
paymentMethod?: string;
}
interface AirbridgeSearchAttributes {
query: string;
}
Search Tracker와 EAPP Tracker 타입의 index signature도 제거했습니다. HMC payload는 product_type, screen_id, check_list만 받도록 좁혔습니다.
컴파일 타입을 우회한 입력도 막기 위해 event key별 런타임 validator registry를 만들었습니다. Validator는 허용되지 않은 키, 타입 불일치, canonical 예약 키, APP_CID와 userId, 중첩 객체와 배열의 금지 키를 adapter 호출 전에 거부합니다.
개인정보 경로 피봇
EAPP Tracker adapter가 APP_CID를 읽지 않더라도 기존 sendTrackerEvent를 호출하면 내부에서 APP_CID를 해시한 user_id가 다시 추가되는 문제를 발견했습니다.
주석으로 위험을 남기는 대신 전송 경로를 바꿨습니다. Canonical EAPP adapter는 공통 메서드를 우회하고 useApiStore().requestApi()로 승인된 envelope를 직접 전송합니다. APP_CID 쿠키가 있는 테스트에서도 payload에 user_id가 없음을 검증했습니다.
이 경험을 통해 개인정보 불변식은 호출부만이 아니라 최종 transport까지 추적해야 한다는 원칙을 얻었습니다.
멀티웹뷰 Flow
구매 flow는 여러 웹뷰를 지나므로 startFlow, resumeFlow, end로 연결했습니다. localStorage에는 flowId, event key, sequence, 시작·만료 시각, schema version만 저장합니다. 주문번호, 상품, 검색어, 사용자 식별자, 전체 이벤트는 저장하지 않습니다.
TTL은 30분이며 terminal 이벤트를 발행하면 즉시 제거합니다. 만료나 같은 종류의 새 flow로 대체됐다는 이유만으로 abandoned 이벤트를 만들지 않습니다.
Clean cutover
Canonical 호출을 추가할 때 같은 변경에서 기존 직접 호출을 제거했습니다. Airbridge, Search Tracker, HMC EAPP 호출부를 기능군별로 이전해 동일 의미 이벤트가 두 번 전송되는 기간을 만들지 않았습니다.
검증 결과
관련 Tracking, Airbridge, Router 테스트 19건이 모두 통과했습니다. 전체 lint에서 신규 오류는 없었고, vue-tsc 결과는 기존 baseline과 동일했습니다. build:dev도 성공했습니다.
닫힌 attributes 작업에서는 다음을 추가로 검증했습니다.
- 미등록 attributes 키는
accepted:false를 반환합니다. - attributes가 없는 이벤트에 attributes를 전달하면 거부됩니다.
- 중첩 APP_CID와 canonical 예약 키를 거부합니다.
- 잘못된 상품 item attributes를 거부합니다.
- 거부된 이벤트는 GTM과 Airbridge adapter를 호출하지 않습니다.
비즈니스와 운영 의미
이 구조는 특정 분석 SDK를 추가한 작업이 아닙니다. 사용자 행동 의미와 벤더 전송 계약을 분리해 새로운 분석 도구, consent 정책, 이벤트 schema 변경을 중앙에서 관리할 수 있게 했습니다.
구매·취소 퍼널에서는 사용자 시도와 실제 서버 성공을 분리할 수 있고, client event를 주문 원장과 대조할 기반도 확보했습니다. 개인정보와 중복 이벤트를 기술적 불변식으로 강제해 분석 데이터 품질을 높였습니다.
일반화한 원칙
- 범용 metadata bag은 확장성이 아니라 계약 우회 경로가 될 수 있습니다.
- 외부 입력은 TypeScript 타입과 런타임 validator를 함께 적용해야 합니다.
- 사용자 입력, 의도, 업무 결과는 서로 다른 축으로 모델링해야 합니다.
- 개인정보 금지는 최종 transport까지 추적해야 합니다.
- Legacy tracker를 통합할 때 dual-write보다 기능군별 clean cutover가 안전합니다.
관련 지식
- emart-canonical-tracking
- emart-architecture
- 캐시를-활용한-웹뷰간-상태-공유