마이크로프론트엔드를 도입하면서 앱들끼리 데이터를 주고받는 방법이 필요했습니다. 처음에는 간단하게 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 의존성 제거
};
이 접근법의 장점:
useRef는 React의 렌더링 사이클과 조화롭게 작동하면서도 변경 시 리렌더링을 발생시키지 않는 특성을 가진 훌륭한 도구입니다. 이런 방식으로 성능과 반응성을 모두 최적화할 수 있었습니다.
이것을 만들면서 많이 배웠습니다: