Message Event Bus 구현

사공광열·2025년 3월 28일

처음 시작은 단순했습니다

마이크로프론트엔드를 도입하면서 앱들끼리 데이터를 주고받는 방법이 필요했습니다. 처음에는 간단하게 Props로 데이터를 전달했고, 그 다음에는 로컬 스토리지를 사용했는데 둘 다 문제가 많았습니다.

그래서 제가 구글링과 기업 컨퍼런스들을 보면서 MessageEventBus라는 것을 만들어보기로 했습니다. 처음에는 간단할 줄 알았지만... 생각보다 만만치 않았습니다.

첫 번째 문제: 모든 앱이 같은 객체를 사용해야 합니다

마이크로프론트엔드는 여러 개의 독립적인 앱들이 함께 동작합니다. 문제는 각 앱마다 별도의 MessageEventBus 인스턴스가 생기면 서로 통신이 안 된다는 것이었습니다.

그래서 싱글톤으로 사용하고 브라우저의 window 객체를 활용했습니다:


  private constructor() {
    this.subscribers = new Map();
    this.lastEvents = new Map();

    // 브라우저 환경에서 window 객체에 인스턴스 저장
    if (typeof window !== "undefined") {
      (window as any).__MESSAGE_EVENT_BUS_INSTANCE__ = this;
    }
  }

  public static getInstance(): MessageEventBus {
    // 브라우저 환경에서 window 객체에서 인스턴스 확인
    if (
      typeof window !== "undefined" &&
      (window as any).__MESSAGE_EVENT_BUS_INSTANCE__
    ) {
      return (window as any).__MESSAGE_EVENT_BUS_INSTANCE__;
    }

    if (!MessageEventBus.instance) {
      MessageEventBus.instance = new MessageEventBus();
    }
    return MessageEventBus.instance;
  }

이렇게 하니 모든 앱이 같은 MessageEventBus 인스턴스를 공유할 수 있게 되었습니다.

두 번째 문제: 페이지 이동하면 데이터가 사라집니다

사용자가 계좌를 선택하고 다른 페이지로 가게되면 선택했던 정보가 사라지는 문제가 있었습니다. 이건 정말 불편한 문제였습니다.

그래서 '마지막 이벤트 기억하기' 기능을 추가했습니다:


// 이벤트 발행할 때 저장하고
public publish<T = any>(eventType: string, payload: T): void {
  const message = {
    type: eventType,
    payload,
    timestamp: Date.now(),
  };

  // 여기서 마지막 이벤트 저장!
  this.lastEvents.set(eventType, message);

  // 구독자들에게 알림
  if (this.subscribers.has(eventType)) {
    this.subscribers.get(eventType)!.forEach(callback => {
      callback(message);
    });
  }
}

// 새로 구독할 때 바로 알려주기
public subscribe<T = any>(eventType: string, callback: Function): () => void {
  // 구독자 등록...

  // 마지막 이벤트가 있으면 바로 알려주기
  const lastEvent = this.lastEvents.get(eventType);
  if (lastEvent) {
    setTimeout(() => callback(lastEvent), 0);
  }

  // 구독 해제 함수 반환...
}

이 기능을 만들면서 setTimeout을 사용하는 부분에서 많이 고민했습니다. 처음에는 그냥 바로 콜백을 호출했는데, 이상하게 버그가 생겼습니다. 프레임워크의 렌더링 사이클 때문에 문제가 생긴 것 같았습니다. 한참을 디버깅하다가 setTimeout을 사용하면 해결된다는 것을 알았습니다.

세 번째 문제: 컴포넌트에서 사용하기 불편합니다

MessageEventBus 자체는 만들었지만, 컴포넌트에서 사용하기에는 불편했습니다. 그래서 사용하기 쉬운 훅을 만들었습니다:


export const useMessageEventBus = () => {
  // 메시지 발행 함수
  const publish = useCallback((messageType, payload) => {
    messageEventBus.publish(messageType, payload);
  }, []);

  // 구독 훅
  const useSubscription = (messageType, callback) => {
    // callback이 계속 바뀌는 문제를 해결하기 위한 ref
    const callbackRef = useRef(callback);

    // callback이 바뀔 때마다 ref 업데이트
    useEffect(() => {
      callbackRef.current = callback;
    }, [callback]);

    // 실제 구독 로직
    useEffect(() => {
      const wrappedCallback = (message) => {
        callbackRef.current(message.payload);
      };

      const unsubscribe = messageEventBus.subscribe(
        messageType,
        wrappedCallback
      );

      return unsubscribe;
    }, [messageType]);
  };

  return { publish, useSubscription };
};

여기서도 문제가 있었는데, 클로저 문제였습니다. 콜백 함수가 바뀔 때마다 구독을 다시 하면 성능이 안 좋아지고, 안 하면 최신 콜백이 호출되지 않는 문제가 있었습니다.

결국 useRef를 사용해서 해결했습니다. 이는 React의 메커니즘을 활용한 효과적인 접근법이었습니다.


// 문제가 있던 기존 방식
const useSubscription = (messageType, callback) => {
  useEffect(() => {
    // 콜백이 변경될 때마다 구독을 해제하고 다시 구독
    const unsubscribe = messageEventBus.subscribe(messageType, callback);
    return unsubscribe;
  }, [messageType, callback]); // callback이 의존성 배열에 포함됨
};

이 방식의 문제는 컴포넌트가 리렌더링될 때마다 새로운 콜백 함수가 생성되어 불필요하게 구독과 해제가 반복된다는 점이었습니다. 또한 의존성 배열에서 callback을 제거하면 최신 콜백을 사용하지 못하는 클로저 문제가 발생했습니다.

useRef를 사용한 해결책:


const useSubscription = (messageType, callback) => {
  // callback 참조를 저장할 ref 생성
  const callbackRef = useRef(callback);

  // callback이 변경될 때마다 ref 업데이트
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // 구독은 messageType이 변경될 때만 수행
  useEffect(() => {
    const wrappedCallback = (message) => {
      // 항상 최신 callback 참조 사용
      callbackRef.current(message.payload);
    };

    const unsubscribe = messageEventBus.subscribe(
      messageType,
      wrappedCallback
    );

    return unsubscribe;
  }, [messageType]); // callback 의존성 제거
};

이 접근법의 장점:

  1. 불필요한 구독/해제 방지: 콜백이 변경되어도 구독을 유지합니다.
  2. 최신 콜백 보장: 항상 최신 콜백 함수를 참조합니다.
  3. 메모리 효율성: 이벤트 핸들러가 불필요하게 재생성되지 않습니다.

useRef는 React의 렌더링 사이클과 조화롭게 작동하면서도 변경 시 리렌더링을 발생시키지 않는 특성을 가진 훌륭한 도구입니다. 이런 방식으로 성능과 반응성을 모두 최적화할 수 있었습니다.

배운 점

이것을 만들면서 많이 배웠습니다:

  1. 싱글톤 패턴이 생각보다 복잡합니다: 특히 여러 앱에서 공유해야 할 때는 더욱 그렇습니다.
  2. 데이터 상태 유지가 중요합니다: 사용자 입장에서는 페이지를 왔다 갔다 해도 데이터가 유지되는 것이 당연한 것이죠.
  3. 클로저 문제: 이것은 정말 이해하기 어려웠지만, 결국 useRef로 해결했습니다.
profile
Interactive Developer

0개의 댓글