초기로딩 부하문제 수정

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
+25 -127
View File
@@ -1,4 +1,5 @@
import type { OHLCVBar, Timeframe } from '../types';
import { subscribeUpbitTrade, subscribeUpbitWsConnection } from './upbitWsBroker';
// ─── Upbit REST API ────────────────────────────────────────────────────────
// 개발: Vite proxy (/upbit-api) → https://api.upbit.com
@@ -180,140 +181,37 @@ export interface UpbitWsOptions {
onError: (err: string) => void;
}
// 개발: Vite proxy (/upbit-ws) → wss://api.upbit.com/websocket/v1
// 프로덕션: nginx proxy (/upbit-ws) → wss://api.upbit.com/websocket/v1
// 항상 동일 오리진 상대 경로를 사용 → CORS / Origin 차단 모두 우회
function getWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${window.location.host}/upbit-ws`;
}
const INITIAL_DELAY = 80; // React StrictMode 이중마운트 방지 (cleanup < 80ms면 연결 안 함)
const RECONNECT_BASE = 2000; // 최초 재연결 대기
const RECONNECT_MAX = 30000; // 최대 재연결 대기
const PING_INTERVAL = 20000; // 20초마다 PING (서버가 60초 내 응답 없으면 끊음)
/** @deprecated 직접 WebSocket 대신 upbitWsBroker 사용. 하위 호환 래퍼. */
export class UpbitWebSocketClient {
private ws: WebSocket | null = null;
private opts: UpbitWsOptions;
private destroyed = false;
private retryCount = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
constructor(opts: UpbitWsOptions) {
this.opts = opts;
// 80ms 지연으로 React StrictMode 이중 마운트 방지
// (cleanup 이 80ms 안에 호출되면 connect 가 취소됨)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, INITIAL_DELAY);
}
private connect() {
if (this.destroyed) return;
try {
const ws = new WebSocket(getWsUrl());
this.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
this.retryCount = 0;
// ── 구독 메시지 ──────────────────────────────────────────────
// format 생략 시 DEFAULT (긴 키명), SIMPLE이면 단축 키명
ws.send(JSON.stringify([
{ ticket: `react_chart_${Date.now()}` },
{ type: 'trade', codes: [this.opts.market.toUpperCase()] },
{ format: 'DEFAULT' },
]));
// ── PING keepalive ─────────────────────────────────────────
this.clearPing();
this.pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('PING');
}
}, PING_INTERVAL);
private destroyed = false;
private unsubTrade: (() => void) | null = null;
private unsubConn: (() => void) | null = null;
constructor(private opts: UpbitWsOptions) {
this.unsubConn = subscribeUpbitWsConnection(connected => {
if (this.destroyed) return;
if (connected) {
this.opts.onConnect();
};
ws.onmessage = (event) => {
// ① 텍스트 메시지: 서버 PING → PONG 응답, 상태 메시지 → 무시
if (typeof event.data === 'string') {
if (event.data === 'PING') ws.send('PONG');
return;
}
// ② 바이너리 메시지: 체결/시세 데이터
let msg: UpbitMessage | null = null;
try {
const text = new TextDecoder('utf-8').decode(event.data as ArrayBuffer);
if (text === 'PING') { ws.send('PONG'); return; }
const parsed = JSON.parse(text) as UpbitMessage;
if (!('type' in parsed)) return;
msg = parsed;
} catch {
// JSON 파싱 실패 → 무시
return;
}
// JSON 파싱과 onMessage 를 분리: 차트 업데이트 오류가 WS 메시지 처리를 중단시키지 않도록
try {
this.opts.onMessage(msg);
} catch (err) {
console.error('[UpbitWS] onMessage 처리 중 오류:', err);
}
};
ws.onclose = (ev) => {
this.clearPing();
if (!this.destroyed) {
// 정상 종료(1000/1001)가 아닐 때만 오류 표시
if (ev.code !== 1000 && ev.code !== 1001) {
this.opts.onError(`연결 끊김 (code ${ev.code})`);
}
this.opts.onDisconnect();
this.scheduleReconnect();
}
};
ws.onerror = () => {
// onerror 직후 onclose가 반드시 발생하므로 여기서는 로그만
// (onclose에서 재연결 처리)
console.warn('[UpbitWS] onerror 발생 → onclose 대기');
};
} catch (err) {
this.opts.onError(`WebSocket 초기화 실패: ${String(err)}`);
this.scheduleReconnect();
}
}
private clearPing() {
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
}
private scheduleReconnect() {
if (this.destroyed) return;
const delay = Math.min(RECONNECT_BASE * 2 ** this.retryCount, RECONNECT_MAX);
this.retryCount++;
console.info(`[UpbitWS] ${delay}ms 후 재연결 시도 (${this.retryCount}회차)`);
this.reconnectTimer = setTimeout(() => this.connect(), delay);
} else {
this.opts.onDisconnect();
}
});
this.unsubTrade = subscribeUpbitTrade(opts.market, msg => {
if (this.destroyed) return;
try {
this.opts.onMessage(msg);
} catch (err) {
console.error('[UpbitWS] onMessage 처리 중 오류:', err);
}
});
}
destroy() {
this.destroyed = true;
this.clearPing();
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.ws) {
this.ws.onclose = null;
this.ws.onerror = null;
this.ws.close(1000, 'destroy');
this.ws = null;
}
this.unsubTrade?.();
this.unsubTrade = null;
this.unsubConn?.();
this.unsubConn = null;
}
}
+274
View File
@@ -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);
});
}