본문 바로가기

개발 일기

[이벤트 로깅을 위한 EventTracker] 파트 2. 리팩토링 과정 기록

 

EventTracker 코드를 작성한 이후, 스스로 코드를 리뷰하며 발견한 문제점과 리팩토링 과정을 기록한다. 이 글은 설계 과정에서 어떤 부분이 부족했는지, 왜 이를 개선해야 했는지에 대해 정리한 글이다. 

 

 

이 전에 작성했던, EventTracker를 초기 설계했던 과정과 기존 코드에 대한 글은 아래 링크에서 확인할 수 있다.

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

https://kodywiththek.tistory.com/26

 

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

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

kodywiththek.tistory.com

 


1. Tracker 클래스의 문제점과 개선 과정

1) 타입 안전성 부족

Before: 초기 코드에서는 createLogString 메서드의 매개변수로 EventType, LinkLog, AdLog를 정의했으나, EventType과 LinkLog의 타입 정의가 명확하지 않아, 잘못된 데이터가 들어올 가능성이 있었다. 특히, 문자열 리터럴 타입("pv" | "ch" | "cb")로 정의된 EventType 타입 정의는 문자열 기반으로 특정 값만 허용하려는 의도가 있긴 하지만, 여전히 가독성과 타입 안정성에서 부족한 부분이 존재했다. 특히, 문자열 리터럴을 허용하기 때문에 새로운 값이 추가될 경우 수정해야 할 코드 위치를 정확히 파악하기 어렵다.

export type EventType = "pv" | "ch" | "cb";
export type LinkLog = [LinkType] | [LinkType, string] | undefined;

type LinkType =
  | "mainbanner"
  | "article"
  // ...생략

 
After: Enum 타입을 도입하여 특정 문자열 값만 허용하도록 강제했다. 이를 통해 허용 가능한 값의 범위를 명확히 정의하고, 코드 작성 시 타입 체크를 강화하여 런타임 에러를 예방할 수 있도록 했다.

export enum EventTypeEnum {
  PAGE_VIEW = "pv",
  CLICK_HOMEPAGE = "ch",
  CLICK_BANNER = "cb",
}

export enum LinkTypeEnum {
  MAINBANNER = "mainbanner",
  ARTICLE = "article",
  EVENT_BIG = "event_big",
  // ...생략
}

 
 
 

2) 하드코딩된 유저 ID 처리 로직

Before: getUserId 메서드에서 user#와 같은 접두사가 하드코딩되어 있었다. 이러한 문자열은 유지보수 시 누락되기 쉽고, 코드 가독성을 저해한다.

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

 
After: 상수를 사용하여 하드코딩된 문자열을 관리하게 했다. 이를 통해 가독성을 높이고, 유지보수를 용이하게 했다.

const USER_PREFIX = "user#";
const GUEST_PREFIX = "user#guest#";

private getUserId(): string {
  if (this.userId.startsWith(USER_PREFIX)) {
    return `${encryptString(this.userId)}:lt`;
  } else {
    return `${encryptString(GUEST_PREFIX + this.userId)}:lf`;
  }
}

 
 

3) 로그 문자열 생성 로직 개선

Before: createLogString 메서드에서 조건문을 통해 문자열을 조합하는 방식은 코드가 길고 반복적이었다. 이를 템플릿 리터럴과Array.filter를 활용해 간결하게 수정했다.

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();
}

 
After: Array.filter를 사용하여 조건부로 문자열을 조합하고, 간결한 템플릿 리터럴을 사용해 중복 코드를 제거했다.

private createLogString(
  eventType: EventTypeEnum,
  linkLog?: LinkLog,
  adLog?: AdLog,
): string {
  const user = this.getUserId();
  const linkLogString = linkLog ? `${linkLog[0]}:${linkLog[1] || ""}` : "";
  const adLogString = adLog ? `${adLog[0]}:${adLog[1]}${adLog[2] ? `:${adLog[2]}` : ""}` : "";

  return [user, this.path, eventType, linkLogString, adLogString]
    .filter(Boolean)
    .join(" ");
}

 
 
 

4) 이벤트 메서드의 확장성 개선

Before: pv,ch,cb메서드는 특정 이벤트 타입만 처리할 수 있었다. 새로운 이벤트 타입이 추가될 경우 메서드를 계속 추가해야 하므로 유지보수가 어려웠다.

public pv(linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLogString("pv", linkLog, adLog);
}

public ch(linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLogString("ch", linkLog, adLog);
}

public cb(linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLogString("cb", linkLog, adLog);
}

 
After: pv, ch, cb 이외에 새로운 이벤트가 추가될 경우를 대비해 createLog 메서드를 추가하여 유연성을 높일 수 있었다.특정 이벤트가 필요할 때마다 메서드를 추가할 필요 없이 createLog 메서드를 활용할 수 있도록 확장성을 개선했다.

public createLog(eventType: EventTypeEnum, linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLogString(eventType, linkLog, adLog);
}

 
 
 

5) 메서드 이름 명확히 읽히도록 개선

Before: pv, ch, cb와 같은 이름은 코드 읽는 사람이 메서드의 목적을 즉시 이해하기 어려웠다. 

public pv(linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLogString("pv", linkLog, adLog);
}

public ch(linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLogString("ch", linkLog, adLog);
}

public cb(linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLogString("cb", linkLog, adLog);
}

 
After: 이를 보다 명확한 이름으로 변경했다.

public pageView(linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLog(EventTypeEnum.PAGE_VIEW, linkLog, adLog);
}

public clickHomePage(linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLog(EventTypeEnum.CLICK_HOMEPAGE, linkLog, adLog);
}

public clickBanner(linkLog?: LinkLog, adLog?: AdLog): string {
  return this.createLog(EventTypeEnum.CLICK_BANNER, linkLog, adLog);
}

 


 

2. EventLogProvider의 문제점과 개선 과정

1) Context 타입을 명확히 했다

Before: LogContext 타입이 Tracker | null로 정의되어 있었다. 

const LogContext = createContext<Tracker | null>(null);

 
After: 불필요한 null 체크를 줄이고자 null값 최소화.

const LogContext = createContext<Tracker | undefined>(undefined);
export const useEventTracker = (): Tracker => useContext(LogContext)

 
 

2) 게스트 ID 생성 방식 개선

Before: Date.now()를 사용하여 게스트 ID를 생성하면 중복될 가능성이 있었다.

const GUEST_KEY = "bt-guest";
useEffect(() => {
  const storedGuestId = localStorage.getItem(GUEST_KEY) ?? String(Date.now());
  setUserId(storedGuestId);
}, []);

 
After: uuid 라이브러리를 사용하여 고유한 게스트 ID를 생성

import { v4 as uuidv4 } from "uuid";

const GUEST_KEY = "bt-guest";
useEffect(() => {
  const storedGuestId = localStorage.getItem(GUEST_KEY) ?? uuidv4();
  localStorage.setItem(GUEST_KEY, storedGuestId);
  setUserId(storedGuestId);
}, []);

 
 

3) Tracker 인스턴스 관리 방식 개선 및 성능 최적화

Before: useMemo를 이용해서 tracker가 관리되고있었고, userId, searchParams가 변경될 때 마다 Tracker 인스턴스가 변경되는 구조였다.

const tracker = useMemo(() => {
    if (userId) {
      const currentPath = window.location.pathname + window.location.search;
      return new Tracker(userId, currentPath);
    }
    return null;
}, [userId, searchParams]);

 
After: useMemo 대신 useRef를 사용하여 Tracker 인스턴스를 유지하여 성능을 개선했다. 이를 통해 렌더링과 무관하게 Tracker 상태를 관리했다. userId와, searchParams로 인한 의존성을 분리하여 관리하도록 해서 더 명확한 로직을 유지하도록 했다.

const trackerRef = useRef<Tracker | null>(null);
useEffect(() => {
  if (userId) {
    trackerRef.current = new Tracker(userId, window.location.pathname + window.location.search);
  }
}, [userId]);

useEffect(() => {
  if (trackerRef.current) {
    const currentPath = window.location.pathname + window.location.search;
    trackerRef.current = new Tracker(trackerRef.current.userId, currentPath);
  }
}, [searchParams]);

 

추가설명: useRef 사용이 왜 더 성능에 좋을까?

useMemo와 useRef는 React에서 상태를 효율적으로 관리하고 재연산을 방지하기 위한 도구로 사용된다. 두 방법 모두 특정 값의 재생성을 제어하지만, 아래와 같은 이유로 useRef를 사용하는 것이 성능 최적화에 유리할 수 있다.

 
useMemo

  • React 컴포넌트가 재렌더링될 때마다 useMemo가 호출되어 종속성 배열을 확인하고, 필요한 경우 재계산한다.
  • 메모이제이션된 값을 반환하며, 렌더링 중에도 의존성이 변하면 새로운 값을 계산한다.

useRef

  • 컴포넌트의 재렌더링과 상관없이 동일한 참조 값을 유지한다.
  • 내부적으로는 변경 가능한 객체(current 속성)로 구현되어 값이 변경되어도 렌더링을 유발하지 않는다.
  • 렌더링이 자주 발생하는 상황에서도 동일한 객체를 재사용하므로, 불필요한 재계산을 완전히 방지한다.

결론 1) useRef는 렌더링 사이에서 값을 유지하면서 재생성을 방지

  • 의존성이 변경될 때마다 값을 재계산하는 useMemo는 위 코드에서 userId가 변경될 때마다 Tracker 인스턴스를 다시 생성한다.
  • 반면, useRef는 값의 변경 여부와 관계없이 동일한 객체 참조(trackerRef.current)를 유지하므로, 불필요한 객체 생성 및 메모리 할당을 방지한다.

결론 2) 외부 상태와 독립적 관리

  • Tracker는 특정 userId와 currentPath를 기반으로 인스턴스를 생성한다. 이 상태는 UI와 직접적으로 연결되지 않으므로, useRef로 관리하는 것이 적합하다.
  • useMemo를 사용하면, React가 종속성 변경을 감지하여 새로운 값을 생성해야 하므로 불필요한 렌더링 논리에 얽힐 수 있다.

useMemo는 의존성 관리가 필요한 메모이제이션에 적합하지만, 렌더링과 독립적인 상태를 유지하는 경우에는 useRef를 사용하는 것이 더 효과적이다. Tracker와 같은 객체는 UI 렌더링과 관련이 없기 때문useRef를 사용하여 렌더링 성능을 최적화하는 것이 적합하다.
 
 

3. 트래킹 데이터 전송 방식 개선

Before: 기존 방식에서는 로그 문자열을 생성하는 책임과 전송하는 책임이 분리되어 있었다. Tracker 클래스는 문자열 생성만 담당하고, 로그 전송은 useEventLog 훅에서 처리했다. 이 방식은 각각의 역할을 분리하여 SRP(단일 책임 원칙)를 따랐지만, 두 컴포넌트 간의 연결성이 약해져 사용성에서 좀 불편했었다.
 
 
After: 이를 개선하기 위해 Tracker 클래스에 로그 전송 기능을 추가해서, createLogString으로 로그 문자열을 생성한 뒤, 해당 문자열을 API로 전송하는sendLogEvent 메서드를 제공하도록 했다. 

import { postEventLog } from "@/utils/api";

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

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

  public async sendLogEvent(logString: string): Promise<void> {
    await postEventLog(logString);
  }
  // ... 나머지 로직 동일
}

 
적용: 로그 생성과 전송

import { AdLog, LinkTypeEnum } from "@/types/log";
import { useEventTracker } from "@/context/EventLogProvider";

//... 생략

// 이베트 로그 생성
const tracker = useEventTracker();
const logString = tracker.clickHomePage([LinkTypeEnum.EVENT_BIG, eventValue.companyId], adTracker)

// 로그 전송
tracker.sendLogEvent(logString);

 


 
 
이번 리팩토링 과정을 통해 타입 안전성을 강화하고, 가독성과 유지보수성을 높였다.
또한, 성능 최적화와 트래킹 데이터 전송 방식을 개선하여 코드의 효율성과 확장성을 향상시킬 수 있었다.
앞으로도 코드 리뷰와 리팩토링을 통해 더 나은 구조와 설계를 꾸준히 고민하고자 한다.