diff --git a/frontend/src/App.css b/frontend/src/App.css
index f08790d..f73ca6a 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -164,6 +164,16 @@ html.theme-blue {
line-height: 1;
}
+.page-load-fallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ min-height: 12rem;
+ color: var(--text-muted, #888);
+ font-size: 14px;
+}
+
/* ══════════════════════════════════════════════════════════════════
TOP MENU BAR (글로벌 네비게이션)
══════════════════════════════════════════════════════════════════ */
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b1152e7..29caf62 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from 'react';
+import React, { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo, lazy, Suspense } from 'react';
import DrawingToolbar from './components/DrawingToolbar';
import BottomBar from './components/BottomBar';
import TradingChart from './components/TradingChart';
@@ -78,8 +78,6 @@ import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
import MarketPanel from './components/MarketPanel';
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
-import StrategyPage from './components/StrategyPage';
-import StrategyEditorPage from './components/StrategyEditorPage';
import BacktestPanel from './components/BacktestPanel';
import { useBacktest } from './hooks/useBacktest';
import LiveStrategyPanel from './components/LiveStrategyPanel';
@@ -95,16 +93,23 @@ import {
} from './utils/tradingAccess';
import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings';
import { AppNotificationLayer } from './components/AppNotificationLayer';
-import TradeNotificationListPage from './components/TradeNotificationListPage';
import BacktestSettingsModal from './components/BacktestSettingsModal';
import { BacktestResultModal } from './components/BacktestResultModal';
-import { BacktestHistoryPage } from './components/BacktestHistoryPage';
-import SettingsPage from './components/SettingsPage';
-import PaperTradingPage from './components/PaperTradingPage';
-import VirtualTradingPage from './components/VirtualTradingPage';
-import TrendSearchPage from './components/TrendSearchPage';
-import VerificationBoardPage from './components/VerificationBoardPage';
-import DashboardPage from './components/DashboardPage';
+
+const DashboardPage = lazy(() => import('./components/DashboardPage'));
+const StrategyPage = lazy(() => import('./components/StrategyPage'));
+const StrategyEditorPage = lazy(() => import('./components/StrategyEditorPage'));
+const TradeNotificationListPage = lazy(() => import('./components/TradeNotificationListPage'));
+const BacktestHistoryPage = lazy(() => import('./components/BacktestHistoryPage').then(m => ({ default: m.BacktestHistoryPage })));
+const SettingsPage = lazy(() => import('./components/SettingsPage'));
+const PaperTradingPage = lazy(() => import('./components/PaperTradingPage'));
+const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage'));
+const TrendSearchPage = lazy(() => import('./components/TrendSearchPage'));
+const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage'));
+
+function PageLoadFallback() {
+ return
화면 로딩 중…
;
+}
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
import ChartLegendBar from './components/ChartLegendBar';
import { resolveChartLiveQuote } from './utils/chartLiveQuote';
@@ -236,8 +241,14 @@ function App() {
const chartVisible = menuPage === 'chart';
const isMobile = useIsMobile();
- // ── 마켓 패널 시세 데이터 ─────────────────────────────────────────────
- const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker();
+ const priorityMarketsRef = useRef([symbol]);
+ const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []);
+ const marketTickerOpts = useMemo(() => ({
+ lite: isMobile && !showMarketPanel,
+ loadFull: !isMobile || showMarketPanel,
+ getPriorityMarkets,
+ }), [isMobile, showMarketPanel, getPriorityMarkets]);
+ const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts);
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
const [layoutDef, setLayoutDef] = useState(() => LAYOUTS[0]);
@@ -297,7 +308,9 @@ function App() {
}, [saveAppDef]);
const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol;
- const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol);
+ const chartScreenActive = menuPage === 'chart';
+ const orderbookEnabled = chartScreenActive && isUpbitMarket(chartSymbol);
+ const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(chartSymbol, orderbookEnabled);
const chartTicker = marketTickers.get(chartSymbol);
const orderbookPrevClose = chartTicker
? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0)
@@ -548,6 +561,10 @@ function App() {
: [...UPBIT_MARKETS.slice(0, 5), ...SYMBOLS.slice(0, 4)];
return [...new Set([...base, ...getFavorites()])];
});
+ priorityMarketsRef.current = useMemo(
+ () => [symbol, ...watchlist],
+ [symbol, watchlist],
+ );
/** 좌측 패널 관심종목(gc_favorites) — 종목 검색 드롭다운 별표·실시간 전략과 동기화 */
const [favoritesList, setFavoritesList] = useState(getFavorites);
// DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작)
@@ -1751,30 +1768,45 @@ function App() {
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
{menuPage === 'dashboard' && (
- { goToMarketChart(m); setMenuPage('chart'); }} />
+ }>
+ { goToMarketChart(m); setMenuPage('chart'); }} />
+
)}
{menuPage === 'strategy' && (
formalLogin
- ?
+ ? (
+ }>
+
+
+ )
:
)}
{menuPage === 'strategy-editor' && (
formalLogin
- ?
+ ? (
+ }>
+
+
+ )
:
)}
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
{menuPage === 'backtest' && (
formalLogin
- ?
+ ? (
+ }>
+
+
+ )
:
)}
{menuPage === 'paper' && (
formalLogin ? (
+ }>
+
) : (
)
@@ -1800,6 +1833,7 @@ function App() {
{menuPage === 'virtual' && (
formalLogin ? (
+ }>
saveAppDef({ paperAutoTradeEnabled: v })}
onPaperOrderFilled={handlePaperOrderFilled}
/>
+
) : (
)
)}
{menuPage === 'trend-search' && (
+ }>
+
)}
{menuPage === 'verification-board' && (
+ }>
setVerificationFocusIssueId(null)}
/>
+
)}
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
{menuPage === 'notifications' && canMenu('notifications') && (
formalLogin ? (
+ }>
+
) : (
)
)}
{menuPage === 'settings' && canMenu('settings') && (
+ }>
+
)}
{/* ── 실시간 차트 화면 ────────────────────────────────────────────── */}
diff --git a/frontend/src/components/RightSidePanel.tsx b/frontend/src/components/RightSidePanel.tsx
index 3cf8fbb..6946d1d 100644
--- a/frontend/src/components/RightSidePanel.tsx
+++ b/frontend/src/components/RightSidePanel.tsx
@@ -78,10 +78,10 @@ const RightSidePanel: React.FC = ({
const [openInternal, setOpenInternal] = useState(false);
const [tabInternal, setTabInternal] = useState('trade');
const tradeSectionRef = useRef(null);
- const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market);
const isOpenControlled = openControlled !== undefined && onOpenChange !== undefined;
const open = isOpenControlled ? !!openControlled : openInternal;
+ const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market, open);
const setOpen = (next: boolean | ((p: boolean) => boolean)) => {
const cur = isOpenControlled ? !!openControlled : openInternal;
const value = typeof next === 'function' ? next(cur) : next;
diff --git a/frontend/src/hooks/useMarketTicker.ts b/frontend/src/hooks/useMarketTicker.ts
index 7386af7..3a83f61 100644
--- a/frontend/src/hooks/useMarketTicker.ts
+++ b/frontend/src/hooks/useMarketTicker.ts
@@ -1,16 +1,15 @@
/**
* 업비트 마켓 목록 + 실시간 시세 훅
*
- * 동작:
- * 1) 앱 마운트 시 /v1/market/all 로 전체 마켓 목록 + 한글명 로드
- * 2) /v1/ticker?markets=... 로 초기 시세 로드 (100개 단위 분할)
- * 3) WebSocket ticker 구독으로 실시간 시세 수신 (배치 업데이트 500ms)
- * 4) REST 폴링 30초마다 보조 갱신 (WebSocket 누락 방지)
+ * 1) /v1/market/all — 마켓 목록
+ * 2) /v1/ticker — 초기 시세 (lite: 우선 종목만, full: 전 종목)
+ * 3) upbitWsBroker ticker 구독
+ * 4) REST 보조 폴링 30초
*/
-
-import { useState, useEffect, useRef, useCallback } from 'react';
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { setMarketNames } from '../utils/marketNameCache';
import { UPBIT_API } from '../utils/upbitApi';
+import { subscribeUpbitWs } from '../utils/upbitWsBroker';
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
@@ -18,14 +17,14 @@ export interface TickerData {
market: string;
koreanName: string;
tradePrice: number | null;
- changeRate: number | null; // 전일대비 등락률 (0.01 = +1%)
- changePrice: number | null; // 전일대비 등락금액
- accTradePrice24: number; // 24h 누적 거래대금 (KRW) – 정렬용 (0 fallback)
- accTradeVolume24: number | null; // 24h 누적 거래량 (코인)
+ changeRate: number | null;
+ changePrice: number | null;
+ accTradePrice24: number;
+ accTradeVolume24: number | null;
openingPrice: number | null;
highPrice: number | null;
lowPrice: number | null;
- change: ChangeDir; // RISE / FALL / EVEN
+ change: ChangeDir;
}
export interface MarketInfo {
@@ -34,10 +33,20 @@ export interface MarketInfo {
englishName: string;
}
-const REST_POLL_INTERVAL = 30_000; // 30초 보조 폴링
-const WS_BATCH_MS = 400; // WebSocket 업데이트 배치 간격
+export interface UseMarketTickerOptions {
+ /** 모바일 등: 우선 종목만 먼저 로드 */
+ lite?: boolean;
+ /** lite 시 즉시 ticker REST·WS 할 마켓 (차트·관심 등) */
+ priorityMarkets?: string[];
+ /** 렌더마다 최신 우선 종목 (watchlist 등 — 훅 순서 유지용) */
+ getPriorityMarkets?: () => string[];
+ /** true면 전 종목 ticker 로드 (마켓 패널 열림 등) */
+ loadFull?: boolean;
+}
-// ── 내부 API 타입 ─────────────────────────────────────────────────────────
+const REST_POLL_INTERVAL = 30_000;
+const WS_BATCH_MS = 400;
+const LITE_BASE_MARKETS = ['KRW-BTC', 'KRW-USDT'];
function toChangeDir(raw: UpbitTickerRaw): ChangeDir {
if (raw.change === 'RISE' || raw.change === 'FALL' || raw.change === 'EVEN') return raw.change;
@@ -52,8 +61,8 @@ interface UpbitMarketAll {
}
interface UpbitTickerRaw {
- market?: string; // REST
- code?: string; // WebSocket
+ market?: string;
+ code?: string;
trade_price: number;
signed_change_rate: number;
signed_change_price: number;
@@ -112,76 +121,42 @@ function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData {
};
}
-// ── WebSocket 연결 관리 ───────────────────────────────────────────────────
-function createTickerWs(
+function subscribeTickerWs(
markets: string[],
onBatch: (rawList: UpbitTickerRaw[]) => void,
): () => void {
- const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
- let ws: WebSocket | null = null;
- let destroyed = false;
- let reconnectTimer: ReturnType | null = null;
-
- // 배치 버퍼: 너무 빈번한 state 업데이트 방지 (500ms 단위로 모아서 전달)
- const batch: Map = new Map();
+ const batch = new Map();
let batchTimer: ReturnType | null = null;
- function flushBatch() {
+ const flushBatch = () => {
batchTimer = null;
if (batch.size === 0) return;
onBatch(Array.from(batch.values()));
batch.clear();
- }
-
- function connect() {
- if (destroyed) return;
- ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`);
- ws.binaryType = 'arraybuffer';
-
- ws.onopen = () => {
- ws?.send(JSON.stringify([
- { ticket: 'market-panel-ticker' },
- { type: 'ticker', codes: markets },
- ]));
- };
-
- ws.onmessage = (e: MessageEvent) => {
- try {
- const decoder = new TextDecoder('utf-8');
- const text = typeof e.data === 'string'
- ? e.data
- : decoder.decode(e.data as ArrayBuffer);
- const msg: UpbitTickerRaw & { type?: string } = JSON.parse(text);
- if (msg.type !== 'ticker' && !msg.code) return;
- const code = getMarketCode(msg);
- if (!code) return;
- batch.set(code, msg);
- if (!batchTimer) {
- batchTimer = setTimeout(flushBatch, WS_BATCH_MS);
- }
- } catch { /* ignore */ }
- };
-
- ws.onerror = () => { /* ignore – onclose will handle */ };
- ws.onclose = () => {
- if (!destroyed) {
- reconnectTimer = setTimeout(connect, 5_000);
- }
- };
- }
-
- connect();
-
- return () => {
- destroyed = true;
- if (batchTimer) { clearTimeout(batchTimer); flushBatch(); }
- if (reconnectTimer) clearTimeout(reconnectTimer);
- ws?.close();
};
+
+ return subscribeUpbitWs('ticker', markets, raw => {
+ if (raw.type !== 'ticker' && !raw.code) return;
+ const msg = raw as unknown as UpbitTickerRaw;
+ const code = getMarketCode(msg);
+ if (!code) return;
+ batch.set(code, msg);
+ if (!batchTimer) batchTimer = setTimeout(flushBatch, WS_BATCH_MS);
+ });
}
-// ── 훅 ──────────────────────────────────────────────────────────────────
-export function useMarketTicker() {
+function mergePriorityMarkets(priorityMarkets?: string[]): string[] {
+ const set = new Set(LITE_BASE_MARKETS);
+ priorityMarkets?.forEach(m => { if (m?.startsWith('KRW-')) set.add(m); });
+ return [...set];
+}
+
+export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
+ const lite = opts.lite ?? false;
+ const loadFull = opts.loadFull ?? !lite;
+ const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets);
+ const priorityKey = priorityList.join(',');
+
const [tickers, setTickers] = useState