From 300542d922cd745bede3212be0ab8eb8be8621b3 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Feb 2026 16:48:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop):=20usePopEvent,=20useDataSource=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=ED=9B=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePopEvent: screenId 기반 이벤트 버스 (publish/subscribe/sharedData) - useDataSource: DataSourceConfig 기반 데이터 CRUD 통합 훅 - 집계/조인 → SQL 빌더 경로 (대시보드 로직 재사용) - 단순 조회 → dataApi 경로 - save/update/remove CRUD 래핑 - popSqlBuilder: dataFetcher.ts에서 SQL 빌더 로직 추출 (순수 유틸) - index.ts: 배럴 파일 (재export) 기존 대시보드(dataFetcher.ts) 미수정, 향후 통합 예정 Co-authored-by: Cursor --- frontend/hooks/pop/index.ts | 15 ++ frontend/hooks/pop/popSqlBuilder.ts | 195 ++++++++++++++ frontend/hooks/pop/useDataSource.ts | 383 ++++++++++++++++++++++++++++ frontend/hooks/pop/usePopEvent.ts | 190 ++++++++++++++ 4 files changed, 783 insertions(+) create mode 100644 frontend/hooks/pop/index.ts create mode 100644 frontend/hooks/pop/popSqlBuilder.ts create mode 100644 frontend/hooks/pop/useDataSource.ts create mode 100644 frontend/hooks/pop/usePopEvent.ts diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts new file mode 100644 index 00000000..c0146c0b --- /dev/null +++ b/frontend/hooks/pop/index.ts @@ -0,0 +1,15 @@ +/** + * POP 공통 훅 배럴 파일 + * + * 사용법: import { usePopEvent, useDataSource } from "@/hooks/pop"; + */ + +// 이벤트 통신 훅 +export { usePopEvent, cleanupScreen } from "./usePopEvent"; + +// 데이터 CRUD 훅 +export { useDataSource } from "./useDataSource"; +export type { MutationResult, DataSourceResult } from "./useDataSource"; + +// SQL 빌더 유틸 (고급 사용 시) +export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; diff --git a/frontend/hooks/pop/popSqlBuilder.ts b/frontend/hooks/pop/popSqlBuilder.ts new file mode 100644 index 00000000..bd9fd599 --- /dev/null +++ b/frontend/hooks/pop/popSqlBuilder.ts @@ -0,0 +1,195 @@ +/** + * POP 공통 SQL 빌더 + * + * DataSourceConfig를 SQL 문자열로 변환하는 순수 유틸리티. + * 원본: pop-dashboard/utils/dataFetcher.ts에서 추출 (로직 동일). + * + * 대시보드(dataFetcher.ts)는 기존 코드를 그대로 유지하고, + * 새 컴포넌트(useDataSource 등)는 이 파일을 사용한다. + * 향후 대시보드 교체 시 dataFetcher.ts가 이 파일을 import하도록 변경 예정. + * + * 보안: + * - escapeSQL: SQL 인젝션 방지 (문자열 이스케이프) + * - sanitizeIdentifier: 테이블명/컬럼명에서 위험 문자 제거 + */ + +import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types"; + +// ===== SQL 값 이스케이프 ===== + +/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */ +function escapeSQL(value: unknown): string { + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; + // 문자열: 작은따옴표 이스케이프 + const str = String(value).replace(/'/g, "''"); + return `'${str}'`; +} + +// ===== 식별자 검증 (테이블명, 컬럼명) ===== + +/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */ +function sanitizeIdentifier(name: string): string { + // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용 + return name.replace(/[^a-zA-Z0-9_.]/g, ""); +} + +// ===== 설정 완료 여부 검증 ===== + +/** + * DataSourceConfig의 필수값이 모두 채워졌는지 검증 + * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는 + * SQL을 생성하지 않도록 사전 차단 + * + * @returns null이면 유효, 문자열이면 미완료 사유 + */ +export function validateDataSourceConfig(config: DataSourceConfig): string | null { + // 테이블명 필수 + if (!config.tableName || !config.tableName.trim()) { + return "테이블이 선택되지 않았습니다"; + } + + // 집계 함수가 설정되었으면 대상 컬럼도 필수 + // (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능) + if (config.aggregation) { + const aggType = config.aggregation.type?.toLowerCase(); + const aggCol = config.aggregation.column?.trim(); + if (aggType !== "count" && !aggCol) { + return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`; + } + } + + // 조인이 있으면 조인 조건 필수 + if (config.joins?.length) { + for (const join of config.joins) { + if (!join.targetTable?.trim()) { + return "조인 대상 테이블이 선택되지 않았습니다"; + } + if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + return "조인 조건 컬럼이 설정되지 않았습니다"; + } + } + } + + return null; +} + +// ===== 필터 조건 SQL 생성 ===== + +/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */ +function buildWhereClause(filters: DataSourceFilter[]): string { + // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어) + const validFilters = filters.filter((f) => f.column?.trim()); + if (!validFilters.length) return ""; + + const conditions = validFilters.map((f) => { + const col = sanitizeIdentifier(f.column); + + switch (f.operator) { + case "between": { + const arr = Array.isArray(f.value) ? f.value : [f.value, f.value]; + return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`; + } + case "in": { + const arr = Array.isArray(f.value) ? f.value : [f.value]; + const vals = arr.map(escapeSQL).join(", "); + return `${col} IN (${vals})`; + } + case "like": + return `${col} LIKE ${escapeSQL(f.value)}`; + default: + return `${col} ${f.operator} ${escapeSQL(f.value)}`; + } + }); + + return `WHERE ${conditions.join(" AND ")}`; +} + +// ===== 집계 SQL 빌더 ===== + +/** + * DataSourceConfig를 SELECT SQL로 변환 + * + * @param config - 데이터 소스 설정 + * @returns SQL 문자열 + */ +export function buildAggregationSQL(config: DataSourceConfig): string { + const tableName = sanitizeIdentifier(config.tableName); + + // SELECT 절 + let selectClause: string; + if (config.aggregation) { + const aggType = config.aggregation.type.toUpperCase(); + const aggCol = config.aggregation.column?.trim() + ? sanitizeIdentifier(config.aggregation.column) + : ""; + + // COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수 + if (!aggCol) { + selectClause = aggType === "COUNT" + ? "COUNT(*) as value" + : `${aggType}(${tableName}.*) as value`; + } else { + selectClause = `${aggType}(${aggCol}) as value`; + } + + // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함 + if (config.aggregation.groupBy?.length) { + const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", "); + selectClause = `${groupCols}, ${selectClause}`; + } + } else { + selectClause = "*"; + } + + // FROM 절 (조인 포함 - 조건이 완전한 조인만 적용) + let fromClause = tableName; + if (config.joins?.length) { + for (const join of config.joins) { + // 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어) + if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + continue; + } + const joinTable = sanitizeIdentifier(join.targetTable); + const joinType = join.joinType.toUpperCase(); + const srcCol = sanitizeIdentifier(join.on.sourceColumn); + const tgtCol = sanitizeIdentifier(join.on.targetColumn); + fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`; + } + } + + // WHERE 절 + const whereClause = config.filters?.length + ? buildWhereClause(config.filters) + : ""; + + // GROUP BY 절 + let groupByClause = ""; + if (config.aggregation?.groupBy?.length) { + groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`; + } + + // ORDER BY 절 + let orderByClause = ""; + if (config.sort?.length) { + const sortCols = config.sort + .map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`) + .join(", "); + orderByClause = `ORDER BY ${sortCols}`; + } + + // LIMIT 절 + const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : ""; + + return [ + `SELECT ${selectClause}`, + `FROM ${fromClause}`, + whereClause, + groupByClause, + orderByClause, + limitClause, + ] + .filter(Boolean) + .join(" "); +} diff --git a/frontend/hooks/pop/useDataSource.ts b/frontend/hooks/pop/useDataSource.ts new file mode 100644 index 00000000..23bd9f93 --- /dev/null +++ b/frontend/hooks/pop/useDataSource.ts @@ -0,0 +1,383 @@ +/** + * useDataSource - POP 컴포넌트용 데이터 CRUD 통합 훅 + * + * DataSourceConfig를 받아서 자동으로 적절한 API를 선택하여 데이터를 조회/생성/수정/삭제한다. + * + * 조회 분기: + * - aggregation 또는 joins가 있으면 → SQL 빌더 + executeQuery (대시보드와 동일) + * - 그 외 → dataApi.getTableData (단순 테이블 조회) + * + * CRUD: + * - save: dataApi.createRecord + * - update: dataApi.updateRecord + * - remove: dataApi.deleteRecord + * + * 사용 패턴: + * ```typescript + * // 집계 조회 (대시보드용) + * const { data, loading } = useDataSource({ + * tableName: "sales_order", + * aggregation: { type: "sum", column: "amount", groupBy: ["category"] }, + * refreshInterval: 30, + * }); + * + * // 단순 목록 조회 (테이블용) + * const { data, refetch } = useDataSource({ + * tableName: "purchase_order", + * sort: [{ column: "created_at", direction: "desc" }], + * limit: 20, + * }); + * + * // 저장/삭제 (버튼용) + * const { save, remove } = useDataSource({ tableName: "inbound_record" }); + * await save({ supplier_id: "SUP-001", quantity: 50 }); + * ``` + */ + +import { useState, useCallback, useEffect, useRef } from "react"; +import { apiClient } from "@/lib/api/client"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { dataApi } from "@/lib/api/data"; +import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types"; +import { validateDataSourceConfig, buildAggregationSQL } from "./popSqlBuilder"; + +// ===== 타입 정의 ===== + +/** 조회 결과 */ +export interface DataSourceResult { + /** 데이터 행 배열 */ + rows: Record[]; + /** 단일 집계 값 (aggregation 시) 또는 전체 행 수 */ + value: number; + /** 전체 행 수 (페이징용) */ + total: number; +} + +/** CRUD 작업 결과 */ +export interface MutationResult { + success: boolean; + data?: unknown; + error?: string; +} + +/** refetch 시 전달할 오버라이드 필터 */ +interface OverrideOptions { + filters?: Record; +} + +// ===== 내부: 집계/조인 조회 ===== + +/** + * 집계 또는 조인이 포함된 DataSourceConfig를 SQL로 변환하여 실행 + * dataFetcher.ts의 fetchAggregatedData와 동일한 로직 + */ +async function fetchWithSqlBuilder( + config: DataSourceConfig +): Promise { + const sql = buildAggregationSQL(config); + + // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백 + let queryResult: { columns: string[]; rows: Record[] }; + try { + // 1차: apiClient (axios 기반, 인증/세션 안정적) + const response = await apiClient.post("/dashboards/execute-query", { query: sql }); + if (response.data?.success && response.data?.data) { + queryResult = response.data.data; + } else { + throw new Error(response.data?.message || "쿼리 실행 실패"); + } + } catch { + // 2차: dashboardApi (fetch 기반, 폴백) + queryResult = await dashboardApi.executeQuery(sql); + } + + if (queryResult.rows.length === 0) { + return { rows: [], value: 0, total: 0 }; + } + + // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 → 숫자 변환 + const processedRows = queryResult.rows.map((row) => { + const converted: Record = { ...row }; + for (const key of Object.keys(converted)) { + const val = converted[key]; + if (typeof val === "string" && val !== "" && !isNaN(Number(val))) { + converted[key] = Number(val); + } + } + return converted; + }); + + // 첫 번째 행의 value 컬럼 추출 + const firstRow = processedRows[0]; + const numericValue = parseFloat( + String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0) + ); + + return { + rows: processedRows, + value: Number.isFinite(numericValue) ? numericValue : 0, + total: processedRows.length, + }; +} + +// ===== 내부: 단순 테이블 조회 ===== + +/** + * aggregation/joins 없는 단순 테이블 조회 + * dataApi.getTableData 래핑 + */ +async function fetchSimpleTable( + config: DataSourceConfig, + overrideFilters?: Record +): Promise { + // config.filters를 Record 형태로 변환 + const baseFilters: Record = {}; + if (config.filters?.length) { + for (const f of config.filters) { + if (f.column?.trim()) { + baseFilters[f.column] = f.value; + } + } + } + + // overrideFilters가 있으면 병합 (같은 키는 override가 덮어씀) + const mergedFilters = overrideFilters + ? { ...baseFilters, ...overrideFilters } + : baseFilters; + + const tableResult = await dataApi.getTableData(config.tableName, { + page: 1, + size: config.limit ?? 100, + sortBy: config.sort?.[0]?.column, + sortOrder: config.sort?.[0]?.direction, + filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : undefined, + }); + + return { + rows: tableResult.data, + value: tableResult.total ?? tableResult.data.length, + total: tableResult.total ?? tableResult.data.length, + }; +} + +// ===== 내부: overrideFilters를 DataSourceFilter 배열에 병합 ===== + +/** + * 기존 config에 overrideFilters를 병합한 새 config 생성 + * 같은 column이 있으면 override 값으로 대체 + */ +function mergeFilters( + config: DataSourceConfig, + overrideFilters?: Record +): DataSourceConfig { + if (!overrideFilters || Object.keys(overrideFilters).length === 0) { + return config; + } + + // 기존 filters에서 override 대상이 아닌 것만 유지 + const overrideColumns = new Set(Object.keys(overrideFilters)); + const existingFilters: DataSourceFilter[] = (config.filters ?? []).filter( + (f) => !overrideColumns.has(f.column) + ); + + // override를 DataSourceFilter로 변환하여 추가 + const newFilters: DataSourceFilter[] = Object.entries(overrideFilters).map( + ([column, value]) => ({ + column, + operator: "=" as const, + value, + }) + ); + + return { + ...config, + filters: [...existingFilters, ...newFilters], + }; +} + +// ===== 메인 훅 ===== + +/** + * POP 컴포넌트용 데이터 CRUD 통합 훅 + * + * @param config - DataSourceConfig (tableName 필수) + * @returns data, loading, error, refetch, save, update, remove + */ +export function useDataSource(config: DataSourceConfig) { + const [data, setData] = useState({ + rows: [], + value: 0, + total: 0, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // config를 ref로 저장 (콜백 안정성) + const configRef = useRef(config); + configRef.current = config; + + // 자동 새로고침 타이머 + const refreshTimerRef = useRef | null>(null); + + // ===== 조회 (READ) ===== + + const refetch = useCallback( + async (options?: OverrideOptions): Promise => { + const currentConfig = configRef.current; + + // 테이블명 없으면 조회하지 않음 + if (!currentConfig.tableName?.trim()) { + return; + } + + setLoading(true); + setError(null); + + try { + const hasAggregation = !!currentConfig.aggregation; + const hasJoins = !!(currentConfig.joins && currentConfig.joins.length > 0); + + let result: DataSourceResult; + + if (hasAggregation || hasJoins) { + // 집계/조인 → SQL 빌더 경로 + // 설정 완료 여부 검증 + const merged = mergeFilters(currentConfig, options?.filters); + const validationError = validateDataSourceConfig(merged); + if (validationError) { + setError(validationError); + setLoading(false); + return; + } + result = await fetchWithSqlBuilder(merged); + } else { + // 단순 조회 → dataApi 경로 + result = await fetchSimpleTable(currentConfig, options?.filters); + } + + setData(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "데이터 조회 실패"; + setError(message); + } finally { + setLoading(false); + } + }, + [] // configRef 사용으로 의존성 불필요 + ); + + // ===== 생성 (CREATE) ===== + + const save = useCallback( + async (record: Record): Promise => { + const tableName = configRef.current.tableName; + if (!tableName?.trim()) { + return { success: false, error: "테이블이 설정되지 않았습니다" }; + } + + try { + const result = await dataApi.createRecord(tableName, record); + return { + success: result.success ?? true, + data: result.data, + error: result.message, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "레코드 생성 실패"; + return { success: false, error: message }; + } + }, + [] + ); + + // ===== 수정 (UPDATE) ===== + + const update = useCallback( + async ( + id: string | number, + record: Record + ): Promise => { + const tableName = configRef.current.tableName; + if (!tableName?.trim()) { + return { success: false, error: "테이블이 설정되지 않았습니다" }; + } + + try { + const result = await dataApi.updateRecord(tableName, id, record); + return { + success: result.success ?? true, + data: result.data, + error: result.message, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "레코드 수정 실패"; + return { success: false, error: message }; + } + }, + [] + ); + + // ===== 삭제 (DELETE) ===== + + const remove = useCallback( + async ( + id: string | number | Record + ): Promise => { + const tableName = configRef.current.tableName; + if (!tableName?.trim()) { + return { success: false, error: "테이블이 설정되지 않았습니다" }; + } + + try { + const result = await dataApi.deleteRecord(tableName, id); + return { + success: result.success ?? true, + data: result.data, + error: result.message, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "레코드 삭제 실패"; + return { success: false, error: message }; + } + }, + [] + ); + + // ===== 자동 조회 + 새로고침 ===== + + // config.tableName 또는 refreshInterval이 변경되면 재조회 + const tableName = config.tableName; + const refreshInterval = config.refreshInterval; + + useEffect(() => { + // 테이블명 있으면 초기 조회 + if (tableName?.trim()) { + refetch(); + } + + // refreshInterval 설정 시 자동 새로고침 + if (refreshInterval && refreshInterval > 0) { + const sec = Math.max(5, refreshInterval); // 최소 5초 + refreshTimerRef.current = setInterval(() => { + refetch(); + }, sec * 1000); + } + + return () => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + }, [tableName, refreshInterval, refetch]); + + return { + data, + loading, + error, + refetch, + save, + update, + remove, + } as const; +} diff --git a/frontend/hooks/pop/usePopEvent.ts b/frontend/hooks/pop/usePopEvent.ts new file mode 100644 index 00000000..c600d838 --- /dev/null +++ b/frontend/hooks/pop/usePopEvent.ts @@ -0,0 +1,190 @@ +/** + * usePopEvent - POP 컴포넌트 간 이벤트 통신 훅 + * + * 같은 화면(screenId) 안에서만 동작하는 이벤트 버스. + * 다른 screenId 간에는 완전히 격리됨. + * + * 주요 기능: + * - publish/subscribe: 일회성 이벤트 (거래처 선택됨, 저장 완료 등) + * - getSharedData/setSharedData: 지속성 상태 (버튼 클릭 시 다른 컴포넌트 값 수집용) + * + * 사용 패턴: + * ```typescript + * const { publish, subscribe, getSharedData, setSharedData } = usePopEvent("S001"); + * + * // 이벤트 구독 (반드시 useEffect 안에서, cleanup 필수) + * useEffect(() => { + * const unsub = subscribe("supplier-selected", (payload) => { + * console.log(payload.supplierId); + * }); + * return unsub; + * }, []); + * + * // 이벤트 발행 + * publish("supplier-selected", { supplierId: "SUP-001" }); + * + * // 공유 데이터 저장/조회 + * setSharedData("selectedSupplier", { id: "SUP-001" }); + * const supplier = getSharedData("selectedSupplier"); + * ``` + */ + +import { useCallback, useRef } from "react"; + +// ===== 타입 정의 ===== + +/** 이벤트 콜백 함수 타입 */ +type EventCallback = (payload: unknown) => void; + +/** 화면별 이벤트 리스너 맵: eventName -> Set */ +type ListenerMap = Map>; + +/** 화면별 공유 데이터 맵: key -> value */ +type SharedDataMap = Map; + +// ===== 전역 저장소 (React 외부, 모듈 스코프) ===== +// SSR 환경에서 서버/클라이언트 간 공유 방지 + +/** screenId별 이벤트 리스너 저장소 */ +const screenBuses: Map = + typeof window !== "undefined" ? new Map() : new Map(); + +/** screenId별 공유 데이터 저장소 */ +const sharedDataStore: Map = + typeof window !== "undefined" ? new Map() : new Map(); + +// ===== 내부 헬퍼 ===== + +/** 해당 screenId의 리스너 맵 가져오기 (없으면 생성) */ +function getListenerMap(screenId: string): ListenerMap { + let map = screenBuses.get(screenId); + if (!map) { + map = new Map(); + screenBuses.set(screenId, map); + } + return map; +} + +/** 해당 screenId의 공유 데이터 맵 가져오기 (없으면 생성) */ +function getSharedMap(screenId: string): SharedDataMap { + let map = sharedDataStore.get(screenId); + if (!map) { + map = new Map(); + sharedDataStore.set(screenId, map); + } + return map; +} + +// ===== 외부 API: 화면 정리 ===== + +/** + * 화면 언마운트 시 해당 screenId의 모든 리스너 + 공유 데이터 정리 + * 메모리 누수 방지용. 뷰어 또는 PopRenderer에서 화면 전환 시 호출. + */ +export function cleanupScreen(screenId: string): void { + screenBuses.delete(screenId); + sharedDataStore.delete(screenId); +} + +// ===== 메인 훅 ===== + +/** + * POP 컴포넌트 간 이벤트 통신 훅 + * + * @param screenId - 화면 ID (같은 screenId 안에서만 통신) + * @returns publish, subscribe, getSharedData, setSharedData + */ +export function usePopEvent(screenId: string) { + // screenId를 ref로 저장 (콜백 안정성) + const screenIdRef = useRef(screenId); + screenIdRef.current = screenId; + + /** + * 이벤트 발행 + * 해당 screenId + eventName에 등록된 모든 콜백에 payload 전달 + */ + const publish = useCallback( + (eventName: string, payload?: unknown): void => { + const listeners = getListenerMap(screenIdRef.current); + const callbacks = listeners.get(eventName); + if (!callbacks || callbacks.size === 0) return; + + // Set을 배열로 복사 후 순회 (순회 중 unsubscribe 안전) + const callbackArray = Array.from(callbacks); + for (const cb of callbackArray) { + try { + cb(payload); + } catch (err) { + // 개별 콜백 에러가 다른 콜백 실행을 막지 않음 + console.error( + `[usePopEvent] 콜백 에러 (screen: ${screenIdRef.current}, event: ${eventName}):`, + err + ); + } + } + }, + [] + ); + + /** + * 이벤트 구독 + * + * 주의: 반드시 useEffect 안에서 호출하고, 반환값(unsubscribe)을 cleanup에서 호출할 것. + * + * @returns unsubscribe 함수 + */ + const subscribe = useCallback( + (eventName: string, callback: EventCallback): (() => void) => { + const listeners = getListenerMap(screenIdRef.current); + + let callbacks = listeners.get(eventName); + if (!callbacks) { + callbacks = new Set(); + listeners.set(eventName, callbacks); + } + callbacks.add(callback); + + // unsubscribe 함수 반환 + const capturedScreenId = screenIdRef.current; + return () => { + const map = screenBuses.get(capturedScreenId); + if (!map) return; + const cbs = map.get(eventName); + if (!cbs) return; + cbs.delete(callback); + // 빈 Set 정리 + if (cbs.size === 0) { + map.delete(eventName); + } + }; + }, + [] + ); + + /** + * 공유 데이터 조회 + * 다른 컴포넌트가 setSharedData로 저장한 값을 가져옴 + */ + const getSharedData = useCallback( + (key: string): T | undefined => { + const shared = sharedDataStore.get(screenIdRef.current); + if (!shared) return undefined; + return shared.get(key) as T | undefined; + }, + [] + ); + + /** + * 공유 데이터 저장 + * 같은 screenId의 다른 컴포넌트가 getSharedData로 읽을 수 있음 + */ + const setSharedData = useCallback( + (key: string, value: unknown): void => { + const shared = getSharedMap(screenIdRef.current); + shared.set(key, value); + }, + [] + ); + + return { publish, subscribe, getSharedData, setSharedData } as const; +}