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>(new Map()); const [marketInfos, setMarketInfos] = useState([]); const [loading, setLoading] = useState(true); @@ -190,6 +165,10 @@ export function useMarketTicker() { const mountedRef = useRef(true); const marketListRef = useRef([]); const marketInfoMapRef = useRef>(new Map()); + const destroyWsRef = useRef<(() => void) | null>(null); + const pollTimerRef = useRef | null>(null); + const wsMarketsRef = useRef([]); + const fullLoadedRef = useRef(false); const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => { if (!mountedRef.current) return; @@ -210,53 +189,92 @@ export function useMarketTicker() { if (usdt?.trade_price) setUsdRate(usdt.trade_price); }, []); + const clearPoll = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + const startPoll = useCallback((markets: string[]) => { + clearPoll(); + pollTimerRef.current = setInterval(async () => { + if (!mountedRef.current) return; + try { + const rawList = await fetchAllTickers(markets); + applyRawList(rawList); + } catch { /* ignore */ } + }, REST_POLL_INTERVAL); + }, [applyRawList, clearPoll]); + + const attachWs = useCallback((markets: string[]) => { + destroyWsRef.current?.(); + wsMarketsRef.current = markets; + destroyWsRef.current = subscribeTickerWs(markets, applyRawList); + }, [applyRawList]); + + const loadTickersFor = useCallback(async (markets: string[]) => { + if (markets.length === 0) return; + try { + const rawList = await fetchAllTickers(markets); + if (mountedRef.current) applyRawList(rawList); + } catch { /* ignore */ } + }, [applyRawList]); + + // 마켓 목록 + 초기 ticker (lite / full) useEffect(() => { mountedRef.current = true; - let pollTimer: ReturnType | null = null; - let destroyWs: (() => void) | null = null; + fullLoadedRef.current = false; (async () => { - // 1) 마켓 목록 let infos: MarketInfo[] = []; try { infos = await fetchAllMarkets(); } - catch { /* 실패 시 빈 목록 */ } + catch { /* ignore */ } if (!mountedRef.current) return; infos.forEach(m => marketInfoMapRef.current.set(m.market, m)); setMarketInfos(infos); - const markets = infos.map(m => m.market); - if (!markets.includes('KRW-USDT')) markets.push('KRW-USDT'); - marketListRef.current = markets; - - // 2) 초기 시세 (REST) - try { - const rawList = await fetchAllTickers(markets); - if (mountedRef.current) applyRawList(rawList); - } catch { /* ignore */ } + const allMarkets = infos.map(m => m.market); + if (!allMarkets.includes('KRW-USDT')) allMarkets.push('KRW-USDT'); + marketListRef.current = allMarkets; + const initialMarkets = loadFull ? allMarkets : priorityList; + await loadTickersFor(initialMarkets); if (!mountedRef.current) return; + setLoading(false); + attachWs(initialMarkets); + startPoll(initialMarkets); - // 3) WebSocket 실시간 구독 - destroyWs = createTickerWs(markets, applyRawList); - - // 4) REST 보조 폴링 (30초 – WebSocket 누락 보완) - pollTimer = setInterval(async () => { - if (!mountedRef.current) return; - try { - const rawList = await fetchAllTickers(marketListRef.current); - applyRawList(rawList); - } catch { /* ignore */ } - }, REST_POLL_INTERVAL); + if (loadFull) fullLoadedRef.current = true; })(); return () => { mountedRef.current = false; - destroyWs?.(); - if (pollTimer) clearInterval(pollTimer); + destroyWsRef.current?.(); + destroyWsRef.current = null; + clearPoll(); }; - }, [applyRawList]); + }, [loadFull, priorityKey, loadTickersFor, attachWs, startPoll, clearPoll, priorityList]); + + // lite → full 확장 (마켓 패널 열림 등) + useEffect(() => { + if (!loadFull || fullLoadedRef.current) return; + if (marketListRef.current.length === 0) return; + + let cancelled = false; + (async () => { + const all = marketListRef.current; + await loadTickersFor(all); + if (cancelled || !mountedRef.current) return; + fullLoadedRef.current = true; + attachWs(all); + startPoll(all); + })(); + + return () => { cancelled = true; }; + }, [loadFull, loadTickersFor, attachWs, startPoll]); return { tickers, marketInfos, loading, usdRate }; } diff --git a/frontend/src/hooks/useUpbitOrderbook.ts b/frontend/src/hooks/useUpbitOrderbook.ts index 7a84990..12810e6 100644 --- a/frontend/src/hooks/useUpbitOrderbook.ts +++ b/frontend/src/hooks/useUpbitOrderbook.ts @@ -1,14 +1,13 @@ /** * 업비트 실시간 호가 훅 * - * frontend_golden/src/hooks/useUpbitOrderbook.ts 를 현재 프로젝트에 맞게 변환: - * - WebSocket: /upbit-ws (nginx 프록시 → wss://api.upbit.com/websocket/v1) + * - WebSocket: upbitWsBroker (단일 /upbit-ws) * - REST: /upbit-api/v1/orderbook - * - console.log 제거, 재연결 로직 강화 */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { coerceFiniteNumber } from '../utils/safeFormat'; +import { subscribeUpbitWs, subscribeUpbitWsConnection } from '../utils/upbitWsBroker'; // ── 타입 ──────────────────────────────────────────────────────────────────── export interface OrderbookUnit { @@ -21,7 +20,7 @@ export interface OrderbookUnit { export interface OrderbookDisplayUnit { price: number; size: number; - percentage: number; // 최대 잔량 대비 비율 (0–100), 배경 바 넓이 + percentage: number; type: 'ask' | 'bid'; } @@ -46,23 +45,18 @@ interface UpbitObWsMessage { stream_type?: string; } -// ── 상수 ──────────────────────────────────────────────────────────────────── -const RECONNECT_DELAY = 3_000; const INITIAL_STATE: OrderbookState = { asks: [], bids: [], totalAskSize: 0, totalBidSize: 0, timestamp: 0, connected: false, }; -// ── 훅 ────────────────────────────────────────────────────────────────────── -export function useUpbitOrderbook(market: string) { +export function useUpbitOrderbook(market: string, enabled = true) { const [orderbook, setOrderbook] = useState(INITIAL_STATE); const [wsStatus, setWsStatus] = useState('disconnected'); - const wsRef = useRef(null); - const reconnTimerRef = useRef | null>(null); - const mountedRef = useRef(true); - const isConnectingRef = useRef(false); + const mountedRef = useRef(true); + const marketRef = useRef(market); + marketRef.current = market; - // ── 데이터 파싱 → state ───────────────────────────────────────────────── const processData = useCallback((msg: UpbitObWsMessage) => { const units = msg.orderbook_units; if (!units || units.length === 0) return; @@ -72,7 +66,6 @@ export function useUpbitOrderbook(market: string) { ...units.map(u => u.bid_size), ); - // 매도: 높은 가격 → 낮은 가격 (역순) const asks: OrderbookDisplayUnit[] = units .map(u => ({ price: coerceFiniteNumber(u.ask_price) ?? 0, @@ -82,7 +75,6 @@ export function useUpbitOrderbook(market: string) { })) .reverse(); - // 매수: 높은 가격 → 낮은 가격 (정순 – 1번 unit이 best bid) const bids: OrderbookDisplayUnit[] = units.map(u => ({ price: coerceFiniteNumber(u.bid_price) ?? 0, size: coerceFiniteNumber(u.bid_size) ?? 0, @@ -100,10 +92,9 @@ export function useUpbitOrderbook(market: string) { }); }, []); - // ── REST 초기 스냅샷 ──────────────────────────────────────────────────── const fetchSnapshot = useCallback(async () => { try { - const res = await fetch(`/upbit-api/v1/orderbook?markets=${market}`); + const res = await fetch(`/upbit-api/v1/orderbook?markets=${marketRef.current}`); if (!res.ok) return; const data: Array<{ market: string; @@ -112,7 +103,7 @@ export function useUpbitOrderbook(market: string) { total_bid_size: number; orderbook_units: OrderbookUnit[]; }> = await res.json(); - if (data && data[0]) { + if (data?.[0]) { const d = data[0]; processData({ type: 'orderbook', @@ -125,96 +116,47 @@ export function useUpbitOrderbook(market: string) { }); } } catch { /* ignore */ } - }, [market, processData]); + }, [processData]); - // ── WebSocket 연결 ────────────────────────────────────────────────────── - const connect = useCallback(() => { - if (!mountedRef.current) return; - if (isConnectingRef.current) return; - if (wsRef.current?.readyState === WebSocket.OPEN) return; - - isConnectingRef.current = true; - setWsStatus('connecting'); - - const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; - const ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`); - ws.binaryType = 'arraybuffer'; - wsRef.current = ws; - - ws.onopen = () => { - if (!mountedRef.current) { ws.close(); return; } - isConnectingRef.current = false; - setWsStatus('connected'); - ws.send(JSON.stringify([ - { ticket: `ob-${market}-${Date.now()}` }, - { type: 'orderbook', codes: [market] }, - ])); - }; - - ws.onmessage = async (e: MessageEvent) => { - if (!mountedRef.current) return; - try { - let text: string; - if (typeof e.data === 'string') { - text = e.data; - } else if (e.data instanceof ArrayBuffer) { - text = new TextDecoder('utf-8').decode(e.data); - } else if (e.data instanceof Blob) { - text = await (e.data as Blob).text(); - } else return; - - const msg: UpbitObWsMessage = JSON.parse(text); - if (msg.type === 'orderbook' && msg.code === market) { - processData(msg); - } - } catch { /* ignore */ } - }; - - ws.onerror = () => { - isConnectingRef.current = false; - if (mountedRef.current) setWsStatus('error'); - }; - - ws.onclose = () => { - isConnectingRef.current = false; - if (!mountedRef.current) return; - setWsStatus('disconnected'); - setOrderbook(prev => ({ ...prev, connected: false })); - // 자동 재연결 - reconnTimerRef.current = setTimeout(connect, RECONNECT_DELAY); - }; - }, [market, processData]); - - // ── 연결 해제 ──────────────────────────────────────────────────────────── - const disconnect = useCallback(() => { - if (reconnTimerRef.current) { clearTimeout(reconnTimerRef.current); reconnTimerRef.current = null; } - if (wsRef.current) { - const ws = wsRef.current; - ws.onopen = null; ws.onmessage = null; ws.onerror = null; ws.onclose = null; - if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) ws.close(); - wsRef.current = null; - } - isConnectingRef.current = false; - }, []); - - // ── 라이프사이클 ───────────────────────────────────────────────────────── useEffect(() => { mountedRef.current = true; setOrderbook(INITIAL_STATE); fetchSnapshot(); - connect(); + + if (!enabled) { + setWsStatus('disconnected'); + return () => { mountedRef.current = false; }; + } + + setWsStatus('connecting'); + const unsubConn = subscribeUpbitWsConnection(connected => { + if (!mountedRef.current) return; + setWsStatus(connected ? 'connected' : 'disconnected'); + if (!connected) { + setOrderbook(prev => ({ ...prev, connected: false })); + } + }); + + const unsubWs = subscribeUpbitWs('orderbook', [market], raw => { + if (!mountedRef.current) return; + const msg = raw as unknown as UpbitObWsMessage; + if (msg.type === 'orderbook' && msg.code === marketRef.current) { + processData(msg); + } + }); + return () => { mountedRef.current = false; - disconnect(); + unsubConn(); + unsubWs(); }; - }, [market]); // eslint-disable-line react-hooks/exhaustive-deps + }, [market, enabled, fetchSnapshot, processData]); - // ── 스프레드 계산 ──────────────────────────────────────────────────────── const spread = useMemo(() => { const { asks, bids } = orderbook; if (!asks.length || !bids.length) return { bestAsk: 0, bestBid: 0, spread: 0, pct: 0 }; - const bestAsk = asks[asks.length - 1].price; // asks 배열 마지막 = 가장 낮은 매도가 - const bestBid = bids[0].price; // bids 배열 첫번째 = 가장 높은 매수가 + const bestAsk = asks[asks.length - 1].price; + const bestBid = bids[0].price; const diff = bestAsk - bestBid; return { bestAsk, bestBid, spread: diff, pct: bestBid > 0 ? (diff / bestBid) * 100 : 0 }; }, [orderbook.asks, orderbook.bids]); diff --git a/frontend/src/hooks/useUpbitRecentTrades.ts b/frontend/src/hooks/useUpbitRecentTrades.ts index 4f63bf4..d53d2db 100644 --- a/frontend/src/hooks/useUpbitRecentTrades.ts +++ b/frontend/src/hooks/useUpbitRecentTrades.ts @@ -2,11 +2,12 @@ * 업비트 최근 체결 (호가창 좌측 체결 목록용) */ import { useState, useEffect, useRef, useCallback } from 'react'; +import { subscribeUpbitWs } from '../utils/upbitWsBroker'; export interface RecentTrade { price: number; volume: number; - side: 'ask' | 'bid'; // ask=매도체결, bid=매수체결 + side: 'ask' | 'bid'; time: number; } @@ -29,12 +30,13 @@ interface UpbitTradeWs { timestamp: number; } -export function useUpbitRecentTrades(market: string) { +export function useUpbitRecentTrades(market: string, enabled = true) { const [trades, setTrades] = useState([]); const [strength, setStrength] = useState(null); - const wsRef = useRef(null); const mountedRef = useRef(true); + const marketRef = useRef(market); + marketRef.current = market; const pushTrade = useCallback((t: RecentTrade) => { setTrades(prev => { @@ -49,7 +51,7 @@ export function useUpbitRecentTrades(market: string) { const fetchSnapshot = useCallback(async () => { try { - const res = await fetch(`/upbit-api/v1/trades/ticks?market=${market}&count=${MAX_TRADES}`); + const res = await fetch(`/upbit-api/v1/trades/ticks?market=${marketRef.current}&count=${MAX_TRADES}`); if (!res.ok) return; const data: UpbitTradeRaw[] = await res.json(); const list: RecentTrade[] = data.map(d => ({ @@ -63,57 +65,37 @@ export function useUpbitRecentTrades(market: string) { const sellVol = list.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0); if (sellVol > 0) setStrength((buyVol / sellVol) * 100); } catch { /* ignore */ } - }, [market]); + }, []); useEffect(() => { mountedRef.current = true; setTrades([]); setStrength(null); + + if (!enabled) { + return () => { mountedRef.current = false; }; + } + fetchSnapshot(); - const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; - const ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`); - ws.binaryType = 'arraybuffer'; - wsRef.current = ws; - - ws.onopen = () => { - ws.send(JSON.stringify([ - { ticket: `tr-${market}-${Date.now()}` }, - { type: 'trade', codes: [market] }, - ])); - }; - - ws.onmessage = async (e: MessageEvent) => { + const unsub = subscribeUpbitWs('trade', [market], raw => { if (!mountedRef.current) return; - try { - let text: string; - if (typeof e.data === 'string') text = e.data; - else if (e.data instanceof ArrayBuffer) text = new TextDecoder().decode(e.data); - else if (e.data instanceof Blob) text = await e.data.text(); - else return; - const msg: UpbitTradeWs = JSON.parse(text); - if (msg.type === 'trade' && msg.code === market) { - pushTrade({ - price: msg.trade_price, - volume: msg.trade_volume, - side: msg.ask_bid === 'ASK' ? 'ask' : 'bid', - time: msg.timestamp, - }); - } - } catch { /* ignore */ } - }; + const msg = raw as unknown as UpbitTradeWs; + if (msg.type === 'trade' && msg.code === marketRef.current) { + pushTrade({ + price: msg.trade_price, + volume: msg.trade_volume, + side: msg.ask_bid === 'ASK' ? 'ask' : 'bid', + time: msg.timestamp, + }); + } + }); return () => { mountedRef.current = false; - if (wsRef.current) { - wsRef.current.onopen = null; - wsRef.current.onmessage = null; - wsRef.current.onclose = null; - wsRef.current.close(); - wsRef.current = null; - } + unsub(); }; - }, [market, fetchSnapshot, pushTrade]); + }, [market, enabled, fetchSnapshot, pushTrade]); return { trades, strength }; } diff --git a/frontend/src/utils/upbitApi.ts b/frontend/src/utils/upbitApi.ts index 2da9e31..5a31342 100644 --- a/frontend/src/utils/upbitApi.ts +++ b/frontend/src/utils/upbitApi.ts @@ -1,4 +1,5 @@ import type { OHLCVBar, Timeframe } from '../types'; +import { subscribeUpbitTrade, subscribeUpbitWsConnection } from './upbitWsBroker'; // ─── Upbit REST API ──────────────────────────────────────────────────────── // 개발: Vite proxy (/upbit-api) → https://api.upbit.com @@ -180,140 +181,37 @@ export interface UpbitWsOptions { onError: (err: string) => void; } -// 개발: Vite proxy (/upbit-ws) → wss://api.upbit.com/websocket/v1 -// 프로덕션: nginx proxy (/upbit-ws) → wss://api.upbit.com/websocket/v1 -// 항상 동일 오리진 상대 경로를 사용 → CORS / Origin 차단 모두 우회 -function getWsUrl(): string { - const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; - return `${proto}://${window.location.host}/upbit-ws`; -} - -const INITIAL_DELAY = 80; // React StrictMode 이중마운트 방지 (cleanup < 80ms면 연결 안 함) -const RECONNECT_BASE = 2000; // 최초 재연결 대기 -const RECONNECT_MAX = 30000; // 최대 재연결 대기 -const PING_INTERVAL = 20000; // 20초마다 PING (서버가 60초 내 응답 없으면 끊음) - +/** @deprecated 직접 WebSocket 대신 upbitWsBroker 사용. 하위 호환 래퍼. */ export class UpbitWebSocketClient { - private ws: WebSocket | null = null; - private opts: UpbitWsOptions; - private destroyed = false; - private retryCount = 0; - private reconnectTimer: ReturnType | null = null; - private pingTimer: ReturnType | null = null; - - constructor(opts: UpbitWsOptions) { - this.opts = opts; - // 80ms 지연으로 React StrictMode 이중 마운트 방지 - // (cleanup 이 80ms 안에 호출되면 connect 가 취소됨) - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.connect(); - }, INITIAL_DELAY); - } - - private connect() { - if (this.destroyed) return; - - try { - const ws = new WebSocket(getWsUrl()); - this.ws = ws; - ws.binaryType = 'arraybuffer'; - - ws.onopen = () => { - this.retryCount = 0; - - // ── 구독 메시지 ────────────────────────────────────────────── - // format 생략 시 DEFAULT (긴 키명), SIMPLE이면 단축 키명 - ws.send(JSON.stringify([ - { ticket: `react_chart_${Date.now()}` }, - { type: 'trade', codes: [this.opts.market.toUpperCase()] }, - { format: 'DEFAULT' }, - ])); - - // ── PING keepalive ───────────────────────────────────────── - this.clearPing(); - this.pingTimer = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send('PING'); - } - }, PING_INTERVAL); + private destroyed = false; + private unsubTrade: (() => void) | null = null; + private unsubConn: (() => void) | null = null; + constructor(private opts: UpbitWsOptions) { + this.unsubConn = subscribeUpbitWsConnection(connected => { + if (this.destroyed) return; + if (connected) { this.opts.onConnect(); - }; - - ws.onmessage = (event) => { - // ① 텍스트 메시지: 서버 PING → PONG 응답, 상태 메시지 → 무시 - if (typeof event.data === 'string') { - if (event.data === 'PING') ws.send('PONG'); - return; - } - - // ② 바이너리 메시지: 체결/시세 데이터 - let msg: UpbitMessage | null = null; - try { - const text = new TextDecoder('utf-8').decode(event.data as ArrayBuffer); - if (text === 'PING') { ws.send('PONG'); return; } - const parsed = JSON.parse(text) as UpbitMessage; - if (!('type' in parsed)) return; - msg = parsed; - } catch { - // JSON 파싱 실패 → 무시 - return; - } - // JSON 파싱과 onMessage 를 분리: 차트 업데이트 오류가 WS 메시지 처리를 중단시키지 않도록 - try { - this.opts.onMessage(msg); - } catch (err) { - console.error('[UpbitWS] onMessage 처리 중 오류:', err); - } - }; - - ws.onclose = (ev) => { - this.clearPing(); - if (!this.destroyed) { - // 정상 종료(1000/1001)가 아닐 때만 오류 표시 - if (ev.code !== 1000 && ev.code !== 1001) { - this.opts.onError(`연결 끊김 (code ${ev.code})`); - } - this.opts.onDisconnect(); - this.scheduleReconnect(); - } - }; - - ws.onerror = () => { - // onerror 직후 onclose가 반드시 발생하므로 여기서는 로그만 - // (onclose에서 재연결 처리) - console.warn('[UpbitWS] onerror 발생 → onclose 대기'); - }; - - } catch (err) { - this.opts.onError(`WebSocket 초기화 실패: ${String(err)}`); - this.scheduleReconnect(); - } - } - - private clearPing() { - if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } - } - - private scheduleReconnect() { - if (this.destroyed) return; - const delay = Math.min(RECONNECT_BASE * 2 ** this.retryCount, RECONNECT_MAX); - this.retryCount++; - console.info(`[UpbitWS] ${delay}ms 후 재연결 시도 (${this.retryCount}회차)`); - this.reconnectTimer = setTimeout(() => this.connect(), delay); + } else { + this.opts.onDisconnect(); + } + }); + this.unsubTrade = subscribeUpbitTrade(opts.market, msg => { + if (this.destroyed) return; + try { + this.opts.onMessage(msg); + } catch (err) { + console.error('[UpbitWS] onMessage 처리 중 오류:', err); + } + }); } destroy() { this.destroyed = true; - this.clearPing(); - if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } - if (this.ws) { - this.ws.onclose = null; - this.ws.onerror = null; - this.ws.close(1000, 'destroy'); - this.ws = null; - } + this.unsubTrade?.(); + this.unsubTrade = null; + this.unsubConn?.(); + this.unsubConn = null; } } diff --git a/frontend/src/utils/upbitWsBroker.ts b/frontend/src/utils/upbitWsBroker.ts new file mode 100644 index 0000000..6997828 --- /dev/null +++ b/frontend/src/utils/upbitWsBroker.ts @@ -0,0 +1,274 @@ +/** + * 업비트 WebSocket 단일 연결 브로커 (/upbit-ws). + * 훅·차트마다 WebSocket 을 열면 모바일에서 429·재연결 폭주가 발생한다. + */ +import type { UpbitMessage } from './upbitApi'; + +export type UpbitWsChannel = 'ticker' | 'orderbook' | 'trade'; + +type RawHandler = (msg: Record) => void; +type ConnectionListener = (connected: boolean) => void; + +interface Listener { + channel: UpbitWsChannel; + codes: Set; + handler: RawHandler; +} + +const INITIAL_DELAY = 80; +const RECONNECT_BASE = 2000; +const RECONNECT_MAX = 30_000; +const PING_INTERVAL = 20_000; +const DISCONNECT_IDLE_MS = 5_000; + +const refCounts = new Map>(); +const listeners = new Set(); +const connectionListeners = new Set(); + +let ws: WebSocket | null = null; +let connected = false; +let connectTimer: ReturnType | null = null; +let disconnectTimer: ReturnType | null = null; +let pingTimer: ReturnType | null = null; +let reconnectTimer: ReturnType | null = null; +let retryCount = 0; +let closingIntentionally = false; + +function getWsUrl(): string { + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; + return `${proto}://${window.location.host}/upbit-ws`; +} + +function notifyConnection(next: boolean): void { + connected = next; + connectionListeners.forEach(l => l(next)); +} + +function bumpRef(channel: UpbitWsChannel, code: string, delta: number): void { + const key = code.toUpperCase(); + let m = refCounts.get(channel); + if (!m) { + m = new Map(); + refCounts.set(channel, m); + } + const next = (m.get(key) ?? 0) + delta; + if (next <= 0) m.delete(key); + else m.set(key, next); +} + +function codesForChannel(channel: UpbitWsChannel): string[] { + return Array.from(refCounts.get(channel)?.keys() ?? []); +} + +function listenerCount(): number { + return listeners.size; +} + +function cancelPendingDisconnect(): void { + if (disconnectTimer) { + clearTimeout(disconnectTimer); + disconnectTimer = null; + } +} + +function cancelPendingConnect(): void { + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } +} + +function clearPing(): void { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } +} + +function sendSubscribe(): void { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const packet: unknown[] = [{ ticket: `gc-${Date.now()}` }]; + for (const ch of ['ticker', 'orderbook', 'trade'] as UpbitWsChannel[]) { + const codes = codesForChannel(ch); + if (codes.length > 0) packet.push({ type: ch, codes }); + } + if (packet.length === 1) return; + packet.push({ format: 'DEFAULT' }); + ws.send(JSON.stringify(packet)); +} + +function parseBinaryOrText(data: string | ArrayBuffer | Blob): Promise { + if (typeof data === 'string') return Promise.resolve(data); + if (data instanceof ArrayBuffer) { + return Promise.resolve(new TextDecoder('utf-8').decode(data)); + } + if (data instanceof Blob) return data.text(); + return Promise.resolve(null); +} + +function dispatch(text: string): void { + if (text === 'PING') { + ws?.send('PONG'); + return; + } + let msg: Record; + try { + msg = JSON.parse(text) as Record; + } catch { + return; + } + const type = msg.type as string | undefined; + if (type !== 'ticker' && type !== 'orderbook' && type !== 'trade') return; + const code = String(msg.code ?? msg.market ?? '').toUpperCase(); + if (!code) return; + + for (const entry of listeners) { + if (entry.channel !== type) continue; + if (entry.codes.size > 0 && !entry.codes.has(code)) continue; + try { + entry.handler(msg); + } catch (err) { + console.error('[upbitWsBroker] handler error', err); + } + } +} + +function scheduleReconnect(): void { + if (listenerCount() === 0) return; + if (reconnectTimer) return; + const delay = Math.min(RECONNECT_BASE * 2 ** retryCount, RECONNECT_MAX); + retryCount += 1; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + openSocket(); + }, delay); +} + +function teardownSocket(): void { + clearPing(); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + closingIntentionally = true; + if (ws) { + ws.onopen = null; + ws.onmessage = null; + ws.onerror = null; + ws.onclose = null; + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.close(1000, 'broker idle'); + } + ws = null; + } + closingIntentionally = false; + if (connected) notifyConnection(false); +} + +function openSocket(): void { + if (listenerCount() === 0) return; + teardownSocket(); + + try { + const socket = new WebSocket(getWsUrl()); + ws = socket; + socket.binaryType = 'arraybuffer'; + + socket.onopen = () => { + retryCount = 0; + notifyConnection(true); + sendSubscribe(); + clearPing(); + pingTimer = setInterval(() => { + if (socket.readyState === WebSocket.OPEN) socket.send('PING'); + }, PING_INTERVAL); + }; + + socket.onmessage = (event: MessageEvent) => { + void parseBinaryOrText(event.data as string | ArrayBuffer | Blob).then(text => { + if (text != null) dispatch(text); + }); + }; + + socket.onerror = () => { + console.warn('[upbitWsBroker] socket error'); + }; + + socket.onclose = () => { + clearPing(); + notifyConnection(false); + ws = null; + if (!closingIntentionally && listenerCount() > 0) scheduleReconnect(); + }; + } catch (err) { + console.warn('[upbitWsBroker] connect failed', err); + scheduleReconnect(); + } +} + +function scheduleConnect(): void { + cancelPendingDisconnect(); + if (ws?.readyState === WebSocket.OPEN) { + sendSubscribe(); + return; + } + if (connectTimer) return; + connectTimer = setTimeout(() => { + connectTimer = null; + openSocket(); + }, INITIAL_DELAY); +} + +function scheduleDisconnect(): void { + cancelPendingConnect(); + cancelPendingDisconnect(); + disconnectTimer = setTimeout(() => { + disconnectTimer = null; + if (listenerCount() > 0) return; + teardownSocket(); + }, DISCONNECT_IDLE_MS); +} + +/** 연결 상태 구독 */ +export function subscribeUpbitWsConnection(listener: ConnectionListener): () => void { + connectionListeners.add(listener); + listener(connected && ws?.readyState === WebSocket.OPEN); + return () => connectionListeners.delete(listener); +} + +/** + * 업비트 WS 채널 구독 (참조 카운트). codes 가 비어 있으면 구독하지 않음. + */ +export function subscribeUpbitWs( + channel: UpbitWsChannel, + codes: string[], + handler: RawHandler, +): () => void { + const normalized = [...new Set(codes.map(c => c.toUpperCase()).filter(Boolean))]; + if (normalized.length === 0) { + return () => {}; + } + + normalized.forEach(code => bumpRef(channel, code, 1)); + const entry: Listener = { channel, codes: new Set(normalized), handler }; + listeners.add(entry); + scheduleConnect(); + + return () => { + listeners.delete(entry); + normalized.forEach(code => bumpRef(channel, code, -1)); + if (listenerCount() === 0) scheduleDisconnect(); + else if (ws?.readyState === WebSocket.OPEN) sendSubscribe(); + }; +} + +/** 차트용 trade 구독 — UpbitMessage 타입 핸들러 */ +export function subscribeUpbitTrade( + market: string, + handler: (msg: UpbitMessage) => void, +): () => void { + return subscribeUpbitWs('trade', [market], raw => { + if (raw.type !== 'trade') return; + handler(raw as unknown as UpbitMessage); + }); +}