diff --git a/src/api/signalApi.js b/src/api/signalApi.js index dead6053..da1f874a 100644 --- a/src/api/signalApi.js +++ b/src/api/signalApi.js @@ -48,4 +48,48 @@ export async function fetchAllSignals() { } } +/** + * 12분 이내 전체 선박 신호 조회 (Raw 문자열 배열 반환, Worker용) + * Web Worker에서 파싱을 수행할 때 사용 + * + * @returns {Promise} 파이프 구분 문자열 배열 + */ +export async function fetchAllSignalsRaw() { + try { + const response = await fetch('/signal-api/all/12'); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + // API 응답 구조: { data: [...] } 또는 직접 배열 + const rawData = result?.data || result || []; + + if (!Array.isArray(rawData)) { + console.warn('[fetchAllSignalsRaw] Invalid response format:', result); + return []; + } + + // 문자열 배열로 변환 (각 행이 이미 문자열이면 그대로, 배열이면 파이프로 조인) + const rawLines = rawData.map((row) => { + if (typeof row === 'string') { + return row; + } + if (Array.isArray(row)) { + return row.join('|'); + } + return ''; + }).filter(line => line.trim()); + + console.log(`[fetchAllSignalsRaw] Loaded ${rawLines.length} raw lines for Worker`); + + return rawLines; + } catch (error) { + console.error('[fetchAllSignalsRaw] Error:', error); + return []; + } +} + export default fetchAllSignals; diff --git a/src/common/stompClient.js b/src/common/stompClient.js index 50ceb13c..03cc2fa4 100644 --- a/src/common/stompClient.js +++ b/src/common/stompClient.js @@ -10,12 +10,18 @@ import { SHIP_MSG_INDEX, STOMP_TOPICS } from '../types/constants'; * STOMP 클라이언트 인스턴스 * 환경변수: VITE_SIGNAL_WS (예: ws://10.26.252.39:9090/connect) */ +const brokerURL = import.meta.env.VITE_SIGNAL_WS || 'ws://localhost:8080/connect'; +console.log('[STOMP] Broker URL:', brokerURL); + export const signalStompClient = new Client({ - brokerURL: import.meta.env.VITE_SIGNAL_WS || 'ws://localhost:8080/connect', + brokerURL, reconnectDelay: 10000, connectionTimeout: 5000, - debug: () => { - // STOMP 디버그 로그 비활성화 (너무 많은 로그 발생) + debug: (str) => { + // STOMP 디버그 로그 (연결 관련 메시지만 출력) + if (str.includes('Opening') || str.includes('connected') || str.includes('error') || str.includes('closed')) { + console.log('[STOMP Debug]', str); + } }, }); @@ -147,7 +153,7 @@ export function disconnectStomp() { } /** - * 선박 토픽 구독 + * 선박 토픽 구독 (파싱된 객체 반환) * - 개발: /topic/ship (실시간) * - 프로덕션: /topic/ship-throttled-60s (위성망 대응) * @param {Function} onMessage - 메시지 수신 콜백 (파싱된 선박 데이터 배열) @@ -155,8 +161,7 @@ export function disconnectStomp() { */ export function subscribeShips(onMessage) { // 환경변수로 쓰로틀 설정 (VITE_SHIP_THROTTLE: 0=실시간, 5/10/30/60=쓰로틀) - // const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10); - const throttleSeconds = 0; + const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10); const topic = throttleSeconds > 0 ? `${STOMP_TOPICS.SHIP_THROTTLED}${throttleSeconds}s` @@ -181,6 +186,33 @@ export function subscribeShips(onMessage) { }); } +/** + * 선박 토픽 구독 (Raw 문자열 반환, Worker용) + * - Web Worker에서 파싱을 수행할 때 사용 + * @param {Function} onMessage - 메시지 수신 콜백 (파이프 구분 문자열 배열) + * @returns {Object} 구독 객체 (unsubscribe 호출용) + */ +export function subscribeShipsRaw(onMessage) { + const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10); + + const topic = throttleSeconds > 0 + ? `${STOMP_TOPICS.SHIP_THROTTLED}${throttleSeconds}s` + : STOMP_TOPICS.SHIP; + + console.log(`[STOMP] Subscribing to ${topic} (Raw mode for Worker)`); + + return signalStompClient.subscribe(topic, (message) => { + try { + const body = message.body; + // 파싱 없이 줄 단위로 분리만 해서 전달 + const lines = body.split('\n').filter(line => line.trim()); + onMessage(lines); + } catch (error) { + console.error('[STOMP] Ship message parse error:', error); + } + }); +} + /** * 선박 삭제 토픽 구독 * @param {Function} onDelete - 삭제 메시지 수신 콜백 (featureId) diff --git a/src/hooks/useShipData.js b/src/hooks/useShipData.js index ebe65e0d..cadf4f8b 100644 --- a/src/hooks/useShipData.js +++ b/src/hooks/useShipData.js @@ -2,8 +2,8 @@ * 선박 데이터 관리 훅 * - 초기 선박 데이터 API 로드 (/all/12) * - STOMP WebSocket 연결 및 구독 - * - 선박 데이터 수신 및 스토어 업데이트 - * - 배치 머지 최적화 (1초 or 500건) + * - Web Worker를 통한 데이터 파싱 (메인 스레드 부담 감소) + * - 배치 머지 최적화 (500ms 인터벌) * * 참조: mda-react-front/src/map/MapUpdater.tsx * 위성통신망 환경 최적화: 최소 트래픽, 최소 스펙 @@ -13,19 +13,25 @@ import { signalStompClient, connectStomp, disconnectStomp, - subscribeShips, + subscribeShipsRaw, subscribeShipDelete, } from '../common/stompClient'; import useShipStore from '../stores/shipStore'; -import { fetchAllSignals } from '../api/signalApi'; +import { fetchAllSignalsRaw } from '../api/signalApi'; // ===================== -// 배치 머지 설정 +// Web Worker 인스턴스 생성 // ===================== -const BATCH_CONFIG = { - maxInterval: 1000, // 최대 대기 시간 (1초) - maxCount: 500, // 최대 버퍼 크기 (500건) -}; +const SignalWorker = new Worker( + new URL('../workers/signalWorker.js', import.meta.url), + { type: 'module' } +); + +// ===================== +// 배치 처리 설정 +// 참조: mda-react-front/src/map/MapUpdater.tsx +// ===================== +const WEBSOCKET_CHUNK_INTERVAL = 500; // WebSocket 데이터 청크 처리 주기 (ms) /** * 선박 데이터 관리 훅 @@ -37,52 +43,86 @@ export default function useShipData(options = {}) { const { autoConnect = true } = options; const subscriptionsRef = useRef([]); - const shipBufferRef = useRef([]); - const batchTimerRef = useRef(null); + const shipBufferRef = useRef([]); // Raw 문자열 버퍼 + const batchIntervalRef = useRef(null); // 배치 처리 인터벌 const initialLoadDoneRef = useRef(false); const [isLoading, setIsLoading] = useState(true); - const { mergeFeatures, deleteFeatureById, setConnected, isConnected } = useShipStore(); + const mergeFeatures = useShipStore((s) => s.mergeFeatures); + const deleteFeatureById = useShipStore((s) => s.deleteFeatureById); + const setConnected = useShipStore((s) => s.setConnected); + const isConnected = useShipStore((s) => s.isConnected); /** - * 버퍼된 선박 데이터 머지 실행 + * Worker 메시지 핸들러 (파싱된 선박 데이터 수신) + */ + const handleWorkerMessage = useCallback((e) => { + const ships = e.data; + if (ships.length > 0) { + mergeFeatures(ships); + } + }, [mergeFeatures]); + + /** + * Worker 설정 + */ + useEffect(() => { + SignalWorker.onmessage = handleWorkerMessage; + SignalWorker.onerror = (err) => { + console.error('[SignalWorker] Error:', err); + }; + + return () => { + SignalWorker.onmessage = null; + SignalWorker.onerror = null; + }; + }, [handleWorkerMessage]); + + /** + * Raw 선박 메시지 수신 핸들러 (버퍼에 누적) + * @param {string[]} lines - 파이프 구분 문자열 배열 + */ + const handleShipMessageRaw = useCallback((lines) => { + // 버퍼에 추가 + shipBufferRef.current.push(...lines); + }, []); + + /** + * 버퍼 플러시 - Worker로 전송 */ const flushBuffer = useCallback(() => { if (shipBufferRef.current.length === 0) return; // 버퍼 복사 후 초기화 - const ships = shipBufferRef.current; + const rawMessages = shipBufferRef.current; shipBufferRef.current = []; - // 타이머 클리어 - if (batchTimerRef.current) { - clearTimeout(batchTimerRef.current); - batchTimerRef.current = null; - } - - // 머지 실행 - mergeFeatures(ships); - }, [mergeFeatures]); + // Worker로 전송 (파싱은 Worker에서 수행) + SignalWorker.postMessage(rawMessages); + }, []); /** - * 선박 메시지 수신 핸들러 (배치 처리) - * 조건: 1초 경과 OR 500건 누적 시 머지 + * 배치 처리 인터벌 시작 */ - const handleShipMessage = useCallback((ships) => { - // 버퍼에 추가 - shipBufferRef.current.push(...ships); + const startBatchInterval = useCallback(() => { + if (batchIntervalRef.current) return; - // 조건 1: 500건 이상이면 즉시 머지 - if (shipBufferRef.current.length >= BATCH_CONFIG.maxCount) { + batchIntervalRef.current = setInterval(() => { flushBuffer(); - return; - } + }, WEBSOCKET_CHUNK_INTERVAL); - // 조건 2: 타이머가 없으면 1초 타이머 설정 - if (!batchTimerRef.current) { - batchTimerRef.current = setTimeout(() => { - flushBuffer(); - }, BATCH_CONFIG.maxInterval); + console.log(`[useShipData] Batch interval started: ${WEBSOCKET_CHUNK_INTERVAL}ms`); + }, [flushBuffer]); + + /** + * 배치 처리 인터벌 중지 + */ + const stopBatchInterval = useCallback(() => { + if (batchIntervalRef.current) { + clearInterval(batchIntervalRef.current); + batchIntervalRef.current = null; } + // 남은 버퍼 플러시 + flushBuffer(); }, [flushBuffer]); /** @@ -107,14 +147,17 @@ export default function useShipData(options = {}) { }); subscriptionsRef.current = []; - // 선박 토픽 구독 - const shipSub = subscribeShips(handleShipMessage); + // 선박 토픽 구독 (Raw 모드 - Worker용) + const shipSub = subscribeShipsRaw(handleShipMessageRaw); subscriptionsRef.current.push(shipSub); // 선박 삭제 토픽 구독 const deleteSub = subscribeShipDelete(handleShipDelete); subscriptionsRef.current.push(deleteSub); - }, [handleShipMessage, handleShipDelete]); + + // 배치 처리 인터벌 시작 + startBatchInterval(); + }, [handleShipMessageRaw, handleShipDelete, startBatchInterval]); /** * 연결 성공 시 토픽 구독 @@ -129,7 +172,8 @@ export default function useShipData(options = {}) { */ const handleDisconnect = useCallback(() => { setConnected(false); - }, [setConnected]); + stopBatchInterval(); + }, [setConnected, stopBatchInterval]); /** * 에러 발생 시 @@ -153,14 +197,8 @@ export default function useShipData(options = {}) { * STOMP 연결 해제 */ const disconnect = useCallback(() => { - // 남은 버퍼 머지 - flushBuffer(); - - // 타이머 클리어 - if (batchTimerRef.current) { - clearTimeout(batchTimerRef.current); - batchTimerRef.current = null; - } + // 배치 처리 인터벌 중지 + stopBatchInterval(); // 구독 해제 subscriptionsRef.current.forEach((sub) => { @@ -173,11 +211,11 @@ export default function useShipData(options = {}) { subscriptionsRef.current = []; disconnectStomp(); - }, [flushBuffer]); + }, [stopBatchInterval]); /** * 초기 선박 데이터 로드 (API 호출) - * 참조: mda-react-front/src/map/MapUpdater.tsx (라인 128-152) + * Worker를 통해 파싱 */ const loadInitialData = useCallback(async () => { if (initialLoadDoneRef.current) return; @@ -185,11 +223,12 @@ export default function useShipData(options = {}) { setIsLoading(true); try { console.log('[useShipData] Loading initial ship data...'); - const ships = await fetchAllSignals(); + const rawLines = await fetchAllSignalsRaw(); - if (ships.length > 0) { - mergeFeatures(ships); - console.log(`[useShipData] Initial load complete: ${ships.length} ships`); + if (rawLines.length > 0) { + // Worker로 전송하여 파싱 + SignalWorker.postMessage(rawLines); + console.log(`[useShipData] Initial data sent to Worker: ${rawLines.length} ships`); } } catch (error) { console.error('[useShipData] Initial load error:', error); @@ -197,14 +236,12 @@ export default function useShipData(options = {}) { initialLoadDoneRef.current = true; setIsLoading(false); } - }, [mergeFeatures]); + }, []); // 초기화: API로 선박 데이터 로드 후 STOMP 연결 useEffect(() => { if (!autoConnect) return; - // 1단계: API로 초기 선박 데이터 로드 - // 2단계: 로드 완료 후 STOMP 연결 (실시간 업데이트) const initialize = async () => { await loadInitialData(); connect(); @@ -213,14 +250,10 @@ export default function useShipData(options = {}) { initialize(); return () => { - // 타이머 클리어 - if (batchTimerRef.current) { - clearTimeout(batchTimerRef.current); - batchTimerRef.current = null; - } + stopBatchInterval(); disconnect(); }; - }, [autoConnect]); // loadInitialData, connect, disconnect를 deps에서 제외 (의도적) + }, [autoConnect]); // loadInitialData, connect, disconnect, stopBatchInterval를 deps에서 제외 (의도적) return { isConnected, diff --git a/src/workers/signalWorker.js b/src/workers/signalWorker.js new file mode 100644 index 00000000..3011f491 --- /dev/null +++ b/src/workers/signalWorker.js @@ -0,0 +1,143 @@ +/** + * 선박 신호 데이터 파싱 Web Worker + * - 메인 스레드 부담 감소를 위해 파싱 + 객체 변환을 Worker에서 수행 + * - 참조: mda-react-front/src/workers/signalWorker.js + * - 참조: dark/src/common/stompClient.js - rowToShipObject() + */ + +// SHIP_MSG_INDEX 인라인 (Worker는 별도 모듈이라 import 불가) +const IDX = { + TARGET_ID: 0, + RECV_DATE_TIME: 1, + SIGNAL_SOURCE_CODE: 2, + COG: 3, + SOG: 4, + LONGITUDE: 5, + LATITUDE: 6, + SHIP_NAME: 7, + SHIP_TYPE: 8, + DIM_A: 9, + DIM_B: 10, + DIM_C: 11, + DIM_D: 12, + LOST: 16, + HAZARDOUS_CATEGORY: 17, + AIS: 19, + VPASS: 20, + ENAV: 21, + VTS_AIS: 22, + D_MF_HF: 23, + VTS_RADAR: 24, + INTEGRATE: 26, + SIGNAL_KIND_CODE: 27, + DRAUGHT: 28, + IMO: 29, + NATIONAL_CODE: 35, + IS_PRIORITY: 36, + ORIGINAL_TARGET_ID: 37, +}; + +/** + * 파이프 구분 문자열을 선박 객체로 변환 + * @param {string[]} row - 파싱된 배열 + * @returns {Object} 선박 데이터 객체 + */ +function rowToShipObject(row) { + const targetId = row[IDX.TARGET_ID] || ''; + const originalTargetId = row[IDX.ORIGINAL_TARGET_ID] || ''; + const signalSourceCode = row[IDX.SIGNAL_SOURCE_CODE] || ''; + const recvDateTime = row[IDX.RECV_DATE_TIME] || ''; + + return { + // 고유 식별자 (signalSourceCode + originalTargetId) + featureId: signalSourceCode + originalTargetId, + + // 수신 시간 (타임스탬프, 정렬/비교용) + receivedTimestamp: recvDateTime ? Number(recvDateTime) : Date.now(), + + // 기본 식별 정보 + targetId, + originalTargetId, + signalSourceCode, + shipName: row[IDX.SHIP_NAME] || '', + shipType: row[IDX.SHIP_TYPE] || '', + + // 위치 정보 + longitude: parseFloat(row[IDX.LONGITUDE]) || 0, + latitude: parseFloat(row[IDX.LATITUDE]) || 0, + + // 항해 정보 + sog: parseFloat(row[IDX.SOG]) || 0, + cog: parseFloat(row[IDX.COG]) || 0, + + // 시간 정보 (표시용) + receivedTime: recvDateTime, + + // 선종 코드 + signalKindCode: row[IDX.SIGNAL_KIND_CODE] || '', + + // 상태 플래그 + lost: row[IDX.LOST] === '1', + integrate: row[IDX.INTEGRATE] === '1', + isPriority: row[IDX.IS_PRIORITY] === '1', + + // 위험물 카테고리 + hazardousCategory: row[IDX.HAZARDOUS_CATEGORY] || '', + + // 국적 코드 + nationalCode: row[IDX.NATIONAL_CODE] || '', + + // IMO 번호 + imo: row[IDX.IMO] || '', + + // 흘수 + draught: row[IDX.DRAUGHT] || '', + + // 선박 크기 (DIM) + dimA: row[IDX.DIM_A] || '', + dimB: row[IDX.DIM_B] || '', + dimC: row[IDX.DIM_C] || '', + dimD: row[IDX.DIM_D] || '', + + // AVETDR 신호장비 플래그 + ais: row[IDX.AIS], + vpass: row[IDX.VPASS], + enav: row[IDX.ENAV], + vtsAis: row[IDX.VTS_AIS], + dMfHf: row[IDX.D_MF_HF], + vtsRadar: row[IDX.VTS_RADAR], + + // 원본 배열 (상세정보 등에 필요) + _raw: row, + }; +} + +/** + * Worker 메시지 핸들러 + * 입력: string[] (파이프 구분 문자열 배열, 각 문자열은 한 척의 선박 데이터) + * 출력: Object[] (선박 객체 배열) + */ +// Worker 초기화 로그 +console.log('[SignalWorker] Initialized'); + +self.onmessage = (e) => { + const rawMessages = e.data; + const ships = []; + + for (let i = 0; i < rawMessages.length; i++) { + try { + const row = rawMessages[i].split('|'); + const ship = rowToShipObject(row); + ships.push(ship); + } catch (err) { + // 파싱 에러는 무시하고 계속 진행 + } + } + + // 처리 결과 로그 (500ms마다 출력되므로 주기적 확인 가능) + if (ships.length > 0) { + console.log(`[SignalWorker] Parsed ${ships.length} ships`); + } + + self.postMessage(ships); +};