345 lines
9.1 KiB
TypeScript
345 lines
9.1 KiB
TypeScript
|
|
/**
|
||
|
|
* V2 EventBus - 타입 안전한 이벤트 버스
|
||
|
|
*
|
||
|
|
* 특징:
|
||
|
|
* - 타입 안전한 이벤트 발행/구독
|
||
|
|
* - 에러 격리 (하나의 핸들러 실패가 다른 핸들러에 영향 없음)
|
||
|
|
* - 병렬/순차 실행 지원
|
||
|
|
* - 디버그 모드 지원
|
||
|
|
* - 구독 자동 정리 (컴포넌트 언마운트 시)
|
||
|
|
*/
|
||
|
|
|
||
|
|
import {
|
||
|
|
V2_EVENTS,
|
||
|
|
V2EventName,
|
||
|
|
V2EventPayloadMap,
|
||
|
|
V2EventHandler,
|
||
|
|
V2Unsubscribe,
|
||
|
|
} from "./types";
|
||
|
|
|
||
|
|
interface SubscriberInfo<T extends V2EventName> {
|
||
|
|
id: string;
|
||
|
|
handler: V2EventHandler<T>;
|
||
|
|
componentId?: string;
|
||
|
|
once: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface EmitOptions {
|
||
|
|
/** 병렬 실행 여부 (기본값: true) */
|
||
|
|
parallel?: boolean;
|
||
|
|
/** 타임아웃 (ms, 기본값: 5000) */
|
||
|
|
timeout?: number;
|
||
|
|
/** 실패 시 재시도 횟수 (기본값: 0) */
|
||
|
|
retryCount?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface EmitResult {
|
||
|
|
success: boolean;
|
||
|
|
handlerCount: number;
|
||
|
|
errors: Array<{ subscriberId: string; error: Error }>;
|
||
|
|
}
|
||
|
|
|
||
|
|
class V2EventBus {
|
||
|
|
private subscribers: Map<V2EventName, Map<string, SubscriberInfo<any>>> =
|
||
|
|
new Map();
|
||
|
|
private subscriberIdCounter = 0;
|
||
|
|
|
||
|
|
/** 디버그 모드 활성화 시 모든 이벤트 로깅 */
|
||
|
|
public debug = false;
|
||
|
|
|
||
|
|
/** 디버그용 로거 */
|
||
|
|
private log(message: string, ...args: any[]) {
|
||
|
|
if (this.debug) {
|
||
|
|
console.log(`[V2EventBus] ${message}`, ...args);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 에러 로거 */
|
||
|
|
private logError(message: string, error: any) {
|
||
|
|
console.error(`[V2EventBus] ${message}`, error);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 이벤트 구독
|
||
|
|
*
|
||
|
|
* @param eventName - 이벤트 이름
|
||
|
|
* @param handler - 이벤트 핸들러
|
||
|
|
* @param options - 구독 옵션
|
||
|
|
* @returns 구독 해제 함수
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* ```typescript
|
||
|
|
* const unsubscribe = v2EventBus.subscribe(
|
||
|
|
* V2_EVENTS.TABLE_REFRESH,
|
||
|
|
* (payload) => {
|
||
|
|
* console.log("테이블 새로고침:", payload.tableName);
|
||
|
|
* },
|
||
|
|
* { componentId: "my-table" }
|
||
|
|
* );
|
||
|
|
*
|
||
|
|
* // 컴포넌트 언마운트 시
|
||
|
|
* unsubscribe();
|
||
|
|
* ```
|
||
|
|
*/
|
||
|
|
subscribe<T extends V2EventName>(
|
||
|
|
eventName: T,
|
||
|
|
handler: V2EventHandler<T>,
|
||
|
|
options?: { componentId?: string; once?: boolean }
|
||
|
|
): V2Unsubscribe {
|
||
|
|
const subscriberId = `sub_${++this.subscriberIdCounter}`;
|
||
|
|
|
||
|
|
if (!this.subscribers.has(eventName)) {
|
||
|
|
this.subscribers.set(eventName, new Map());
|
||
|
|
}
|
||
|
|
|
||
|
|
const eventSubscribers = this.subscribers.get(eventName)!;
|
||
|
|
eventSubscribers.set(subscriberId, {
|
||
|
|
id: subscriberId,
|
||
|
|
handler,
|
||
|
|
componentId: options?.componentId,
|
||
|
|
once: options?.once ?? false,
|
||
|
|
});
|
||
|
|
|
||
|
|
this.log(
|
||
|
|
`구독 등록: ${eventName} (${subscriberId})`,
|
||
|
|
options?.componentId ? `컴포넌트: ${options.componentId}` : ""
|
||
|
|
);
|
||
|
|
|
||
|
|
// 구독 해제 함수 반환
|
||
|
|
return () => {
|
||
|
|
this.unsubscribe(eventName, subscriberId);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 일회성 이벤트 구독 (한 번 실행 후 자동 해제)
|
||
|
|
*/
|
||
|
|
once<T extends V2EventName>(
|
||
|
|
eventName: T,
|
||
|
|
handler: V2EventHandler<T>,
|
||
|
|
options?: { componentId?: string }
|
||
|
|
): V2Unsubscribe {
|
||
|
|
return this.subscribe(eventName, handler, { ...options, once: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 이벤트 구독 해제
|
||
|
|
*/
|
||
|
|
private unsubscribe(eventName: V2EventName, subscriberId: string): void {
|
||
|
|
const eventSubscribers = this.subscribers.get(eventName);
|
||
|
|
if (eventSubscribers) {
|
||
|
|
eventSubscribers.delete(subscriberId);
|
||
|
|
this.log(`구독 해제: ${eventName} (${subscriberId})`);
|
||
|
|
|
||
|
|
// 구독자가 없으면 Map 정리
|
||
|
|
if (eventSubscribers.size === 0) {
|
||
|
|
this.subscribers.delete(eventName);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 특정 컴포넌트의 모든 구독 해제
|
||
|
|
*
|
||
|
|
* @param componentId - 컴포넌트 ID
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* ```typescript
|
||
|
|
* // useEffect cleanup에서 사용
|
||
|
|
* useEffect(() => {
|
||
|
|
* return () => {
|
||
|
|
* v2EventBus.unsubscribeByComponent("my-table-component");
|
||
|
|
* };
|
||
|
|
* }, []);
|
||
|
|
* ```
|
||
|
|
*/
|
||
|
|
unsubscribeByComponent(componentId: string): void {
|
||
|
|
let unsubscribedCount = 0;
|
||
|
|
|
||
|
|
this.subscribers.forEach((eventSubscribers, eventName) => {
|
||
|
|
eventSubscribers.forEach((subscriber, subscriberId) => {
|
||
|
|
if (subscriber.componentId === componentId) {
|
||
|
|
eventSubscribers.delete(subscriberId);
|
||
|
|
unsubscribedCount++;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// 구독자가 없으면 Map 정리
|
||
|
|
if (eventSubscribers.size === 0) {
|
||
|
|
this.subscribers.delete(eventName);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
this.log(
|
||
|
|
`컴포넌트 구독 해제: ${componentId} (${unsubscribedCount}개 해제)`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 이벤트 발행
|
||
|
|
*
|
||
|
|
* @param eventName - 이벤트 이름
|
||
|
|
* @param payload - 이벤트 데이터
|
||
|
|
* @param options - 발행 옵션
|
||
|
|
* @returns 발행 결과
|
||
|
|
*
|
||
|
|
* @example
|
||
|
|
* ```typescript
|
||
|
|
* await v2EventBus.emit(V2_EVENTS.TABLE_REFRESH, {
|
||
|
|
* tableName: "item_info",
|
||
|
|
* target: "single",
|
||
|
|
* });
|
||
|
|
* ```
|
||
|
|
*/
|
||
|
|
async emit<T extends V2EventName>(
|
||
|
|
eventName: T,
|
||
|
|
payload: V2EventPayloadMap[T],
|
||
|
|
options: EmitOptions = {}
|
||
|
|
): Promise<EmitResult> {
|
||
|
|
const { parallel = true, timeout = 5000, retryCount = 0 } = options;
|
||
|
|
|
||
|
|
const eventSubscribers = this.subscribers.get(eventName);
|
||
|
|
|
||
|
|
if (!eventSubscribers || eventSubscribers.size === 0) {
|
||
|
|
this.log(`이벤트 발행 (구독자 없음): ${eventName}`);
|
||
|
|
return { success: true, handlerCount: 0, errors: [] };
|
||
|
|
}
|
||
|
|
|
||
|
|
this.log(`이벤트 발행: ${eventName} → ${eventSubscribers.size}개 구독자`);
|
||
|
|
|
||
|
|
const errors: Array<{ subscriberId: string; error: Error }> = [];
|
||
|
|
const subscribersToRemove: string[] = [];
|
||
|
|
|
||
|
|
// 핸들러 실행 함수
|
||
|
|
const executeHandler = async (
|
||
|
|
subscriber: SubscriberInfo<T>
|
||
|
|
): Promise<void> => {
|
||
|
|
const executeWithRetry = async (retriesLeft: number): Promise<void> => {
|
||
|
|
try {
|
||
|
|
// 타임아웃 적용
|
||
|
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||
|
|
setTimeout(
|
||
|
|
() => reject(new Error(`Handler timeout after ${timeout}ms`)),
|
||
|
|
timeout
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
const handlerPromise = Promise.resolve(subscriber.handler(payload));
|
||
|
|
await Promise.race([handlerPromise, timeoutPromise]);
|
||
|
|
|
||
|
|
if (subscriber.once) {
|
||
|
|
subscribersToRemove.push(subscriber.id);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
if (retriesLeft > 0) {
|
||
|
|
this.log(
|
||
|
|
`핸들러 재시도: ${subscriber.id} (남은 횟수: ${retriesLeft})`
|
||
|
|
);
|
||
|
|
await executeWithRetry(retriesLeft - 1);
|
||
|
|
} else {
|
||
|
|
const err =
|
||
|
|
error instanceof Error ? error : new Error(String(error));
|
||
|
|
this.logError(`핸들러 실행 실패: ${subscriber.id}`, err);
|
||
|
|
errors.push({ subscriberId: subscriber.id, error: err });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
await executeWithRetry(retryCount);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 병렬 또는 순차 실행
|
||
|
|
const subscriberArray = Array.from(eventSubscribers.values());
|
||
|
|
|
||
|
|
if (parallel) {
|
||
|
|
// 병렬 실행 (Promise.allSettled로 에러 격리)
|
||
|
|
await Promise.allSettled(
|
||
|
|
subscriberArray.map((subscriber) => executeHandler(subscriber))
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
// 순차 실행
|
||
|
|
for (const subscriber of subscriberArray) {
|
||
|
|
await executeHandler(subscriber);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 일회성 구독자 정리
|
||
|
|
subscribersToRemove.forEach((id) => {
|
||
|
|
eventSubscribers.delete(id);
|
||
|
|
});
|
||
|
|
|
||
|
|
if (eventSubscribers.size === 0) {
|
||
|
|
this.subscribers.delete(eventName);
|
||
|
|
}
|
||
|
|
|
||
|
|
const success = errors.length === 0;
|
||
|
|
|
||
|
|
if (errors.length > 0) {
|
||
|
|
this.log(
|
||
|
|
`이벤트 완료: ${eventName} (성공: ${subscriberArray.length - errors.length}, 실패: ${errors.length})`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
success,
|
||
|
|
handlerCount: subscriberArray.length,
|
||
|
|
errors,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 동기적 이벤트 발행 (결과 대기 없음)
|
||
|
|
*
|
||
|
|
* 빠른 발행이 필요하고 결과를 기다릴 필요 없을 때 사용
|
||
|
|
*/
|
||
|
|
emitSync<T extends V2EventName>(
|
||
|
|
eventName: T,
|
||
|
|
payload: V2EventPayloadMap[T]
|
||
|
|
): void {
|
||
|
|
this.emit(eventName, payload).catch((error) => {
|
||
|
|
this.logError(`동기 이벤트 발행 실패: ${eventName}`, error);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 이벤트 구독자 수 조회
|
||
|
|
*/
|
||
|
|
getSubscriberCount(eventName: V2EventName): number {
|
||
|
|
return this.subscribers.get(eventName)?.size ?? 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 모든 구독 해제 (테스트용)
|
||
|
|
*/
|
||
|
|
clear(): void {
|
||
|
|
this.subscribers.clear();
|
||
|
|
this.log("모든 구독 해제됨");
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 현재 구독 상태 출력 (디버그용)
|
||
|
|
*/
|
||
|
|
printState(): void {
|
||
|
|
console.log("=== V2EventBus 상태 ===");
|
||
|
|
this.subscribers.forEach((subscribers, eventName) => {
|
||
|
|
console.log(`${eventName}: ${subscribers.size}개 구독자`);
|
||
|
|
subscribers.forEach((sub) => {
|
||
|
|
console.log(` - ${sub.id} (컴포넌트: ${sub.componentId ?? "없음"})`);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
console.log("======================");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 싱글톤 인스턴스 생성 및 내보내기
|
||
|
|
export const v2EventBus = new V2EventBus();
|
||
|
|
|
||
|
|
// 개발 환경에서 window에 노출 (디버깅용)
|
||
|
|
if (typeof window !== "undefined" && process.env.NODE_ENV === "development") {
|
||
|
|
(window as any).__v2EventBus = v2EventBus;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 클래스도 내보내기 (테스트용)
|
||
|
|
export { V2EventBus };
|
||
|
|
|