/** * Mandu useFetch Composable * SSR 데이터 중복 방지 + pending/error 상태 + 클라이언트 캐시 */ import { useState, useEffect, useCallback, useRef } from "react"; import { LRUCache } from "../utils/lru-cache"; import { registerCacheSize } from "../observability/metrics"; // ========== Types ========== export interface UseFetchOptions { query?: Record; headers?: Record; method?: string; body?: unknown; /** SSR 데이터 있으면 클라이언트 fetch 생략 (기본: true) */ dedupe?: boolean; /** SSR에서 전달된 초기 데이터 */ initialData?: T; /** 캐시 유지 시간 (ms, 0이면 캐시 안 함) */ cacheTime?: number; /** 자동 실행 여부 (기본: true) */ immediate?: boolean; /** 응답 변환 함수 */ transform?: (data: unknown) => T; } export interface UseFetchReturn { data: T | null; error: Error | null; loading: boolean; refresh: () => Promise; mutate: (updater: T | ((prev: T | null) => T)) => void; } // ========== Cache (LRU, 최대 200 엔트리) ========== // // Phase 17 — swapped hand-rolled Map for `LRUCache` so `get()` actually // promotes the entry to most-recently-used (was FIFO before). Size is // registered with the observability module for `/_mandu/heap` + the // Prometheus `mandu_cache_entries{cache="fetchCache"}` series. const MAX_CACHE_SIZE = 200; interface CacheEntry { data: unknown; timestamp: number; } const fetchCache = new LRUCache({ maxSize: MAX_CACHE_SIZE }); registerCacheSize("fetchCache", () => fetchCache.size); function getCached(key: string, maxAge: number): unknown | undefined { const entry = fetchCache.get(key); if (!entry) return undefined; if (Date.now() - entry.timestamp > maxAge) { fetchCache.delete(key); return undefined; } return entry.data; } function setCache(key: string, data: unknown): void { // LRUCache handles eviction — we just set. fetchCache.set(key, { data, timestamp: Date.now() }); } function stableStringify(value: unknown): string { if (value === undefined || value === null) return ""; if (typeof value !== "object") return String(value); // 키를 정렬하여 삽입 순서에 무관한 안정적 직렬화 const obj = value as Record; const sorted: Record = {}; for (const key of Object.keys(obj).sort()) { sorted[key] = obj[key]; } return JSON.stringify(sorted); } export function buildFetchCacheKey( url: string, options: { queryKey?: string; method?: string; headersKey?: string; bodyKey?: string; } = {} ): string { return [ (options.method ?? "GET").toUpperCase(), url, options.queryKey ?? "", options.headersKey ?? "", options.bodyKey ?? "", ].join("::"); } function buildUrl(url: string, query?: Record): string { if (!query || Object.keys(query).length === 0) return url; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null) { params.set(key, String(value)); } } return `${url}${url.includes("?") ? "&" : "?"}${params.toString()}`; } // ========== Hook ========== /** * 데이터 페칭 훅 — SSR 중복 방지, 캐싱, pending/error 상태 관리 * * @example * ```tsx * const { data, loading, error, refresh } = useFetch("/api/posts", { * query: { page: 1 }, * cacheTime: 300_000, * }); * * const { data, mutate } = useFetch("/api/todos"); * const addTodo = (todo: Todo) => mutate(prev => [...(prev ?? []), todo]); * ``` */ export function useFetch( url: string, options?: UseFetchOptions ): UseFetchReturn { const { query, headers, method = "GET", body, dedupe = true, initialData, cacheTime = 0, immediate = true, transform, } = options ?? {}; // 안정적 직렬화 — useRef로 이전 값과 비교하여 변경 시에만 갱신 const queryStr = stableStringify(query); const headersStr = stableStringify(headers); const bodyStr = stableStringify(body); const prevQueryRef = useRef(queryStr); const prevHeadersRef = useRef(headersStr); const prevBodyRef = useRef(bodyStr); const stableQuery = prevQueryRef.current === queryStr ? prevQueryRef.current : (prevQueryRef.current = queryStr); const stableHeaders = prevHeadersRef.current === headersStr ? prevHeadersRef.current : (prevHeadersRef.current = headersStr); const stableBody = prevBodyRef.current === bodyStr ? prevBodyRef.current : (prevBodyRef.current = bodyStr); const cacheKey = buildFetchCacheKey(url, { queryKey: stableQuery, method, headersKey: stableHeaders, bodyKey: stableBody, }); // URL/query 변경 감지 const prevCacheKeyRef = useRef(cacheKey); const [data, setData] = useState(() => { if (initialData !== undefined) return initialData; if (cacheTime > 0) { const cached = getCached(cacheKey, cacheTime); if (cached !== undefined) return (transform ? transform(cached) : cached) as T; } return null; }); const [error, setError] = useState(null); const [loading, setLoading] = useState(!data && immediate); const abortRef = useRef(null); // URL/query 변경 시 data 초기화 (useEffect로 React 규칙 준수) useEffect(() => { if (prevCacheKeyRef.current === cacheKey) return; prevCacheKeyRef.current = cacheKey; if (cacheTime > 0) { const cached = getCached(cacheKey, cacheTime); if (cached !== undefined) { setData((transform ? transform(cached) : cached) as T); return; } } setData(null); }, [cacheKey, cacheTime, transform]); const fetchData = useCallback(async () => { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setLoading(true); setError(null); try { const fetchUrl = buildUrl(url, query); const response = await fetch(fetchUrl, { method, headers: { "Accept": "application/json", ...headers }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (controller.signal.aborted) return; if (!response.ok) throw new Error(`HTTP ${response.status}`); // Content-Type 확인 후 파싱 const contentType = response.headers.get("content-type") ?? ""; let result: unknown; if (contentType.includes("application/json")) { result = await response.json(); } else if (response.status === 204) { result = null; } else { result = await response.text(); } if (transform) result = transform(result); setData(result as T); if (cacheTime > 0) setCache(cacheKey, result); } catch (e) { if (e instanceof DOMException && e.name === "AbortError") return; setError(e instanceof Error ? e : new Error(String(e))); } finally { if (!controller.signal.aborted) setLoading(false); } }, [url, method, stableQuery, stableHeaders, stableBody, cacheTime, cacheKey, transform]); useEffect(() => { if (!immediate) return; if (data && dedupe) return; void fetchData(); return () => { abortRef.current?.abort(); }; }, [fetchData, immediate, dedupe]); const mutate = useCallback((updater: T | ((prev: T | null) => T)) => { setData(prev => typeof updater === "function" ? (updater as (prev: T | null) => T)(prev) : updater); }, []); return { data, error, loading, refresh: fetchData, mutate }; }