Frontend

디자인 패턴(Singleton, Facade)을 사용한 Analytics 코드설계

mechaniccoder 2023. 1. 15. 01:30

최근 개발중인 product의 kpi를 측정하기 위해 analytics에 관한 코드를 설계했습니다. analytics tool로 많이들 사용하는 google analytics 뿐만 아니라 mixpanel, datadog에서도 같은 데이터를 수집해야 했고 어떻게 코드를 설계해야 복잡도를 줄일 수 있을지를 고민했습니다. 평소에 디자인 패턴을 공부하고 있어서 이를 적용해 복잡도를 줄이고자 시도한 경험에 대해 공유해보려 합니다.

 

이 포스팅을 읽고 난 뒤에는 아래의 내용들을 이해하게 됩니다.

  • TypeScript로 Singleton 패턴을 구현하기
  • Singleton, Facade 패턴을 실무에 적용하기

Singleton

Singleton 패턴은 인스턴스가 하나만 존재하도록 하는 디자인 패턴입니다. 어플리케이션 코드 어디서나 접근하게 하고 싶지만 2개 이상의 인스턴스가 생성될 때 문제가 발생하는 상황에서 사용됩니다. 예를 들어, redux store 객체가 2개가 존재한다고 생각하면 이해하기가 쉬울겁니다.


TypeScript로 singleton을 구현해보죠.

class Singleton {
  private static instance: Singleton;

  public static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton();
    }
    return Singleton.instance;
  }

  private constructor() {}

  // other methods
}

Facade

Facade 패턴은 클라이언트에서 어떤 동작들이 수행되는지 숨기도록(캡슐화) 통합된 인터페이스를 제공하는 역할을 합니다. 뒤에서 어떻게 사용했는지 더 자세히 알아보겠습니다.

Analytics 코드설계

Google Analytics, Mixpanel 그리고 Datadog을 추상화하여 Analytics라는 인터페이스로 설계했습니다. 그리고 이를 호출하는 클라이언트에서는 어떤 analytics가 호출되는지를 숨기도록 facade 역할을 하는 함수를 활용했죠. 이를 다이어그램으로 확인해보겠습니다.

null

  • Google Analtycis, Mixpanel, Datadog은 Analytcis 인터페이스를 implement합니다.
  • 클라리언트에게 구현을 숨기도록 facade 역할을 하는 함수들을 생성합니다.

그럼 실제 코드로 확인해보죠. 먼저 Analytics를 구현하는 구체 클래스들에 대한 코드입니다. 앞서 언급했던 singleton패턴이 활용된 것을 확인할 수 있습니다.

interface Analytics {
  trackEvent: (name: string, data: Record<string, any>) => void;
  setUser: (id: string, data: Record<string, any>) => void;
}

class GoogleAnalytics implements Analytics {
  static instance: GoogleAnalytics;

  static getInstance() {
    if (!GoogleAnalytics.instance) {
      GoogleAnalytics.instance = new GoogleAnalytics();
    }
    return GoogleAnalytics.instance;
  }

  private constructor() {}

  trackEvent(name: string, data: Record<string, any>) {
    // ...
  }

  setUser(id: string, data: Record<string, any>) {
    // ...
  }
}

class Mixpanel implements Analytics {
  static instance: Mixpanel;

  static getInstance() {
    if (!Mixpanel.instance) {
      Mixpanel.instance = new Mixpanel();
    }
    return Mixpanel.instance;
  }

  private constructor() {}

  trackEvent(name: string, data: Record<string, any>) {
    // ...
  }

  setUser(id: string, data: Record<string, any>) {
    // ...
  }
}

class Datadog implements Analytics {
  static instance: Datadog;

  static getInstance() {
    if (!Datadog.instance) {
      Datadog.instance = new Datadog();
    }
    return Datadog.instance;
  }

  private constructor() {}

  trackEvent(name: string, data: Record<string, any>) {
    // ...
  }

  setUser(id: string, data: Record<string, any>) {
    // ...
  }
}

위에서 구현한 Analytics 객체들을 사용하는 함수 코드입니다. Facade 패턴을 생각하며 클라이언트에게 캡슐화한다는 것이 어떤 의미인지를 생각해보면 좋겠네요.

function trackAnalyticsEvent(name: string, data: Record<string, any>) {
  const analytics: Analytics[] = [
    GoogleAnalytics.getInstance(),
    Mixpanel.getInstance(),
    Datadog.getInstance(),
  ];

  analytics.forEach((analytic) => {
    analytic.trackEvent(name, data);
  });
}

function setAnalyticsUser(id: string, data: Record<string, any>) {
  const analytics: Analytics[] = [
    GoogleAnalytics.getInstance(),
    Mixpanel.getInstance(),
    Datadog.getInstance(),
  ];

  analytics.forEach((analytic) => {
    analytic.setUser(id, data);
  });
}

analytics array를 생성하는 부분을 추출해줘도 되겠네요.

const analytics: Analytics[] = [
  GoogleAnalytics.getInstance(),
  Mixpanel.getInstance(),
  Datadog.getInstance(),
];

function trackAnalyticsEvent(name: string, data: Record<string, any>) {
  analytics.forEach((analytic) => {
    analytic.trackEvent(name, data);
  });
}

function setAnalyticsUser(id: string, data: Record<string, any>) {
  analytics.forEach((analytic) => {
    analytic.setUser(id, data);
  });
}

Component에서 호출할때는 어떤 analytics가 호출되는지 모르고 간단한 인터페이스만을 노출시키게 됩니다.

const SomePage = () => {
  const user = useUser();

  const handleClick = () => {
    trackAnalyticsEvent("some_event", { some_data: "some_data" });
  };

  useEffect(() => {
    if (user) {
      const { id: userId, ...rest } = user;
      setAnalyticsUser(userId, rest);
    }
  }, [user]);

  return (
    <div>
      {/* ... *}
      <Button onClick={}>Some important event</Button>
    </div>
  );
};

이렇게 코드를 설계함으로서 analytics 플랫폼마다 다르게 구현되는 코드를 구체 클래스로 캡슐화하기 때문에 유지보수가 편리해집니다.

마치며

평소에 디자인 패턴을 공부하면서 실제로 실무에 적용해볼 기회를 찾고 있었는데 실제로 적용해보니 디자인 패턴의 중요성에 대해 더 공감하게 된 것 같습니다. 특히 facade 패턴을 사용한 예시의 경우 패턴을 모르고 있더라도 이런 방식으로 다들 사용할 것 같은데요. 이게 facade 패턴을 응용해서 사용한 것이라고 명시적으로 알게 됐으니 코드를 바라보는 눈이 조금 더 트이는 것 같습니다.

물론 디자인 패턴은 맹목적으로 찬양하는 것은 있어서는 안될 일입니다. 여기에 이 디자인 패턴을 활용할때 확실히 복잡도를 줄일 수 있다는 근거가 있어야하고 팀원들과의 공감대도 형성을 해야겠죠. 패턴 그 자체에 매몰되는 배보다 배꼽이 더 큰 상황은 지양해야겠습니다.