들어가며
프론트엔드 애플리케이션에서 이벤트 시스템은 다양한 라이브러리를 사용하거나, Node.js 및 브라우저에서 제공하는 커스텀 이벤트를 활용하여 비교적 쉽게 구현할 수 있지만 각 이벤트에 맞는 데이터(payload) 타입을 개발자가 직접 지정하고 관리해야 하는 경우가 많습니다.
애플리케이션 규모가 커지고 이벤트의 수가 증가하면, 어떤 이벤트에 어떤 payload를 사용해야 하는지 일일이 기억하고 매번 타입을 찾아 지정하는 작업은 번거롭고 오류 발생 가능성도 높아집니다. 이러한 불편함을 해소하기 위해, 이벤트 키에 따라 payload 타입이 자동으로 추론되고 매칭되는 효율적인 이벤트 모듈을 고민하게 됩니다.
1. 타입 안전 이벤트 모듈 구현
이벤트 모듈의 주요 요구사항은 다음과 같습니다.
- 이벤트 키(event key)에 따라 해당 payload의 타입이 자동으로 추론되어야 합니다.
- 사용자 친화적인 API를 제공하여 개발자가 쉽게 이벤트를 정의하고 사용할 수 있어야 합니다.
타입스크립트의 타입 시스템과 제네릭을 활용하면 이러한 요구사항을 만족하는 모듈을 구현해봅시다.
// 이벤트 발송
emit('onLogin', { id: 'user123', name: '홍길동' })
// 이벤트 수신
const listen = <T>(eventName: string, handler: (payload: T) => void) => {
// 타입 T는 수동으로 지정해야 함
}
// 사용할 때마다 타입을 직접 지정
listen<{ id: string; name: string }>('onLogin', (payload) => {
// payload의 타입을 매번 기억해서 명시해야 함
})
위 코드의 가장 큰 문제점은 emit 함수로 이벤트를 발생시킬 때 사용한 이벤트 키('onLogin')와 listen 함수에서 해당 이벤트를 구독할 때 명시한 payload 타입({ id: string; name: string }) 간의 연결 점이 없다는 것입니다.
개발자가 매번 수동으로 타입을 지정해야 하므로 실수가 발생하기 쉽고, 이는 마치 onLoginListen, onClickListen처럼 각 이벤트별로 리스너 함수를 하나하나 만드는 것과 크게 다르지 않아 개발하기 불편합니다.

이를 해결하기 위해서는 이벤트 key와 payload 타입 간의 명확한 관계를 정의해야 합니다.
타입스크립트의 정적 타이핑 기능을 활용하면, '이벤트 맵(Event Map)'을 구성하여 key에 맞는 payload를 사전에 정의하고 이를 기반으로 추론할 수 있습니다.
// 이벤트 맵 정의 - 키와 payload를 매칭해주는 맵으로 키와 payload를 사전에 정의
interface AppEventMap {
[Login]: { id: string; name: string }
}
export const LOGIN = Symbol('LOGIN') // 심볼로 키를 정의하여 키를 중복선언 하는 실수 방지
function typedEmit<K extends keyof AppEventMap>(eventKey: K, payload: AppEventMap[K]) {
// 구현...
}
// 타입 안전한 listen 함수
function typedListen<K extends keyof AppEventMap>(
eventKey: K,
handler: (payload: AppEventMap[K]) => void
) {
// 구현...
}
// 사용 시 - 타입이 자동으로 추론됨!
typedEmit(LoginEvent.SUCCESS, {
id: 'user123',
name: '홍길동',
})
typedListen(LoginEvent.SUCCESS, (payload) => {
// payload는 자동으로 { id: string; name: string; } 타입으로 추론
console.log(payload.id, payload.name)
})
이렇게 AppEventMap을 정의하고 typedEmit과 typedListen 함수에 적용하면, 이벤트 키만 정확히 명시해주면 타입스크립트가 해당 키에 매핑된 payload 타입을 자동으로 추론해줍니다. 따라서 개발자가 매번 타입을 수동으로 지정해야 하는 번거로움이 사라지고, 타입 안전하게 이벤트를 구현 할 수 있습니다.

2. 이벤트 키가 너무 많아지면?
앞서 구현한 AppEventMap 은 애플리케이션이 복잡해지고 이벤트의 종류가 점점 늘어난다면 어떨까요? 단일 AppEventMap에 모든 이벤트 정의가 집중되면 관리가 어려워질 수 있습니다. 예를 들어, 'clear'와 관련된 이벤트가 여러 개 필요하다고 가정해봅시다. onShoppingCartClear, onShoppingListPageClear, onSearchHistoryClear 등 유사한 목적이지만 대상이 다른 이벤트들이 계속 추가될 것입니다.
이벤트 키의 이름이 계속 길어지고, 이 이벤트가 어떤 일을 하는 이벤트인지 바로 인지가 되지 않습니다. 결국 이벤트를 개별적으로 선언하고 import해서 사용하는게 낫겠다는 생각이 듭니다.
이러한 문제를 해결하려면 어떻게 해야 할까요?
한 가지 방법은 연관된 이벤트들을 그룹화하여 별도의 이벤트 맵으로 분리하여 관리하는 것입니다. 예를 들어, 로그인 관련 이벤트는 LoginEventMap으로, 장바구니 관련 이벤트는 ShoppingCartEventMap으로 나누는 것이죠.
이를 통해 각 모듈이나 기능 단위로 이벤트를 관리할 수 있습니다.
// 이벤트 맵 정의 - 키와 payload를 매칭해주는 맵으로 키와 payload를 사전에 정의
interface LoginEventMap {
[Login]: { id: string; name: string }
}
interface ShoppingCartEventMap {
[onClear]: { prevCartInfo: ~~~ }
}
function typedEmit<K extends keyof ???EventMap>(eventKey: K, payload: ???EventMap[K]) {
// 구현...
}
// 타입 안전한 listen 함수
function typedListen<K extends keyof ???EventMap>(
eventKey: K,
handler: (payload: ???EventMap[K]) => void
) {
// 구현...
}
하지만 또 다른 문제가 생겼습니다. 기존의 emit과 listen 함수에서 어떤 EventMap을 사용해야 할지 알 수가 없게 되었습니다.

이 문제를 해결하기 위한 몇 가지 접근 방식을 고려해볼 수 있습니다.
- 이벤트 키에 출처 정보 포함: 이벤트 키 자체에 어떤 이벤트 맵에 속하는지를 나타내는 정보를 추가하여 이를 기반으로 타입스크립트가 이벤트맵을 추론하게합니다.
- 팩토리 패턴 활용: 각 이벤트 맵에 특화된
emit과listen함수를 생성하는 팩토리 함수를 제공하는 방식입니다. - 이벤트 맵 통합 유지: 기존 방식을 유지하여 이벤트 맵을 분리하지 않고 단일 맵을 계속 사용합니다. (앞서 제기된 확장성 문제를 감수하는 것이죠)
저는 2번, 즉 팩토리 패턴을 활용하여 각 이벤트 맵에 맞는 타입 안전한 이벤트 발행 및 수신 함수를 생성하는 방법을 선택하겠습니다. 1번 방법에서도 결국 이벤트 키를 편리하게 생성하기 위해서는 추가적인 추상화 과정이 필요 할 것 같고(예: 심볼 팩토리 함수, 키생성 유틸 같은) 그렇다면 그냥 2번을 선택하는 게 나을 것 같습니다.
팩토리 함수 구현
/**
* 모듈별 이벤트 시스템을 생성하는 함수
* 타입 안전한 emit과 useEventListener를 제공
* 제네릭으로 사용할 이벤트 맵을 직접 받아 사용함
*/
export function createTypedEventModule<M extends EventMap>() {
const typedEmit = <K extends keyof M>(eventKey: K, payload: M[K]): void => {
return emit<M, K>(eventKey, payload)
}
const typedListener = <K extends keyof M>(
eventKey: K,
handler: (payload: M[K]) => void
): void => {
return listen<M, K>(eventKey, handler)
}
return {
emit: typedEmit,
useListener: typedListener,
}
}
function emit<M extends EventMap, K extends keyof M>(eventKey: K, payload: M[K]) {
// 구현...
}
// 타입 안전한 listen 함수
function listen<M extends EventMap, K extends keyof M>(
eventKey: K,
handler: (payload: M[K]) => void
) {
// 구현...
}
emit과 listen 함수에 지정하는 제네릭은 각각에 맞는 key와 payload를 모두 지정 해야하지만 이 팩토리 함수에서는 제네릭 타입 M extends EventMap만 지정해주면 된다는 것에 주목해주세요.
이 팩토리 함수를 사용하면 이벤트 맵만 주입해서 타입 안전하게 이벤트를 사용 할 수 있습니다.
실제 사용 예시
1. 이벤트 맵과 키 정의
import { createTypedEventModule, EventMap } from '../createEventModule'
// payload 타입 정의
export type MessageInfo = {
id: number
text: string
timestamp: number
}
export type CounterInfo = {
value: number
previousValue: number
}
// 이벤트 키를 Symbol로 정의
export namespace MyEvent {
export const MESSAGE_SENT = Symbol('MESSAGE_SENT')
export const COUNTER_CHANGED = Symbol('COUNTER_CHANGED')
}
// 이벤트 맵 정의
export interface TypedMyEventMap extends EventMap {
[MyEvent.MESSAGE_SENT]: MessageInfo
[MyEvent.COUNTER_CHANGED]: CounterInfo
}
// 팩토리 함수로 모듈별 이벤트 시스템 생성
export const { emit: myEmit, useListener: useMyEventListener } =
createTypedEventModule<TypedMyEventMap>()
2. 이벤트 발생 (Emit)
const EventSender: React.FC = () => {
const [messageText, setMessageText] = useState('')
const [counter, setCounter] = useState(0)
// 메시지 전송 이벤트 발생
const handleSendMessage = () => {
if (!messageText.trim()) return
const messagePayload = {
id: Date.now(),
text: messageText,
timestamp: Date.now(),
}
// 타입 안전한 이벤트 발생
myEmit(MyEvent.MESSAGE_SENT, messagePayload)
setMessageText('')
}
// 카운터 변경 이벤트 발생
const handleChangeCounter = (increment: boolean) => {
const previousValue = counter
const newValue = increment ? counter + 1 : counter - 1
setCounter(newValue)
const counterPayload = {
value: newValue,
previousValue: previousValue,
}
// 타입 안전한 이벤트 발생
myEmit(MyEvent.COUNTER_CHANGED, counterPayload)
}
const EventReceiver: React.FC = () => {
const [messages, setMessages] = useState<MessageInfo[]>([])
const [counterHistory, setCounterHistory] = useState<CounterInfo[]>([])
// 메시지 이벤트 리스너 - 타입 안전하게 payload 받음
useMyEventListener(MyEvent.MESSAGE_SENT, (payload) => {
// payload는 자동으로 MessageInfo 타입으로 추론됨
setMessages((prev) => [...prev, payload])
})
// 카운터 이벤트 리스너 - 타입 안전하게 payload 받음
useMyEventListener(MyEvent.COUNTER_CHANGED, (payload) => {
// payload는 자동으로 CounterInfo 타입으로 추론됨
setCounterHistory((prev) => [...prev, payload])
})
// JSX 생략...
}
단일 이벤트 사용 기능 추가
팩토리 함수를 사용하여 EventMap을 통해 카테고리별로 타입 안정성을 확보했습니다. 이제 코드에서 이벤트를 사용할 때마다 payload와 event key를 일일이 매칭하지 않아도 적절하게 타입 추론이 가능합니다.
그러나 대부분 단일 이벤트만 사용하게 될 텐데 그 때마다 매번 EventMap을 만들고 createTypedEventModule로 새로운 이벤트 모듈을 생성하는 과정이 너무 번거롭습니다. 단일 이벤트 하나만 발행/구독하고 싶을 때 더 편리하게 사용할 수 있도록 개선이 필요합니다.
고차함수(HOF)를 활용하여 이 요구사항을 만족하는 createSingleEventModule를 구현해봅시다. 제네릭으로 Payload 타입만 넘겨주고, 인자로는 어떤 이벤트인지 구별 할 수 있도록 이벤트키를 사용하여 단일 이벤트 모듈을 생성하는 팩토리 함수를 구현해봅시다.
function createSingleEventModule<P>(eventKey: symbol) {
const { emit, useListener } = createTypedEventModule<{ [K in typeof eventKey]: P }>();
return {
emit: (payload: P) => emit(eventKey, payload),
useListener: (handler: (payload: P) => void) => useListener(eventKey, handler),
}
}
이 함수는 이벤트 맵 타입을 내부에서 직접 정의하여 createTypedEventModule에 주입하고, 기존의 emit과 useListener를 고차 함수로 감싸서 eventKey를 자동으로 제공합니다.
이렇게 하면 기존 createTypedEventModule의 구현을 수정하지 않고도, 개발자가 이벤트 맵과 이벤트 키를 일일이 지정할 필요 없이 아래와 같이 간단하게 이벤트 모듈을 사용할 수 있습니다.
export const {emit, useListener} = createSimpleEventModule<{data: any}>(Symbol('onTest'));
emit();
const TestComponent = () => {
useListener(({data}) => {
console.log(data);
});
return <div>TestComponent</div>;
}
추가 개선방안
지금까지 구현한 이벤트 시스템에 개선점을 고려해보면
1. useListener에 메모이제이션 기능 추가
현재 useListener에 전달하는 콜백 함수는 별도로 useCallback으로 감싸지 않으면, 컴포넌트가 리렌더링될 때마다 이벤트 리스너가 해지되고 다시 등록됩니다.
이로 인해 불필요한 이벤트 등록/해지가 반복될 수 있습니다.
이를 방지하기 위해, useListener에서 의존성 배열을 인자로 받아 내부적으로 콜백을 메모이제이션하는 기능을 추가할 필요가 있습니다.
// 예시: useListener에 deps 인자 추가
useListener(eventKey, handler, deps)
2. useListener에 이벤트 활성/비활성 기능 제공
컴포넌트의 특정 조건에 따라 이벤트 리스너를 동적으로 활성화하거나 비활성화해야 할 때가 있습니다.
이를 위해 useListener에 isActive와 같은 불리언 인자를 추가하여, 리스너의 활성화 여부를 제어할 수 있도록 개선할 수 있습니다.
// 예시: useListener에 isActive 인자 추가
useListener(eventKey, handler, { isActive })
isActive가 false일 경우, 이벤트 리스너가 등록되지 않거나 자동으로 해지되어 불필요한 이벤트 처리를 방지할 수 있습니다.
3. 훅 외의 다른 이벤트 구독 방법 제공
현재는 리액트 컴포넌트 내에서만 훅(useListener)을 통해 이벤트를 구독할 수 있습니다.
하지만 컴포넌트 외부(예: 서비스, 유틸리티, 비동기 로직 등)에서도 이벤트를 구독해야 하는 경우가 있습니다.
이를 위해 일반 함수 형태의 이벤트 구독/해지 API를 추가로 제공하면 활용도를 높일 수 있습니다.
// 예시: on/off 함수 제공
const unsubscribe = on(eventKey, handler)
// 필요 시
unsubscribe()
그 외 기타등등 여러가지 개선방안을 각자의 프로젝트에 맞게 적용해 보시길 바랍니다.
