기본으로돌아가자_이벤트이미터

안승찬·2026년 6월 15일

Node.js의 EventEmitter는 매우 익숙한 API지만, 실제로 내부를 직접 구현해보면 생각보다 많은 디테일이 숨어 있습니다.

이번 글에서는 주어진 요구사항을 기준으로, 브라우저에서도 동작하는 경량 EventEmitter를 직접 구현하면서 pub/sub 패턴을 정리해보겠습니다!!

  • 배경
    ────
    앱 내부에서 여러 컴포넌트 간 상태를 공유할 때,pub/sub 패턴이 자주 쓰입니다. Node.js EventEmitter와
    유사하지만 브라우저/React Native에서도 동작하는 경량 이벤트 버스를 직접 구현해보세요.
  • 스펙
    ────
    on(event, listener) → unsubscribe 함수 반환
    - 이벤트 구독. 반환된 함수를 호출하면 구독 해제
    off(event, listener)
    - 특정 리스너 제거
    emit(event, ...args)
    - 이벤트 발행. 구독 중인 모든 리스너 호출
    - 리스너 중 하나가 throw해도 나머지 리스너는 실행됨
    once(event, listener)
    - 한 번만 실행되는 리스너 등록
    onAny(listener)
    - 모든 이벤트를 받는 리스너 등록 (첫 인자로 event명 전달)
    offAny(listener)
    - onAny로 등록된 리스너 제거
    listenerCount(event) → number
    -특정 이벤트의 리스너 수 반환
    removeAllListeners(event?)
    - event 지정 시 해당 이벤트 리스너 전체 제거
    미지정 시 모든 리스너 제거
  • 제약
    ────
    emit 중 리스너가 추가/제거되어도 현재 emit에는 영향 없음
    */
class EventEmitter {
  constructor() {    
    //TODO
  }

  on(event, listener) {
     //TODO
  }

  off(event, listener) {    
    //TODO
  }

  emit(event, ...args) {
    //TODO
  }

  once(event, listener) {
    //TODO
  }

  onAny(listener) {    
    //TODO
  }

  offAny(listener) {    
    //TODO
  }

  listenerCount(event) {    
    //TODO    
  }

  removeAllListeners(event) {    
     //TODO
  }
}

if (typeof module !== 'undefined') module.exports = { EventEmitter };

자 먼저 생성자부터 정의를 해봅시다.

constructor() {    
    this.listeners = new Map()
  }

이벤트 이름을 key로, 해당 이벤트에 등록된 리스너 목록을 value로 관리하는 구조인데, 이때 Object 대신 Map을 사용했습니다.

Object로도 구현은 가능하지만,사용성 측면에서 몇 가지 한계가 있습니다.
예를 들어 size, delete, get 같은 기본적인 API가 없고, 순회 시에도 prototype 체인을 고려해야 하는 등 추가적인 처리가 필요합니다.

반면 Map은 이러한 기능을 기본으로 제공하기 때문에, 이벤트 버스처럼 동적 key를 다루는 구조에서는 더 적합하다고 판단을 했습니다.

   on(event, listener) {
    if(!this.listeners.has(event))// 동일한 이벤트가 없는가
    {
      this.listeners.set(event, new Set()) // 등록된 이벤트가 없으면 등록
    }
    this.listeners.get(event).add(listener) // 반환된 함수를 호출
    return ()=>this.off(event,listener); // 구독 해제
      
  }

그 후 본격적으로 on()부터 구현을 시작했습니다.

먼저 이벤트에 등록되는 리스너들을 Set으로 구현하여 동일한 함수가 중복 등록되는 것을 방지 합니다.
그리고 on()의 특징은 단순히 리스너를 등록하는 것에서 끝나지 않고, 구독 해제를 위한 함수를 즉시 반환한다는 점으로 밑에서 구현할 off() 함수를 리턴합니다.

   off(event, listener) {    
    this.listeners.get(event)?.delete(listener);    
  }

off의 경우 간단하게 delete를 활용해서 해당 리스너를 지워줬습니다.

 emit(event, ...args) {
   this.listeners.get(event).forEach(fn => {
     try{
	  fn();
     }
     catch(e){
     }
     //리스너가 실패해도 나머지 리스너는 실행되기 위한 예외처리
	});

그 후 이벤트들을 실행하는 emit을 만들어 줬습니다.
초기에 위와 같이 구성 했습니다만 emit() 실행도중 원본 listeners가 수정될수도 있다는 점을 고려를 못했습니다.

 emit(event, ...args) {
    const listeners = [...(this.listeners.get(event) ?? [])];
	listeners.forEach(fn => {
     try{
	  fn();
     }
     catch(e){
     }
     //리스너가 실패해도 나머지 리스너는 실행되기 위한 예외처리
	});

그래서 원본 listeners을 얕은 복사 후 참조하도록 변경하였습니다.

그 다음 가장 헷갈렸던 once() 구현입니다.
“한 번만 실행되는 리스너”라는 요구사항 자체는 단순해 보이지만, 실제 구현에서는 리스너 자기 자신을 언제, 어떻게 제거할지가 핵심이었습니다.
한 번만 실행되는데, on과 off를 어떻게 한 함수에 담지...?가 고민이였습니다.

	once(event, listener) {
    const wrapper = (...args)=>{
      listener(...args);
      this.off(event,wrapper)
    }
    this.on(event,wrapper);
  }

결국 해결 방법은 원본 listener를 그대로 등록하는 것이 아니라, 이를 감싸는 wrapper 함수를 만들어 등록하는 방식이었습니다.

이 wrapper는 이벤트가 발생했을 때:

  • 원본 listener를 실행하고
  • 자기 자신(wrapper)을 이벤트 목록에서 제거하는 역할을 합니다.

즉, once()는 단순한 단일 함수가 아니라 “실행 + 자기 제거 로직을 포함한 클로저 함수”를 등록하는 구조입니다.

 constructor() {    
    this.listeners = new Map();
    this.anyListeners = new Set();
  }

  emit(event, ...args) {
    const listeners = [...(this.listeners.get(event) ?? [])];
    const anyListeners = [...this.anyListeners];

    for(const listener of listeners){
      try{
      listener(...args);
      }
      catch(e){
      }
    }
    for(const listener of anyListeners){
      try{
      listener(event,...args);
      }
      catch(e){}
    }
  }

  onAny(listener) {    
    this.anyListeners.add(listener);
  }

  offAny(listener) {    
    this.anyListeners.delete(listener);
  }

그 다음 부터는 어렵지 않았습니다. 모든 이벤트를 받는 listeners가 필요 했기에, key값이 필요하지 않아 Set()을 통해 구현 하였습니다.
emit()에도 anyListeners 실행코드를 추가해 줌으로 anyListenr 구현은 끝났습니다.

마지막으로

 listenerCount(event) {    
    return this.listeners.get(event)?.size ?? 0;
    
  }

  removeAllListeners(event) {    
    if(event){
      return this.listeners.delete(event)
    }
    this.listeners.clear();
    this.anyListeners.clear();
  }

등록된 특정 이벤트에 listenrs의 숫자를 리턴하는 listenerCount(), 등록된 이벤트를 모두 지우는 removeAllListeners()를 구현해 주었습니다. 다만 removeAllListeners에 event 옵셔널 옵션만 따로 구현 해줬습니다.

채점 로그!!


[ 1. on / emit / off ]
  ✅  emit이 리스너를 호출한다
  ✅  emit이 인자를 전달한다
  ✅  off로 리스너를 제거한다
  ✅  on이 반환한 함수로 구독 해제된다
  ✅  리스너가 없는 이벤트를 emit해도 에러가 없다

[ 2. once ]
  ✅  once 리스너는 한 번만 실행된다
  ✅  once 실행 후 listenerCount가 줄어든다

[ 3. onAny / offAny ]
  ✅  onAny는 모든 이벤트를 받는다
  ✅  onAny 리스너는 이벤트명을 첫 인자로 받는다
  ✅  offAny로 리스너가 제거된다

[ 4. 에러 격리 / emit 중 변경 ]
  ✅  리스너가 throw해도 나머지 리스너가 실행된다
  ✅  emit 중에 리스너를 추가해도 현재 emit에 영향 없다

[ 5. listenerCount / removeAllListeners ]
  ✅  listenerCount가 현재 리스너 수를 반환한다
  ✅  removeAllListeners(event)로 특정 이벤트 리스너만 제거된다
  ✅  removeAllListeners()로 모든 리스너가 제거된

✍️ 구현하면서 배운 점

이번 EventEmitter를 직접 구현하면서 단순히 API를 만드는 것이 아니라, 이벤트 기반 구조가 어떻게 동작하는지에 대한 감각을 얻을 수 있었습니다.

1. 자료구조 선택이 설계를 결정한다

처음에는 Object로도 충분히 구현할 수 있을 것이라 생각했지만, 실제로는 Map이 훨씬 적합했습니다.

이벤트 기반 구조는 key-value 형태지만 단순 데이터가 아니라 “동적 등록/삭제”가 핵심이고 순회, 삭제, 크기 관리가 자주 발생합니다.

결국 자료구조 선택이 API 설계의 복잡도를 직접 결정한다는 점을 느꼈습니다.

2. “실행 중 변경”이라는 문제

emit을 구현하면서 가장 중요한 포인트는 실행 중에 상태가 변경될 수 있다는 점이었습니다.

listener 추가,listener 제거,once에 의한 자기 제거 등 이런 변화 때문에 원본을 그대로 순회하면 버그가 발생할 수 있고,
그래서 스냅샷(얕은 복사)을 만들어 안정적인 실행을 보장해야 했습니다.

3. 클로저를 이용한 상태 캡슐화

once 구현에서는 wrapper 함수가 핵심이었습니다.

원본 listener를 감싸고,실행 후 자기 자신을 제거하는 구조

이 과정에서 클로저를 통해 event, listener, wrapper 자신까지 참조할 수 있다는 점이 중요한 포인트였습니다.

0개의 댓글