카테고리 없음

GA 로깅과 추상화

lodado 2026. 5. 2. 17:43
반응형

Saas 런칭 2~3주만에 뜬금없이 패들을 통한 첫 구독자가 생겨서,
급하게 로깅 관련 기능을 넣기로 했는데 Google Tag Manager를 사용하기로 했다.

대충 아래와 같은 코드를 넣는 식이다.

window.dataLayer= window.dataLayer || [];
window.dataLayer.push({
  event:"template_created",
  template_id:template.id,
});

Google Tag Manager에서도 dataLayer.push({ event: "event_name" }) 형태로 이벤트를 전달하고, GTM이 그 이벤트를 기준으로 태그를 실행하는 구조를 사용한다. 즉, 기술적으로는 이 방식 자체가 틀린 건 아니다.

 

문제는 React 코드 안에 이걸 직접 넣기 시작하면 금방 지저분해진다는 거였다.

 

const handleSubmit=async () => {
const result= await createTemplate(payload);

window.dataLayer=window.dataLayer|| [];
window.dataLayer.push({
    event:"template_created",
    template_id:result.id,
  });

router.push("/templates");
};

 

이러면 하나의 함수 안에 세 가지 관심사가 섞인다.

1. 액션을 실행한다.
2. 로그를 보낸다
3. 페이지를 이동한다

 

처음 한두 개는 괜찮다.

그런데 회원가입, 로그인, 비즈니스 로직, 페이지 진입 로그까지 붙기 시작하면 코드가 금방 “분석 도구에 점령당한 비즈니스 로직”이 된다.

그래서 이번에는 최소한의 구조를 잡고 넣기로 했다.

 

먼저 React 앱에서 직접 GTM을 알지 않게 했다.

나중에 GA4를 쓰든, PostHog를 쓰든, 자체 로그 서버를 붙이든 바꿀 수 있게 AnalyticsAdapter를 만들었다.

 

export interface AnalyticsAdapter {
  track(event:AnalyticsEvent):void;
  identify?(userId:string):void;
}

 

GTM용 구현체는 이렇게 숨겼다.

export const gtmAdapter:AnalyticsAdapter= {
  track(event) {
if (typeof window==="undefined") return;

window.dataLayer=window.dataLayer|| [];
window.dataLayer.push(event);
  },

  identify(userId) {
if (typeof window==="undefined") return;

window.dataLayer=window.dataLayer|| [];
window.dataLayer.push({
      user_id:userId,
    });
  },
};

 

그리고 앱에서는 Provider로 주입했다.

<AnalyticsProvideradapter={gtmAdapter}>
  {children}
</AnalyticsProvider>

이렇게 하면 앱의 나머지 코드는 window.dataLayer를 모른다.

 

그냥 이렇게만 호출한다.

analytics.track({
  event:"template_created",
  template_id:template.id,
});

 

PostHog 같은 제품도 React에서는 Provider로 클라이언트를 주입하고 hook으로 접근하는 패턴을 제공한다. 그래서 analytics를 앱 전역 관심사로 보고 Provider 뒤에 숨기는 건 꽤 자연스러운 방식이라고 봤다.

 

2. 비즈니스 로직과 로깅을 고차함수로 분리

 

다음 문제는 이거였다.
useLoginSuccessMutation 등 어떤 액션을 하고, 이 액션에 대한
로깅을 넣을려고 할때였다.

 

await createTemplate(payload);
analytics.track(...);

 

이 코드도 나쁘진 않지만, 결국 모든 비즈니스 함수 주변에 로깅 코드가 붙는다.

그래서 성공/실패 로그가 필요한 액션은 고차함수로 감쌌다.

 

export function withTracking<TArgs extends unknown[], TResult>(
  fn: (...args: TArgs) => Promise<TResult>,
  options: {
    onSuccess?: (result: TResult, args: TArgs) => AnalyticsEvent;
    onError?: (error: unknown, args: TArgs) => AnalyticsEvent;
  }
) {
  return async (...args: TArgs) => {
    try {
      const result = await fn(...args);

      if (options.onSuccess) {
        analytics.track(options.onSuccess(result, args));
      }

      return result;
    } catch (error) {
      if (options.onError) {
        analytics.track(options.onError(error, args));
      }

      throw error;
    }
  };
}`

 

사용은 이런 느낌이다.

const createTemplateWithTracking=withTracking(createTemplate, {
  onSuccess: (template) => ({
    event:"template_created",
    template_id:template.id,
    template_name:template.name,
  }),
  onError: (error) => ({
    event:"template_create_failed",
    reason:errorinstanceofError?error.message:"unknown",
  }),
});

 

이렇게 하면 createTemplate 자체는 순수하게 API 호출만 담당한다.

async function createTemplate(payload:CreateTemplatePayload) {
return api.post("/templates",payload);
}

 

로깅은 바깥에서 감싼다.

물론 모든 클릭 이벤트를 고차함수로 감싸면 오히려 복잡해질 수 있다.

그래서 기준을 나눴다.

API 성공/실패, 핵심 액션 완료
→ withTracking 또는 mutation onSuccess

단순 버튼 클릭, 탭 클릭
→ cloneElement onClick event로 + children로 이벤트 전송 또는 data attribute

페이지 진입
→ HOC 또는 전역 PageViewTracker

3. 페이지 진입 로깅은 HOC로 분리했다

페이지 진입 로그도 컴포넌트 안에서 매번 useEffect로 넣고 싶지 않았다.

useEffect(() => {
analytics.track({
    event:"page_view",
    page_name:"template_list",
  });
}, []);

 

이런 코드가 페이지마다 흩어지면 결국 똑같은 문제가 생긴다.

그래서 특정 페이지 진입 로그는 HOC로 뺐다.

export function withPageTracking<Pextendsobject>(
Component:React.ComponentType<P>,
createEvent: () =>AnalyticsEvent
) {
return function PageWithTracking(props:P) {
const analytics= useAnalytics();

useEffect(() => {
analytics.track(createEvent());
    }, [analytics]);

return <Component {...props}/>;
  };
}

 

사용은 이렇게 한다.

export default withPageTracking(TemplatePage, () => ({
  event:"page_view",
  page_name:"template_list",
  page_path:"/templates",
}));

 

그래서 아래처럼 기준을 잡았다.

공통 page_view
→ 전역 PageViewTracker

특정 의미가 있는 페이지 진입
→ withPageTracking

 

급하게 넣었지만, 기준은 세웠다

 

이번 작업에서 가장 중요하게 본 건 “완벽한 분석 시스템”이 아니었다.

한 달 만에 첫 구독자가 생겼고, 이제부터는 최소한 아래 질문에 답할 수 있어야 했다.

 

사용자는 어디서 들어오는가?
회원가입까지 도달하는가?
핵심 기능을 실행하는가?
결제 전 어디서 이탈하는가?
결제한 사용자는 이후에도 기능을 쓰는가?
어떤 기능에서 에러가 많이 나는가?

 

PostHog 문서에서도 이벤트를 사용자 행동의 기본 단위로 설명하고, 예시로 버튼 클릭, 페이지뷰, 회원가입 같은 상호작용을 든다. 결국 제품을 운영하려면 “사용자가 뭘 했는지”를 이벤트 단위로 볼 수 있어야 한다.

처음부터 모든 걸 추적할 필요는 없다.

하지만 적어도 제품의 핵심 퍼널은 알아야 한다.

반응형