/** * Upbit 캔들 REST 요청 중복 제거(deduplication) + 단기 캐시 * * 멀티차트 모드에서 동일한 종목/타임프레임을 여러 슬롯이 동시에 요청하면 * 단 1개의 HTTP 요청만 실행하고 나머지는 같은 Promise를 공유한다. * 데이터는 CACHE_TTL(30초) 동안 메모리에 보관해 재요청을 방지한다. * * ┌ 슬롯0 ─── fetchUpbitCandlesCached('KRW-BTC', '1D', 300) ───┐ * ├ 슬롯1 ─── fetchUpbitCandlesCached('KRW-BTC', '1D', 300) ───┼─▶ fetchUpbitCandles 1회만 실행 * └ 슬롯2 ─── fetchUpbitCandlesCached('KRW-BTC', '1D', 300) ───┘ */ import type { OHLCVBar, Timeframe } from '../types'; import { fetchUpbitCandles } from './upbitApi'; // ─── 과거(이전 시점) 데이터 전용 캐시 ───────────────────────────────────────── // 역사 캔들은 변하지 않으므로 일반 30초보다 긴 TTL 을 사용한다. const HISTORY_CACHE_TTL = 5 * 60_000; // 5분 const historyCache = new Map(); const historyPending = new Map>(); function historyKey(market: string, timeframe: Timeframe, count: number, before: string) { return `${market.toUpperCase()}:${timeframe}:${count}:${before}`; } /** 캐시 유효 기간 (ms) */ const CACHE_TTL = 30_000; interface CacheEntry { data: OHLCVBar[]; fetchedAt: number; } // 완료된 요청 캐시 (market:timeframe:count → entry) const dataCache = new Map(); // 진행 중인 요청 (중복 방지용, market:timeframe:count → Promise) const pendingMap = new Map>(); function cacheKey(market: string, timeframe: Timeframe, count: number): string { return `${market.toUpperCase()}:${timeframe}:${count}`; } /** * 캐시·in-flight 중복 제거를 적용한 캔들 요청. * - 동일 인자로 동시 호출 시: 첫 번째 요청 Promise 를 공유한다. * - CACHE_TTL 이내 재호출 시: 캐시된 데이터를 즉시 반환한다. * - invalidate=true 로 호출 시: 캐시를 무효화하고 새 요청을 실행한다. */ export async function fetchUpbitCandlesCached( market: string, timeframe: Timeframe, count: number, invalidate = false, ): Promise { const key = cacheKey(market, timeframe, count); // 캐시 무효화 요청이면 기존 항목 삭제 if (invalidate) { dataCache.delete(key); pendingMap.delete(key); } // 유효한 캐시가 있으면 즉시 반환 const cached = dataCache.get(key); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) { return cached.data; } // 이미 진행 중인 요청이 있으면 공유 const pending = pendingMap.get(key); if (pending) return pending; // 새 요청 실행 const promise = fetchUpbitCandles(market, timeframe, count) .then(data => { dataCache.set(key, { data, fetchedAt: Date.now() }); pendingMap.delete(key); return data; }) .catch(err => { // 실패 시 pending 제거 → 다음 호출이 재시도할 수 있도록 pendingMap.delete(key); throw err; }); pendingMap.set(key, promise); return promise; } /** 특정 종목의 캐시 항목을 모두 삭제 (종목 변경 시 호출) */ export function invalidateMarketCache(market: string): void { const prefix = market.toUpperCase() + ':'; for (const key of [...dataCache.keys(), ...pendingMap.keys()]) { if (key.startsWith(prefix)) { dataCache.delete(key); pendingMap.delete(key); } } // 역사 캐시도 함께 삭제 for (const key of [...historyCache.keys(), ...historyPending.keys()]) { if (key.startsWith(prefix)) { historyCache.delete(key); historyPending.delete(key); } } } /** * 특정 시각 이전 캔들 페치 (캐시·in-flight 중복 제거 적용). * * @param before - ISO 8601 UTC exclusive (예: '2024-01-01T00:00:00') * candleEndpoint 에서 'Z' 를 붙여 요청하므로 'Z' 없이 전달한다. */ export async function fetchUpbitCandlesBeforeCached( market: string, timeframe: Timeframe, count: number, before: string, ): Promise { const key = historyKey(market, timeframe, count, before); const cached = historyCache.get(key); if (cached && Date.now() - cached.fetchedAt < HISTORY_CACHE_TTL) { return cached.data; } const pending = historyPending.get(key); if (pending) return pending; const promise = fetchUpbitCandles(market, timeframe, count, before) .then(data => { historyCache.set(key, { data, fetchedAt: Date.now() }); historyPending.delete(key); return data; }) .catch(err => { historyPending.delete(key); throw err; }); historyPending.set(key, promise); return promise; }