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]);
// 선박 데이터 변경 시 레이어 업데이트
// ※ 성능 최적화: 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] ||

파일 보기

@ -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<string>} 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,
});
}));
},
/**