Files
goldenChart/frontend/src/utils/requestCache.ts
T
2026-05-23 15:11:48 +09:00

145 lines
4.8 KiB
TypeScript

/**
* 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<string, { data: OHLCVBar[]; fetchedAt: number }>();
const historyPending = new Map<string, Promise<OHLCVBar[]>>();
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<string, CacheEntry>();
// 진행 중인 요청 (중복 방지용, market:timeframe:count → Promise)
const pendingMap = new Map<string, Promise<OHLCVBar[]>>();
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<OHLCVBar[]> {
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<OHLCVBar[]> {
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;
}