perf: Map/Set mutable update + 버전 카운터 패턴 적용

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 <noreply@anthropic.com>
This commit is contained in:
HeungTak Lee 2026-01-30 13:06:56 +09:00
부모 f4f0cb274f
커밋 ce54d9d0db
2개의 변경된 파일102개의 추가작업 그리고 93개의 파일을 삭제

파일 보기

@ -224,12 +224,14 @@ export default function useShipLayer(map) {
}, [map, initDeck, render, syncViewState, handleBatchRender]); }, [map, initDeck, render, syncViewState, handleBatchRender]);
// 선박 데이터 변경 시 레이어 업데이트 // 선박 데이터 변경 시 레이어 업데이트
// ※ 성능 최적화: features/darkSignalIds는 mutable이므로 참조 비교 불가
// → featuresVersion/darkSignalVersion(숫자)으로 변경 감지
useEffect(() => { useEffect(() => {
// 스토어 구독하여 변경 감지 // 스토어 구독하여 변경 감지
const unsubscribe = useShipStore.subscribe( 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) => { (current, prev) => {
// 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalIds) // 필터 변경 감지 (kindVisibility, sourceVisibility, isShipVisible, isIntegrate, nationalVisibility, showLabels, labelOptions, darkSignalVisible, darkSignalVersion)
const filterChanged = const filterChanged =
current[1] !== prev[1] || current[1] !== prev[1] ||
current[2] !== prev[2] || current[2] !== prev[2] ||

파일 보기

@ -187,13 +187,24 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
// 상태 (State) // 상태 (State)
// ===================== // =====================
/** 선박 데이터 맵 (featureId -> shipData), featureId = signalSourceCode + targetId */ /** (featureId -> shipData), featureId = signalSourceCode + targetId
* 성능 최적화: Map 인스턴스를 직접 mutate하고 featuresVersion으로 변경 감지
* (5000 기준 new Map() 전체 복사 제거 배치당 O(batch) 발생) */
features: new Map(), features: new Map(),
/** features (Zustand )
* features Map은 동일 인스턴스를 유지하면서 내부만 변경하므로,
* 구독자가 변경을 감지할 있도록 버전 번호를 증가시킨다. */
featuresVersion: 0,
/** ID Set (features , ) /** 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: new Set(),
/** darkSignalIds 변경 버전 카운터 */
darkSignalVersion: 0,
/** 선박 종류별 카운트 */ /** 선박 종류별 카운트 */
kindCounts: { ...initialKindCounts }, kindCounts: { ...initialKindCounts },
@ -269,53 +280,57 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
* @param {Array} ships - 선박 데이터 배열 * @param {Array} ships - 선박 데이터 배열
*/ */
mergeFeatures: (ships) => { mergeFeatures: (ships) => {
set((state) => { // ※ 성능 최적화: Map/Set을 직접 mutate하여 O(n) 전체 복사를 제거
const newFeatures = new Map(state.features); // 기존: new Map(state.features) → O(5000) 복사 + O(batch) 변경
const newDarkSignalIds = new Set(state.darkSignalIds); // 최적화: state.features에 직접 set/delete → O(batch)만 발생
const newChangedIds = new Set(); // Zustand 변경 감지는 featuresVersion 카운터로 트리거
const state = get();
const { features, darkSignalIds } = state;
let darkChanged = false;
ships.forEach((ship) => { ships.forEach((ship) => {
const featureId = ship.featureId; const featureId = ship.featureId;
if (!featureId) return; if (!featureId) return;
// 좌표가 없으면 스킵 // 좌표가 없으면 스킵
if (!ship.longitude || !ship.latitude) { if (!ship.longitude || !ship.latitude) {
return; 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); // receivedTimestamp 부여: 서버 수신시간 기반 (초기 로드 시 정확한 경과 시간 판단)
// 장비 비활성 신호도 상태 플래그(위치, 시간 등)는 항상 갱신
// 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 → 저장하지 않음 (완전 삭제) features.set(featureId, { ...ship, receivedTimestamp: parseReceivedTime(ship.receivedTime) });
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,
};
}); });
// 버전 카운터 증가로 구독자에게 변경 알림
set((s) => ({
featuresVersion: s.featuresVersion + 1,
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
}));
// 카운트 재계산 (다크시그널 전환, 레이더 삭제도 여기서 처리) // 카운트 재계산 (다크시그널 전환, 레이더 삭제도 여기서 처리)
// 쓰로틀 카운트 업데이트 // 쓰로틀 카운트 업데이트
get().updateCountsThrottled(); get().updateCountsThrottled();
@ -420,24 +435,21 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
countCache.lastCalcTime = now; countCache.lastCalcTime = now;
countCache.lastFilterHash = currentFilterHash; countCache.lastFilterHash = currentFilterHash;
// features/darkSignalIds 변경이 필요한 경우 // features/darkSignalIds 변경이 필요한 경우 (직접 mutate + 버전 카운터)
if (newDarkIds.length > 0 || deleteIds.length > 0) { if (newDarkIds.length > 0 || deleteIds.length > 0) {
const newFeatures = new Map(features); newDarkIds.forEach((fid) => darkSignalIds.add(fid));
const newDarkSignalIds = new Set(darkSignalIds);
newDarkIds.forEach((fid) => newDarkSignalIds.add(fid));
deleteIds.forEach((fid) => { deleteIds.forEach((fid) => {
newFeatures.delete(fid); features.delete(fid);
newDarkSignalIds.delete(fid); darkSignalIds.delete(fid);
}); });
set({ set((s) => ({
features: newFeatures, featuresVersion: s.featuresVersion + 1,
darkSignalIds: newDarkSignalIds, darkSignalVersion: s.darkSignalVersion + 1,
kindCounts: newKindCounts, kindCounts: newKindCounts,
totalCount, totalCount,
darkSignalCount: newDarkSignalCount, darkSignalCount: newDarkSignalCount,
}); }));
} else { } else {
set({ set({
kindCounts: newKindCounts, kindCounts: newKindCounts,
@ -470,19 +482,15 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
* @param {string} featureId - 삭제할 선박 ID (signalSourceCode + targetId) * @param {string} featureId - 삭제할 선박 ID (signalSourceCode + targetId)
*/ */
deleteFeatureById: (featureId) => { deleteFeatureById: (featureId) => {
set((state) => { const state = get();
const newFeatures = new Map(state.features); state.features.delete(featureId);
newFeatures.delete(featureId); const darkChanged = state.darkSignalIds.delete(featureId);
const newDarkSignalIds = new Set(state.darkSignalIds); set((s) => ({
newDarkSignalIds.delete(featureId); featuresVersion: s.featuresVersion + 1,
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
return { selectedShipId: s.selectedShipId === featureId ? null : s.selectedShipId,
features: newFeatures, }));
darkSignalIds: newDarkSignalIds,
selectedShipId: state.selectedShipId === featureId ? null : state.selectedShipId,
};
});
// 쓰로틀 카운트 업데이트 // 쓰로틀 카운트 업데이트
get().updateCountsThrottled(); get().updateCountsThrottled();
@ -493,21 +501,19 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
* @param {Array<string>} featureIds - 삭제할 선박 ID 배열 (signalSourceCode + targetId) * @param {Array<string>} featureIds - 삭제할 선박 ID 배열 (signalSourceCode + targetId)
*/ */
deleteFeaturesByIds: (featureIds) => { deleteFeaturesByIds: (featureIds) => {
set((state) => { const state = get();
const newFeatures = new Map(state.features); let darkChanged = false;
const newDarkSignalIds = new Set(state.darkSignalIds); featureIds.forEach((featureId) => {
featureIds.forEach((featureId) => { state.features.delete(featureId);
newFeatures.delete(featureId); if (state.darkSignalIds.delete(featureId)) darkChanged = true;
newDarkSignalIds.delete(featureId);
});
return {
features: newFeatures,
darkSignalIds: newDarkSignalIds,
selectedShipId: featureIds.includes(state.selectedShipId) ? null : state.selectedShipId,
};
}); });
set((s) => ({
featuresVersion: s.featuresVersion + 1,
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
selectedShipId: featureIds.includes(s.selectedShipId) ? null : s.selectedShipId,
}));
// 쓰로틀 카운트 업데이트 // 쓰로틀 카운트 업데이트
get().updateCountsThrottled(); get().updateCountsThrottled();
}, },
@ -574,16 +580,16 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
* 다크시그널 선박 일괄 삭제 * 다크시그널 선박 일괄 삭제
*/ */
clearDarkSignals: () => { clearDarkSignals: () => {
set((state) => { const state = get();
const newFeatures = new Map(state.features); state.darkSignalIds.forEach((fid) => {
state.darkSignalIds.forEach((fid) => { state.features.delete(fid);
newFeatures.delete(fid);
});
return {
features: newFeatures,
darkSignalIds: new Set(),
};
}); });
state.darkSignalIds.clear();
set((s) => ({
featuresVersion: s.featuresVersion + 1,
darkSignalVersion: s.darkSignalVersion + 1,
}));
get().recalculateCounts(); get().recalculateCounts();
}, },
@ -843,17 +849,18 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
countCache.lastCalcTime = 0; countCache.lastCalcTime = 0;
countCache.lastFilterHash = ''; countCache.lastFilterHash = '';
set({ set((s) => ({
features: new Map(), features: new Map(),
featuresVersion: s.featuresVersion + 1,
darkSignalIds: new Set(), darkSignalIds: new Set(),
darkSignalVersion: s.darkSignalVersion + 1,
kindCounts: { ...initialKindCounts }, kindCounts: { ...initialKindCounts },
changedIds: new Set(),
selectedShipId: null, selectedShipId: null,
selectedShipIds: [], selectedShipIds: [],
contextMenu: null, contextMenu: null,
totalCount: 0, totalCount: 0,
darkSignalCount: 0, darkSignalCount: 0,
}); }));
}, },
/** /**