본문 바로가기

개발 일기

[이벤트 로깅을 위한 EventTracker] 파트 1. 요구사항 분석과 설계 및 구현 과정 기록

 

이번 글은 일전에, 개발 요청 문서를 바탕으로, 로그 이벤트를 추적하는 EventTracker를 설계하고 구현했던 과정을 정리한 글이다. 요청문서의 요구사항을 고려하며, 유지보수성과 확장성을 고려한 구조로 설계하려 고민했다. 


1. 요청 문서 요약

개발 요청 문서는 서비스 내에서 발생하는 다양한 사용자 이벤트를 효과적으로 추적하고 기록하기 위한 로그 포맷과 관련 요구사항을 상세히 설명하고 있다. 주요 내용은 다음과 같다.

1) 로그 포맷

로그는 여러 개의 값을 공백(blank)으로 연결한 형태로 구성된다.

const log:string = `${user} ${path} ${eventName} ${linkLog} ${adLog}`

 

user (필수):

  • 로그인 했을 경우: 유저 아이디의 암호화 값 (userId:lt)
  • 로그인하지 않았을 경우: IP의 암호화 값 (userId:lf)
  • 예시: 16bac7f52f40db568c7b980c9fd824…:lf

path (필수): 현재 페이지의 경로 및 쿼리 스트링을 포함

  • 예시: /camps, /camps?category=web

eventName (필수): 이벤트의 종류 (예: pv, ch, cb)

linkLog (선택): 클릭한 링크의 타입과 ID

  • 형식: ${linkType}:${id}
  • 예시: camp_cta:kosa-kcc2_20240419113456

adLog (선택): 광고 관련 정보

  • 형식: ${adKey}:${adProduct}:${adContentId}
  • 예시: 20240315104656-5_kosa-kcc2_2:event:1

 

2) user 상세

로그인 여부에 따라 user 값이 다르게 설정된다.

// 로그인했을 경우
const user = `${userId}:lt`

// 로그인 안했을 경우
const user = `${userId}:lf`

userId

  • 로그인하지 않았을 때: 비로그인 유저를 나타내는 특정 문자열을 정해 암호화하여 식별 가능하게 설정
  • 로그인 했을 때: userId를 암호화한 값 (예: kakao#1234123512)

 

3) eventName 상세

현재는 최소한의 이벤트 타입만 운영 중이나, 향후 추가될 가능성을 고려함.

  • pv(page view) - 모든 페이지 진입 시 트리거
  • ch(click homepage) - 부트텐트가 아닌 외부 페이지 링크 클릭 시(메인배너 제외) 트리거
  • cb(click banner) - 매인배너 클릭 시 트리거

 

4) linkLog 상세

어떤 링크를 클릭했는지에 대한 정보가 포함되며, 값들은 :로 구분된다.

const linkLog = `${linkType}:${id}`

 

LinkType: mainbanner, article, camp_cta, curriculum, company, event ... 등등

 

5) adLog 상세

광고 관련 정보가 포함되며, 특정 이벤트 발생 시 해당 이벤트가 광고로 인한 경우에만 포함된다. 값들은 :로 구분된다.

const adLog = `${ad.adKey}:${ad.adProduct}:${adContentId}`;

 

 

2. 요청 문서를 기반으로, 설계 구조 고민

요청 문서를 분석한 결과, Tracker 설계에 있어 다음과 같은 핵심적인 요구사항과 고려사항이 도출되었다:

1) 요구사항 분석

  • 동적 데이터 처리: linkLog와 adLog는 이벤트에 따라 선택적으로 추가된다. 이를 반영해 동적으로 로그 문자열을 생성하는 메서드가 필요하다.
  • 유저 ID 처리: 로그인 상태를 구분하여 user 값을 생성하며, userId를 암호화해 식별 가능하게 만들어야 한다. 그리고 암호화된 유저 ID에 상태를 표시하는 접미사(:lt 또는 :lf)를 추가해야 한다.
  • 이벤트 분류 및 확장성: 현재는 pv, ch, cb 세 가지 이벤트만 존재하지만, 요청 문서에서 향후 이벤트 타입이 추가될 가능성을 언급했다. 따라서 이벤트 타입 추가 시 코드 수정 없이 확장 가능하도록 구조를 설계해야 한다.
  • 일관성 있는 로그 생성: 로그 문자열은 항상 user, path, eventName을 포함하며, linkLog와 adLog는 상황에 따라 추가된다. 이러한 규칙을 자동으로 관리하는 메커니즘이 필요하다.

2) Tracker의 역할 정의

  1. 로그 데이터를 표준화된 형식으로 생성: 로그 포맷 규칙에 따라 데이터를 정리하고, 최종 문자열로 변환한다.
  2. 유저 정보 관리: 로그인 여부에 따라 유저 ID를 동적으로 처리하며, 암호화 및 상태 구분을 수행한다.
  3. 이벤트 중심 설계: 각 이벤트 유형(pv, ch, cb)에 대해 별도의 메서드를 제공하며, 공통 로직은 재사용한다.
  4. 확장 가능성 확보: 새로운 이벤트나 로그 데이터가 추가될 때 기존 코드를 변경하지 않더라도 쉽게 적용 가능하도록 설계한다.

3) 설계 방향성

Tracker 설계는 “단일 책임 원칙(Single Responsibility Principle)“ “확장에 열린 구조(Open/Closed Principle)“를 기반으로 설계했다.

  1. 단일 책임 원칙 적용: Tracker 클래스는 오직 로그 문자열 생성과 관련된 책임만 가지도록 한다. 로그 데이터의 구조나 전송 방식은 별도 모듈에서 관리하도록 분리한다.
  2. 공통 로직과 개별 로직 분리: 공통적인 로그 생성 로직을 처리하는 매서드를 설계하고, 이벤트별 메서드(pv, ch, cb)는 간결한 인터페이스만 제공하도록 한다.
  3. 유연한 확장성: eventType, linkLog, adLog 등 주요 데이터를 매개변수로 처리하며, 추가적인 데이터가 필요할 경우 기존 구조를 변경하지 않고 확장 가능하게 설계한다.
  4. 상태 관리의 명확성: getUserId 메서드에서 유저 ID와 상태(:lt 또는 :lf)를 일관되게 처리하며, 외부에서 불필요한 중복 처리를 하지 않도록 설계한다.

4) 전체적인 설계 흐름

  1. 로그의 핵심 요소 추출: user, path, eventName, linkLog, adLog를 각각의 메서드와 매개변수로 처리하며, 각 요소가 로그에서 차지하는 역할을 명확히 정의한다.
  2. Tracker 클래스의 역할 분리: Tracker는 로그 문자열 생성에만 집중하며, 데이터 전송이나 상태 관리는 별도의 Provider(EventLogProvider)가 담당하도록 설계한다.
  3. 유연한 로그 확장: 이벤트와 관련된 데이터(linkLog, adLog)는 선택적 매개변수로 처리하며, 데이터가 없는 경우에도 기본적인 로그 형식을 유지하도록 한다.
  4. React Context와 연동: Tracker 인스턴스를 EventLogProvider를 통해 React Context에 주입하여, 전역적으로 로그 기록 기능을 활용할 수 있도록 한다.

 

3. Tracker 클래스 설계

Tracker 클래스는 로그 문자열을 생성하는 핵심 역할을 한다.

import { encryptString } from "@/hook/auth";
import CONFIG from "@/static/config";
import { EventType, LinkType } from "@/types/log";

const { pv, ch, cb } = CONFIG.LOG_EVENT_NAME;

export type LinkLog = [LinkType] | [LinkType, string] | undefined;
type AdKey = string;
type AdProduct = string;
type AdContentId = string | number;
export type AdLog =
  | [AdKey, AdProduct]
  | [AdKey, AdProduct, AdContentId]
  | undefined;

export class Tracker {
  private userId: string;
  private path: string;

  constructor(userId: string, path: string) {
    this.userId = userId;
    this.path = path;
  }

  private getUserId(): string {
    if (this.userId.startsWith("user#")) {
      return `${encryptString(this.userId)}:lt`;
    } else {
      return `${encryptString("user#guest#" + this.userId)}:lf`;
    }
  }

  private createLogString(
    eventType: EventType,
    linkLog?: LinkLog,
    adLog?: AdLog,
  ): string {
    const user = this.getUserId();

    let linkLogString = "";
    if (linkLog) {
      linkLogString = linkLog[1]
        ? `${linkLog[0]}:${linkLog[1]}`
        : `${linkLog[0]}`;
    }

    const adLogString = adLog
      ? `${adLog[0]}:${adLog[1]}${adLog[2] ? ":" + adLog[2] : ""}`
      : "";

    return `${user} ${this.path} ${eventType} ${linkLogString} ${adLogString}`.trim();
  }

  // 페이지 뷰 로그 생성
  public pv(linkLog?: LinkLog, adLog?: AdLog): string {
    return this.createLogString(pv.value, linkLog, adLog);
  }

  // 클릭 로그 생성
  public ch(linkLog?: LinkLog, adLog?: AdLog): string {
    return this.createLogString(ch.value, linkLog, adLog);
  }

  // 버튼 클릭 로그 생성
  public cb(linkLog?: LinkLog, adLog?: AdLog): string {
    return this.createLogString(cb.value, linkLog, adLog);
  }
}

 

  1. 유저 ID 처리: getUserId 메서드는 유저가 로그인했는지 여부를 판단하여 적절한 포맷으로 ID를 암호화한다. 로그인한 경우 :lt, 로그인하지 않은 경우 :lf를 접미사로 추가했다. 이는 로그에서 유저의 상태를 쉽게 식별할 수 있도록 하기 위함이다.
  2. 로그 문자열 생성: createLogString 메서드는 eventType, linkLog, adLog를 조합하여 최종 로그 문자열을 생성한다. 각 로그 항목은 조건에 따라 존재할 수도, 존재하지 않을 수도 있으므로 이를 처리하는 로직을 포함했다.
  3. 확장성: 현재 사용 중인 이벤트 타입(pv, ch, cb) 외에 미래에 추가될 가능성을 염두에 두고, 이벤트 타입을 매개변수로 받아 처리하는 구조로 설계했다.

 

 

4. EventLogProvider 구현

로그 트래킹을 애플리케이션 전반에서 쉽게 사용할 수 있도록 EventLogProvider를 구현했다. 이 컴포넌트는 React Context를 사용하여 Tracker 인스턴스를 전역으로 제공한다.

 

"use client";

import React, {
  createContext,
  useContext,
  ReactNode,
  useEffect,
  useState,
  useMemo,
} from "react";
import { useAuth } from "@/context/AuthProvider";
import { Tracker } from "@/utils/log";
import { useEventLog } from "@/hook/log";
import { useIsClient } from "usehooks-ts";
import useQueryParams from "@/hook/react-hooks/useQueryParams";

// LogContext를 생성하여 Tracker 인스턴스를 전역으로 제공
const LogContext = createContext<Tracker | null>(null);

interface Props {
  children: ReactNode;
}

// 커스텀 훅을 통해 LogContext에 접근
export const useEventTracker = (): Tracker | null => useContext(LogContext);

/**
 * EventLogProvider 컴포넌트
 * 자식 컴포넌트들에게 Tracker 인스턴스를 컨텍스트로 제공하고,
 * 사용자 ID 및 경로 변경 시 이벤트를 로그합니다.
 */
export default function EventLogProvider({ children }: Props) {
  // 클라이언트 사이드 여부 확인
  const isClient = useIsClient();

  // URL의 쿼리 파라미터를 가져옴
  const [searchParams] = useQueryParams();

  // 인증 상태 및 세션 데이터 가져오기
  const { data: session, status } = useAuth();

  // 사용자 ID 상태 관리 (초기값은 null)
  const [userId, setUserId] = useState<string | null>(null);

  // 이벤트 로그를 기록하는 함수 가져오기
  const { logEvent } = useEventLog();

  /**
   * 사용자 ID 설정
   * - 클라이언트 사이드에서만 실행
   * - 세션이 인증된 경우 사용자 고유 ID를 사용
   * - 인증되지 않은 경우 로컬 스토리지의 게스트 ID를 사용하거나 새로운 ID를 생성
   */
  const GUEST_KEY = "bt-guest";
  useEffect(() => {
    if (isClient) {
      // 로컬 스토리지에서 게스트 ID 가져오기, 없으면 현재 시간으로 설정
      const storedGuestId =
        localStorage.getItem(GUEST_KEY) ?? String(Date.now());

      // 인증 상태에 따라 사용자 ID 결정
      const resolvedUserId =
        session && status === "authenticated"
          ? session.user?.bootUser.account?.pk || storedGuestId
          : storedGuestId;

      setUserId(resolvedUserId);
    }
  }, [session, status, isClient]);

  /**
   * Tracker 인스턴스 생성
   * - userId 또는 searchParams가 변경될 때마다 실행
   * - currentPath는 현재 페이지의 경로와 쿼리 스트링을 포함
   * - Tracker 인스턴스는 사용자 ID와 현재 경로를 기반으로 생성
   */
  const tracker = useMemo(() => {
    if (userId) {
      const currentPath = window.location.pathname + window.location.search;
      return new Tracker(userId, currentPath);
    }
    return null;
  }, [userId, searchParams]);

  /**
   * 이벤트 로그 기록
   * - Tracker 인스턴스 또는 searchParams가 변경될 때마다 실행
   * - Tracker의 pv() 메서드를 호출하여 현재 경로를 가져오고 로그 기록
   */
  useEffect(() => {
    const logCurrentEvent = async () => {
      if (tracker) {
        const pathname = tracker.pv();
        await logEvent(pathname);
      }
    };
    logCurrentEvent();
  }, [tracker, searchParams]);

  return (
    // LogContext.Provider를 사용하여 Tracker 인스턴스를 하위 컴포넌트에 제공
    <LogContext.Provider value={tracker}>{children}</LogContext.Provider>
  );
}

 

  1. 글로벌 트래커 제공: LogContext를 생성하여 애플리케이션의 모든 컴포넌트에서 Tracker 인스턴스에 접근할 수 있도록 했다. 이를 통해 각 컴포넌트에서 개별적으로 트래킹 로직을 구현할 필요 없이 일관된 방식으로 로그를 기록할 수 있다.
  2. 사용자 ID 관리: useAuth 훅을 사용하여 사용자 인증 상태를 확인하고, 인증된 경우 암호화된 유저 ID를, 인증되지 않은 경우 로컬 스토리지에 저장된 게스트 ID를 사용하도록 했다. 이는 로그에서 유저의 상태를 명확히 구분할 수 있게 한다.
  3. 경로 및 쿼리 파라미터 관리: useQueryParams 훅을 사용하여 URL의 쿼리 파라미터를 추적하고, 이를 Tracker 인스턴스의 생성에 반영했다. 사용자가 페이지를 이동하거나 쿼리 파라미터가 변경될 때마다 로그를 기록할 수 있도록 하기 위함이다.
  4. 페이지 뷰 로그 자동 기록: useEffect를 사용하여 Tracker 인스턴스가 변경될 때마다 자동으로 페이지 뷰 로그를 기록하도록 했다. 사용자가 페이지에 진입할 때마다 로그가 자동으로 생성되도록 했다.

 

5. EventTracker적용해보기: CTAButton

CTAButton 컴포넌트는 사용자가 캠프 상세 페이지에서 외부 페이지로 이동하는 버튼이다. 이 버튼을 클릭할 때 로그를 기록하도록 EventTracker를 적용했다.

export default function CTAButton({
  camp,
  adTracker,
}: PropsWithChildren<Props>) {
  // tracker
  const tracker = useEventTracker();

  // params 가져오기
  const searchParams = useSearchParams();
  const cardNum = searchParams.get("card");

  // 비공개 캠프인가
  const isHiddenBatch =
    camp && camp.statusValue === statusValues.batchStatus[9999].value;

  // CTA URL 설정
  const campUrl = getCtaUrl(camp, isMobile, cardNum);

  return (
    <ExternalLink
      href={campUrl}
      disabled={isHiddenBatch}
      tracker={tracker?.ch(
        ["camp_cta", `${camp.campId}_${camp.batchId}`],
        adTracker,
      )}
      className="w-full"
    >
      <CTALink />
    </ExternalLink>
  );
}

 

  1. 트래커 생성: CTAButton 컴포넌트는 Tracker 인스턴스를 useEventTracker를 통해 주입 받아 사용한다. 
  2. 링크 및 광고 로그: tracker?.ch 메서드를 호출할 때 linkLog와 adLog를 전달하여 클릭 이벤트에 대한 상세한 정보를 로그에 포함시킨다. 이는 사용자가 어떤 링크를 클릭했는지, 해당 클릭이 광고와 관련된 것인지를 기록할 수 있게 한다.

 

6. 전체적인 흐름 정리

  1. 글로벌 상태 관리: EventLogProvider를 통해 Tracker 인스턴스를 글로벌하게 제공하고, 이를 통해 로그를 기록한다. 이는 트래킹 로직을 중앙집중화하여 일관성을 유지.
  2. 사용자 인증 및 식별: EventLogProvider는 useAuth 훅을 통해 사용자 인증 상태를 확인하고, 인증된 사용자인 경우 암호화된 유저 ID를, 인증되지 않은 사용자인 경우 게스트 ID를 생성하여 Tracker에 전달한다.
  3. 경로 및 쿼리 파라미터 추적: 현재 페이지의 경로와 쿼리 파라미터를 추적하여 Tracker 인스턴스에 반영한다. 페이지 뷰 로그와 함께 정확한 경로 정보를 기록할 수 있게 한다.
  4. 로그 기록: 페이지 로드 시 자동으로 페이지 뷰 로그가 자동으로 기록되고, 버튼 클릭 등 특정 이벤트가 발생할 때마다 해당 이벤트에 대한 로그가 기록된다.
  5. 광고 로그 통합: 광고 관련 이벤트가 발생할 경우 adLog 정보를 함께 전달하여 광고 효과를 측정할 수 있게 했다.

 

7. 리팩토링

리팩토링은 아래 포스팅(파트2)를 확인해주세요 :)

 

https://kodywiththek.tistory.com/27