초기로딩 부하문제 수정

This commit is contained in:
Macbook
2026-05-31 22:26:54 +09:00
parent 4f2d98d4ba
commit 32d735c172
8 changed files with 554 additions and 387 deletions
+120 -102
View File
@@ -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 };
}
+38 -96
View File
@@ -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<OrderbookState>(INITIAL_STATE);
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 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]);
+25 -43
View File
@@ -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<RecentTrade[]>([]);
const [strength, setStrength] = useState<number | null>(null);
const wsRef = useRef<WebSocket | null>(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 };
}