From 011059f5edb57d3dbc6b77d5166c4706bf50345c Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 8 Jun 2026 15:25:44 +0900 Subject: [PATCH] =?UTF-8?q?frontend=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ChartSlot.tsx | 4 +- frontend/src/components/PaperTradingPage.tsx | 25 +++-- .../components/TradeNotificationListPage.tsx | 2 +- frontend/src/components/TradingChart.tsx | 4 +- .../TradeNotificationListRow.tsx | 41 ++++++++- .../trendSearch/TrendSearchCardChart.tsx | 15 ++- .../trendSearch/TrendSearchResultCard.tsx | 5 + .../components/virtual/VirtualTargetCard.tsx | 9 +- .../virtual/VirtualTargetCardChart.tsx | 16 ++-- .../components/virtual/VirtualTargetGrid.tsx | 4 +- .../src/contexts/TradeNotificationContext.tsx | 23 ++++- frontend/src/hooks/useIntersectionVisible.ts | 43 +++++++++ frontend/vite.config.ts | 12 +++ server-restart.sh | 92 +++++++++++++++++++ 14 files changed, 258 insertions(+), 37 deletions(-) create mode 100644 frontend/src/hooks/useIntersectionVisible.ts create mode 100755 server-restart.sh diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 1e42d14..3aab90b 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -590,9 +590,9 @@ const ChartSlot = forwardRef(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; diff --git a/frontend/src/components/PaperTradingPage.tsx b/frontend/src/components/PaperTradingPage.tsx index 2b35d12..4d06489 100644 --- a/frontend/src/components/PaperTradingPage.tsx +++ b/frontend/src/components/PaperTradingPage.tsx @@ -161,17 +161,22 @@ const PaperTradingPage: React.FC = ({ 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( diff --git a/frontend/src/components/TradeNotificationListPage.tsx b/frontend/src/components/TradeNotificationListPage.tsx index dffe889..48e4ca2 100644 --- a/frontend/src/components/TradeNotificationListPage.tsx +++ b/frontend/src/components/TradeNotificationListPage.tsx @@ -466,7 +466,7 @@ export const TradeNotificationListPage: React.FC = ({ onTrade={() => handleTradeFromAlert(item)} onReport={() => void handleReportFromAlert(item)} reportLoading={reportLoadingId === item.id} - tickers={tickers} + ticker={tickers?.get(normalizeMarketCode(item.market)) ?? tickers?.get(item.market)} /> ), [ theme, diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 436d0ee..2ba1136 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -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); diff --git a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx index 1e264fb..0d95ce6 100644 --- a/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx +++ b/frontend/src/components/tradeNotification/TradeNotificationListRow.tsx @@ -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; + /** 해당 종목 시세 (전체 tickers Map 대신 행 단위 전달 — memo·리렌더 최소화) */ + ticker?: TickerData; onSelect: () => void; onDelete: (e: React.MouseEvent) => void; onDetail: () => void; @@ -84,7 +85,7 @@ const TradeNotificationListRow: React.FC = ({ layoutMode = 'list', chartsEnabled = true, chartLiveReceiveHighlight = true, - tickers, + ticker, onSelect, onDelete, onDetail, @@ -185,7 +186,6 @@ const TradeNotificationListRow: React.FC = ({ 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 = ({ ); }; -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); diff --git a/frontend/src/components/trendSearch/TrendSearchCardChart.tsx b/frontend/src/components/trendSearch/TrendSearchCardChart.tsx index 37ff285..6bfbd8f 100644 --- a/frontend/src/components/trendSearch/TrendSearchCardChart.tsx +++ b/frontend/src/components/trendSearch/TrendSearchCardChart.tsx @@ -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 = ({ chartCandleAreaPriceLabels = true, chartSeriesPriceLabels = true, chartPaneSeparator, + chartStreamEnabled = true, }) => { const chartTf = toChartTimeframe(timeframe); const { getParams, getVisualConfig } = useIndicatorSettings(); @@ -90,6 +93,7 @@ const TrendSearchCardChart: React.FC = ({ 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 = ({ 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 = ({ }, [market, chartTf, bars.length]); useEffect(() => { - if (!useUpbit) return; + if (!streamActive) return; void pinCandleWatch(market, timeframeToCandleType(chartTf)); - }, [market, chartTf, useUpbit]); + }, [market, chartTf, streamActive]); return (
@@ -170,6 +174,7 @@ const TrendSearchCardChart: React.FC = ({ )} = ({ 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 = ({ return (
= ({ chartCandleAreaPriceLabels={chartCandleAreaPriceLabels} chartSeriesPriceLabels={chartSeriesPriceLabels} chartPaneSeparator={chartPaneSeparator} + chartStreamEnabled={chartStreamEnabled} /> ) : ( = ({ }, [running, lastTickAt, snapshot?.updatedAt]); const receiving = useLiveReceiveFlash(receiveSignal, running); const highlightReceiving = chartLiveReceiveHighlight && receiving; + const { ref: cardRef, visible: cardInView } = useIntersectionVisible(); + const chartStreamEnabled = isChart && cardInView; return (
= ({ chartSeriesPriceLabels={chartSeriesPriceLabels} chartPaneSeparator={chartPaneSeparator} chartTimeframe={chartTimeframe} + chartStreamEnabled={chartStreamEnabled} /> ) : ( = ({ ); }; -export default VirtualTargetCard; +export default memo(VirtualTargetCard); diff --git a/frontend/src/components/virtual/VirtualTargetCardChart.tsx b/frontend/src/components/virtual/VirtualTargetCardChart.tsx index 64e65a9..fadf84d 100644 --- a/frontend/src/components/virtual/VirtualTargetCardChart.tsx +++ b/frontend/src/components/virtual/VirtualTargetCardChart.tsx @@ -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 = ({ chartPaneSeparator, fillHeight = false, chartTimeframe, + chartStreamEnabled = true, }) => { const { getParams, getVisualConfig } = useIndicatorSettings(); const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]); @@ -120,6 +123,7 @@ const VirtualTargetCardChart: React.FC = ({ }, [applyRealtimeBar]); const useUpbit = isUpbitMarket(market); + const streamActive = useUpbit && chartStreamEnabled; const { bars, barsMarket, isLoading } = useChartRealtimeData( market, @@ -127,15 +131,15 @@ const VirtualTargetCardChart: React.FC = ({ 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 = ({ }, [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 (
@@ -239,7 +243,7 @@ const VirtualTargetCardChart: React.FC = ({ )} = ({ }, [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>({}); const [focusMarket, setFocusMarket] = useState(null); diff --git a/frontend/src/contexts/TradeNotificationContext.tsx b/frontend/src/contexts/TradeNotificationContext.tsx index cced43b..8311f43 100644 --- a/frontend/src/contexts/TradeNotificationContext.tsx +++ b/frontend/src/contexts/TradeNotificationContext.tsx @@ -638,7 +638,7 @@ export const TradeNotificationProvider: React.FC = ({ setDetailCentered(false); }, []); - const value: TradeNotificationContextValue = { + const value = useMemo(() => ({ toastNotifications, allNotifications, unreadCount, @@ -657,7 +657,26 @@ export const TradeNotificationProvider: React.FC = ({ detailCentered, openDetail, closeDetail, - }; + }), [ + toastNotifications, + allNotifications, + unreadCount, + addNotification, + dismissToast, + dismissToasts, + dismissAllToasts, + markAsRead, + markAllAsRead, + deleteNotification, + deleteNotifications, + deleteUnreadNotifications, + deleteAllNotifications, + refreshHistory, + detailSignal, + detailCentered, + openDetail, + closeDetail, + ]); return ( diff --git a/frontend/src/hooks/useIntersectionVisible.ts b/frontend/src/hooks/useIntersectionVisible.ts new file mode 100644 index 0000000..2710d24 --- /dev/null +++ b/frontend/src/hooks/useIntersectionVisible.ts @@ -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(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 }; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 71dcd55..2cf294d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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 사용) ── diff --git a/server-restart.sh b/server-restart.sh new file mode 100755 index 0000000..ba4baa8 --- /dev/null +++ b/server-restart.sh @@ -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 <&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