frontend 성능 개선

This commit is contained in:
Macbook
2026-06-08 15:25:44 +09:00
parent a84a11e21c
commit 011059f5ed
14 changed files with 258 additions and 37 deletions
+2 -2
View File
@@ -590,9 +590,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
enabled: useUpbit && chartVisible,
source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
}), [handleTickUpdate, handleNewCandle, useUpbit, chartVisible, chartRealtimeSource]),
);
const bars = useUpbit ? upbitBars : simBars;
+15 -10
View File
@@ -161,17 +161,22 @@ const PaperTradingPage: React.FC<Props> = ({
const lastPriceKeyRef = useRef('');
useEffect(() => {
if (!priceKey || initialLoading || priceKey === lastPriceKeyRef.current) return;
lastPriceKeyRef.current = priceKey;
let cancelled = false;
(async () => {
const marks = buildMarkPrices(summaryRef.current, tickersRef.current);
if (!Object.keys(marks).length) return;
try {
const full = await loadPaperSummary(marks);
if (!cancelled) setSummary(full);
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
const timer = setTimeout(() => {
lastPriceKeyRef.current = priceKey;
void (async () => {
const marks = buildMarkPrices(summaryRef.current, tickersRef.current);
if (!Object.keys(marks).length) return;
try {
const full = await loadPaperSummary(marks);
if (!cancelled) setSummary(full);
} catch { /* ignore */ }
})();
}, 400);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [priceKey, initialLoading]);
const strategyNames = useMemo(
@@ -466,7 +466,7 @@ export const TradeNotificationListPage: React.FC<Props> = ({
onTrade={() => handleTradeFromAlert(item)}
onReport={() => void handleReportFromAlert(item)}
reportLoading={reportLoadingId === item.id}
tickers={tickers}
ticker={tickers?.get(normalizeMarketCode(item.market)) ?? tickers?.get(item.market)}
/>
), [
theme,
+2 -2
View File
@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react';
import React, { useRef, useEffect, useLayoutEffect, useState, useCallback, memo } from 'react';
import type { MouseEventParams, Time } from 'lightweight-charts';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
import { ChartManager } from '../utils/ChartManager';
@@ -1996,4 +1996,4 @@ function indKey(inds: IndicatorConfig[]): string {
return paramKey(inds) + '@@' + styleKey(inds);
}
export default TradingChart;
export default memo(TradingChart);
@@ -1,7 +1,7 @@
/**
* 매매 시그널 알림 목록 행 — 4칸이 목록 너비를 꽉 채움, 보조지표 2개↑ 시 우측 슬롯만 스크롤
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react';
import type { Theme } from '../../types';
import type { TickerData } from '../../hooks/useMarketTicker';
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
@@ -59,7 +59,8 @@ interface Props {
layoutMode?: TradeNotificationRowLayout;
chartsEnabled?: boolean;
chartLiveReceiveHighlight?: boolean;
tickers?: Map<string, TickerData>;
/** 해당 종목 시세 (전체 tickers Map 대신 행 단위 전달 — memo·리렌더 최소화) */
ticker?: TickerData;
onSelect: () => void;
onDelete: (e: React.MouseEvent) => void;
onDetail: () => void;
@@ -84,7 +85,7 @@ const TradeNotificationListRow: React.FC<Props> = ({
layoutMode = 'list',
chartsEnabled = true,
chartLiveReceiveHighlight = true,
tickers,
ticker,
onSelect,
onDelete,
onDetail,
@@ -185,7 +186,6 @@ const TradeNotificationListRow: React.FC<Props> = ({
const chartExpanded = isListLayout && expandedChartKey != null;
const chartActive = isListLayout && chartsEnabled && panelActive;
const signalCardEnabled = isListLayout && panelActive && !chartExpanded;
const ticker = tickers?.get(marketCode) ?? tickers?.get(item.market);
const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey);
const signalSnapshotEnabled = item.strategyId != null && (
@@ -488,4 +488,35 @@ const TradeNotificationListRow: React.FC<Props> = ({
);
};
export default TradeNotificationListRow;
function listRowPropsEqual(prev: Props, next: Props): boolean {
if (prev.item !== next.item) {
if (
prev.item.id !== next.item.id
|| prev.item.isRead !== next.item.isRead
|| prev.item.receivedAt !== next.item.receivedAt
|| prev.item.strategyId !== next.item.strategyId
|| prev.item.strategyName !== next.item.strategyName
) return false;
}
return (
prev.theme === next.theme
&& prev.isSelected === next.isSelected
&& prev.layoutMode === next.layoutMode
&& prev.chartsEnabled === next.chartsEnabled
&& prev.chartLiveReceiveHighlight === next.chartLiveReceiveHighlight
&& prev.reportLoading === next.reportLoading
&& prev.itemTag === next.itemTag
&& prev.prefetchedStrategies === next.prefetchedStrategies
&& prev.ticker?.tradePrice === next.ticker?.tradePrice
&& prev.ticker?.changeRate === next.ticker?.changeRate
&& prev.ticker?.accTradePrice24 === next.ticker?.accTradePrice24
&& prev.onSelect === next.onSelect
&& prev.onDelete === next.onDelete
&& prev.onDetail === next.onDetail
&& prev.onGoToChart === next.onGoToChart
&& prev.onTrade === next.onTrade
&& prev.onReport === next.onReport
);
}
export default memo(TradeNotificationListRow, listRowPropsEqual);
@@ -26,6 +26,8 @@ interface Props {
chartCandleAreaPriceLabels?: boolean;
chartSeriesPriceLabels?: boolean;
chartPaneSeparator?: import('../../types/chartPaneSeparator').ChartPaneSeparatorOptions;
/** false — 뷰포트 밖: STOMP·히스토리 중지 (기본 true) */
chartStreamEnabled?: boolean;
}
const noop = () => {};
@@ -46,6 +48,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
chartCandleAreaPriceLabels = true,
chartSeriesPriceLabels = true,
chartPaneSeparator,
chartStreamEnabled = true,
}) => {
const chartTf = toChartTimeframe(timeframe);
const { getParams, getVisualConfig } = useIndicatorSettings();
@@ -90,6 +93,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
const handleNewCandle = useCallback((bar: OHLCVBar) => applyRealtimeBar(bar, true), [applyRealtimeBar]);
const useUpbit = isUpbitMarket(market);
const streamActive = useUpbit && chartStreamEnabled;
const { bars, barsMarket, isLoading } = useChartRealtimeData(
market,
@@ -97,15 +101,15 @@ const TrendSearchCardChart: React.FC<Props> = ({
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
enabled: streamActive,
source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
}), [handleTickUpdate, handleNewCandle, streamActive, chartRealtimeSource]),
);
const { isLoadingMore } = useHistoryLoader({
symbol: market,
timeframe: chartTf,
isUpbit: useUpbit,
isUpbit: streamActive,
managerRef,
});
@@ -151,9 +155,9 @@ const TrendSearchCardChart: React.FC<Props> = ({
}, [market, chartTf, bars.length]);
useEffect(() => {
if (!useUpbit) return;
if (!streamActive) return;
void pinCandleWatch(market, timeframeToCandleType(chartTf));
}, [market, chartTf, useUpbit]);
}, [market, chartTf, streamActive]);
return (
<div className="vtd-card-chart-panel vtd-card-chart-panel--fill">
@@ -170,6 +174,7 @@ const TrendSearchCardChart: React.FC<Props> = ({
)}
<TradingChart
key={`${market}-${chartTf}-${chartReloadTick}`}
chartVisible={chartStreamEnabled}
bars={bars}
barsMarket={barsMarket}
market={market}
@@ -5,6 +5,7 @@ import { tfLabelShort } from '../../utils/trendSearchMetrics';
import type { Theme } from '../../types';
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import type { TickerData } from '../../hooks/useMarketTicker';
import { useIntersectionVisible } from '../../hooks/useIntersectionVisible';
import VirtualTargetQuote from '../virtual/VirtualTargetQuote';
import VirtualLiveBadge from '../virtual/VirtualLiveBadge';
import TrendSearchCardSignalPanel from './TrendSearchCardSignalPanel';
@@ -75,6 +76,8 @@ const TrendSearchResultCard: React.FC<Props> = ({
const quoteTicker = ticker ?? resultToTicker(result);
const ko = quoteTicker.koreanName || getKoreanName(result.market) || en;
const isChart = displayMode === 'chart';
const { ref: cardRef, visible: cardInView } = useIntersectionVisible();
const chartStreamEnabled = isChart && cardInView;
const flashCls = useMemo(() => {
if (!flash) return '';
@@ -83,6 +86,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
return (
<div
ref={cardRef}
className={[
'vtd-card',
'vtd-card--signal',
@@ -145,6 +149,7 @@ const TrendSearchResultCard: React.FC<Props> = ({
chartCandleAreaPriceLabels={chartCandleAreaPriceLabels}
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartPaneSeparator={chartPaneSeparator}
chartStreamEnabled={chartStreamEnabled}
/>
) : (
<TrendSearchCardSignalPanel
@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import type { StrategyDto } from '../../utils/backendApi';
import {
resolveVirtualTargetNames,
@@ -11,6 +11,7 @@ import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
import type { Timeframe } from '../../types';
import { useIntersectionVisible } from '../../hooks/useIntersectionVisible';
import VirtualLiveBadge from './VirtualLiveBadge';
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
@@ -128,9 +129,12 @@ const VirtualTargetCard: React.FC<Props> = ({
}, [running, lastTickAt, snapshot?.updatedAt]);
const receiving = useLiveReceiveFlash(receiveSignal, running);
const highlightReceiving = chartLiveReceiveHighlight && receiving;
const { ref: cardRef, visible: cardInView } = useIntersectionVisible();
const chartStreamEnabled = isChart && cardInView;
return (
<div
ref={cardRef}
className={[
'vtd-card',
'vtd-card--signal',
@@ -234,6 +238,7 @@ const VirtualTargetCard: React.FC<Props> = ({
chartSeriesPriceLabels={chartSeriesPriceLabels}
chartPaneSeparator={chartPaneSeparator}
chartTimeframe={chartTimeframe}
chartStreamEnabled={chartStreamEnabled}
/>
) : (
<VirtualTargetSignalPanel
@@ -257,4 +262,4 @@ const VirtualTargetCard: React.FC<Props> = ({
);
};
export default VirtualTargetCard;
export default memo(VirtualTargetCard);
@@ -37,6 +37,8 @@ interface Props {
fillHeight?: boolean;
/** 미지정 시 전략 DSL 대표 분봉 사용 */
chartTimeframe?: Timeframe;
/** false — 뷰포트 밖 등: STOMP·히스토리 구독 중지 (기본 true) */
chartStreamEnabled?: boolean;
}
const noop = () => {};
@@ -52,6 +54,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
chartPaneSeparator,
fillHeight = false,
chartTimeframe,
chartStreamEnabled = true,
}) => {
const { getParams, getVisualConfig } = useIndicatorSettings();
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
@@ -120,6 +123,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
}, [applyRealtimeBar]);
const useUpbit = isUpbitMarket(market);
const streamActive = useUpbit && chartStreamEnabled;
const { bars, barsMarket, isLoading } = useChartRealtimeData(
market,
@@ -127,15 +131,15 @@ const VirtualTargetCardChart: React.FC<Props> = ({
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: useUpbit,
enabled: streamActive,
source: chartRealtimeSource,
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
}), [handleTickUpdate, handleNewCandle, streamActive, chartRealtimeSource]),
);
const { isLoadingMore } = useHistoryLoader({
symbol: market,
timeframe,
isUpbit: useUpbit,
isUpbit: streamActive,
managerRef,
});
@@ -199,12 +203,12 @@ const VirtualTargetCardChart: React.FC<Props> = ({
}, [barsReady, applyPaneHeights, requestChartReload]);
useEffect(() => {
if (!useUpbit) return;
if (!streamActive) return;
void pinCandleWatch(market, timeframeToCandleType(timeframe));
if (timeframeToCandleType(timeframe) !== '1m') {
void pinCandleWatch(market, '1m');
}
}, [market, timeframe, useUpbit, running]);
}, [market, timeframe, streamActive, running]);
return (
<div className={`vtd-card-chart-panel${fillHeight ? ' vtd-card-chart-panel--fill' : ''}`}>
@@ -239,7 +243,7 @@ const VirtualTargetCardChart: React.FC<Props> = ({
)}
<TradingChart
key={`${market}-${timeframe}-${chartReloadTick}`}
chartVisible
chartVisible={chartStreamEnabled}
bars={bars}
barsMarket={barsMarket}
market={market}
@@ -74,8 +74,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
}, [viewMode, globalDisplayMode]);
const gridRemeasureKey = useMemo(
() => `${viewMode}:${globalDisplayMode}:${targets.map(t => t.market).join(',')}:${Object.values(snapshots).map(s => s?.updatedAt ?? 0).join('|')}`,
[viewMode, globalDisplayMode, targets, snapshots],
() => `${viewMode}:${globalDisplayMode}:${targets.map(t => t.market).join(',')}`,
[viewMode, globalDisplayMode, targets],
);
const [cardDisplayOverrides, setCardDisplayOverrides] = useState<Record<string, VirtualCardDisplayMode>>({});
const [focusMarket, setFocusMarket] = useState<string | null>(null);
@@ -638,7 +638,7 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
setDetailCentered(false);
}, []);
const value: TradeNotificationContextValue = {
const value = useMemo<TradeNotificationContextValue>(() => ({
toastNotifications,
allNotifications,
unreadCount,
@@ -657,7 +657,26 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
detailCentered,
openDetail,
closeDetail,
};
}), [
toastNotifications,
allNotifications,
unreadCount,
addNotification,
dismissToast,
dismissToasts,
dismissAllToasts,
markAsRead,
markAllAsRead,
deleteNotification,
deleteNotifications,
deleteUnreadNotifications,
deleteAllNotifications,
refreshHistory,
detailSignal,
detailCentered,
openDetail,
closeDetail,
]);
return (
<TradeNotificationContext.Provider value={value}>
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from 'react';
export interface UseIntersectionVisibleOptions {
root?: Element | Document | null;
rootMargin?: string;
threshold?: number | number[];
/** true — 포커스·전체화면 등 항상 활성 */
initialVisible?: boolean;
}
/**
* 요소가 뷰포트(또는 root)에 들어왔는지 — 차트·폴링 gating 용
*/
export function useIntersectionVisible(
options: UseIntersectionVisibleOptions = {},
): { ref: (node: HTMLElement | null) => void; visible: boolean } {
const {
root = null,
rootMargin = '100px 0px',
threshold = 0.05,
initialVisible = false,
} = options;
const [element, setElement] = useState<HTMLElement | null>(null);
const [visible, setVisible] = useState(initialVisible);
const ref = useCallback((node: HTMLElement | null) => {
setElement(node);
}, []);
useEffect(() => {
if (!element) return;
const io = new IntersectionObserver(
entries => {
setVisible(entries[0]?.isIntersecting ?? false);
},
{ root, rootMargin, threshold },
);
io.observe(element);
return () => io.disconnect();
}, [element, root, rootMargin, threshold]);
return { ref, visible };
}
+12
View File
@@ -19,6 +19,18 @@ export default defineConfig({
optimizeDeps: {
include: ['react', 'react-dom'],
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/lightweight-charts')) return 'vendor-charts';
if (id.includes('node_modules/@xyflow')) return 'vendor-flow';
if (id.includes('node_modules/@stomp') || id.includes('node_modules/sockjs-client')) return 'vendor-stomp';
if (id.includes('node_modules/@tanstack/react-virtual')) return 'vendor-virtual';
},
},
},
},
server: {
proxy: {
// ── 모바일 APK 정보/다운로드 → exdev (로컬 QR·PC 다운로드가 exdev APK 사용) ──
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# GoldenChart — 소스 push 없이 서버 deploy.sh만 실행 (재배포)
#
# 사용:
# ./server-restart.sh
# ./server-restart.sh --frontend
# ./server-restart.sh --backend
# SERVICE_NAME=docker-only ./server-restart.sh
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# shellcheck disable=SC1091
source "$ROOT/scripts/deploy-target.defaults"
if [[ -f "$ROOT/scripts/deploy-target.local.env" ]]; then
# shellcheck disable=SC1091
source "$ROOT/scripts/deploy-target.local.env"
fi
REMOTE_HOST="${REMOTE_HOST:-exdev.co.kr}"
REMOTE_USER="${REMOTE_USER:-aidev}"
REMOTE_PORT="${REMOTE_PORT:-22}"
REMOTE_DEPLOY_SCRIPT="${REMOTE_DEPLOY_SCRIPT:-/Volumes/ADATA/git/deploy.sh}"
SERVICE_NAME="${SERVICE_NAME:-all}"
SSH_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
SSH_CMD=(ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -p "$REMOTE_PORT")
print_usage() {
cat <<EOF
사용법: $0 [옵션]
(기본) git push 없이 서버 deploy.sh 실행 (전체 재배포)
--frontend SERVICE_NAME=frontend
--backend SERVICE_NAME=backend
--docker-only SERVICE_NAME=docker-only (이미지 빌드 생략, compose up)
--service NAME SERVICE_NAME=NAME
--host HOST SSH 호스트 (기본: $REMOTE_HOST)
--user USER SSH 사용자 (기본: $REMOTE_USER)
--script PATH 서버 deploy.sh 경로 (기본: $REMOTE_DEPLOY_SCRIPT)
-h, --help 도움말
실행 명령 (내부):
ssh ${SSH_TARGET} 'SERVICE_NAME=... ${REMOTE_DEPLOY_SCRIPT}'
설정: scripts/deploy-target.defaults
scripts/deploy-target.local.env (선택)
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--frontend) SERVICE_NAME=frontend; shift ;;
--backend) SERVICE_NAME=backend; shift ;;
--docker-only) SERVICE_NAME=docker-only; shift ;;
--service) SERVICE_NAME="$2"; shift 2 ;;
--host) REMOTE_HOST="$2"; SSH_TARGET="${REMOTE_USER}@${REMOTE_HOST}"; shift 2 ;;
--user) REMOTE_USER="$2"; SSH_TARGET="${REMOTE_USER}@${REMOTE_HOST}"; shift 2 ;;
--script) REMOTE_DEPLOY_SCRIPT="$2"; shift 2 ;;
-h|--help) print_usage; exit 0 ;;
*)
echo -e "${RED}알 수 없는 옵션: $1${NC}" >&2
print_usage
exit 1
;;
esac
done
echo -e "${CYAN}▶ 서버 재배포 (push 없음)${NC}"
echo -e " 대상 : ${SSH_TARGET}"
echo -e " 스크립트: ${REMOTE_DEPLOY_SCRIPT}"
echo -e " 서비스 : ${SERVICE_NAME}"
echo ""
REMOTE_CMD="SERVICE_NAME=${SERVICE_NAME} ${REMOTE_DEPLOY_SCRIPT}"
if "${SSH_CMD[@]}" "$SSH_TARGET" "$REMOTE_CMD"; then
echo ""
echo -e "${GREEN}✓ deploy.sh 완료${NC}"
echo -e "${YELLOW}로그: ssh ${SSH_TARGET} 'tail -50 /Volumes/ADATA/logs/deploy/deploy.log'${NC}"
else
echo ""
echo -e "${RED}✗ deploy.sh 실패${NC}" >&2
exit 1
fi