초기로딩 부하문제 수정
This commit is contained in:
@@ -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<typeof setTimeout> | null = null;
|
||||
|
||||
// 배치 버퍼: 너무 빈번한 state 업데이트 방지 (500ms 단위로 모아서 전달)
|
||||
const batch: Map<string, UpbitTickerRaw> = new Map();
|
||||
const batch = new Map<string, UpbitTickerRaw>();
|
||||
let batchTimer: ReturnType<typeof setTimeout> | 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<Map<string, TickerData>>(new Map());
|
||||
const [marketInfos, setMarketInfos] = useState<MarketInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -190,6 +165,10 @@ export function useMarketTicker() {
|
||||
const mountedRef = useRef(true);
|
||||
const marketListRef = useRef<string[]>([]);
|
||||
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[]) => {
|
||||
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<typeof setInterval> | 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user