perf: Web Worker 데이터 파싱 및 배치 처리 최적화

- signalWorker.js 추가 (메인 스레드 파싱 오프로딩)
- useShipData 500ms 배치 인터벌 + Worker 연동
- stompClient에 subscribeShipsRaw 추가 (Worker용)
- signalApi에 fetchAllSignalsRaw 추가 (Worker용)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HeungTak Lee 2026-02-05 06:37:53 +09:00
부모 f273080d5e
커밋 fa34c6cd0c
4개의 변경된 파일322개의 추가작업 그리고 70개의 파일을 삭제

파일 보기

@ -48,4 +48,48 @@ export async function fetchAllSignals() {
} }
} }
/**
* 12 이내 전체 선박 신호 조회 (Raw 문자열 배열 반환, Worker용)
* Web Worker에서 파싱을 수행할 사용
*
* @returns {Promise<string[]>} 파이프 구분 문자열 배열
*/
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; export default fetchAllSignals;

파일 보기

@ -10,12 +10,18 @@ import { SHIP_MSG_INDEX, STOMP_TOPICS } from '../types/constants';
* STOMP 클라이언트 인스턴스 * STOMP 클라이언트 인스턴스
* 환경변수: VITE_SIGNAL_WS (: ws://10.26.252.39:9090/connect) * 환경변수: 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({ export const signalStompClient = new Client({
brokerURL: import.meta.env.VITE_SIGNAL_WS || 'ws://localhost:8080/connect', brokerURL,
reconnectDelay: 10000, reconnectDelay: 10000,
connectionTimeout: 5000, connectionTimeout: 5000,
debug: () => { debug: (str) => {
// STOMP 디버그 로그 비활성화 (너무 많은 로그 발생) // 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 ()
* - 프로덕션: /topic/ship-throttled-60s ( ) * - 프로덕션: /topic/ship-throttled-60s ( )
* @param {Function} onMessage - 메시지 수신 콜백 (파싱된 선박 데이터 배열) * @param {Function} onMessage - 메시지 수신 콜백 (파싱된 선박 데이터 배열)
@ -155,8 +161,7 @@ export function disconnectStomp() {
*/ */
export function subscribeShips(onMessage) { export function subscribeShips(onMessage) {
// 환경변수로 쓰로틀 설정 (VITE_SHIP_THROTTLE: 0=실시간, 5/10/30/60=쓰로틀) // 환경변수로 쓰로틀 설정 (VITE_SHIP_THROTTLE: 0=실시간, 5/10/30/60=쓰로틀)
// const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10); const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10);
const throttleSeconds = 0;
const topic = throttleSeconds > 0 const topic = throttleSeconds > 0
? `${STOMP_TOPICS.SHIP_THROTTLED}${throttleSeconds}s` ? `${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) * @param {Function} onDelete - 삭제 메시지 수신 콜백 (featureId)

파일 보기

@ -2,8 +2,8 @@
* 선박 데이터 관리 * 선박 데이터 관리
* - 초기 선박 데이터 API 로드 (/all/12) * - 초기 선박 데이터 API 로드 (/all/12)
* - STOMP WebSocket 연결 구독 * - STOMP WebSocket 연결 구독
* - 선박 데이터 수신 스토어 업데이트 * - Web Worker를 통한 데이터 파싱 (메인 스레드 부담 감소)
* - 배치 머지 최적화 (1 or 500) * - 배치 머지 최적화 (500ms 인터벌)
* *
* 참조: mda-react-front/src/map/MapUpdater.tsx * 참조: mda-react-front/src/map/MapUpdater.tsx
* 위성통신망 환경 최적화: 최소 트래픽, 최소 스펙 * 위성통신망 환경 최적화: 최소 트래픽, 최소 스펙
@ -13,19 +13,25 @@ import {
signalStompClient, signalStompClient,
connectStomp, connectStomp,
disconnectStomp, disconnectStomp,
subscribeShips, subscribeShipsRaw,
subscribeShipDelete, subscribeShipDelete,
} from '../common/stompClient'; } from '../common/stompClient';
import useShipStore from '../stores/shipStore'; import useShipStore from '../stores/shipStore';
import { fetchAllSignals } from '../api/signalApi'; import { fetchAllSignalsRaw } from '../api/signalApi';
// ===================== // =====================
// 배치 머지 설정 // Web Worker 인스턴스 생성
// ===================== // =====================
const BATCH_CONFIG = { const SignalWorker = new Worker(
maxInterval: 1000, // 최대 대기 시간 (1초) new URL('../workers/signalWorker.js', import.meta.url),
maxCount: 500, // 최대 버퍼 크기 (500건) { 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 { autoConnect = true } = options;
const subscriptionsRef = useRef([]); const subscriptionsRef = useRef([]);
const shipBufferRef = useRef([]); const shipBufferRef = useRef([]); // Raw 문자열 버퍼
const batchTimerRef = useRef(null); const batchIntervalRef = useRef(null); // 배치 처리 인터벌
const initialLoadDoneRef = useRef(false); const initialLoadDoneRef = useRef(false);
const [isLoading, setIsLoading] = useState(true); 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(() => { const flushBuffer = useCallback(() => {
if (shipBufferRef.current.length === 0) return; if (shipBufferRef.current.length === 0) return;
// 버퍼 복사 후 초기화 // 버퍼 복사 후 초기화
const ships = shipBufferRef.current; const rawMessages = shipBufferRef.current;
shipBufferRef.current = []; shipBufferRef.current = [];
// 타이머 클리어 // Worker로 전송 (파싱은 Worker에서 수행)
if (batchTimerRef.current) { SignalWorker.postMessage(rawMessages);
clearTimeout(batchTimerRef.current); }, []);
batchTimerRef.current = null;
}
// 머지 실행
mergeFeatures(ships);
}, [mergeFeatures]);
/** /**
* 선박 메시지 수신 핸들러 (배치 처리) * 배치 처리 인터벌 시작
* 조건: 1 경과 OR 500 누적 머지
*/ */
const handleShipMessage = useCallback((ships) => { const startBatchInterval = useCallback(() => {
// 버퍼에 추가 if (batchIntervalRef.current) return;
shipBufferRef.current.push(...ships);
// 조건 1: 500건 이상이면 즉시 머지 batchIntervalRef.current = setInterval(() => {
if (shipBufferRef.current.length >= BATCH_CONFIG.maxCount) {
flushBuffer(); flushBuffer();
return; }, WEBSOCKET_CHUNK_INTERVAL);
}
// 조건 2: 타이머가 없으면 1초 타이머 설정 console.log(`[useShipData] Batch interval started: ${WEBSOCKET_CHUNK_INTERVAL}ms`);
if (!batchTimerRef.current) { }, [flushBuffer]);
batchTimerRef.current = setTimeout(() => {
flushBuffer(); /**
}, BATCH_CONFIG.maxInterval); * 배치 처리 인터벌 중지
*/
const stopBatchInterval = useCallback(() => {
if (batchIntervalRef.current) {
clearInterval(batchIntervalRef.current);
batchIntervalRef.current = null;
} }
// 남은 버퍼 플러시
flushBuffer();
}, [flushBuffer]); }, [flushBuffer]);
/** /**
@ -107,14 +147,17 @@ export default function useShipData(options = {}) {
}); });
subscriptionsRef.current = []; subscriptionsRef.current = [];
// 선박 토픽 구독 // 선박 토픽 구독 (Raw 모드 - Worker용)
const shipSub = subscribeShips(handleShipMessage); const shipSub = subscribeShipsRaw(handleShipMessageRaw);
subscriptionsRef.current.push(shipSub); subscriptionsRef.current.push(shipSub);
// 선박 삭제 토픽 구독 // 선박 삭제 토픽 구독
const deleteSub = subscribeShipDelete(handleShipDelete); const deleteSub = subscribeShipDelete(handleShipDelete);
subscriptionsRef.current.push(deleteSub); subscriptionsRef.current.push(deleteSub);
}, [handleShipMessage, handleShipDelete]);
// 배치 처리 인터벌 시작
startBatchInterval();
}, [handleShipMessageRaw, handleShipDelete, startBatchInterval]);
/** /**
* 연결 성공 토픽 구독 * 연결 성공 토픽 구독
@ -129,7 +172,8 @@ export default function useShipData(options = {}) {
*/ */
const handleDisconnect = useCallback(() => { const handleDisconnect = useCallback(() => {
setConnected(false); setConnected(false);
}, [setConnected]); stopBatchInterval();
}, [setConnected, stopBatchInterval]);
/** /**
* 에러 발생 * 에러 발생
@ -153,14 +197,8 @@ export default function useShipData(options = {}) {
* STOMP 연결 해제 * STOMP 연결 해제
*/ */
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
// 남은 버퍼 머지 // 배치 처리 인터벌 중지
flushBuffer(); stopBatchInterval();
// 타이머 클리어
if (batchTimerRef.current) {
clearTimeout(batchTimerRef.current);
batchTimerRef.current = null;
}
// 구독 해제 // 구독 해제
subscriptionsRef.current.forEach((sub) => { subscriptionsRef.current.forEach((sub) => {
@ -173,11 +211,11 @@ export default function useShipData(options = {}) {
subscriptionsRef.current = []; subscriptionsRef.current = [];
disconnectStomp(); disconnectStomp();
}, [flushBuffer]); }, [stopBatchInterval]);
/** /**
* 초기 선박 데이터 로드 (API 호출) * 초기 선박 데이터 로드 (API 호출)
* 참조: mda-react-front/src/map/MapUpdater.tsx (라인 128-152) * Worker를 통해 파싱
*/ */
const loadInitialData = useCallback(async () => { const loadInitialData = useCallback(async () => {
if (initialLoadDoneRef.current) return; if (initialLoadDoneRef.current) return;
@ -185,11 +223,12 @@ export default function useShipData(options = {}) {
setIsLoading(true); setIsLoading(true);
try { try {
console.log('[useShipData] Loading initial ship data...'); console.log('[useShipData] Loading initial ship data...');
const ships = await fetchAllSignals(); const rawLines = await fetchAllSignalsRaw();
if (ships.length > 0) { if (rawLines.length > 0) {
mergeFeatures(ships); // Worker로 전송하여 파싱
console.log(`[useShipData] Initial load complete: ${ships.length} ships`); SignalWorker.postMessage(rawLines);
console.log(`[useShipData] Initial data sent to Worker: ${rawLines.length} ships`);
} }
} catch (error) { } catch (error) {
console.error('[useShipData] Initial load error:', error); console.error('[useShipData] Initial load error:', error);
@ -197,14 +236,12 @@ export default function useShipData(options = {}) {
initialLoadDoneRef.current = true; initialLoadDoneRef.current = true;
setIsLoading(false); setIsLoading(false);
} }
}, [mergeFeatures]); }, []);
// 초기화: API로 선박 데이터 로드 후 STOMP 연결 // 초기화: API로 선박 데이터 로드 후 STOMP 연결
useEffect(() => { useEffect(() => {
if (!autoConnect) return; if (!autoConnect) return;
// 1단계: API로 초기 선박 데이터 로드
// 2단계: 로드 완료 후 STOMP 연결 (실시간 업데이트)
const initialize = async () => { const initialize = async () => {
await loadInitialData(); await loadInitialData();
connect(); connect();
@ -213,14 +250,10 @@ export default function useShipData(options = {}) {
initialize(); initialize();
return () => { return () => {
// 타이머 클리어 stopBatchInterval();
if (batchTimerRef.current) {
clearTimeout(batchTimerRef.current);
batchTimerRef.current = null;
}
disconnect(); disconnect();
}; };
}, [autoConnect]); // loadInitialData, connect, disconnect를 deps에서 제외 (의도적) }, [autoConnect]); // loadInitialData, connect, disconnect, stopBatchInterval를 deps에서 제외 (의도적)
return { return {
isConnected, isConnected,

143
src/workers/signalWorker.js Normal file
파일 보기

@ -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);
};