커스텀 이벤트 완전 이해

CustomEvent 생성, bubbles/detail/cancelable 옵션, dispatchEvent 반환값, EventTarget 상속 이벤트 버스 패턴까지 정리합니다.

· 6 min read · PALDYN Team

지난 글에서 addEventListener의 네 가지 옵션을 살펴봤습니다. 이번에는 브라우저 내장 이벤트가 아닌 직접 만든 이벤트 — 커스텀 이벤트 — 를 생성하고 발송하는 방법을 다룹니다.


CustomEvent란

브라우저는 click, keydown, submit 같은 내장 이벤트를 자동으로 발생시킵니다. CustomEvent는 개발자가 임의로 이름을 붙이고 데이터를 담아 발송할 수 있는 이벤트입니다. 컴포넌트 간 느슨한 결합, 모듈 간 통신, 이벤트 기반 아키텍처를 구현할 때 핵심 도구입니다.

const event = new CustomEvent('user:login', {
  bubbles: true,
  cancelable: true,
  detail: { userId: 42, role: 'admin' },
});

document.dispatchEvent(event);

new CustomEvent(type, init) 형태로 생성합니다. type은 이벤트 이름 문자열이고, init에 옵션을 담습니다. 네임스페이스 충돌을 피하려면 'user:login'처럼 콜론(:)으로 구분하는 관습이 널리 쓰입니다.

커스텀 이벤트 생명주기


bubbles

기본값은 false입니다. true로 설정하면 이벤트가 DOM 트리를 따라 버블링됩니다.

// bubbles: false (기본) — 발송 요소에서만 처리됨
const evt1 = new CustomEvent('modal:close');
modal.dispatchEvent(evt1);
// document에서는 수신 불가

// bubbles: true — 상위 요소에서도 수신 가능
const evt2 = new CustomEvent('modal:close', { bubbles: true });
modal.dispatchEvent(evt2);
// document까지 버블링됨

컴포넌트 내부에서만 처리하려면 false, 여러 조상 요소가 반응해야 하면 true를 씁니다.


detail

detail 프로퍼티는 리스너에게 전달할 임의 데이터를 담습니다. e.detail로 접근합니다.

const event = new CustomEvent('cart:updated', {
  bubbles: true,
  detail: {
    items: cartItems,
    total: calculateTotal(cartItems),
  },
});

document.dispatchEvent(event);

document.addEventListener('cart:updated', (e) => {
  renderCartBadge(e.detail.items.length);
  renderTotal(e.detail.total);
});

detail에 원시값, 객체, 배열 모두 담을 수 있지만, postMessage처럼 복사되지 않고 참조가 전달되므로 이벤트 발송 후 원본 객체를 변경하면 리스너에서도 변경이 보입니다. 불변성이 필요하다면 structuredClone()으로 복사해 담습니다.


cancelable과 dispatchEvent 반환값

cancelable: true이면 리스너에서 e.preventDefault()를 호출할 수 있습니다. dispatchEvent()동기적으로 모든 리스너를 실행한 뒤, preventDefault()가 호출됐으면 false, 아니면 true를 반환합니다.

const event = new CustomEvent('form:submit', {
  bubbles: true,
  cancelable: true,
  detail: { data: formData },
});

const allowed = form.dispatchEvent(event);

if (allowed) {
  submitToServer(formData);
} else {
  console.log('리스너가 제출을 취소함');
}

이 패턴은 “이벤트 기반 훅(hook)” — 리스너가 기본 동작을 취소할 권한을 갖는 구조 — 을 구현할 때 유용합니다.


EventTarget 상속 이벤트 버스

DOM 요소 없이도 EventTarget을 상속하면 순수 JS 객체에서 이벤트 시스템을 사용할 수 있습니다.

class Store extends EventTarget {
  #data = {};

  set(key, value) {
    const prev = this.#data[key];
    this.#data[key] = value;
    this.dispatchEvent(
      new CustomEvent('change', { detail: { key, value, prev } })
    );
  }

  get(key) {
    return this.#data[key];
  }
}

const store = new Store();

store.addEventListener('change', (e) => {
  console.log(`${e.detail.key} 변경:`, e.detail.prev, '→', e.detail.value);
});

store.set('theme', 'dark'); // "theme 변경: undefined → dark"

프레임워크 없이 반응형 상태 관리를 구현할 때 강력한 패턴입니다.


TypeScript에서 타입 안전하게 사용하기

interface CartDetail {
  items: CartItem[];
  total: number;
}

type CartUpdatedEvent = CustomEvent<CartDetail>;

document.addEventListener('cart:updated', (e: CartUpdatedEvent) => {
  // e.detail은 CartDetail로 추론됨
  renderTotal(e.detail.total);
});

// 발송 헬퍼
function emitCartUpdate(items: CartItem[]) {
  document.dispatchEvent(
    new CustomEvent<CartDetail>('cart:updated', {
      bubbles: true,
      detail: { items, total: items.reduce((s, i) => s + i.price, 0) },
    })
  );
}

커스텀 이벤트 코드 패턴


주의사항

발송 전 리스너 등록: dispatchEvent는 동기 실행이므로 발송 전에 리스너가 등록되어 있어야 합니다. 비동기 리스너가 필요하면 setTimeout(0) 또는 queueMicrotask로 발송을 지연합니다.

이벤트 이름 네이밍: 브라우저 내장 이름(click, input 등)을 재사용하면 혼란을 유발합니다. 네임스페이스:동작 형태(예: modal:open, cart:update)를 권장합니다.

메모리 관리: 이벤트 버스 패턴에서는 컴포넌트 파괴 시 리스너를 제거해야 합니다. AbortControllersignal 옵션을 활용하면 일괄 제거가 쉽습니다.


정리

항목설명
bubblesDOM 위로 전파 여부
cancelablepreventDefault() 허용 여부
detail리스너에 전달할 데이터
dispatchEvent()동기 실행, 취소 여부 반환
EventTarget 상속DOM 없이 이벤트 시스템 구현

커스텀 이벤트는 컴포넌트 경계를 넘는 통신을 DOM 트리에 자연스럽게 통합하는 표준 방법입니다.


지난 글: addEventListener 옵션 완전 이해

다음 글: 키보드·마우스·터치·포인터 이벤트 완전 이해


읽어주셔서 감사합니다. 😊