초기로딩 부하문제 수정
This commit is contained in:
@@ -164,6 +164,16 @@ html.theme-blue {
|
|||||||
line-height: 1;
|
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 (글로벌 네비게이션)
|
TOP MENU BAR (글로벌 네비게이션)
|
||||||
══════════════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
+60
-17
@@ -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 DrawingToolbar from './components/DrawingToolbar';
|
||||||
import BottomBar from './components/BottomBar';
|
import BottomBar from './components/BottomBar';
|
||||||
import TradingChart from './components/TradingChart';
|
import TradingChart from './components/TradingChart';
|
||||||
@@ -78,8 +78,6 @@ import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
|
|||||||
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
|
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
|
||||||
import MarketPanel from './components/MarketPanel';
|
import MarketPanel from './components/MarketPanel';
|
||||||
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
|
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
|
||||||
import StrategyPage from './components/StrategyPage';
|
|
||||||
import StrategyEditorPage from './components/StrategyEditorPage';
|
|
||||||
import BacktestPanel from './components/BacktestPanel';
|
import BacktestPanel from './components/BacktestPanel';
|
||||||
import { useBacktest } from './hooks/useBacktest';
|
import { useBacktest } from './hooks/useBacktest';
|
||||||
import LiveStrategyPanel from './components/LiveStrategyPanel';
|
import LiveStrategyPanel from './components/LiveStrategyPanel';
|
||||||
@@ -95,16 +93,23 @@ import {
|
|||||||
} from './utils/tradingAccess';
|
} from './utils/tradingAccess';
|
||||||
import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings';
|
import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings';
|
||||||
import { AppNotificationLayer } from './components/AppNotificationLayer';
|
import { AppNotificationLayer } from './components/AppNotificationLayer';
|
||||||
import TradeNotificationListPage from './components/TradeNotificationListPage';
|
|
||||||
import BacktestSettingsModal from './components/BacktestSettingsModal';
|
import BacktestSettingsModal from './components/BacktestSettingsModal';
|
||||||
import { BacktestResultModal } from './components/BacktestResultModal';
|
import { BacktestResultModal } from './components/BacktestResultModal';
|
||||||
import { BacktestHistoryPage } from './components/BacktestHistoryPage';
|
|
||||||
import SettingsPage from './components/SettingsPage';
|
const DashboardPage = lazy(() => import('./components/DashboardPage'));
|
||||||
import PaperTradingPage from './components/PaperTradingPage';
|
const StrategyPage = lazy(() => import('./components/StrategyPage'));
|
||||||
import VirtualTradingPage from './components/VirtualTradingPage';
|
const StrategyEditorPage = lazy(() => import('./components/StrategyEditorPage'));
|
||||||
import TrendSearchPage from './components/TrendSearchPage';
|
const TradeNotificationListPage = lazy(() => import('./components/TradeNotificationListPage'));
|
||||||
import VerificationBoardPage from './components/VerificationBoardPage';
|
const BacktestHistoryPage = lazy(() => import('./components/BacktestHistoryPage').then(m => ({ default: m.BacktestHistoryPage })));
|
||||||
import DashboardPage from './components/DashboardPage';
|
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 <div className="page-load-fallback" role="status">화면 로딩 중…</div>;
|
||||||
|
}
|
||||||
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
|
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
|
||||||
import ChartLegendBar from './components/ChartLegendBar';
|
import ChartLegendBar from './components/ChartLegendBar';
|
||||||
import { resolveChartLiveQuote } from './utils/chartLiveQuote';
|
import { resolveChartLiveQuote } from './utils/chartLiveQuote';
|
||||||
@@ -236,8 +241,14 @@ function App() {
|
|||||||
const chartVisible = menuPage === 'chart';
|
const chartVisible = menuPage === 'chart';
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// ── 마켓 패널 시세 데이터 ─────────────────────────────────────────────
|
const priorityMarketsRef = useRef<string[]>([symbol]);
|
||||||
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker();
|
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<LayoutDef>(() => LAYOUTS[0]);
|
const [layoutDef, setLayoutDef] = useState<LayoutDef>(() => LAYOUTS[0]);
|
||||||
@@ -297,7 +308,9 @@ function App() {
|
|||||||
}, [saveAppDef]);
|
}, [saveAppDef]);
|
||||||
|
|
||||||
const chartSymbol = layoutDef.count > 1 ? activeSlotSymbol : symbol;
|
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 chartTicker = marketTickers.get(chartSymbol);
|
||||||
const orderbookPrevClose = chartTicker
|
const orderbookPrevClose = chartTicker
|
||||||
? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0)
|
? (chartTicker.tradePrice ?? 0) - (chartTicker.changePrice ?? 0)
|
||||||
@@ -548,6 +561,10 @@ function App() {
|
|||||||
: [...UPBIT_MARKETS.slice(0, 5), ...SYMBOLS.slice(0, 4)];
|
: [...UPBIT_MARKETS.slice(0, 5), ...SYMBOLS.slice(0, 4)];
|
||||||
return [...new Set([...base, ...getFavorites()])];
|
return [...new Set([...base, ...getFavorites()])];
|
||||||
});
|
});
|
||||||
|
priorityMarketsRef.current = useMemo(
|
||||||
|
() => [symbol, ...watchlist],
|
||||||
|
[symbol, watchlist],
|
||||||
|
);
|
||||||
/** 좌측 패널 관심종목(gc_favorites) — 종목 검색 드롭다운 별표·실시간 전략과 동기화 */
|
/** 좌측 패널 관심종목(gc_favorites) — 종목 검색 드롭다운 별표·실시간 전략과 동기화 */
|
||||||
const [favoritesList, setFavoritesList] = useState<string[]>(getFavorites);
|
const [favoritesList, setFavoritesList] = useState<string[]>(getFavorites);
|
||||||
// DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작)
|
// DB 로드 완료 여부 (false → DB 로드 전, localStorage 기반 동작)
|
||||||
@@ -1751,30 +1768,45 @@ function App() {
|
|||||||
|
|
||||||
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
|
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
|
||||||
{menuPage === 'dashboard' && (
|
{menuPage === 'dashboard' && (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
<DashboardPage theme={theme} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} />
|
<DashboardPage theme={theme} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} />
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{menuPage === 'strategy' && (
|
{menuPage === 'strategy' && (
|
||||||
formalLogin
|
formalLogin
|
||||||
? <StrategyPage theme={theme} activeIndicators={indicators} />
|
? (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
|
<StrategyPage theme={theme} activeIndicators={indicators} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
: <FormalLoginGate onLogin={requireFormalLogin} />
|
: <FormalLoginGate onLogin={requireFormalLogin} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{menuPage === 'strategy-editor' && (
|
{menuPage === 'strategy-editor' && (
|
||||||
formalLogin
|
formalLogin
|
||||||
? <StrategyEditorPage theme={theme} />
|
? (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
|
<StrategyEditorPage theme={theme} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
: <FormalLoginGate onLogin={requireFormalLogin} />
|
: <FormalLoginGate onLogin={requireFormalLogin} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
|
{/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */}
|
||||||
{menuPage === 'backtest' && (
|
{menuPage === 'backtest' && (
|
||||||
formalLogin
|
formalLogin
|
||||||
? <BacktestHistoryPage theme={theme} />
|
? (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
|
<BacktestHistoryPage theme={theme} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
: <FormalLoginGate onLogin={requireFormalLogin} />
|
: <FormalLoginGate onLogin={requireFormalLogin} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{menuPage === 'paper' && (
|
{menuPage === 'paper' && (
|
||||||
formalLogin ? (
|
formalLogin ? (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
<PaperTradingPage
|
<PaperTradingPage
|
||||||
theme={theme}
|
theme={theme}
|
||||||
tickers={marketTickers}
|
tickers={marketTickers}
|
||||||
@@ -1793,6 +1825,7 @@ function App() {
|
|||||||
setMenuPage('chart');
|
setMenuPage('chart');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
<FormalLoginGate onLogin={requireFormalLogin} title="투자관리" />
|
<FormalLoginGate onLogin={requireFormalLogin} title="투자관리" />
|
||||||
)
|
)
|
||||||
@@ -1800,6 +1833,7 @@ function App() {
|
|||||||
|
|
||||||
{menuPage === 'virtual' && (
|
{menuPage === 'virtual' && (
|
||||||
formalLogin ? (
|
formalLogin ? (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
<VirtualTradingPage
|
<VirtualTradingPage
|
||||||
theme={theme}
|
theme={theme}
|
||||||
tickers={marketTickers}
|
tickers={marketTickers}
|
||||||
@@ -1810,12 +1844,14 @@ function App() {
|
|||||||
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
|
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
|
||||||
onPaperOrderFilled={handlePaperOrderFilled}
|
onPaperOrderFilled={handlePaperOrderFilled}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
<FormalLoginGate onLogin={requireFormalLogin} title="가상매매" />
|
<FormalLoginGate onLogin={requireFormalLogin} title="가상매매" />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{menuPage === 'trend-search' && (
|
{menuPage === 'trend-search' && (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
<TrendSearchPage
|
<TrendSearchPage
|
||||||
theme={theme}
|
theme={theme}
|
||||||
chartRealtimeSource={(appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource}
|
chartRealtimeSource={(appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource}
|
||||||
@@ -1823,19 +1859,23 @@ function App() {
|
|||||||
chartSeriesPriceLabels={appDefaults.chartSeriesPriceLabels}
|
chartSeriesPriceLabels={appDefaults.chartSeriesPriceLabels}
|
||||||
tickers={marketTickers}
|
tickers={marketTickers}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{menuPage === 'verification-board' && (
|
{menuPage === 'verification-board' && (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
<VerificationBoardPage
|
<VerificationBoardPage
|
||||||
theme={theme}
|
theme={theme}
|
||||||
focusIssueId={verificationFocusIssueId}
|
focusIssueId={verificationFocusIssueId}
|
||||||
onFocusIssueConsumed={() => setVerificationFocusIssueId(null)}
|
onFocusIssueConsumed={() => setVerificationFocusIssueId(null)}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
|
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
|
||||||
{menuPage === 'notifications' && canMenu('notifications') && (
|
{menuPage === 'notifications' && canMenu('notifications') && (
|
||||||
formalLogin ? (
|
formalLogin ? (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
<TradeNotificationListPage
|
<TradeNotificationListPage
|
||||||
theme={theme}
|
theme={theme}
|
||||||
tickers={marketTickers}
|
tickers={marketTickers}
|
||||||
@@ -1848,12 +1888,14 @@ function App() {
|
|||||||
setMenuPage('chart');
|
setMenuPage('chart');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
<FormalLoginGate onLogin={requireFormalLogin} title="알림목록" />
|
<FormalLoginGate onLogin={requireFormalLogin} title="알림목록" />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{menuPage === 'settings' && canMenu('settings') && (
|
{menuPage === 'settings' && canMenu('settings') && (
|
||||||
|
<Suspense fallback={<PageLoadFallback />}>
|
||||||
<SettingsPage
|
<SettingsPage
|
||||||
isFormalLogin={formalLogin}
|
isFormalLogin={formalLogin}
|
||||||
onRequireFormalLogin={requireFormalLogin}
|
onRequireFormalLogin={requireFormalLogin}
|
||||||
@@ -1990,6 +2032,7 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
initialCategory={settingsInitialCategory}
|
initialCategory={settingsInitialCategory}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 실시간 차트 화면 ────────────────────────────────────────────── */}
|
{/* ── 실시간 차트 화면 ────────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -78,10 +78,10 @@ const RightSidePanel: React.FC<RightSidePanelProps> = ({
|
|||||||
const [openInternal, setOpenInternal] = useState(false);
|
const [openInternal, setOpenInternal] = useState(false);
|
||||||
const [tabInternal, setTabInternal] = useState<TradeRightPanelTab>('trade');
|
const [tabInternal, setTabInternal] = useState<TradeRightPanelTab>('trade');
|
||||||
const tradeSectionRef = useRef<HTMLDivElement>(null);
|
const tradeSectionRef = useRef<HTMLDivElement>(null);
|
||||||
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market);
|
|
||||||
|
|
||||||
const isOpenControlled = openControlled !== undefined && onOpenChange !== undefined;
|
const isOpenControlled = openControlled !== undefined && onOpenChange !== undefined;
|
||||||
const open = isOpenControlled ? !!openControlled : openInternal;
|
const open = isOpenControlled ? !!openControlled : openInternal;
|
||||||
|
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market, open);
|
||||||
const setOpen = (next: boolean | ((p: boolean) => boolean)) => {
|
const setOpen = (next: boolean | ((p: boolean) => boolean)) => {
|
||||||
const cur = isOpenControlled ? !!openControlled : openInternal;
|
const cur = isOpenControlled ? !!openControlled : openInternal;
|
||||||
const value = typeof next === 'function' ? next(cur) : next;
|
const value = typeof next === 'function' ? next(cur) : next;
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* 업비트 마켓 목록 + 실시간 시세 훅
|
* 업비트 마켓 목록 + 실시간 시세 훅
|
||||||
*
|
*
|
||||||
* 동작:
|
* 1) /v1/market/all — 마켓 목록
|
||||||
* 1) 앱 마운트 시 /v1/market/all 로 전체 마켓 목록 + 한글명 로드
|
* 2) /v1/ticker — 초기 시세 (lite: 우선 종목만, full: 전 종목)
|
||||||
* 2) /v1/ticker?markets=... 로 초기 시세 로드 (100개 단위 분할)
|
* 3) upbitWsBroker ticker 구독
|
||||||
* 3) WebSocket ticker 구독으로 실시간 시세 수신 (배치 업데이트 500ms)
|
* 4) REST 보조 폴링 30초
|
||||||
* 4) REST 폴링 30초마다 보조 갱신 (WebSocket 누락 방지)
|
|
||||||
*/
|
*/
|
||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { setMarketNames } from '../utils/marketNameCache';
|
import { setMarketNames } from '../utils/marketNameCache';
|
||||||
import { UPBIT_API } from '../utils/upbitApi';
|
import { UPBIT_API } from '../utils/upbitApi';
|
||||||
|
import { subscribeUpbitWs } from '../utils/upbitWsBroker';
|
||||||
|
|
||||||
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
|
export type ChangeDir = 'RISE' | 'FALL' | 'EVEN';
|
||||||
|
|
||||||
@@ -18,14 +17,14 @@ export interface TickerData {
|
|||||||
market: string;
|
market: string;
|
||||||
koreanName: string;
|
koreanName: string;
|
||||||
tradePrice: number | null;
|
tradePrice: number | null;
|
||||||
changeRate: number | null; // 전일대비 등락률 (0.01 = +1%)
|
changeRate: number | null;
|
||||||
changePrice: number | null; // 전일대비 등락금액
|
changePrice: number | null;
|
||||||
accTradePrice24: number; // 24h 누적 거래대금 (KRW) – 정렬용 (0 fallback)
|
accTradePrice24: number;
|
||||||
accTradeVolume24: number | null; // 24h 누적 거래량 (코인)
|
accTradeVolume24: number | null;
|
||||||
openingPrice: number | null;
|
openingPrice: number | null;
|
||||||
highPrice: number | null;
|
highPrice: number | null;
|
||||||
lowPrice: number | null;
|
lowPrice: number | null;
|
||||||
change: ChangeDir; // RISE / FALL / EVEN
|
change: ChangeDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketInfo {
|
export interface MarketInfo {
|
||||||
@@ -34,10 +33,20 @@ export interface MarketInfo {
|
|||||||
englishName: string;
|
englishName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REST_POLL_INTERVAL = 30_000; // 30초 보조 폴링
|
export interface UseMarketTickerOptions {
|
||||||
const WS_BATCH_MS = 400; // WebSocket 업데이트 배치 간격
|
/** 모바일 등: 우선 종목만 먼저 로드 */
|
||||||
|
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 {
|
function toChangeDir(raw: UpbitTickerRaw): ChangeDir {
|
||||||
if (raw.change === 'RISE' || raw.change === 'FALL' || raw.change === 'EVEN') return raw.change;
|
if (raw.change === 'RISE' || raw.change === 'FALL' || raw.change === 'EVEN') return raw.change;
|
||||||
@@ -52,8 +61,8 @@ interface UpbitMarketAll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UpbitTickerRaw {
|
interface UpbitTickerRaw {
|
||||||
market?: string; // REST
|
market?: string;
|
||||||
code?: string; // WebSocket
|
code?: string;
|
||||||
trade_price: number;
|
trade_price: number;
|
||||||
signed_change_rate: number;
|
signed_change_rate: number;
|
||||||
signed_change_price: number;
|
signed_change_price: number;
|
||||||
@@ -112,76 +121,42 @@ function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket 연결 관리 ───────────────────────────────────────────────────
|
function subscribeTickerWs(
|
||||||
function createTickerWs(
|
|
||||||
markets: string[],
|
markets: string[],
|
||||||
onBatch: (rawList: UpbitTickerRaw[]) => void,
|
onBatch: (rawList: UpbitTickerRaw[]) => void,
|
||||||
): () => void {
|
): () => void {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const batch = new Map<string, UpbitTickerRaw>();
|
||||||
let ws: WebSocket | null = null;
|
|
||||||
let destroyed = false;
|
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
// 배치 버퍼: 너무 빈번한 state 업데이트 방지 (500ms 단위로 모아서 전달)
|
|
||||||
const batch: Map<string, UpbitTickerRaw> = new Map();
|
|
||||||
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function flushBatch() {
|
const flushBatch = () => {
|
||||||
batchTimer = null;
|
batchTimer = null;
|
||||||
if (batch.size === 0) return;
|
if (batch.size === 0) return;
|
||||||
onBatch(Array.from(batch.values()));
|
onBatch(Array.from(batch.values()));
|
||||||
batch.clear();
|
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) => {
|
return subscribeUpbitWs('ticker', markets, raw => {
|
||||||
try {
|
if (raw.type !== 'ticker' && !raw.code) return;
|
||||||
const decoder = new TextDecoder('utf-8');
|
const msg = raw as unknown as UpbitTickerRaw;
|
||||||
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);
|
const code = getMarketCode(msg);
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
batch.set(code, msg);
|
batch.set(code, msg);
|
||||||
if (!batchTimer) {
|
if (!batchTimer) batchTimer = setTimeout(flushBatch, WS_BATCH_MS);
|
||||||
batchTimer = setTimeout(flushBatch, WS_BATCH_MS);
|
});
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => { /* ignore – onclose will handle */ };
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (!destroyed) {
|
|
||||||
reconnectTimer = setTimeout(connect, 5_000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connect();
|
function mergePriorityMarkets(priorityMarkets?: string[]): string[] {
|
||||||
|
const set = new Set(LITE_BASE_MARKETS);
|
||||||
return () => {
|
priorityMarkets?.forEach(m => { if (m?.startsWith('KRW-')) set.add(m); });
|
||||||
destroyed = true;
|
return [...set];
|
||||||
if (batchTimer) { clearTimeout(batchTimer); flushBatch(); }
|
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
||||||
ws?.close();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 훅 ──────────────────────────────────────────────────────────────────
|
export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
||||||
export function useMarketTicker() {
|
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<Map<string, TickerData>>(new Map());
|
const [tickers, setTickers] = useState<Map<string, TickerData>>(new Map());
|
||||||
const [marketInfos, setMarketInfos] = useState<MarketInfo[]>([]);
|
const [marketInfos, setMarketInfos] = useState<MarketInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -190,6 +165,10 @@ export function useMarketTicker() {
|
|||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const marketListRef = useRef<string[]>([]);
|
const marketListRef = useRef<string[]>([]);
|
||||||
const marketInfoMapRef = useRef<Map<string, MarketInfo>>(new Map());
|
const marketInfoMapRef = useRef<Map<string, MarketInfo>>(new Map());
|
||||||
|
const destroyWsRef = useRef<(() => void) | null>(null);
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const wsMarketsRef = useRef<string[]>([]);
|
||||||
|
const fullLoadedRef = useRef(false);
|
||||||
|
|
||||||
const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => {
|
const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
@@ -210,53 +189,92 @@ export function useMarketTicker() {
|
|||||||
if (usdt?.trade_price) setUsdRate(usdt.trade_price);
|
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(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
fullLoadedRef.current = false;
|
||||||
let destroyWs: (() => void) | null = null;
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
// 1) 마켓 목록
|
|
||||||
let infos: MarketInfo[] = [];
|
let infos: MarketInfo[] = [];
|
||||||
try { infos = await fetchAllMarkets(); }
|
try { infos = await fetchAllMarkets(); }
|
||||||
catch { /* 실패 시 빈 목록 */ }
|
catch { /* ignore */ }
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
infos.forEach(m => marketInfoMapRef.current.set(m.market, m));
|
infos.forEach(m => marketInfoMapRef.current.set(m.market, m));
|
||||||
setMarketInfos(infos);
|
setMarketInfos(infos);
|
||||||
|
|
||||||
const markets = infos.map(m => m.market);
|
const allMarkets = infos.map(m => m.market);
|
||||||
if (!markets.includes('KRW-USDT')) markets.push('KRW-USDT');
|
if (!allMarkets.includes('KRW-USDT')) allMarkets.push('KRW-USDT');
|
||||||
marketListRef.current = markets;
|
marketListRef.current = allMarkets;
|
||||||
|
|
||||||
// 2) 초기 시세 (REST)
|
|
||||||
try {
|
|
||||||
const rawList = await fetchAllTickers(markets);
|
|
||||||
if (mountedRef.current) applyRawList(rawList);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
|
const initialMarkets = loadFull ? allMarkets : priorityList;
|
||||||
|
await loadTickersFor(initialMarkets);
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
attachWs(initialMarkets);
|
||||||
|
startPoll(initialMarkets);
|
||||||
|
|
||||||
// 3) WebSocket 실시간 구독
|
if (loadFull) fullLoadedRef.current = true;
|
||||||
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);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
destroyWs?.();
|
destroyWsRef.current?.();
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
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 };
|
return { tickers, marketInfos, loading, usdRate };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 업비트 실시간 호가 훅
|
* 업비트 실시간 호가 훅
|
||||||
*
|
*
|
||||||
* frontend_golden/src/hooks/useUpbitOrderbook.ts 를 현재 프로젝트에 맞게 변환:
|
* - WebSocket: upbitWsBroker (단일 /upbit-ws)
|
||||||
* - WebSocket: /upbit-ws (nginx 프록시 → wss://api.upbit.com/websocket/v1)
|
|
||||||
* - REST: /upbit-api/v1/orderbook
|
* - REST: /upbit-api/v1/orderbook
|
||||||
* - console.log 제거, 재연결 로직 강화
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||||
|
import { subscribeUpbitWs, subscribeUpbitWsConnection } from '../utils/upbitWsBroker';
|
||||||
|
|
||||||
// ── 타입 ────────────────────────────────────────────────────────────────────
|
// ── 타입 ────────────────────────────────────────────────────────────────────
|
||||||
export interface OrderbookUnit {
|
export interface OrderbookUnit {
|
||||||
@@ -21,7 +20,7 @@ export interface OrderbookUnit {
|
|||||||
export interface OrderbookDisplayUnit {
|
export interface OrderbookDisplayUnit {
|
||||||
price: number;
|
price: number;
|
||||||
size: number;
|
size: number;
|
||||||
percentage: number; // 최대 잔량 대비 비율 (0–100), 배경 바 넓이
|
percentage: number;
|
||||||
type: 'ask' | 'bid';
|
type: 'ask' | 'bid';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,23 +45,18 @@ interface UpbitObWsMessage {
|
|||||||
stream_type?: string;
|
stream_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 상수 ────────────────────────────────────────────────────────────────────
|
|
||||||
const RECONNECT_DELAY = 3_000;
|
|
||||||
const INITIAL_STATE: OrderbookState = {
|
const INITIAL_STATE: OrderbookState = {
|
||||||
asks: [], bids: [], totalAskSize: 0, totalBidSize: 0, timestamp: 0, connected: false,
|
asks: [], bids: [], totalAskSize: 0, totalBidSize: 0, timestamp: 0, connected: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 훅 ──────────────────────────────────────────────────────────────────────
|
export function useUpbitOrderbook(market: string, enabled = true) {
|
||||||
export function useUpbitOrderbook(market: string) {
|
|
||||||
const [orderbook, setOrderbook] = useState<OrderbookState>(INITIAL_STATE);
|
const [orderbook, setOrderbook] = useState<OrderbookState>(INITIAL_STATE);
|
||||||
const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected');
|
const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected');
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const reconnTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const isConnectingRef = useRef(false);
|
const marketRef = useRef(market);
|
||||||
|
marketRef.current = market;
|
||||||
|
|
||||||
// ── 데이터 파싱 → state ─────────────────────────────────────────────────
|
|
||||||
const processData = useCallback((msg: UpbitObWsMessage) => {
|
const processData = useCallback((msg: UpbitObWsMessage) => {
|
||||||
const units = msg.orderbook_units;
|
const units = msg.orderbook_units;
|
||||||
if (!units || units.length === 0) return;
|
if (!units || units.length === 0) return;
|
||||||
@@ -72,7 +66,6 @@ export function useUpbitOrderbook(market: string) {
|
|||||||
...units.map(u => u.bid_size),
|
...units.map(u => u.bid_size),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 매도: 높은 가격 → 낮은 가격 (역순)
|
|
||||||
const asks: OrderbookDisplayUnit[] = units
|
const asks: OrderbookDisplayUnit[] = units
|
||||||
.map(u => ({
|
.map(u => ({
|
||||||
price: coerceFiniteNumber(u.ask_price) ?? 0,
|
price: coerceFiniteNumber(u.ask_price) ?? 0,
|
||||||
@@ -82,7 +75,6 @@ export function useUpbitOrderbook(market: string) {
|
|||||||
}))
|
}))
|
||||||
.reverse();
|
.reverse();
|
||||||
|
|
||||||
// 매수: 높은 가격 → 낮은 가격 (정순 – 1번 unit이 best bid)
|
|
||||||
const bids: OrderbookDisplayUnit[] = units.map(u => ({
|
const bids: OrderbookDisplayUnit[] = units.map(u => ({
|
||||||
price: coerceFiniteNumber(u.bid_price) ?? 0,
|
price: coerceFiniteNumber(u.bid_price) ?? 0,
|
||||||
size: coerceFiniteNumber(u.bid_size) ?? 0,
|
size: coerceFiniteNumber(u.bid_size) ?? 0,
|
||||||
@@ -100,10 +92,9 @@ export function useUpbitOrderbook(market: string) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── REST 초기 스냅샷 ────────────────────────────────────────────────────
|
|
||||||
const fetchSnapshot = useCallback(async () => {
|
const fetchSnapshot = useCallback(async () => {
|
||||||
try {
|
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;
|
if (!res.ok) return;
|
||||||
const data: Array<{
|
const data: Array<{
|
||||||
market: string;
|
market: string;
|
||||||
@@ -112,7 +103,7 @@ export function useUpbitOrderbook(market: string) {
|
|||||||
total_bid_size: number;
|
total_bid_size: number;
|
||||||
orderbook_units: OrderbookUnit[];
|
orderbook_units: OrderbookUnit[];
|
||||||
}> = await res.json();
|
}> = await res.json();
|
||||||
if (data && data[0]) {
|
if (data?.[0]) {
|
||||||
const d = data[0];
|
const d = data[0];
|
||||||
processData({
|
processData({
|
||||||
type: 'orderbook',
|
type: 'orderbook',
|
||||||
@@ -125,96 +116,47 @@ export function useUpbitOrderbook(market: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} 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(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
setOrderbook(INITIAL_STATE);
|
setOrderbook(INITIAL_STATE);
|
||||||
fetchSnapshot();
|
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 () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
disconnect();
|
unsubConn();
|
||||||
|
unsubWs();
|
||||||
};
|
};
|
||||||
}, [market]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [market, enabled, fetchSnapshot, processData]);
|
||||||
|
|
||||||
// ── 스프레드 계산 ────────────────────────────────────────────────────────
|
|
||||||
const spread = useMemo(() => {
|
const spread = useMemo(() => {
|
||||||
const { asks, bids } = orderbook;
|
const { asks, bids } = orderbook;
|
||||||
if (!asks.length || !bids.length) return { bestAsk: 0, bestBid: 0, spread: 0, pct: 0 };
|
if (!asks.length || !bids.length) return { bestAsk: 0, bestBid: 0, spread: 0, pct: 0 };
|
||||||
const bestAsk = asks[asks.length - 1].price; // asks 배열 마지막 = 가장 낮은 매도가
|
const bestAsk = asks[asks.length - 1].price;
|
||||||
const bestBid = bids[0].price; // bids 배열 첫번째 = 가장 높은 매수가
|
const bestBid = bids[0].price;
|
||||||
const diff = bestAsk - bestBid;
|
const diff = bestAsk - bestBid;
|
||||||
return { bestAsk, bestBid, spread: diff, pct: bestBid > 0 ? (diff / bestBid) * 100 : 0 };
|
return { bestAsk, bestBid, spread: diff, pct: bestBid > 0 ? (diff / bestBid) * 100 : 0 };
|
||||||
}, [orderbook.asks, orderbook.bids]);
|
}, [orderbook.asks, orderbook.bids]);
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
* 업비트 최근 체결 (호가창 좌측 체결 목록용)
|
* 업비트 최근 체결 (호가창 좌측 체결 목록용)
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { subscribeUpbitWs } from '../utils/upbitWsBroker';
|
||||||
|
|
||||||
export interface RecentTrade {
|
export interface RecentTrade {
|
||||||
price: number;
|
price: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
side: 'ask' | 'bid'; // ask=매도체결, bid=매수체결
|
side: 'ask' | 'bid';
|
||||||
time: number;
|
time: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +30,13 @@ interface UpbitTradeWs {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpbitRecentTrades(market: string) {
|
export function useUpbitRecentTrades(market: string, enabled = true) {
|
||||||
const [trades, setTrades] = useState<RecentTrade[]>([]);
|
const [trades, setTrades] = useState<RecentTrade[]>([]);
|
||||||
const [strength, setStrength] = useState<number | null>(null);
|
const [strength, setStrength] = useState<number | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
|
const marketRef = useRef(market);
|
||||||
|
marketRef.current = market;
|
||||||
|
|
||||||
const pushTrade = useCallback((t: RecentTrade) => {
|
const pushTrade = useCallback((t: RecentTrade) => {
|
||||||
setTrades(prev => {
|
setTrades(prev => {
|
||||||
@@ -49,7 +51,7 @@ export function useUpbitRecentTrades(market: string) {
|
|||||||
|
|
||||||
const fetchSnapshot = useCallback(async () => {
|
const fetchSnapshot = useCallback(async () => {
|
||||||
try {
|
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;
|
if (!res.ok) return;
|
||||||
const data: UpbitTradeRaw[] = await res.json();
|
const data: UpbitTradeRaw[] = await res.json();
|
||||||
const list: RecentTrade[] = data.map(d => ({
|
const list: RecentTrade[] = data.map(d => ({
|
||||||
@@ -63,36 +65,23 @@ export function useUpbitRecentTrades(market: string) {
|
|||||||
const sellVol = list.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0);
|
const sellVol = list.filter(x => x.side === 'ask').reduce((s, x) => s + x.volume, 0);
|
||||||
if (sellVol > 0) setStrength((buyVol / sellVol) * 100);
|
if (sellVol > 0) setStrength((buyVol / sellVol) * 100);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [market]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
setTrades([]);
|
setTrades([]);
|
||||||
setStrength(null);
|
setStrength(null);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return () => { mountedRef.current = false; };
|
||||||
|
}
|
||||||
|
|
||||||
fetchSnapshot();
|
fetchSnapshot();
|
||||||
|
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const unsub = subscribeUpbitWs('trade', [market], raw => {
|
||||||
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) => {
|
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
try {
|
const msg = raw as unknown as UpbitTradeWs;
|
||||||
let text: string;
|
if (msg.type === 'trade' && msg.code === marketRef.current) {
|
||||||
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({
|
pushTrade({
|
||||||
price: msg.trade_price,
|
price: msg.trade_price,
|
||||||
volume: msg.trade_volume,
|
volume: msg.trade_volume,
|
||||||
@@ -100,20 +89,13 @@ export function useUpbitRecentTrades(market: string) {
|
|||||||
time: msg.timestamp,
|
time: msg.timestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
if (wsRef.current) {
|
unsub();
|
||||||
wsRef.current.onopen = null;
|
|
||||||
wsRef.current.onmessage = null;
|
|
||||||
wsRef.current.onclose = null;
|
|
||||||
wsRef.current.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [market, fetchSnapshot, pushTrade]);
|
}, [market, enabled, fetchSnapshot, pushTrade]);
|
||||||
|
|
||||||
return { trades, strength };
|
return { trades, strength };
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-119
@@ -1,4 +1,5 @@
|
|||||||
import type { OHLCVBar, Timeframe } from '../types';
|
import type { OHLCVBar, Timeframe } from '../types';
|
||||||
|
import { subscribeUpbitTrade, subscribeUpbitWsConnection } from './upbitWsBroker';
|
||||||
|
|
||||||
// ─── Upbit REST API ────────────────────────────────────────────────────────
|
// ─── Upbit REST API ────────────────────────────────────────────────────────
|
||||||
// 개발: Vite proxy (/upbit-api) → https://api.upbit.com
|
// 개발: Vite proxy (/upbit-api) → https://api.upbit.com
|
||||||
@@ -180,140 +181,37 @@ export interface UpbitWsOptions {
|
|||||||
onError: (err: string) => void;
|
onError: (err: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 개발: Vite proxy (/upbit-ws) → wss://api.upbit.com/websocket/v1
|
/** @deprecated 직접 WebSocket 대신 upbitWsBroker 사용. 하위 호환 래퍼. */
|
||||||
// 프로덕션: 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초 내 응답 없으면 끊음)
|
|
||||||
|
|
||||||
export class UpbitWebSocketClient {
|
export class UpbitWebSocketClient {
|
||||||
private ws: WebSocket | null = null;
|
|
||||||
private opts: UpbitWsOptions;
|
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
private retryCount = 0;
|
private unsubTrade: (() => void) | null = null;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private unsubConn: (() => void) | null = null;
|
||||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
constructor(opts: UpbitWsOptions) {
|
constructor(private opts: UpbitWsOptions) {
|
||||||
this.opts = opts;
|
this.unsubConn = subscribeUpbitWsConnection(connected => {
|
||||||
// 80ms 지연으로 React StrictMode 이중 마운트 방지
|
|
||||||
// (cleanup 이 80ms 안에 호출되면 connect 가 취소됨)
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
this.connect();
|
|
||||||
}, INITIAL_DELAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
private connect() {
|
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
|
if (connected) {
|
||||||
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);
|
|
||||||
|
|
||||||
this.opts.onConnect();
|
this.opts.onConnect();
|
||||||
};
|
} else {
|
||||||
|
this.opts.onDisconnect();
|
||||||
ws.onmessage = (event) => {
|
|
||||||
// ① 텍스트 메시지: 서버 PING → PONG 응답, 상태 메시지 → 무시
|
|
||||||
if (typeof event.data === 'string') {
|
|
||||||
if (event.data === 'PING') ws.send('PONG');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// ② 바이너리 메시지: 체결/시세 데이터
|
this.unsubTrade = subscribeUpbitTrade(opts.market, msg => {
|
||||||
let msg: UpbitMessage | null = null;
|
if (this.destroyed) return;
|
||||||
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 {
|
try {
|
||||||
this.opts.onMessage(msg);
|
this.opts.onMessage(msg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[UpbitWS] onMessage 처리 중 오류:', 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
this.clearPing();
|
this.unsubTrade?.();
|
||||||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
this.unsubTrade = null;
|
||||||
if (this.ws) {
|
this.unsubConn?.();
|
||||||
this.ws.onclose = null;
|
this.unsubConn = null;
|
||||||
this.ws.onerror = null;
|
|
||||||
this.ws.close(1000, 'destroy');
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* 업비트 WebSocket 단일 연결 브로커 (/upbit-ws).
|
||||||
|
* 훅·차트마다 WebSocket 을 열면 모바일에서 429·재연결 폭주가 발생한다.
|
||||||
|
*/
|
||||||
|
import type { UpbitMessage } from './upbitApi';
|
||||||
|
|
||||||
|
export type UpbitWsChannel = 'ticker' | 'orderbook' | 'trade';
|
||||||
|
|
||||||
|
type RawHandler = (msg: Record<string, unknown>) => void;
|
||||||
|
type ConnectionListener = (connected: boolean) => void;
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
channel: UpbitWsChannel;
|
||||||
|
codes: Set<string>;
|
||||||
|
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<UpbitWsChannel, Map<string, number>>();
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
const connectionListeners = new Set<ConnectionListener>();
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let connected = false;
|
||||||
|
let connectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | 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<string | null> {
|
||||||
|
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<string, unknown>;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(text) as Record<string, unknown>;
|
||||||
|
} 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user