프론트엔드 입장에서 생각하는 이벤트 로그 찍기

선언적이고 type-safe한 로그를 찍어보자

2024-03-24 · 11분 · 조회 2

유저 행동 데이터

사용자들이 서비스를 어떻게 사용하는지, 어떤 행동을 하는지 데이터를 기록하고 분석하는 것은 필수적이다.
이를 통해 사용자 경험 개선이나 비즈니스 의사결정을 내릴 수 있다.

이번 글에서는 Firebase의 Custom Event를 사용해 어떻게 Type-safe하고 선언적인 로깅 구조를 설계했는지 공유하고자 한다.

이벤트 로깅의 개념과 중요성

이벤트 로깅이란 웹서비스에서 사용자나 시스템에서 발생하는 주요 이벤트를 기록하는 과정이다.
이를 통해 얻을 수 있는 이점은 다음과 같다.

  1. 사용자 분석 및 행동 추적
    • A/B 테스트를 통해 사용자 행동 패턴을 이해하고 서비스 개선에 활용할 수 있다.
  2. 디버깅 및 보안
    • 문제 발생 시 이벤트 로그를 통해 원인을 빠르게 파악하고 대응할 수 있다.

이벤트를 체계적으로 관리하려면 어떤 이벤트를 기록할지, 어떻게 관리할지 명확히 정의해야 한다.
필자의 경우에는, 이벤트 로그는 다음 두 가지로 나눌 수 있었다.

  1. Click Event: 버튼 클릭, 링크 클릭과 같은 사용자 인터랙션.
  2. View Event: 페이지 진입, 화면 표시와 같은 뷰 관련 이벤트.

기존 방식의 문제점

Type-safe하지 않은 구조

기존의 로깅 방식은 문자열로 이벤트 이름과 매개변수를 전달했다.

logEvent(analytics, 'EVENT_LOG_1', { entry_point: 'home' });

이 방식은

  • 잘못된 이벤트 이름 전달
  • 누락된 매개변수

등으로 인해 런타임 오류가 발생할 가능성이 높았다.

비즈니스 로직과의 결합

이벤트 로깅이 다른 비즈니스 로직과 섞여있었다.

예를 들어, 클릭 이벤트에서 데이터를 처리하고 로깅을 남길 때 코드가 다음처럼 작성되었다.

const handleClick = () => {
  apiCall().then((data) => {
    logEvent(analytics, 'EVENT_LOG_1', { entry_point: 'home', data });
  });
};

이 코드의 문제는 다음과 같다.

  1. 로직이 뒤섞임: 이벤트 로깅과 데이터 처리 코드가 섞여 유지보수가 어렵다.
  2. 확장성 부족: 새로운 이벤트를 추가하거나 수정할 때 중복 코드가 생긴다.

재사용성과 확장성 부족

매번 새로운 이벤트를 추가할 때마다 로그 함수를 새로 정의하거나 복사해야 했다.

이는 중복된 코드비효율성을 낳았다.

목표

  1. Type-safe한 구조: 이벤트 이름과 매개변수를 명확히 정의해 실수를 방지한다.
  2. 관심사 분리: 이벤트 로깅과 비즈니스 로직을 분리한다.
  3. 재사용성과 확장성: 코드 중복을 최소화하고 새로운 요구사항에도 쉽게 대응한다.

Type-safe한 이벤트 관리

이벤트 타입과 매개변수 정의

먼저, Click Event와 View Event를 각각 Enum으로 정의했다.
이벤트별로 / 매개변수를 인터페이스로 명확히 구분했다.

// Click Event 타입 정의
export enum ClickEventType {
  BUTTON_CLICK = 'BUTTON_CLICK',
  LINK_CLICK = 'LINK_CLICK',
}
 
export interface ButtonClickParams {
  button_id: string;
  entry_point: string;
}
 
// View Event 타입 정의
export enum ViewEventType {
  PAGE_VIEW = 'PAGE_VIEW',
  SCREEN_VIEW = 'SCREEN_VIEW',
}
 
export interface PageViewParams {
  page_name: string;
  entry_point: string;
}

타입 매핑

Click Event와 View Event 각각을 매핑해 관리하고, 이를 하나로 결합했다.

// 이벤트 매핑 정의
export type ClickEventMap = {
  [ClickEventType.BUTTON_CLICK]: ButtonClickParams;
};
 
export type ViewEventMap = {
  [ViewEventType.PAGE_VIEW]: PageViewParams;
};
 
// 모든 이벤트 매핑
export type CombinedEventMap = ClickEventMap & ViewEventMap;

Type-safe한 logEvent 함수

매핑된 타입을 기반으로 Firebase의 logEvent를 감싸는 함수를 작성했다.

export const logEvent = <T extends keyof CombinedEventMap>(
  analytics: Analytics,
  eventName: T,
  params: CombinedEventMap[T],
) => {
  firebaseLogEvent(analytics, String(eventName), params);
};

선언적 로깅으로 관심사 분리

이벤트 로깅을 독립적으로 처리하기 위해 컴포넌트 기반 선언적 구조를 도입했다.

LogClickComponent
클릭 이벤트를 처리하는 컴포넌트로, 자식 컴포넌트의 onClick에 로깅 로직을 추가했다.

const LogClickComponent = ({ children, eventName, params }) => {
  const child = React.Children.only(children);
 
  return React.cloneElement(child, {
    onClick: () => {
      logEvent(analytics, eventName, params);
      if (child.props.onClick) child.props.onClick();
    },
  });
};

LogViewComponent
뷰 이벤트를 처리하는 컴포넌트로, useEffect를 사용해 페이지 렌더링 시 이벤트를 기록했다.

const LogViewComponent = ({ children, eventName, params }) => {
  useEffect(() => {
    logEvent(analytics, eventName, params);
  }, [eventName, params]);
 
  return <>{children}</>;
};

컴포넌트 사용 예시

Click Event
<LogClickComponent eventName={ClickEventType.BUTTON_CLICK} params={{ button_id: 'submit', entry_point: 'home' }}>
  <button>Submit</button>
</LogClickComponent>
View Event
<LogViewComponent eventName={ViewEventType.PAGE_VIEW} params={{ page_name: 'dashboard', entry_point: 'main' }}>
  <div>Dashboard</div>
</LogViewComponent>

재사용성과 확장성

일부 이벤트는 API 호출 결과나 Global Property를 함께 기록해야 했다.
이를 처리하기 위해 Custom Hook을 설계했다.

useLogEvent hook
export const useLogEvent = (includeGlobal: boolean) => {
  const analytics = useAnalytics();
  const globalProperty = useGlobalProperty();
 
  const handleLogEvent = <T extends keyof CombinedEventMap>(eventName: T, params: CombinedEventMap[T]) => {
    const finalParams = includeGlobal ? { ...params, ...globalProperty } : params;
    logEvent(analytics, eventName, finalParams);
  };
 
  return { handleLogEvent };
};

사용 예시

const { handleLogEvent } = useLogEvent(true);
 
handleLogEvent(ClickEventType.BUTTON_CLICK, {
  button_id: 'submit',
  entry_point: 'home',
});

결론

이벤트 로깅 설계를 하면서 기존 방식의 불편함을 개선하고 코드 유지보수성과 확장성을 개선해 볼 수 있었던 좋은 경험이였다.

  • Type-Safe하게 이벤트 이름과 매개변수를 타입으로 정의하여, 잘못된 사용을 사전에 방지 할 수 있었고
    이벤트가 많아지더라도 일관성을 유지할 수 있게 되었다.
  • 로깅 로직과 비즈니스 로직을 분리하여 코드가 더 깔끔해지고 수정에 용이해졌다.
  • Custom Hook을 도입하여 로깅과 관련된 로직을 통합하고 재사용 할 수 있게 되었다.
    API 결과나 Global Property를 함께 처리해야하는 복잡한 요구사항도 한 곳에서 해결할 수 있게 되었다.

앞으로의 개선 방향

  1. 이벤트 네이밍 컨벤션 개선
    현재 이벤트 이름은 단순한 형태(sign-up, sign-in)로 구성되어 있다.
    이를 What, Where 기반의 Naming Convention으로 전환해 이벤트 이름만으로도 이벤트의 목적과 맥락을 파악할 수 있도록 개선해보고 싶다.
  2. 서버와의 로깅 Sync 관리
    현재는 Google Docs로 이벤트 정의를 관리하고 있지만, Sync를 유지하는 것이 점점 어려워질 수 있을 것 같다.
    이를 해결하기 위해 자동화된 도구를 도입하거나, 중앙 집중화된 관리 방식을 고려해볼 예정이다.
  3. 더 많은 선언적 컴포넌트 개발
    현재는 클릭과 뷰 이벤트를 처리하는 컴포넌트를 설계했지만, 더 다양한 사용자 행동(예: Drag & Drop, Scroll Event)도 선언적 방식으로 관리할 수 있도록 컴포넌트를 확장해보면 좋을 것 같다.

현재 프로젝트에서는 Firebase에서 Amplitude로 전환한 상태다.
하지만 이 글에서 소개한 이벤트 로깅 방식은 모든 이벤트 타입을 직접 작성해야 하는 점에서 큰 불편함을 느꼈다.
결국, Type-safe하지 않은 구조로 돌아가게 되었고, 그 결과 생각보다 많은 휴먼 에러가 발생하고 있다.

이 경험을 통해 Type-safe한 로깅의 중요성을 다시 실감하게 되었으며,
하루빨리 타입 체킹을 자동화할 수 있게 하여, 실수를 줄이고 효율성을 높여야겠다는 생각이 들었다.

Ref.

Home
Posts
Notes