MAKE IT SIMPLE
EventBus에 대해서 알아야 할것들 (feat. 잘쓰면 넘나 좋다) 본문
// 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로 확인하는 방법:
- Memory 탭 열기
- Take heap snapshot 클릭
- 페이지 이동 몇 번 반복
- 다시 스냅샷 찍기
- 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 도입 전 체크리스트
- 이게 정말 "이벤트"인가, "상태"인가?
- 일회성 알림이면 Event Bus
- 지속적으로 참조되는 데이터면 상태관리
- 직접적인 연결이 정말 불가능한가?
- props 2-3단계면 그냥 drilling
- 같은 트리 안이면 Context 고려
- 구독자가 몇 명이나 될 것 같은가?
- 1-2개면 그냥 직접 호출이 나을 수 있음
- 여러 군데서 반응해야 하면 Event Bus 가치 있음
- 나중에 유지보수할 사람이 이해할 수 있는가?
- 이벤트 흐름이 복잡해지면 문서화 필수
마치며
Event Bus는 분명 유용한 패턴이지만, "쓰기 쉬운 만큼 오용하기도 쉬운" 양날의 검이다. 핵심은 적절한 상황 판단과 안전한 사용 패턴이다.
기억할 것들:
- 상태와 이벤트를 구분하자
- cleanup은 선택이 아닌 필수다
- 타입으로 안전성을 확보하자
- 디버깅을 위한 로깅을 고려하자
- 남용하지 말자. Event Bus가 5개 이상 쓰이고 있다면 아키텍처를 다시 생각해볼 때다
프론트엔드 개발에서 "이 기술을 쓸 줄 안다"보다 중요한 건 "언제 쓰고 언제 안 쓰는지 판단할 수 있다"는 것이다. Event Bus도 마찬가지다. 망치를 들면 모든 게 못으로 보인다고 하던가. Event Bus라는 망치를 들었을 때, 진짜 못인지 한 번 더 생각해보자.
'React' 카테고리의 다른 글
| React로 동적 피벗 테이블 만들기: 트리 모델링부터 rowSpan까지 (0) | 2026.05.30 |
|---|---|
| SSE 기반 LLM 응답 스트리밍 구현기 (1) | 2026.05.19 |
| 프론트엔드가 복잡해질수록 먼저 무너지는 것: 상태 관리 (2) | 2025.12.30 |
| Error: Image Optimization using Next.js' default loader is not compatible with next export. (0) | 2022.02.10 |