/** * useHistoryLoader * * 차트를 왼쪽으로 스크롤할 때 과거 캔들 데이터를 추가로 불러오는 훅. * * 데이터 소스 우선순위: * 1. 백엔드 GET /api/candles/history (Ta4jStorage + 업비트 REST 프록시 + RSI 포함) * 2. 백엔드 호출 실패 시 → 업비트 REST 직접 폴백 * * 중복 방지 락(Lock): * loadingRef = true 인 동안에는 동일 타임스탬프 중복 요청을 원천 차단. * * 사용 방법: * 1. 훅에서 반환한 { isLoadingMore, loadMoreRef } 를 받는다. * 2. ChartManager.subscribeVisibleLogicalRange 콜백 안에서 * `r.from < LOAD_MORE_TRIGGER` 조건 시 `loadMoreRef.current()` 를 호출한다. */ import { useState, useEffect, useRef, useCallback } from 'react'; import type { RefObject, MutableRefObject } from 'react'; import type { ChartManager } from '../utils/ChartManager'; import { fetchUpbitCandlesBeforeCached } from '../utils/requestCache'; import type { OHLCVBar, Timeframe } from '../types'; /** 이 값(바 인덱스)보다 왼쪽에 가시 범위의 시작이 오면 추가 로드 트리거 */ export const LOAD_MORE_TRIGGER = 30; /** 한 번에 불러올 추가 캔들 수 */ const LOAD_BATCH = 200; /** 연속 스크롤 중 불필요한 요청을 막는 디바운스 (ms) */ const DEBOUNCE_MS = 300; /** 백엔드 API 기본 URL */ import { API_BASE } from '../utils/backendApi'; interface UseHistoryLoaderOptions { symbol: string; timeframe: Timeframe; isUpbit: boolean; managerRef: RefObject; } interface UseHistoryLoaderReturn { isLoadingMore: boolean; /** onManagerReady 의 subscribeVisibleLogicalRange 콜백에서 직접 호출 */ loadMoreRef: MutableRefObject<() => void>; } // ── 백엔드 /api/candles/history 호출 ────────────────────────────────────────── /** * 백엔드에서 과거 캔들을 가져온다. * 응답에 rsi 필드가 포함되어 있으며, 시간 오름차순으로 정렬된다. * * @param market 업비트 마켓 코드 (e.g. "KRW-BTC") * @param type 캔들 타입 (e.g. "1m", "1d") * @param to 기준 시간 이전 ISO-8601 (e.g. "2026-05-20T10:00:00") * @param count 요청 캔들 수 */ async function fetchHistoryFromBackend( market: string, type: string, to: string, count: number, ): Promise { const params = new URLSearchParams({ market, type, to, count: String(count), }); const res = await fetch(`${API_BASE}/candles/history?${params.toString()}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: any[] = await res.json(); // 백엔드 CandleBarDto → OHLCVBar 변환 (rsi 는 extra 필드로 보존) return data.map(d => ({ time: d.time as number, open: d.open as number, high: d.high as number, low: d.low as number, close: d.close as number, volume: d.volume as number, })); } // ── 훅 본체 ─────────────────────────────────────────────────────────────────── export function useHistoryLoader({ symbol, timeframe, isUpbit, managerRef, }: UseHistoryLoaderOptions): UseHistoryLoaderReturn { const [isLoadingMore, setIsLoadingMore] = useState(false); /** * 중복 요청 방지 락(Lock): * ref 를 사용하여 re-render 없이 동기적으로 상태를 업데이트한다. * true 인 동안에는 스크롤 이벤트가 아무리 발생해도 API 를 재호출하지 않는다. */ const loadingRef = useRef(false); /** false 가 되면 더 이상 API 를 호출하지 않음 */ const hasMoreRef = useRef(true); const debounceTimer = useRef | null>(null); // 심볼·타임프레임이 바뀌면 상태 초기화 useEffect(() => { hasMoreRef.current = true; loadingRef.current = false; if (debounceTimer.current) { clearTimeout(debounceTimer.current); debounceTimer.current = null; } }, [symbol, timeframe]); const doLoadMore = useCallback(async () => { if (!isUpbit) return; // 시뮬레이션 모드는 건너뜀 if (loadingRef.current) return; // ★ 중복 방지 락: 이미 로딩 중이면 즉시 리턴 if (!hasMoreRef.current) return; // 더 이상 데이터 없음 const mgr = managerRef.current; if (!mgr || mgr.getRawBarsLength() === 0) return; const oldestTime = mgr.getOldestBarTime(); if (oldestTime === null) return; // Unix 타임스탬프(초) → ISO 8601 UTC ('Z' 없이 전달, 백엔드에서 처리) const beforeIso = new Date(oldestTime * 1000) .toISOString() .replace('.000Z', ''); // '2026-05-20T10:00:00' // ★ 락 설정: 이 시점부터 동일 타임스탬프 중복 요청 차단 loadingRef.current = true; setIsLoadingMore(true); try { let olderBars: OHLCVBar[]; // ── 1. 백엔드 /api/candles/history 우선 시도 ────────────────────────── try { olderBars = await fetchHistoryFromBackend(symbol, timeframe, beforeIso, LOAD_BATCH); console.debug(`[HistoryLoader] 백엔드에서 ${olderBars.length}개 수신`); } catch (backendErr) { // ── 2. 백엔드 실패 시 업비트 REST 직접 폴백 ─────────────────────── console.warn('[HistoryLoader] 백엔드 실패, 업비트 직접 폴백:', backendErr); olderBars = await fetchUpbitCandlesBeforeCached( symbol, timeframe, LOAD_BATCH, beforeIso, ); } if (olderBars.length === 0) { hasMoreRef.current = false; // 더 이상 데이터 없음 } else { // ── 3. 차트 앞부분에 부드럽게 병합 (Prepend) ────────────────────── await mgr.prependBars(olderBars); } } catch (e) { console.warn('[HistoryLoader] 과거 데이터 로드 실패:', e); } finally { // ★ 락 해제: 다음 스크롤 이벤트부터 재요청 허용 loadingRef.current = false; setIsLoadingMore(false); } }, [symbol, timeframe, isUpbit, managerRef]); /** * 디바운스 래퍼 — subscribeVisibleLogicalRange 는 스크롤 중 매 프레임 호출되므로 * 사용자가 잠시 멈췄을 때 한 번만 실제 API 요청이 나가도록 한다. */ const loadMoreRef = useRef<() => void>(() => {}); loadMoreRef.current = useCallback(() => { if (debounceTimer.current) clearTimeout(debounceTimer.current); debounceTimer.current = setTimeout(() => { debounceTimer.current = null; doLoadMore(); }, DEBOUNCE_MS); }, [doLoadMore]); return { isLoadingMore, loadMoreRef }; }