타입 안전한 CustomEvents: 네이티브 API로 더 나은 메시징

발행: (2026년 3월 1일 오전 06:49 GMT+9)
5 분 소요
원문: Dev.to

Source: Dev.to

내부 메시징을 위한 타입‑안전 커스텀 이벤트: 네이티브 API로 더 나은 메시징

네이티브 EventTarget은 내부 메시징을 위한 숨겨진 보석입니다. 내장되어 있고 빠르며 방해가 되지 않습니다. 하지만 표준 CustomEvent 인터페이스는 TypeScript 입장에서 약간의 “블랙 박스”입니다. 보통 데이터를 접근하려면 매번 e as CustomEvent 로 캐스팅하게 됩니다.

우리는 더 나은 방법을 사용할 수 있습니다. 타깃과 이벤트 생성을 래핑함으로써 런타임 오버헤드 없이 완전히 타입‑안전한 이벤트 버스를 만들 수 있습니다.

문제

CustomEvent를 디스패치하면 데이터가 detail 속성에 숨겨집니다. 기본적으로 addEventListener는 그 detail이 무엇을 포함하는지 알지 못하므로, 리스너를 직접 타입 지정해야 합니다.

해결책: TypedEventTarget

type EventListener = (evt: E) => void;

interface EventListenerObject {
  handleEvent(evt: CustomEvent): void;
}

// The type of our listener receives the CustomEvent with our specific data
type TEL = EventListener> | EventListenerObject>;

export class TypedEventTarget> {
  private readonly target = new EventTarget();

  addEventListener(
    type: K & string,
    listener: TEL,
    options?: boolean | AddEventListenerOptions,
  ) {
    // We cast to EventListenerOrEventListenerObject because the browser expects the base Event type
    this.target.addEventListener(type, listener as EventListenerOrEventListenerObject, options);
  }

  removeEventListener(
    type: K & string,
    listener: TEL,
    options?: boolean | EventListenerOptions,
  ) {
    this.target.removeEventListener(type, listener as EventListenerOrEventListenerObject, options);
  }

  // A helper to ensure we always dispatch a properly formatted CustomEvent
  dispatchEvent(type: K, ...args: M[K] extends void ? [detail?: undefined] : [detail: M[K]]) {
    const [detail] = args;
    return this.target.dispatchEvent(new CustomEvent(String(type), { detail }));
  }
}

물론 이것은 기본 클래스일 뿐이며, 자체 데이터를 사용하려면 이를 확장해야 합니다.

실제 사용 사례

온라인 쇼핑 카트를 추적하고 싶다고 가정해 보겠습니다. 사이트 어디에서든 카트를 변경할 수 있어야 하지만 UI나 총 가격이 동기화되지 않기를 원합니다.

import { TypedEventTarget } from './TypedEventTarget';

export interface CartItem {
  id: string;
  name: string;
  price: number;
}

// This map defines exactly what data each event emits
type ShoppingCartEvents = {
  'item-added': CartItem;
  'item-removed': { id: string };
  'cart-cleared': void;
};

export default class ShoppingCart extends TypedEventTarget {
  private _items: CartItem[] = [];

  addItem(item: CartItem) {
    this._items.push(item);
    this.dispatchEvent('item-added', item);
  }

  removeItem(id: CartItem['id']) {
    this._items = this._items.filter(item => item.id !== id);
    this.dispatchEvent('item-removed', { id });
  }

  clear() {
    this._items = [];
    this.dispatchEvent('cart-cleared');
  }

  get items() {
    return this._items;
  }

  get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

이제 데이터를 저장할 클래스를 만들었으니, 자동으로 이벤트를 디스패치하는 메서드를 추가한 것을 볼 수 있습니다. 이러한 이벤트가 엄격하게 타입이 지정되어 있기 때문에, 이벤트를 청취할 때 완전한 자동 완성과 안전성을 얻을 수 있습니다:

shoppingCartSingleton.addEventListener('item-added', event => {
  const item = event.detail; // TypeScript knows this is a CartItem
  console.log(item);
});

데모

위 클래스를 사용한 간단한 데모입니다.

(데모 코드는 간결함을 위해 생략되었습니다.)

Signing‑off

저는 이 기법으로 가능한 것의 표면만 살짝 건드렸을 뿐입니다. 우리는 다양한 주류 프레임워크가 각각 다른 방식을 가지고 있는 세상에 살고 있습니다. 저는 이제 일반화된 싱글톤이 돌아오는 순간에 서 있다고 생각합니다: 선택한 프레임워크에 연결될 수 있는 하나의 네이티브 JS/TS 클래스. 하지만 그것은 또 다른 날, 어쩌면 또 다른 포스트에서 다룰 의견입니다.

읽어 주셔서 감사합니다! 연결하고 싶으시면 제 BlueSkyLinkedIn 프로필을 확인해 주세요. 인사해 주세요 😊

0 조회
Back to Blog

관련 글

더 보기 »

스프린트

!Sprint: Express 코드 중복 없이 https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9mcbu1c3wuvlq0tiuup0.png 소개 Sprint: 코드 중복을 없애세요