본문 바로가기
React

pub/sub 패턴으로 프론트엔드 데이터 태깅 관리하기

by Luke K 2024. 3. 24.

프론트엔드에서는 사용자가 화면을 움직이는 방식과 데이터를 함께 다루게 됩니다.

그렇기에 코드의 복잡도가 늘어나는 경우를 많이 접하게 됩니다.

코드의 복잡도가 늘어나는 문제를 해결하기 위한 재료들을 얻는 것은 상당히 중요합니다.

상태관리를 공부하고 상태관리 오픈소스를 계속해서 읽고 기여까지 하며 얻은 pub/sub 패턴이란 재료를 알게 되었습니다.
이 재료를 실무에 적용하여 제품의 맛을 높인 경험을 공유하고 싶어 글을 작성합니다.

 

팀에서 겪었던 문제

온라인 쇼핑몰을 개발하고 있습니다.

다른 쇼핑몰과 비슷하게 개발하고 있는 커머스는 유저가 상품을 구매하는 것이 본질이고 비즈니스 모델입니다.

유저에게 적합한 상품을 알려주고, 유저의 행동을 바탕으로 힘을 줄 기획을 정하기 위해, 유저의 데이터를 수집하고 분석하는 것은 무척 중요한 일입니다.

유저가 UI를 어떻게 조작하냐에 대한 데이터를 수집하기 위해 데이터를 태깅하는 함수를 이벤트마다 추가해야 했습니다.

하지만 데이터를 전달해야 할 엔드포인트가 점점 늘어나게 됐습니다.

또 이에 따라 각각의 엔드포인트에 전달해야할 데이터의 형태도 많이 달라지게 됐습니다.

그러다 보니 이벤트핸들러가 굉장히 비대해졌습니다.

실제 코드는 아니지만 예를 들어보면 이런 형태였습니다.

const handleClickProduct = async (productId) => {
    const endpointAData = { productId, detail: "Some detail for A" };
    const endpointBData = { productId, user: "user123", preferences: ["pref1", "pref2"] };
    const endpointCData = { productId, timestamp: new Date(), comments: ["comment1", "comment2"] };
    const endpointDData = { productId, status: "active", tags: ["tag1", "tag2", "tag3"] };

    try {
      await axios.post('https://api.example.com/endpointA', endpointAData);
      await axios.post('https://api.example.com/endpointB', endpointBData);
      await axios.post('https://api.example.com/endpointC', endpointCData);
      await axios.post('https://api.example.com/endpointD', endpointDData);
    } catch (error) {
      console.error(error);
    }
    
    //... 클릭 이벤트에 관한 여러가지 처리
  }

이렇게 되니 다음과 같은 문제들이 생겼습니다.

  1. 데이터 태깅에 대한 책임을 가진 모듈들이 응집되지 않습니다.
  2. 데이터가 늘어날 때마다 이벤트 핸들러가 비대해집니다.
  3. 찍어야 할 이벤트의 키와 파라미터를 확인할 때마다 다른 플랫폼에 적힌 문서를 보기 위해 왔다 갔다 해야 합니다.

이러한 문제들을 한 번에 해결할 방법이 없을까 하다 보인 것이 있었습니다.

문제를 해결하기 위해 떠올랐던 pub/sub 패턴 (feat: 상태관리)

상태관리에서 사용하는 setState 혹은 zustand create안에서 사용가능한 set 내부를 생각해 보면 만들어진 state와 관련 있는 모든 변경사항을 적용합니다.

예를 들면 이런 상황입니다.

import create from 'zustand';

const useStore = create(set => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 })),
  decrement: () => set(state => ({ count: state.count - 1 })),
}));

function CounterComponent() {
  const { count, increment, decrement } = useStore();
  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

export default CounterComponent;

여기서 버튼이 눌리면, increment 함수를 실행시키면서, 렌더링 또한 시키게 되죠.

이것이 가능한 이유는 zustand의 create안에 pub/sub 패턴이 적용돼 있기 때문입니다.

즉 pub/sub 패턴으로 api를 간략화시켜 사용처의 코드를 줄일 수 있다는 것입니다.

 

적용방법

구체적인 구현을 위해 코드를 관리하기 쉬운 형태에 대해 고민했습니다.

두 가지를 가져가고 싶었습니다.

  • 이벤트를 추가할 수 있는 방법이 하나로 정해져 있어야 한다.
  • 이벤트를 실행하는 방법이 일관적이어야 한다.

제약은 다음과 같았습니다.

  • react, next 환경에서 자연스러운 API 여야 한다.
  • 모든 브라우저에서 문제없이 동작해야 한다.

그래서 전 다음과 같은 형태로 사용하는 것을 생각했습니다.

  • 유저가 처음 도메인에 랜딩 될 때 이벤트를 구독한다.
  • 구독된 이벤트들은 리액트 훅으로 가져다 쓸 수 있다.

그래서 이벤트 에미터 클래스를 만들고 이를 훅으로 가져다 쓸 수 있도록 설계했습니다.

이벤트 에미터의 구조는 다음과 같이 만들 수 있었습니다.

class DataTaggingEventEmitter {
  private events = new Map<keyof EventMap, Function[]>();

  on(eventName: keyof EventMap, callback: Function) {
    const listeners = this.events.get(eventName) || [];
    listeners.push(callback);
    this.events.set(eventName, listeners);
  }

  emit<T extends keyof EventMap>(eventName: T, data?: EventMap[T]) {
    const listeners = this.events.get(eventName);
    if (listeners) {
      listeners.forEach((listener) => {
        listener(data);
      });
    }
  }
}

 

EventMap은 key로 이벤트 이름과 value로 각 이벤트이름에 해당하는 파라미터가 있는 typescript interface입니다.

즉 DataTaggingEventEmitter 를 사용하기 위해선 이제 EventMap 에 이벤트명과 파라미터를 등록해야 합니다.

그러지 않을 경우 타입 에러가 발생하게 됩니다.

 

이제 이 이벤트를 구독하고 사용하는 훅을 살펴보겠습니다.

const DataTaggingProvider = ({ children }: { children: ReactNode }) => {
  const [dataTaggingEventEmitter] = useState<DataTaggingEventEmitter>(
    new DataTaggingEventEmitter()
  );

  subscribeFirebaseEvents(dataTaggingEventEmitter);
  subscribeBluxEvents(dataTaggingEventEmitter);
  subscribeErrorEvents(dataTaggingEventEmitter);


  return (
    <DataTaggingContext.Provider
      value={{
        dataTaggingEventEmitter: dataTaggingEventEmitter,
      }}
    >
      {children}
    </DataTaggingContext.Provider>
  );
};

export const useDataTaggingEmitter = () => {
  const { dataTaggingEventEmitter } = useContext(DataTaggingContext);
  return dataTaggingEventEmitter;
};
export default DataTaggingProvider;

Provider에 데이터 전달 이벤트들을 구독해 두고 useDataTaggingEmitter를 통해 EventEmitter를 사용할 수 있습니다.

 

이제 제 목적이 다 이루어졌고, 처음 코드는 다음과 같이 변경될 수 있습니다.

const dataTaggingEmitter = useDataTaggingEmitter();

const handleClickProduct = async (productId) => {
	dataTaggingEmitter.emit('click_product', {
        productId,
        isLiked,
        tags,
    })
    //... 클릭 이벤트에 관한 여러가지 처리
  }

 

적용하여 얻은 효과와 인사이트

효과

  1. 이벤트 추가 방식이 통일됐습니다.
  2. EventMap 인터페이스가 하나의 살아있는 이벤트 관리 문서가 됐습니다.
  3. 사용처에서 이벤트 태깅 관련 코드의 줄 수가 현저히 줄었습니다.
  4. 사용처에서 어떠한 이벤트가 일어나는지 몰라도 되게끔 변경됐습니다.
  5. 이벤트 이름이 자동완성되므로 실수가 줄었습니다.
  6. const clickProduct = 'click_product' 와 같은 상수들이 메모리를 잡아먹지 않게 됐습니다.

인사이트

Typescript가 단순히 타입으로 실수를 방지하거나 Intelisense를 더 잘 활용할 수 있게 하는 것뿐만이 아닌 하나의 살아있는 문서가 되도록 할 수 있었습니다.

오픈소스의 아이디어로 코드에 임팩트를 내는 재미를 얻었습니다.

 

마무리

UI와 사용자 이벤트에 대한 데이터의 중요도가 올라가고 있는 걸 느낍니다.

좋은 소프트웨어는 사용자의 동작을 기반으로 성장하게 됩니다.

하지만 코드 관점에서는 사용자의 동작을 전달하는 엔드포인트가 많아지면 많아질수록 이벤트 핸들러가 복잡해집니다.

간단한 패턴을 통해 복잡도를 낮추고 사용자 이벤트를 여러 엔드포인트에 전달하는 키와 파라미터에 대한 응집도를 높여 팀원들이 만족하는 것을 보며 굉장히 뿌듯했습니다.

이러한 뿌듯함을 계속 느끼기 위해, 앞으로도 많은 재료들을 오픈소스, 책, 강의등을 통해 터득하고 적용해야 하는 시점이 올 때 알맞게 적용하는 사람이 되고 싶습니다.

 

 

댓글