From 0495cb1d1a55c9143fe22131f227a983810c409d Mon Sep 17 00:00:00 2001 From: HeHelee Date: Mon, 24 Nov 2025 19:51:56 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../serverView/rack/components/RackView.tsx | 49 +++++++- .../hooks/useAllEquipmentBackgroundSSE.ts | 80 ++++++++---- .../serverDashboard/hooks/useEquipmentSSE.ts | 117 +++++++++++------- 3 files changed, 177 insertions(+), 69 deletions(-) diff --git a/src/domains/serverView/rack/components/RackView.tsx b/src/domains/serverView/rack/components/RackView.tsx index ff6731f..2faf01f 100644 --- a/src/domains/serverView/rack/components/RackView.tsx +++ b/src/domains/serverView/rack/components/RackView.tsx @@ -3,7 +3,7 @@ import { useRackManager } from "../hooks/useRackManager"; import Sidebar from "./Sidebar"; import RackHeader from "./RackHeader"; import Button from "./Button"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import ServerDashboard from "@domains/serverView/serverDashboard/components/ServerDashboard"; import { useMonitoringStore } from "../../serverDashboard/stores/monitoringStore"; import { useEquipmentSSE } from "../../serverDashboard/hooks/useEquipmentSSE"; @@ -25,7 +25,14 @@ function RackView({ rackName, serverRoomId, onClose }: RackViewProps) { name: string; } | null>(null); - const { deviceMetricsMap, setSelectedDeviceId } = useMonitoringStore(); + const { + deviceMetricsMap, + setSelectedDeviceId, + setSystemData, + setDiskData, + setNetworkData, + setDeviceMetrics, + } = useMonitoringStore(); const { user } = useAuthStore(); const view = user?.role === "VIEWER"; @@ -68,10 +75,44 @@ function RackView({ rackName, serverRoomId, onClose }: RackViewProps) { return []; }, [rackManager.isLoading, selectableEquipmentIds]); - useAllEquipmentBackgroundSSE(backgroundSSEEquipmentIds); + const backgroundCallbacks = useCallback( + () => ({ + onMetricsUpdate: setDeviceMetrics, + onConnectionError: (equipmentId: number, error: Event) => { + console.error( + `[Rack ${rackId}] Background SSE error for equipment ${equipmentId}:`, + error + ); + }, + }), + [rackId, setDeviceMetrics] + ); + + const equipmentCallbacks = useCallback( + () => ({ + onSystemData: setSystemData, + onDiskData: setDiskData, + onNetworkData: setNetworkData, + onError: (error: Event) => { + console.error( + `[Rack ${rackId}] Equipment SSE error for device ${selectedDevice?.id}:`, + error + ); + }, + }), + [rackId, selectedDevice?.id, setSystemData, setDiskData, setNetworkData] + ); + + // 백그라운드 메트릭 수집 (모든 장비) + useAllEquipmentBackgroundSSE( + backgroundSSEEquipmentIds, + backgroundCallbacks() + ); + // 선택된 장비 상세 데이터 수집 useEquipmentSSE( - selectedDevice?.id || 0, + selectedDevice?.id || null, + equipmentCallbacks(), dashboardOpen && selectedDevice?.id !== 0 ); diff --git a/src/domains/serverView/serverDashboard/hooks/useAllEquipmentBackgroundSSE.ts b/src/domains/serverView/serverDashboard/hooks/useAllEquipmentBackgroundSSE.ts index 23a41c3..7df8d76 100644 --- a/src/domains/serverView/serverDashboard/hooks/useAllEquipmentBackgroundSSE.ts +++ b/src/domains/serverView/serverDashboard/hooks/useAllEquipmentBackgroundSSE.ts @@ -1,16 +1,23 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { EventSourcePolyfill } from "event-source-polyfill"; import { getAccessToken, BASE_URL } from "@/api/client"; import type { DiskMonitoringData, SystemMonitoringData } from "../types"; -import { - useMonitoringStore, - type SimpleMetrics, -} from "../stores/monitoringStore"; interface SSEEvent extends Event { data: string; } +export interface SimpleMetrics { + cpu: number; + memory: number; + disk: number; +} + +interface BackgroundSSECallbacks { + onMetricsUpdate: (equipmentId: number, metrics: SimpleMetrics) => void; + onConnectionError?: (equipmentId: number, error: Event) => void; +} + const isSystemMonitoringData = ( data: unknown ): data is SystemMonitoringData => { @@ -21,7 +28,6 @@ const isDiskMonitoringData = (data: unknown): data is DiskMonitoringData => { return typeof data === "object" && data !== null && "usedPercentage" in data; }; -// 메트릭 추출 함수 const extractMetricsFromSystemData = ( systemData: SystemMonitoringData | null, diskData: DiskMonitoringData | null @@ -39,15 +45,18 @@ const extractMetricsFromSystemData = ( }; }; -// 여러 장비의 백그라운드 메트릭을 동시에 수집합니다 +export const useAllEquipmentBackgroundSSE = ( + equipmentIds: number[], + callbacks: BackgroundSSECallbacks +) => { + // ✅ useRef로 콜백 참조 유지 (매번 새로 생성되어도 dependency에 영향 없음) + const callbacksRef = useRef(callbacks); -export const useAllEquipmentBackgroundSSE = (equipmentIds: number[]) => { - const setDeviceMetrics = useMonitoringStore( - (state) => state.setDeviceMetrics - ); + useEffect(() => { + callbacksRef.current = callbacks; + }, [callbacks]); useEffect(() => { - // equipmentIds가 없으면 리턴 if (!equipmentIds || equipmentIds.length === 0) { return; } @@ -57,7 +66,6 @@ export const useAllEquipmentBackgroundSSE = (equipmentIds: number[]) => { return; } - // 각 장비별 SSE 연결 저장 const eventSourceMap = new Map(); const connectToEquipment = (equipmentId: number) => { @@ -65,6 +73,9 @@ export const useAllEquipmentBackgroundSSE = (equipmentIds: number[]) => { const url = `${BASE_URL}/monitoring/subscribe/equipment/${equipmentId}`; let latestSystemData: SystemMonitoringData | null = null; + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 5; + const RECONNECT_DELAY = 3000; const eventSource = new EventSourcePolyfill(url, { headers: { @@ -81,9 +92,17 @@ export const useAllEquipmentBackgroundSSE = (equipmentIds: number[]) => { if (isSystemMonitoringData(parsedData)) { latestSystemData = parsedData; + } else { + console.warn( + `[Equipment ${equipmentId}] Invalid system data:`, + parsedData + ); } } catch (error) { - console.error(error); + console.error( + `[Equipment ${equipmentId}] System data parse error:`, + error + ); } }); @@ -99,7 +118,7 @@ export const useAllEquipmentBackgroundSSE = (equipmentIds: number[]) => { parsedData ); if (metrics) { - setDeviceMetrics(equipmentId, metrics); + callbacksRef.current.onMetricsUpdate(equipmentId, metrics); } } else { console.warn( @@ -115,19 +134,33 @@ export const useAllEquipmentBackgroundSSE = (equipmentIds: number[]) => { } }); - eventSource.onerror = (error) => { + eventSource.onerror = (error: Event) => { console.error( `[Equipment ${equipmentId}] SSE connection error:`, error ); eventSource.close(); eventSourceMap.delete(equipmentId); - - setTimeout(() => { - if (equipmentIds.includes(equipmentId)) { - connectToEquipment(equipmentId); - } - }, 3000); + callbacksRef.current.onConnectionError?.(equipmentId, error); + + // 지수 백오프로 재연결 + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + const delay = RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1); + + setTimeout(() => { + if (equipmentIds.includes(equipmentId)) { + console.log( + `[Equipment ${equipmentId}] Reconnecting... (attempt ${reconnectAttempts})` + ); + connectToEquipment(equipmentId); + } + }, delay); + } else { + console.error( + `[Equipment ${equipmentId}] Max reconnection attempts reached` + ); + } }; eventSourceMap.set(equipmentId, eventSource); @@ -152,5 +185,6 @@ export const useAllEquipmentBackgroundSSE = (equipmentIds: number[]) => { }); eventSourceMap.clear(); }; - }, [equipmentIds, setDeviceMetrics]); + // ✅ equipmentIds만 dependency에 포함 (callbacks은 제외) + }, [equipmentIds]); }; diff --git a/src/domains/serverView/serverDashboard/hooks/useEquipmentSSE.ts b/src/domains/serverView/serverDashboard/hooks/useEquipmentSSE.ts index 99c72d1..f1c9098 100644 --- a/src/domains/serverView/serverDashboard/hooks/useEquipmentSSE.ts +++ b/src/domains/serverView/serverDashboard/hooks/useEquipmentSSE.ts @@ -1,18 +1,34 @@ -import { useEffect } from "react"; +// ============================================================================ +// useEquipmentSSE.ts - 콜백 패턴 (안정적 버전) +// ============================================================================ + +import { useEffect, useRef } from "react"; import { EventSourcePolyfill } from "event-source-polyfill"; import { getAccessToken, BASE_URL } from "@/api/client"; -import { useMonitoringStore } from "../stores/monitoringStore"; import type { DiskMonitoringData, NetworkMonitoringData, SystemMonitoringData, } from "../types"; +interface SSECallbacks { + onSystemData?: (data: SystemMonitoringData) => void; + onDiskData?: (data: DiskMonitoringData) => void; + onNetworkData?: (data: NetworkMonitoringData[]) => void; + onError?: (error: Event) => void; +} + export const useEquipmentSSE = ( - equipmentId: number, + equipmentId: number | null, + callbacks: SSECallbacks, enabled: boolean = true ) => { - const { setSystemData, setDiskData, setNetworkData } = useMonitoringStore(); + // ✅ useRef로 콜백 참조 유지 (매번 새로 생성되어도 dependency에 영향 없음) + const callbacksRef = useRef(callbacks); + + useEffect(() => { + callbacksRef.current = callbacks; + }, [callbacks]); useEffect(() => { if (!enabled || !equipmentId) return; @@ -22,47 +38,64 @@ export const useEquipmentSSE = ( const url = `${BASE_URL}/monitoring/subscribe/equipment/${equipmentId}`; - const eventSource = new EventSourcePolyfill(url, { - headers: { - Authorization: `Bearer ${token}`, - }, - withCredentials: true, - }); + try { + const eventSource = new EventSourcePolyfill(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + withCredentials: true, + }); - eventSource.addEventListener("system", (event) => { - try { - const data: SystemMonitoringData = JSON.parse(event.data); - setSystemData(data); - } catch (error) { - console.error("System data parse error:", error); - } - }); + eventSource.addEventListener("system", (event) => { + try { + const data: SystemMonitoringData = JSON.parse(event.data); + callbacksRef.current.onSystemData?.(data); + } catch (error) { + console.error( + `[Equipment ${equipmentId}] System data parse error:`, + error + ); + } + }); - eventSource.addEventListener("disk", (event) => { - try { - const data: DiskMonitoringData = JSON.parse(event.data); - setDiskData(data); - } catch (error) { - console.error("Disk data parse error:", error); - } - }); + eventSource.addEventListener("disk", (event) => { + try { + const data: DiskMonitoringData = JSON.parse(event.data); + callbacksRef.current.onDiskData?.(data); + } catch (error) { + console.error( + `[Equipment ${equipmentId}] Disk data parse error:`, + error + ); + } + }); - eventSource.addEventListener("network", (event) => { - try { - const data: NetworkMonitoringData[] = JSON.parse(event.data); - setNetworkData(data); - } catch (error) { - console.error("Network data parse error:", error); - } - }); + eventSource.addEventListener("network", (event) => { + try { + const data: NetworkMonitoringData[] = JSON.parse(event.data); + callbacksRef.current.onNetworkData?.(data); + } catch (error) { + console.error( + `[Equipment ${equipmentId}] Network data parse error:`, + error + ); + } + }); - eventSource.onerror = (error) => { - console.error("SSE Error:", error); - eventSource.close(); - }; + eventSource.onerror = (error) => { + console.error(`[Equipment ${equipmentId}] SSE Error:`, error); + eventSource.close(); + }; - return () => { - eventSource.close(); - }; - }, [equipmentId, enabled, setSystemData, setDiskData, setNetworkData]); + return () => { + eventSource.close(); + }; + } catch (error) { + console.error( + `[Equipment ${equipmentId}] Failed to create SSE connection:`, + error + ); + } + // ✅ equipmentId와 enabled만 dependency에 포함 (callbacks는 제외) + }, [equipmentId, enabled]); };