이 글은 ‘프론트엔드 아키텍처 패턴’ 시리즈의 한 편이다.

들어가며

이벤트 로깅은 처음엔 참 쉬워 보인다. 버튼 클릭할 때 track('click') 한 줄 넣으면 되니까. 그런데 우리 서비스는 마케팅 어트리뷰션은 Airbridge, 제품 분석은 GA4, 앱 푸시/CRM은 Notifly… 이렇게 SDK가 하나둘 늘어났다. 그러다 보니 화면 곳곳에 이런 코드가 복붙되기 시작했다.

const events: IEventItem[] = [
  { type: 'airbridge', eventName, eventData },
  { type: 'notifly', eventName, eventData },
  { type: 'ga', eventName, eventData },
];
sendEvent(events);

똑같은 배열을 화면마다 다시 쓰고 있었다. SDK 하나 추가되면? 이 배열을 쓰는 모든 파일을 다 찾아 고쳐야 한다. 띠용..

그래서 여러 애널리틱스 SDK를 하나의 이벤트 레이어로 추상화하는 작업을 진행했다. 이 글은 그 설계 이야기다.

문제: SDK마다 다른 전송 방식

각 SDK는 요구하는 데이터 형태가 다르다.

  • Airbridge: action, label, value는 최상위 필드로, 나머지는 customAttributes로 넣어야 한다.
  • GA4: gtag('event', action, {...}) 형태.
  • Notifly: notifly.trackEvent(name, data), 게다가 웹에서는 동작하지 않는다(앱 웹뷰 전용).

이걸 화면 컴포넌트가 하나하나 알고 있어야 한다면, 이벤트 하나 붙일 때마다 SDK 3개의 스펙을 다 신경 써야 한다. 관심사가 완전히 잘못 새어 나온 상태였다.

설계: 단일 진입점 + 타입별 어댑터

핵심 아이디어는 두 가지다.

  1. 화면은 **“무슨 이벤트를, 어떤 데이터로”**만 말한다.
  2. **“어느 SDK에 어떻게 보낼지”**는 레이어 안쪽 어댑터가 책임진다.

이벤트 항목의 인터페이스를 이렇게 정의했다.

export interface IEventItem {
  type: 'airbridge' | 'ga' | 'notifly' | 'picky';
  eventName: string;
  eventData: EVENT_DATA;
}

그리고 단일 진입점 sendEventtype에 따라 알맞은 어댑터로 분기한다.

import { gtagSendEvent } from '@shared/GA/ga';
import { sendAirBridge } from './airbridge';
import { sendNotiflyEvent } from './notifly';

export function sendEvent(eventList: IEventItem[]) {
  try {
    eventList.forEach(({ type, eventName, eventData }) => {
      switch (type) {
        case 'airbridge':
          sendAirBridge(eventName, eventData);
          break;
        case 'ga':
          gtagSendEvent({ action: eventName, rest: eventData });
          break;
        case 'notifly':
          sendNotiflyEvent(eventName, eventData);
          break;
        default:
          break;
      }
    });
  } catch (err) {
    // XXX: sentry 추가 후 로그설정 보완 필요
    console.error(err);
  }
}

각 어댑터는 자기 SDK의 스펙만 안다. Airbridge 어댑터를 보면 Airbridge가 원하는 body 모양으로 가공하는 책임이 여기 갇혀 있다.

/**
 * Airbridge에 보낼 데이터를 가공하고 요청한다.
 */
export function sendAirBridge(eventName: string, eventData: EVENT_DATA) {
  if (process.env.NEXT_PUBLIC_ENV !== 'pro') return;
  if (!eventName) return;

  if (Array.isArray(eventData.airbridge)) {
    eventData.airbridge.forEach(item => {
      Airbridge.trackEvent(eventName, getAirbridgeBody(item));
    });
  } else if ('airbridge' in eventData && eventData.airbridge) {
    Airbridge.trackEvent(eventName, getAirbridgeBody(eventData.airbridge));
  } else if (!isEmpty(eventData)) {
    Airbridge.trackEvent(eventName, getAirbridgeBody(eventData));
  }
}

// action/label/value는 최상위로, 나머지는 customAttributes로
function getAirbridgeBody(eventData: AIRBRIDGE_EVENT_DATA) {
  return {
    ...(eventData.action && { action: eventData.action }),
    ...(eventData.label && { label: eventData.label }),
    ...(eventData.value && { value: eventData.value }),
    customAttributes: { ...eventData },
  };
}

Notifly 어댑터는 특히 “웹에서는 안 돌아간다”는 SDK의 제약을 안쪽에 숨긴다. 프로덕션 환경에서만, 이벤트명이 있을 때만 전송한다.

import notifly from 'notifly-js-sdk';

/**
 * Notifly에 로그를 보낸다. Notifly는 웹에서는 동작하지 않는다.
 */
export async function sendNotiflyEvent(eventName: string, eventData: object) {
  if (process.env.NEXT_PUBLIC_ENV !== 'pro') return;
  if (!eventName) return;

  notifly.trackEvent(eventName, eventData);
}

💡 어댑터 안쪽에 NEXT_PUBLIC_ENV !== 'pro' 가드를 두면, 화면 코드는 “지금 프로덕션인가?”를 신경 쓸 필요가 없다. 개발 환경에서 실 이벤트가 새어 나가는 사고도 막힌다.

반복 제거: sendAllTypeEvent 헬퍼

대부분의 이벤트는 “전 채널로 다 보내라”였다. 그래서 화면마다 3줄짜리 배열을 다시 쓰고 있었는데, 배너 섹션을 리팩토링하면서 이 중복을 모듈 헬퍼로 추출했다.

export function sendAllTypeEvent(eventName: string, eventData: any) {
  const events: IEventItem[] = [
    { type: 'airbridge', eventName, eventData },
    { type: 'notifly', eventName, eventData },
    { type: 'ga', eventName, eventData },
  ];
  sendEvent(events);
}

이제 화면은 이렇게 한 줄이면 된다.

sendAllTypeEvent('home_article_click', { action: '아티클 클릭' });

그런데 여기서 문제가 하나 더 나왔다. 어떤 이벤트는 “Airbridge랑 GA만, Notifly는 빼고” 보내야 했다(로그인 완료 같은 앱 전용 이벤트 이슈). 그래서 헬퍼를 기본은 전 채널, 필요하면 대상 채널 배열로 좁힐 수 있게 확장했다.

export type EventType = 'airbridge' | 'notifly' | 'ga';

export function sendAllTypeEvent(
  eventName: string,
  eventData: any,
  targets: EventType[] = ['airbridge', 'ga', 'notifly'],
) {
  const events: IEventItem[] = targets.map(type => ({
    type,
    eventName,
    eventData,
  }));
  sendEvent(events);
}

호출부는 이렇게 자연스러워진다.

// 전 채널로
sendAllTypeEvent('view_lease', eventData);

// 특정 채널만
sendAllTypeEvent('login_complete', eventData, ['airbridge', 'ga']);

기본값을 전 채널로 둔 게 포인트다. 대부분의 호출부는 세 번째 인자를 몰라도 되고, 예외 케이스만 명시적으로 채널을 좁힌다.

이벤트명은 반드시 상수에서

레이어를 잘 만들어놔도, 호출부에서 sendAllTypeEvent('community_post_detail_click', ...) 처럼 문자열을 그때그때 손으로 쓰면 오타 한 방에 데이터가 새 이벤트로 잘못 잡힌다. 실제로 커뮤니티 상세에서 하드코딩된 이벤트명이 흩어져 있었고, 이걸 정의된 상수를 쓰도록 싹 정리했다.

import { COMMUNITY_EVENT_NAME } from '@shared/utils/constant/event';

sendAllTypeEvent(
  COMMUNITY_EVENT_NAME.community_post_detail_click,
  { ...eventData, action: '투표 참여하기' },
  ['airbridge', 'ga'],
);

이벤트명을 상수 객체(XXX_EVENT_NAME)로 모아두면 데이터팀의 수집 문서와 코드가 1:1로 맞아떨어진다. 자동완성도 되고, 오타로 인한 유령 이벤트도 사라진다.

중복 이벤트와의 싸움

멀티 채널 레이어가 완성되고 나서 진짜 골치 아픈 건 따로 있었다. 같은 이벤트가 두 번 찍히는 문제. 특히 view 계열 이벤트가 그랬다.

원인은 이전 URL을 구독하는 로직이었다. 이전 페이지를 알아야 prevScreen을 채우는데, 이 값이 처음엔 null로 시작한다. 그런데 useEffectnull일 때 한 번, 값이 채워지고 또 한 번 도는 바람에 view 이벤트가 중복 발화됐다.

해결은 단순하다. 이전 URL이 확정되기 전엔 전송하지 않는다.

useEffect(() => {
  if (prevUrl !== null) {
    const eventName = ARTICLE_EVENT_NAME.view_article;
    const eventData = {
      from: searchParams.get('from'),
      screen: '아티클 상세',
      prevScreen: getPageTypeFromUrl(prevUrl),
      articleId,
      label: articleTitle,
    };

    sendEvent([
      { type: 'airbridge', eventName, eventData },
      { type: 'notifly', eventName, eventData },
      { type: 'ga', eventName, eventData },
    ]);
  }
}, [prevUrl, searchParams]);

여기에 더해 prevScreen을 계산하는 getPageTypeFromUrl도 손봤다. 원래 url.includes('articles') 같은 부분 문자열 매칭이었는데, 이러면 /lease-articles 같은 경로가 엉뚱하게 잡힌다. 정규표현식으로 경로 시작을 정확히 매칭하도록 바꿨다.

const ARTICLE_Regex = /^\/articles(?:\/|$)/;
const LEASE_Regex = /^\/lease(?:\/|$)/;
const RENT_Regex = /^\/rent(?:\/|$)/;
const AUTO_CASHBACK_Regex = /^\/auto-cashback(?:\/|$)/;

export const getPageTypeFromUrl = (url: string | null): string => {
  if (url === null) return '';
  if (LEASE_Regex.test(url)) return '리스';
  if (RENT_Regex.test(url)) return '장기렌트';
  if (AUTO_CASHBACK_Regex.test(url)) return '오토캐시백';
  if (ARTICLE_Regex.test(url)) return '아티클';
  return '';
};

includes → 정규식 매칭으로 바꾸니 잘못된 화면 분류 때문에 생기던 데이터 오염도 같이 잡혔다.

정리

정리하면 이 아키텍처는 세 겹이다.

화면 컴포넌트
   │  sendAllTypeEvent(NAME.xxx, data, targets?)

이벤트 레이어  (sendEvent: type별 분기)

   ├─ airbridge 어댑터  (customAttributes 가공)
   ├─ ga 어댑터         (gtag)
   └─ notifly 어댑터    (앱 전용, env 가드)
  • 화면은 이벤트명 상수 + 데이터만 신경 쓴다.
  • SDK가 늘어도 어댑터 하나만 추가하고 switch에 한 줄 붙이면 끝. 화면 코드는 안 건드린다.
  • 전송 대상 채널은 헬퍼 인자로 유연하게 좁힌다.
  • 오타·중복은 상수화와 발화 조건 정리로 막는다.

이벤트 로깅은 “한 줄 넣으면 되는 일”이 아니라, 시간이 지날수록 SDK와 화면이 곱으로 늘어나는 영역이다. 레이어 하나 잘 세워두면 나중에 훨씬 편하다.

관련 작업

  • Picky/airbridge event function 추가, GA4·notifly event 전송 추가, 이벤트 중복 발생 수정, 정규표현식을 사용한 패턴 매칭 추가, 아티클 상세 중복 이벤트 로그 방지코드 추가
  • Banner section 리팩토링 — 반복되는 sendAllTypeEvent를 모듈 헬퍼로 추출
  • sendAllTypeEvent 수정 — 기본으로 all type을 보내되 마지막 params에 타입 배열로 대상 채널 지정 가능
  • 정의된 이벤트명(상수) 사용하도록 수정, 커뮤니티 상세 이벤트 정리