Best Practice - 정적 타입 분석을 통한 데이터 텍소노미 구조화하기
개요
현대 서비스 기업에서 데이터 분석과 사용자 행동 추적은 필수적인 요소로 자리잡고 있다.
그러나 이러한 중요성에도 불구하고, 많은 스타트업에서 Google Analytics(GA)의 주요 관리 권한은 마케팅 조직이 보유하고 있는 실정이다.
(우습게도, 마케팅 조직에서는 개발자와 협업하는 것 자체가 피로하다는 이유로, 적극적으로 도구를 활용하지 못하기도 함.)
이로 인해 프론트엔드 개발자의 역할은 GA 설치, Google Tag Manager(GTM) 구현, Deep Link 추가 등 제한적인 영역에 국한되어 있는 경우가 많다.
최근 그로스 마케팅의 고도화로 인해 개발자의 역할과 책임이 점차 확대되고 있다.
그러나 이에 따른 이벤트 추적 코드의 효율적 관리와 유지보수는 여전히 큰 과제로 남아있다.
(레퍼런스가 별로 없기도 하고..)
이러한 문제를 해결하기 위해 데이터 텍소노미를 구조화하는 방법으로 정적 타입 분석과 Zod 라이브러리를 활용한 경험을 공유하고자 한다.
배경
모든 문제는 제품팀으로부터 데이터 텍소노미가 정의된 엑셀 시트를 전달받으면서 시작되었다.
PO : "여기 우리가 추적하고 싶은 모든 이벤트가 정리되어 있으니, 이걸 구현해 주세요. 이거 만드느라 밤을 샜어요. 흐흑"
그 엑셀 시트를 열어보니... 어마어마했다.
페이지 열기, 버튼 클릭, 스크롤, 결제 등등 정말 다양한 이벤트가 있었고, 각 이벤트마다 추적해야 할 파라미터도 한가득이었다.
처음에는 가끔은 머리를 비우고 노가다하는것도 해야하는 것이라고 생각했지만, 여러 문제에 부딛혔다.
문제점
이 과정에서 다음과 같은 문제점들이 대두되었다:
- 엑셀 시트의 내용이 너무 많고 자주 바뀌었다.
개발팀이 작업을 시작하니, 마케팅팀에서 "이 데이터도 필요해요!", "이 이벤트는 이렇게 바꿔주세요!" 하는 요청이 계속 들어왔다. - 이벤트 호출 코드가 여기저기 흩어져 있었다.
버튼 클릭 이벤트는 이 파일, 페이지 뷰 이벤트는 저 파일... 나중에는 어디에 뭐가 있는지 기억도 안 났다. - 변경 사항을 적용하기가 너무 어려웠다.
하나를 바꾸면 다른 곳에서 문제가 생기고, 그걸 고치면 또 다른 문제가 생기고... - 오류가 났을 때 원인을 찾기가 너무 힘들었다.
"이 이벤트가 왜 안 찍히지?" 하고 디버깅하다 보면 하루가 다 지나갔다.
해결 방안
상기한 문제들을 해결하기 위해, 본 팀은 이벤트 호출 시 사용되는 파라미터를 구조화하기로 결정하였다.
그러다 발견한 게 바로 '정적 타입 분석'과 'Zod' 라이브러리'였다.
TypeScript: 정적 타입 분석
TypeScript를 사용하여 이벤트 타입과 구조를 정의함으로써, 컴파일 시점에서 많은 오류를 잡아낼 수 있었다.
// 이벤트 타입 정의
type EventType = 'PAGE_VIEW' | 'BUTTON_CLICK' | 'PURCHASE';
// 이벤트 구조 정의
interface Event {
type: EventType;
properties: Record<string, any>;
}
// 이벤트 발송 함수
function sendEvent(event: Event) {
// 이벤트 발송 로직
}
sendEvent({ type: 'PAGE_VEW', properties: {} }); // 오타 발견!
이런식으로 이벤트 타입을 정의해놓으면 타입을 잘못 입력했을때 에러가 뜬다.
Zod: 런타임 검증
그러나 TypeScript는 컴파일 타임에만 체크를 한다.
런타임에서 발생할 수 있는 문제는 어떻게 잡을까 고민하다가 이전에 Zod를 사용하여 페이지 별 쿼리를 고도화 한 것이 생각이 났다.
Best Practice - Next.js useSearchParams 는 안쓰는게 좋다
Zod를 사용하면 이렇게 할 수 있다:
import { z } from 'zod';
const EventSchema = z.object({
type: z.enum(['PAGE_VIEW', 'BUTTON_CLICK', 'PURCHASE']),
properties: z.record(z.string(), z.any()),
});
function sendEvent(event: z.infer<typeof EventSchema>) {
const result = EventSchema.safeParse(event);
if (result.success) {
// 이벤트 발송 로직
} else {
console.error('Invalid event:', result.error);
}
}이렇게 런타임에서도 이벤트 데이터가 올바른지 확인할 수 있다.
심층 구현 방법
실제로 이루어진 구현 과정은 다음과 같다:
1. 이벤트 관리를 위한 설정 파일 (eventConfig) 생성
이벤트의 타입을 정의, 각 이벤트별 세부 타입을 등록, 관리할 수 있는 eventConfig.ts를 작성한다.
2. TypeScript, Zod를 사용한 기본 타입 정의
TypeScript 를 사용하여 정적 타입을 정의하고, Zod로 스키마를 정의한다.
각 이벤트명을 Key로 하는 eventConfigs 이 핵심이다.
Zod 스키마에서 제공하는 z.infer를 사용하면, Zod 스키마로부터 타입을 추론할 수 있다.
이 덕분에 expertSchema와 같은 Zod 스키마는 런타임 유효성 검사를 위해 존재하면서도, 타입스크립트의 타입 시스템에서 해당 스키마에 맞는 타입을 추출할 수 있게 된다.
EventConfig 에 대해서는 후술한다
import { z } from "zod";
// 이벤트 타입 정의
type EventType = "page" | "banner" | "bottom tab" | "btn";
type EventCategory = ...
type Platform = ...
// 기본 이벤트 설정 인터페이스
interface BaseEventConfig {
event_type: EventType;
event_category: EventCategory;
platform: Platform;
properties?: z.ZodObject<any>;
}
...
const expertProductSchema = z.object({
expert_id: z.string(),
product_name: z.string(),
value: z.string().optional(),
});
export const eventConfigs = {
...
["expert_product_list_view"]: {
event_type: "page",
event_category: "view",
platform: "web",
properties: expertSchema,
},
["expert_time_view"]: {
event_type: "page",
event_category: "view",
platform: "web",
properties: expertProductSchema,
},
...
} as const satisfies EventConfig;
3. 유틸리티 타입을 활용한 정적 타입 분석
사용할 타입이 정의되면, 유틸리티 타입을 활용하여 함수의 파라미터를 정적 타입으로 정리할 수 있다.
이렇게 인덱스 시그니쳐를 통해 문자열을 key로하고, BaseEventConfig를 value로 가지는 객체 타입을 정의하고, 위에서 정의한 eventConfigs 에 타입으로 지정해준다.
eventConfigs는 각 key를 단순 string이 아니라 구체적인 리터럴로 변환 -> satisfies를 사용하여 객체가 특정 타입(EventConfig)을 만족하는지 검사하지만, 객체 내부의 리터럴 타입을 그대로 유지하게 한다.
// EventConfig 타입 정의
type EventConfig = { [K in string]: Omit<BaseEventConfig, "event_name"> };
// 타입 추론 최적화
...
platform: "web",
properties: expertProductSchema,
},
...
} as const satisfies EventConfig;4. 이벤트 이름 및 속성 타입 정의
이렇게 타입이 추론 된 eventConfigs 를 통해 이벤트 이름 타입을 지정할 수 있고,
EventName을 활용하여 EventProperties 타입도 만들 수 있다.
// 이벤트명 타입
export type EventName = keyof typeof eventConfigs;
// 이벤트속성 타입
type EventProperties<T extends EventName> = (typeof eventConfigs)[T] extends {
properties: z.ZodObject<any>;
}
? z.infer<(typeof eventConfigs)[T]["properties"]>
: Record<string, never>;
여기서 typeof로 eventConfigs 의 타입을 가져올 수 있고, keyof로 eventConfigs의 모든 키를 타입으로 가져올 수 있다.
즉, keyof typeof eventConfigs는 eventConfigs 객체의 모든 키를 타입으로 추출한다.
EventProperties은 아주 복잡한 타입인데, 순서대로 살펴보면 아래와 같다.
EventProperties<T extends EventName>는 EventName을 인자로 받는 타입이다.
예시:EventProperties<"expert_time_view">(typeof eventConfigs)[T]는 eventConfigs에서 타입을 가져와 T(위에서는 "expert_time_view")를 매핑한다. 즉eventConfigs["expert_time_view"]를 가져온다.extends { properties: z.ZodObject<any>; }는 eventConfigs[T]에 properties 속성이 존재하고, ZodObject 타입인지 확인한다.- 조건부 타입을 사용하여:
- 만약 3이 참이라면
z.infer<(typeof eventConfigs)[T]["properties"]>를 사용하여 EventProperties의 properties 속성을 eventConfigs[T].properties의 스키마에서 참조한다. - 만약 3이 거짓이면
Record<string, never>를 사용하여 EventProperties의 properties 속성을 빈 객체 타입으로 설정한다.
- 만약 3이 참이라면
5. 이벤트 파라미터 타입 및 함수 정의
EventParams는 실제로 사용될 최종 함수의 매개변수 이다.
platform은 이벤트별로 정의하는게 아니라 함수 호출 시점에 정해야하기 때문에 기존 타입에 유니온으로 작성했다.
//
export type EventParams<T extends EventName> = EventProperties<T> & {
platform?: Platform;
};이 sendGtagEvent 함수는 이벤트 이름과 속성을 받아 Google Analytics로 이벤트를 전송한다.
함수는 다음과 같은 단계를 수행한다.:
- 이벤트 설정을 가져온다.
- 플랫폼을 확인하고 설정다.
- 속성의 유효성을 검사한다.
- 이벤트 데이터를 구성한다.
- Google Analytics로 이벤트를 전송한다.
function hasProperties(
config: BaseEventConfig
): config is BaseEventConfig & { properties: z.ZodObject<any> } {
return "properties" in config;
}
// sendGtagEvent 함수 정의
export function sendGtagEvent<T extends EventName>(
eventName: T,
properties: EventParams<T>
): void {
const config = eventConfigs[eventName];
// 플랫폼 검사 및 설정
const platform = properties.platform || config.platform;
// 속성 유효성 검사
if (hasProperties(config)) {
try {
config.properties.parse(properties);
} catch (error) {
console.error(`Invalid properties for event ${eventName}:`, error);
return;
}
}
// 이벤트 데이터 구성
const eventData = {
event_category: config.event_category,
event_name: eventName,
event_type: config.event_type,
platform,
...properties,
};
// Google Analytics로 이벤트 전송
console.log(`Sending event: ${eventName}`, eventData);
window.gtag("event", eventName, eventData);
}Zod의 장점
Zod 라이브러리 사용의 주요 장점은 다음과 같다:
- 런타임 타입 안전성: TypeScript가 컴파일 시점에서만 타입을 체크하는 반면, Zod는 런타임에서도 데이터 유효성을 검증한다.
- 스키마 재사용: 공통 속성을 별도의 스키마로 정의하고 재사용할 수 있어 코드 중복을 최소화할 수 있다.
- 복잡한 유효성 검사: 단순한 타입 체크를 넘어 복잡한 조건의 유효성 검사가 가능하다.
- TypeScript와의 통합: Zod 스키마로부터 TypeScript 타입을 추론할 수 있어, 타입 정의의 중복을 방지할 수 있다.
추가 기능
구현하다보니 다음과 같은 추가 기능이 포함되었다:
-
페이지 로드 시 단 한 번만 호출되는 이벤트를 위한 커스텀 훅 생성
usePageUpload훅을 사용하여 페이지 최초 로드 시 이벤트 호출useRef를 활용하여 중복 호출 방지
-
앱에서의 이벤트 호출 처리
- 웹뷰에서 네이티브 앱의 GA 이벤트를 호출할 수 있도록 브릿지 분리
결론
정적 타입 분석과 Zod를 활용한 데이터 텍소노미 구조화는 대규모 웹 애플리케이션에서 이벤트 추적을 관리하는 효과적인 방법임이 입증되었다. 이 접근 방식을 통해 다음과 같은 이점을 얻을 수 있었다:
- 컴파일 시점과 런타임에서의 타입 안정성 향상
- 코드 자동완성 기능 활용
- 런타임 오류 감소
- 유지보수성 향상
- 이벤트 추적의 일관성 유지
- 복잡한 데이터 구조의 유효성 검증
댓글
첫 번째 댓글을 남겨보세요.