From ce54d9d0db4e1dc8a3ff1669b6a5a388fc1cf49f Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Fri, 30 Jan 2026 13:06:56 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20Map/Set=20mutable=20update=20+=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EC=B9=B4=EC=9A=B4=ED=84=B0=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mergeFeatures, updateCountsThrottled, deleteFeatureById, deleteFeaturesByIds, clearDarkSignals에서 new Map()/new Set() 전체 복사를 제거하고 기존 인스턴스를 직접 mutate. Zustand 변경 감지는 featuresVersion/darkSignalVersion 카운터로 트리거. 5000척 기준 배치당 O(5000) Map 복사 → O(batch) 변경으로 개선. Co-Authored-By: Claude Opus 4.5 --- src/hooks/useShipLayer.js | 6 +- src/stores/shipStore.js | 189 ++++++++++++++++++++------------------ 2 files changed, 102 insertions(+), 93 deletions(-) diff --git a/src/hooks/useShipLayer.js b/src/hooks/useShipLayer.js index 7e869915..b3c8b9ea 100644 --- a/src/hooks/useShipLayer.js +++ b/src/hooks/useShipLayer.js @@ -224,12 +224,14 @@ export default function useShipLayer(map) { }, [map, initDeck, render, syncViewState, handleBatchRender]); // 선박 데이터 변경 시 레이어 업데이트 + // ※ 성능 최적화: features/darkSignalIds는 mutable이므로 참조 비교 불가 + // → featuresVersion/darkSignalVersion(숫자)으로 변경 감지 useEffect(() => { // 스토어 구독하여 변경 감지 const unsubscribe = useShipStore.subscribe( - (state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds], + (state) => [state.featuresVersion, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalVersion], (current, prev) => { - // 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalIds) + // 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalVersion) const filterChanged = current[1] !== prev[1] || current[2] !== prev[2] || diff --git a/src/stores/shipStore.js b/src/stores/shipStore.js index 0fe6508d..44b4a513 100644 --- a/src/stores/shipStore.js +++ b/src/stores/shipStore.js @@ -187,13 +187,24 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ // 상태 (State) // ===================== - /** 선박 데이터 맵 (featureId -> shipData), featureId = signalSourceCode + targetId */ + /** 선박 데이터 맵 (featureId -> shipData), featureId = signalSourceCode + targetId + * ※ 성능 최적화: Map 인스턴스를 직접 mutate하고 featuresVersion으로 변경 감지 + * (5000척 기준 new Map() 전체 복사 제거 → 배치당 O(batch)만 발생) */ features: new Map(), + /** features 변경 버전 카운터 (Zustand 참조 동등성 감지용) + * features Map은 동일 인스턴스를 유지하면서 내부만 변경하므로, + * 구독자가 변경을 감지할 수 있도록 버전 번호를 증가시킨다. */ + featuresVersion: 0, + /** 다크시그널 선박 ID Set (features와 별도 관리, 메인 프로젝트 동일 구조) - * 참조: mda-react-front/src/shared/model/deckStore.ts - darkSignalIds */ + * 참조: mda-react-front/src/shared/model/deckStore.ts - darkSignalIds + * ※ 성능 최적화: Set 인스턴스를 직접 mutate하고 darkSignalVersion으로 변경 감지 */ darkSignalIds: new Set(), + /** darkSignalIds 변경 버전 카운터 */ + darkSignalVersion: 0, + /** 선박 종류별 카운트 */ kindCounts: { ...initialKindCounts }, @@ -269,53 +280,57 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * @param {Array} ships - 선박 데이터 배열 */ mergeFeatures: (ships) => { - set((state) => { - const newFeatures = new Map(state.features); - const newDarkSignalIds = new Set(state.darkSignalIds); - const newChangedIds = new Set(); + // ※ 성능 최적화: Map/Set을 직접 mutate하여 O(n) 전체 복사를 제거 + // 기존: new Map(state.features) → O(5000) 복사 + O(batch) 변경 + // 최적화: state.features에 직접 set/delete → O(batch)만 발생 + // Zustand 변경 감지는 featuresVersion 카운터로 트리거 + const state = get(); + const { features, darkSignalIds } = state; + let darkChanged = false; - ships.forEach((ship) => { - const featureId = ship.featureId; - if (!featureId) return; + ships.forEach((ship) => { + const featureId = ship.featureId; + if (!featureId) return; - // 좌표가 없으면 스킵 - if (!ship.longitude || !ship.latitude) { - return; + // 좌표가 없으면 스킵 + if (!ship.longitude || !ship.latitude) { + return; + } + + const hasActive = isAnyEquipmentActive(ship); + + // 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 → 저장하지 않음 (완전 삭제) + if (!ship.lost && !hasActive) { + features.delete(featureId); + if (darkSignalIds.delete(featureId)) darkChanged = true; + return; + } + + // 다크시그널 상태 판정 + if (hasActive) { + // 장비 활성 → 다크시그널 해제 (회복) + if (darkSignalIds.delete(featureId)) darkChanged = true; + } else { + // 모든 장비 비활성 (상태 전환용 신호) + // → 이미 다크시그널이면 유지, 아니면 등록 + // → 상태 플래그(위치/시간 등)는 갱신하되 카운트에 반영하지 않음 + if (!darkSignalIds.has(featureId)) { + darkSignalIds.add(featureId); + darkChanged = true; } + } - const hasActive = isAnyEquipmentActive(ship); - - // 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 → 저장하지 않음 (완전 삭제) - if (!ship.lost && !hasActive) { - newFeatures.delete(featureId); - newDarkSignalIds.delete(featureId); - return; - } - - // 다크시그널 상태 판정 - if (hasActive) { - // 장비 활성 → 다크시그널 해제 (회복) - newDarkSignalIds.delete(featureId); - } else { - // 모든 장비 비활성 (상태 전환용 신호) - // → 이미 다크시그널이면 유지, 아니면 등록 - // → 상태 플래그(위치/시간 등)는 갱신하되 카운트에 반영하지 않음 - newDarkSignalIds.add(featureId); - } - - // receivedTimestamp 부여: 서버 수신시간 기반 (초기 로드 시 정확한 경과 시간 판단) - // 장비 비활성 신호도 상태 플래그(위치, 시간 등)는 항상 갱신 - newFeatures.set(featureId, { ...ship, receivedTimestamp: parseReceivedTime(ship.receivedTime) }); - newChangedIds.add(featureId); - }); - - return { - features: newFeatures, - darkSignalIds: newDarkSignalIds, - changedIds: newChangedIds, - }; + // receivedTimestamp 부여: 서버 수신시간 기반 (초기 로드 시 정확한 경과 시간 판단) + // 장비 비활성 신호도 상태 플래그(위치, 시간 등)는 항상 갱신 + features.set(featureId, { ...ship, receivedTimestamp: parseReceivedTime(ship.receivedTime) }); }); + // 버전 카운터 증가로 구독자에게 변경 알림 + set((s) => ({ + featuresVersion: s.featuresVersion + 1, + ...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}), + })); + // 카운트 재계산 (다크시그널 전환, 레이더 삭제도 여기서 처리) // 쓰로틀 카운트 업데이트 get().updateCountsThrottled(); @@ -420,24 +435,21 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ countCache.lastCalcTime = now; countCache.lastFilterHash = currentFilterHash; - // features/darkSignalIds 변경이 필요한 경우 + // features/darkSignalIds 변경이 필요한 경우 (직접 mutate + 버전 카운터) if (newDarkIds.length > 0 || deleteIds.length > 0) { - const newFeatures = new Map(features); - const newDarkSignalIds = new Set(darkSignalIds); - - newDarkIds.forEach((fid) => newDarkSignalIds.add(fid)); + newDarkIds.forEach((fid) => darkSignalIds.add(fid)); deleteIds.forEach((fid) => { - newFeatures.delete(fid); - newDarkSignalIds.delete(fid); + features.delete(fid); + darkSignalIds.delete(fid); }); - set({ - features: newFeatures, - darkSignalIds: newDarkSignalIds, + set((s) => ({ + featuresVersion: s.featuresVersion + 1, + darkSignalVersion: s.darkSignalVersion + 1, kindCounts: newKindCounts, totalCount, darkSignalCount: newDarkSignalCount, - }); + })); } else { set({ kindCounts: newKindCounts, @@ -470,19 +482,15 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * @param {string} featureId - 삭제할 선박 ID (signalSourceCode + targetId) */ deleteFeatureById: (featureId) => { - set((state) => { - const newFeatures = new Map(state.features); - newFeatures.delete(featureId); + const state = get(); + state.features.delete(featureId); + const darkChanged = state.darkSignalIds.delete(featureId); - const newDarkSignalIds = new Set(state.darkSignalIds); - newDarkSignalIds.delete(featureId); - - return { - features: newFeatures, - darkSignalIds: newDarkSignalIds, - selectedShipId: state.selectedShipId === featureId ? null : state.selectedShipId, - }; - }); + set((s) => ({ + featuresVersion: s.featuresVersion + 1, + ...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}), + selectedShipId: s.selectedShipId === featureId ? null : s.selectedShipId, + })); // 쓰로틀 카운트 업데이트 get().updateCountsThrottled(); @@ -493,21 +501,19 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * @param {Array} featureIds - 삭제할 선박 ID 배열 (signalSourceCode + targetId) */ deleteFeaturesByIds: (featureIds) => { - set((state) => { - const newFeatures = new Map(state.features); - const newDarkSignalIds = new Set(state.darkSignalIds); - featureIds.forEach((featureId) => { - newFeatures.delete(featureId); - newDarkSignalIds.delete(featureId); - }); - - return { - features: newFeatures, - darkSignalIds: newDarkSignalIds, - selectedShipId: featureIds.includes(state.selectedShipId) ? null : state.selectedShipId, - }; + const state = get(); + let darkChanged = false; + featureIds.forEach((featureId) => { + state.features.delete(featureId); + if (state.darkSignalIds.delete(featureId)) darkChanged = true; }); + set((s) => ({ + featuresVersion: s.featuresVersion + 1, + ...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}), + selectedShipId: featureIds.includes(s.selectedShipId) ? null : s.selectedShipId, + })); + // 쓰로틀 카운트 업데이트 get().updateCountsThrottled(); }, @@ -574,16 +580,16 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * 다크시그널 선박 일괄 삭제 */ clearDarkSignals: () => { - set((state) => { - const newFeatures = new Map(state.features); - state.darkSignalIds.forEach((fid) => { - newFeatures.delete(fid); - }); - return { - features: newFeatures, - darkSignalIds: new Set(), - }; + const state = get(); + state.darkSignalIds.forEach((fid) => { + state.features.delete(fid); }); + state.darkSignalIds.clear(); + + set((s) => ({ + featuresVersion: s.featuresVersion + 1, + darkSignalVersion: s.darkSignalVersion + 1, + })); get().recalculateCounts(); }, @@ -843,17 +849,18 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ countCache.lastCalcTime = 0; countCache.lastFilterHash = ''; - set({ + set((s) => ({ features: new Map(), + featuresVersion: s.featuresVersion + 1, darkSignalIds: new Set(), + darkSignalVersion: s.darkSignalVersion + 1, kindCounts: { ...initialKindCounts }, - changedIds: new Set(), selectedShipId: null, selectedShipIds: [], contextMenu: null, totalCount: 0, darkSignalCount: 0, - }); + })); }, /**