MAKE IT SIMPLE

EventBus에 대해서 알아야 할것들 (feat. 잘쓰면 넘나 좋다) 본문

React

EventBus에 대해서 알아야 할것들 (feat. 잘쓰면 넘나 좋다)

빈빠끄 2026. 1. 19. 16:39
// ProductDetail.jsx
const handleAddToCart = async (product) => {
  await cartAPI.add(product);
  eventBus.emit('cart:item-added', { product, timestamp: Date.now() });
};

// CartIcon.jsx
useEffect(() => {
  const handleItemAdded = ({ product }) => {
    showToast(`${product.name} 담김!`);
    // 또는 refetch cart count
  };
  
  eventBus.on('cart:item-added', handleItemAdded);
  return () => eventBus.off('cart:item-added', handleItemAdded);
}, []);

프론트엔드 개발을 하다 보면 "컴포넌트 A에서 발생한 일을 컴포넌트 B가 알아야 하는데, 둘 사이에 직접적인 연결고리가 없다"는 상황을 자주 마주친다. props를 타고 타고 올라갔다가 다시 내려오는 건 너무 번거롭고, 전역 상태관리를 도입하자니 배보다 배꼽이 큰 것 같고. 이럴 때 Event Bus가 매력적인 선택지로 떠오른다.

하지만 솔직히 말하면, Event Bus는 양날의 검이다. 잘 쓰면 코드가 깔끔해지고 관심사가 분리되지만, 잘못 쓰면 디버깅 지옥에 빠지고 메모리 누수로 프로덕션에서 장애를 맞는다. 이 글에서는 Event Bus를 실무에서 어떻게 판단하고 사용해야 하는지, 그리고 흔히 저지르는 실수들을 어떻게 피할 수 있는지 이야기해보려 한다.


Event Bus란 무엇인가

Event Bus는 Publish-Subscribe 패턴의 구현체다. 핵심 개념은 단순하다.

// 가장 기본적인 Event Bus 구조
class EventBus {
  constructor() {
    this.listeners = {};
  }

  on(event, callback) {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
  }

  off(event, callback) {
    if (!this.listeners[event]) return;
    this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
  }

  emit(event, data) {
    if (!this.listeners[event]) return;
    this.listeners[event].forEach(callback => callback(data));
  }
}

export const eventBus = new EventBus();

발행자(Publisher)는 이벤트를 emit하고, 구독자(Subscriber)는 해당 이벤트를 on으로 구독한다. 발행자는 누가 듣고 있는지 모르고, 구독자는 누가 보냈는지 신경 쓰지 않는다. 이 "서로 모른다"는 특성이 Event Bus의 핵심이자, 동시에 가장 위험한 부분이다.


언제 Event Bus를 선택해야 하는가

선택지들을 먼저 정리하자

컴포넌트 간 통신 문제를 해결하는 방법은 여러 가지가 있다.

방법 적합한 상황 주의점
Props Drilling depth가 2-3 이내 깊어지면 유지보수 헬파티
Context API 같은 트리 내에서 공유되는 상태 리렌더링 범위 
상태관리 Library 복잡한 전역 상태, 여러 곳에서 읽고 쓰는 데이터 보일러플레이트,
학습곡선에 따른 진입장벽
Event Bus 서로 독립적인 모듈 간 일회성 통신 암묵적 의존성, 메모리 누수

핵심은 "상태"와 "이벤트"를 구분하는 것이다.

상태 vs 이벤트

"사용자가 로그인되어 있는가?"는 상태다. 여러 컴포넌트가 이 값을 계속 참조하고, 값이 바뀌면 UI가 반응해야 한다. 이건 Context나 상태관리 라이브러리가 적합하다.

"사용자가 방금 로그인했다"는 이벤트다. 특정 시점에 발생한 일이고, 이 알림을 받아서 일회성 작업(토스트 표시, 애널리틱스 전송 등)을 수행하면 끝이다. 이런 경우 Event Bus가 깔끔하다.

// 이벤트성 통신 - Event Bus가 적합
eventBus.emit('user:login-success', { userId: '123' });

// 상태성 데이터 - 상태관리가 적합
// userStore.setUser({ id: '123', name: 'Kim' });

 

Event Bus가 유용한 순간들

1. 서로 모르는 컴포넌트 간 통신

장바구니 아이콘은 헤더에 있고, "장바구니에 담기" 버튼은 상품 상세 페이지 깊숙한 곳에 있다. 이 둘은 컴포넌트 트리상 완전히 다른 위치에 있다. 버튼을 누르면 헤더의 장바구니 아이콘에 숫자가 바뀌어야 한다.

// ProductDetail.jsx 
const handleAddToCart = async (product) => {
  await cartAPI.add(product);
  eventBus.emit('cart:item-added', { product, timestamp: Date.now() });
};

// CartIcon.jsx
useEffect(() => {
  const handleItemAdded = ({ product }) => {
    showToast(`${product.name} 담김!`);
    // 또는 refetch cart count
  };
  
  eventBus.on('cart:item-added', handleItemAdded);
  return () => eventBus.off('cart:item-added', handleItemAdded);
}, []);

물론 이건 장바구니 상태를 전역으로 관리해도 되는 케이스다. 하지만 "장바구니에 담겼다"는 이벤트에 반응해서 토스트를 띄우거나, 애니메이션을 트리거하거나, 애널리틱스를 보내는 등의 "일회성 사이드 이펙트"가 여러 군데 있다면 Event Bus가 더 자연스럽다.

2. 레거시 코드와의 브릿지

jQuery로 작성된 레거시 위젯과 새로 작성한 React 컴포넌트가 공존하는 상황. 이런 과도기에 Event Bus는 훌륭한 접착제 역할을 한다.

// 레거시 jQuery 위젯
$('#legacy-form').on('submit', function() {
  const formData = $(this).serialize();
  window.eventBus.emit('legacy:form-submitted', formData);
});

// 새로운 React 컴포넌트
useEffect(() => {
  const handler = (formData) => {
    // React 쪽에서 후처리
    trackAnalytics('form_submit', formData);
  };
  
  window.eventBus.on('legacy:form-submitted', handler);
  return () => window.eventBus.off('legacy:form-submitted', handler);
}, []);

3. 마이크로 프론트엔드 환경

서로 다른 팀이 개발한 독립적인 애플리케이션들이 한 페이지에서 동작할 때, Event Bus는 느슨한 결합을 유지하면서 통신할 수 있는 좋은 방법이다. 각 앱은 서로의 내부 구현을 전혀 모른 채 이벤트만 주고받는다.

// Team A의 앱
eventBus.emit('teamA:user-selected', { userId: '123' });

// Team B의 앱 - Team A의 코드를 전혀 모름
eventBus.on('teamA:user-selected', ({ userId }) => {
  loadUserDetails(userId);
});

4. 크로스 커팅 관심사 처리

에러 로깅, 애널리틱스, 토스트 알림 같은 횡단 관심사를 처리할 때 유용하다.

// API 호출
try {
  await api.call();
} catch (error) {
  eventBus.emit('error:api', { error, context: 'user-profile' });
}

// 에러 핸들러 
eventBus.on('error:api', ({ error, context }) => {
  logToSentry(error, { context });
  showErrorToast(getErrorMessage(error));
});

Event Bus가 독이 되는 순간

1. 암묵적 의존성의 늪

Event Bus의 가장 큰 문제는 코드만 봐서는 의존 관계를 알 수 없다는 것이다.

const handleUpdate = () => {
  eventBus.emit('user:updated', userData);
};

이 코드만 보면 user:updated 이벤트를 누가 듣고 있는지 전혀 알 수 없다. IDE에서 "Find References"를 해도 문자열이라 잡히지 않는다. 시간이 지나면 이 이벤트를 구독하는 곳이 5군데, 10군데로 늘어나고, 어느 날 이벤트 이름을 바꾸거나 페이로드 구조를 변경하면 런타임에서야 버그가 터진다.

2. 디버깅 지옥

버그가 발생했을 때 콜스택을 따라가면 Event Bus의 emit에서 끊긴다. 누가 이 이벤트를 발생시켰는지, 어떤 핸들러가 문제인지 추적하려면 프로젝트 전체를 grep 해야 한다.

Error: Cannot read property 'name' of undefined
    at CartHandler.handleItemAdded (cart-handler.js:15)
    at EventBus.emit (event-bus.js:23)
    // 여기서 끊김. 누가 emit했는지 모름

3. 실행 순서 불확실성

여러 핸들러가 같은 이벤트를 구독할 때, 실행 순서를 보장하기 어렵다. 순서에 의존하는 로직이 있다면 잠재적 버그다.

// 핸들러 A - 먼저 등록됨
eventBus.on('data:loaded', () => {
  processData(); // 데이터 가공
});

// 핸들러 B - 나중에 등록됨
eventBus.on('data:loaded', () => {
  renderChart(); // 가공된 데이터로 차트 그림
});

// B가 A보다 먼저 실행되면? 가공 안 된 데이터로 차트를 그림

4. 상태와 이벤트의 혼동

이벤트로 상태를 관리하려 하면 금방 카오스가 된다.

// 이러면 안 됨
eventBus.on('user:updated', (user) => {
  this.currentUser = user; // 컴포넌트마다 각자 상태를 들고 있음
});

// 어느 컴포넌트의 currentUser가 최신인지 알 수 없음
// 동기화 문제 발생

메모리 누수

Event Bus를 처음 알게되었을때 레거시 프로젝트 환경에서 일하던 터라 구현에 급급해서 남발했던 때 있었다. 이때 메모리 누수 문제로 인한 서비스 장애를 겪었던 적이 있는데, 처음부터 이벤트 버스 때문일거라고는 생각치 못했다.

// 이렇게 쓰면 컴포넌트가 언마운트돼도 핸들러는 Event Bus에 계속 남아있음
useEffect(() => {
  eventBus.on('notification:received', handleNotification);
}, []);

컴포넌트가 마운트될 때 이벤트를 구독하고, 언마운트될 때 구독을 해제하지 않으면 핸들러가 계속 쌓인다. 10번 페이지 이동하면 같은 핸들러가 10개 등록되고, 이벤트가 발생하면 10번 실행된다.

React 18 Strict Mode의 함정

React 18에서 Strict Mode를 켜면 개발 환경에서 컴포넌트가 두 번 마운트된다. 이게 cleanup 누락을 찾는 데 도움을 주려는 의도인데, Event Bus와 만나면 이상한 버그처럼 보인다.

useEffect(() => {
  console.log('구독!');
  eventBus.on('test', handler);
}, []);

// Strict Mode에서 콘솔:
// "구독!"
// "구독!"
// 핸들러가 2개 등록됨

// 정답: 항상 cleanup 해주기
useEffect(() => {
  const handler = (data) => {
    // ...
  };
  
  eventBus.on('notification:received', handler);
  
  return () => {
    eventBus.off('notification:received', handler);
  };
}, []);

cleanup 함수에서 반드시 구독을 해제해야 한다. 여기서 주의할 점은 같은 함수 참조를 off에 전달해야 한다는 것이다.

커스텀 훅으로 실수 방지하기

매번 cleanup을 신경 쓰는 건 귀찮고 실수하기 쉽다. 커스텀 훅으로 추상화하면 안전하다.

// useEventBus.js
import { useEffect, useRef } from 'react';
import { eventBus } from './eventBus';

export function useEventBus(event, handler) {
  // 핸들러의 최신 참조 유지
  const handlerRef = useRef(handler);
  handlerRef.current = handler;

  useEffect(() => {
    const listener = (data) => handlerRef.current(data);
    
    eventBus.on(event, listener);
    return () => eventBus.off(event, listener);
  }, [event]);
}

// 사용하는 곳
function NotificationBadge() {
  const [count, setCount] = useState(0);
  
  useEventBus('notification:received', (data) => {
    setCount(prev => prev + 1);
  });
  
  return <Badge count={count} />;
}

handlerRef를 쓰는 이유는 핸들러 내부에서 최신 state나 props를 참조할 수 있게 하면서도, 이벤트 리스너 자체는 다시 등록하지 않기 위해서다.

DevTools로 누수 확인하기

메모리 누수가 의심될 때 Chrome DevTools로 확인하는 방법:

  1. Memory 탭 열기
  2. Take heap snapshot 클릭
  3. 페이지 이동 몇 번 반복
  4. 다시 스냅샷 찍기
  5. Comparison 뷰에서 늘어난 객체 확인

Event Bus 관련 누수라면 핸들러 함수들이 계속 늘어나는 게 보인다.


타입 안전한 Event Bus 만들기

TypeScript를 쓴다면 Event Bus의 가장 큰 약점인 "이벤트 이름 오타"와 "페이로드 타입 불일치"를 컴파일 타임에 잡을 수 있다.

기본적인 타입 정의

// events.ts
export interface EventMap {
  'user:login': { userId: string; timestamp: number };
  'user:logout': void;
  'cart:item-added': { productId: string; quantity: number };
  'cart:cleared': void;
  'notification:received': { id: string; message: string; type: 'info' | 'error' };
}

타입 안전한 Event Bus 구현

// typedEventBus.ts
import type { EventMap } from './events';

type EventKey = keyof EventMap;
type EventCallback<K extends EventKey> = EventMap[K] extends void
  ? () => void
  : (data: EventMap[K]) => void;

class TypedEventBus {
  private listeners: {
    [K in EventKey]?: Array<EventCallback<K>>;
  } = {};

  on<K extends EventKey>(event: K, callback: EventCallback<K>): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    (this.listeners[event] as Array<EventCallback<K>>).push(callback);
  }

  off<K extends EventKey>(event: K, callback: EventCallback<K>): void {
    const callbacks = this.listeners[event];
    if (!callbacks) return;
    
    this.listeners[event] = callbacks.filter(
      cb => cb !== callback
    ) as typeof callbacks;
  }

  emit<K extends EventKey>(
    event: K,
    ...args: EventMap[K] extends void ? [] : [EventMap[K]]
  ): void {
    const callbacks = this.listeners[event];
    if (!callbacks) return;
    
    callbacks.forEach(callback => {
      (callback as Function)(...args);
    });
  }
}

export const eventBus = new TypedEventBus();

커스텀 훅도 타입 안전하게

// useEventBus.ts
import { useEffect, useRef } from 'react';
import { eventBus } from './typedEventBus';
import type { EventMap } from './events';

type EventKey = keyof EventMap;
type EventCallback<K extends EventKey> = EventMap[K] extends void
  ? () => void
  : (data: EventMap[K]) => void;

export function useEventBus<K extends EventKey>(
  event: K,
  handler: EventCallback<K>
): void {
  const handlerRef = useRef(handler);
  handlerRef.current = handler;

  useEffect(() => {
    const listener = ((data: EventMap[K]) => {
      (handlerRef.current as Function)(data);
    }) as EventCallback<K>;
    
    eventBus.on(event, listener);
    return () => eventBus.off(event, listener);
  }, [event]);
}

// 사용
useEventBus('cart:item-added', (data) => {
  // data는 { productId: string; quantity: number } 타입으로 추론됨
  console.log(`상품 ${data.productId} ${data.quantity}개 추가됨`);
});

디버깅을 위한 미들웨어 패턴

Event Bus의 디버깅 문제를 완화하려면 모든 이벤트를 로깅하는 미들웨어를 추가하는 게 좋다.

class EventBusWithMiddleware {
  private listeners = new Map<string, Set<Function>>();
  private middlewares: Array<(event: string, data: any) => void> = [];

  use(middleware: (event: string, data: any) => void) {
    this.middlewares.push(middleware);
  }

  emit(event: string, data?: any) {
    // 미들웨어 실행
    this.middlewares.forEach(mw => mw(event, data));
    
    // 핸들러 실행
    const handlers = this.listeners.get(event);
    if (handlers) {
      handlers.forEach(handler => handler(data));
    }
  }
  
  // ...
}

// 로깅 미들웨어
eventBus.use((event, data) => {
  console.group(`📢 Event: ${event}`);
  console.log('Data:', data);
  console.log('Time:', new Date().toISOString());
  console.trace('Emitted from:');
  console.groupEnd();
});

// 개발 환경에서만 이벤트 히스토리 저장
if (process.env.NODE_ENV === 'development') {
  const eventHistory: Array<{ event: string; data: any; time: number }> = [];
  
  eventBus.use((event, data) => {
    eventHistory.push({ event, data, time: Date.now() });
    
    // 전역에 노출해서 콘솔에서 확인 가능하게
    (window as any).__EVENT_HISTORY__ = eventHistory;
  });
}

이렇게 하면 브라우저 콘솔에서 __EVENT_HISTORY__를 입력해 지금까지 발생한 모든 이벤트를 확인할 수 있다.


실무 가이드라인 정리

Event Bus 도입 전 체크리스트

  1. 이게 정말 "이벤트"인가, "상태"인가?
    • 일회성 알림이면 Event Bus
    • 지속적으로 참조되는 데이터면 상태관리
  2. 직접적인 연결이 정말 불가능한가?
    • props 2-3단계면 그냥 drilling
    • 같은 트리 안이면 Context 고려
  3. 구독자가 몇 명이나 될 것 같은가?
    • 1-2개면 그냥 직접 호출이 나을 수 있음
    • 여러 군데서 반응해야 하면 Event Bus 가치 있음
  4. 나중에 유지보수할 사람이 이해할 수 있는가?
    • 이벤트 흐름이 복잡해지면 문서화 필수

마치며

Event Bus는 분명 유용한 패턴이지만, "쓰기 쉬운 만큼 오용하기도 쉬운" 양날의 검이다. 핵심은 적절한 상황 판단과 안전한 사용 패턴이다.

기억할 것들:

  • 상태와 이벤트를 구분하자
  • cleanup은 선택이 아닌 필수다
  • 타입으로 안전성을 확보하자
  • 디버깅을 위한 로깅을 고려하자
  • 남용하지 말자. Event Bus가 5개 이상 쓰이고 있다면 아키텍처를 다시 생각해볼 때다

프론트엔드 개발에서 "이 기술을 쓸 줄 안다"보다 중요한 건 "언제 쓰고 언제 안 쓰는지 판단할 수 있다"는 것이다. Event Bus도 마찬가지다. 망치를 들면 모든 게 못으로 보인다고 하던가. Event Bus라는 망치를 들었을 때, 진짜 못인지 한 번 더 생각해보자.

Comments