타입 안전한 이벤트 시스템은 이벤트 이름과 페이로드를 타입으로 연결 해서, 잘못된 이벤트 이름이나 페이로드 전달을 컴파일 타임에 잡습니다.

문제: 타입 없는 이벤트

TYPESCRIPT
// Node.js EventEmitter — 타입 안전하지 않음
import { EventEmitter } from 'events';

const emitter = new EventEmitter();

emitter.on('user:login', (data) => {
  // data: any — 어떤 타입인지 모름
  console.log(data.username); // 런타임에서 에러 가능
});

emitter.emit('user:loginn', { username: 'test' }); // 오타인데 에러 없음

타입 안전한 EventEmitter 구현

TYPESCRIPT
// 이벤트 맵: 이벤트 이름 → 페이로드 타입
type EventMap = {
  'user:login': { userId: number; username: string };
  'user:logout': { userId: number };
  'message:send': { from: number; to: number; content: string };
  'error': { code: number; message: string };
};

class TypedEventEmitter<Events extends Record<string, any>> {
  private listeners: Partial<{
    [K in keyof Events]: ((data: Events[K]) => void)[];
  }> = {};

  on<K extends keyof Events>(
    event: K,
    listener: (data: Events[K]) => void
  ): this {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
    return this;
  }

  off<K extends keyof Events>(
    event: K,
    listener: (data: Events[K]) => void
  ): this {
    const listeners = this.listeners[event];
    if (listeners) {
      this.listeners[event] = listeners.filter((l) => l !== listener) as any;
    }
    return this;
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): this {
    const listeners = this.listeners[event];
    if (listeners) {
      listeners.forEach((listener) => listener(data));
    }
    return this;
  }

  once<K extends keyof Events>(
    event: K,
    listener: (data: Events[K]) => void
  ): this {
    const onceListener = (data: Events[K]) => {
      listener(data);
      this.off(event, onceListener);
    };
    return this.on(event, onceListener);
  }
}

사용

TYPESCRIPT
const emitter = new TypedEventEmitter<EventMap>();

// ✅ 타입 안전한 이벤트 리스닝
emitter.on('user:login', (data) => {
  // data: { userId: number; username: string } — 자동 추론
  console.log(`${data.username}이 로그인했습니다`);
});

// ✅ 타입 안전한 이벤트 발신
emitter.emit('user:login', { userId: 1, username: 'hong' });

// ❌ 잘못된 이벤트 이름
// emitter.on('user:loginn', () => {}); // Error

// ❌ 잘못된 페이로드
// emitter.emit('user:login', { userId: '1' }); // Error — string은 number에 할당 불가

// ❌ 페이로드 누락
// emitter.emit('user:login', { userId: 1 }); // Error — username이 없음

페이로드 없는 이벤트

TYPESCRIPT
type AppEvents = {
  'app:start': void;          // 페이로드 없음
  'app:stop': void;
  'data:loaded': { count: number };
};

class TypedEmitter<Events extends Record<string, any>> {
  // void 이벤트는 data 매개변수를 생략 가능하도록
  emit<K extends keyof Events>(
    ...args: Events[K] extends void ? [event: K] : [event: K, data: Events[K]]
  ): this {
    // 구현
    return this;
  }

  on<K extends keyof Events>(
    event: K,
    listener: Events[K] extends void ? () => void : (data: Events[K]) => void
  ): this {
    // 구현
    return this;
  }
}

const app = new TypedEmitter<AppEvents>();

app.emit('app:start');                    // ✅ OK — 데이터 불필요
app.emit('data:loaded', { count: 42 });  // ✅ OK — 데이터 필수
// app.emit('data:loaded');               // ❌ Error — 데이터 누락

와일드카드 리스너

TYPESCRIPT
type WithWildcard<Events extends Record<string, any>> = Events & {
  '*': { event: keyof Events; data: Events[keyof Events] };
};

// 모든 이벤트를 수신하는 리스너
emitter.on('*', ({ event, data }) => {
  console.log(`이벤트 발생: ${String(event)}`, data);
});

실전 활용: 상태 관리

TYPESCRIPT
type StoreEvents<State> = {
  'change': { prev: State; next: State };
  'reset': void;
};

class Store<State> {
  private state: State;
  private emitter = new TypedEventEmitter<StoreEvents<State>>();

  constructor(initialState: State) {
    this.state = initialState;
  }

  getState(): Readonly<State> {
    return this.state;
  }

  setState(updater: (prev: State) => State): void {
    const prev = this.state;
    this.state = updater(prev);
    this.emitter.emit('change', { prev, next: this.state });
  }

  subscribe(listener: (data: { prev: State; next: State }) => void) {
    this.emitter.on('change', listener);
    return () => this.emitter.off('change', listener);
  }
}

// 사용
const counterStore = new Store({ count: 0 });
counterStore.subscribe(({ prev, next }) => {
  console.log(`${prev.count}${next.count}`);
});

정리

  • 이벤트 맵으로 이벤트 이름과 페이로드 타입을 연결한다
  • 제네릭 EventEmitter로 이벤트 이름 오타와 잘못된 페이로드를 컴파일 타임에 잡는다
  • void 페이로드를 조건부 타입으로 처리해서 데이터 없는 이벤트를 지원한다
  • 상태 관리, pub/sub 시스템 등에 활용할 수 있다
댓글 로딩 중...