import { FieldValue } from "firebase-admin/firestore"; import { firestore } from "../firebase"; const CACHE_COLLECTION = "kboCache"; const LOCK_COLLECTION = "kboLocks"; const LOCK_TTL_MS = 30_000; interface CacheDoc { data: T; updatedAt: FirebaseFirestore.Timestamp; ttlMs?: number; } interface LockDoc { acquiredAt: FirebaseFirestore.Timestamp; } /** * 캐시 키로 사용할 안전한 문자열을 생성한다. * * `undefined`/빈 문자열은 제외하고, 영숫자/하이픈/언더스코어 이외의 문자는 `_`로 치환한 뒤 `__`로 연결한다. * * @param parts - 키를 구성할 값의 배열 * @returns Firestore 문서 ID로 안전하게 쓸 수 있는 문자열 */ export function encodeKey(parts: Array): string { return parts .filter((p) => p !== undefined && p !== "") .map((p) => String(p).replace(/[^\w-]/g, "_")) .join("__"); } /** * 캐시 문서를 조회하고 TTL 이내면 데이터를 반환한다. * * @param key - 캐시 키 * @param ttlMs - 캐시 유효 시간(ms). 이보다 오래된 문서는 `null` 반환. * @returns 유효한 캐시 데이터. 없거나 만료됐으면 `null`. */ export async function getCached(key: string, ttlMs: number): Promise { const snap = await firestore.collection(CACHE_COLLECTION).doc(key).get(); if (!snap.exists) return null; const doc = snap.data() as CacheDoc; const effectiveTtl = doc.ttlMs ?? ttlMs; const age = Date.now() - doc.updatedAt.toMillis(); if (age > effectiveTtl) return null; return doc.data; } /** * 캐시 doc의 메타데이터를 조회한다. 부분 갱신이 필요한 키에 대해 * `data`를 매번 전송받지 않고 유효성만 판단하고 싶을 때 사용한다. */ export async function getCachedMeta( key: string ): Promise<{ updatedAt: FirebaseFirestore.Timestamp; ttlMs?: number } | null> { const snap = await firestore.collection(CACHE_COLLECTION).doc(key).get(); if (!snap.exists) return null; const doc = snap.data() as CacheDoc; return { updatedAt: doc.updatedAt, ttlMs: doc.ttlMs }; } /** * 캐시 문서를 저장한다. `updatedAt`은 서버 타임스탬프로 기록된다. * * @param key - 캐시 키 * @param data - 저장할 데이터 */ export async function setCached( key: string, data: T, ttlMs?: number ): Promise { const payload: Record = { data, updatedAt: FieldValue.serverTimestamp(), }; if (ttlMs !== undefined) payload.ttlMs = ttlMs; await firestore.collection(CACHE_COLLECTION).doc(key).set(payload); } /** * 분산 락을 획득한다. 트랜잭션으로 기존 락의 TTL을 확인해 갱신 여부를 결정한다. * * @param key - 락 키 (보통 캐시 키와 동일) * @returns 락 획득 성공 시 `true`, 이미 유효한 락이 있으면 `false`. */ async function acquireLock(key: string): Promise { const ref = firestore.collection(LOCK_COLLECTION).doc(key); try { await firestore.runTransaction(async (tx) => { const snap = await tx.get(ref); if (snap.exists) { const doc = snap.data() as LockDoc; const age = Date.now() - doc.acquiredAt.toMillis(); if (age < LOCK_TTL_MS) throw new Error("LOCKED"); } tx.set(ref, { acquiredAt: FieldValue.serverTimestamp() }); }); return true; } catch { return false; } } /** * 락을 해제한다. 실패해도 무시한다(TTL로 자동 만료됨). */ async function releaseLock(key: string): Promise { await firestore.collection(LOCK_COLLECTION).doc(key).delete().catch(() => undefined); } /** * 락이 걸려있는 동안 주기적으로 캐시를 폴링한다. * * 다른 요청이 fetch 중일 때 동일 키로 들어온 요청이 불필요하게 외부 API를 호출하지 않도록 대기시키는 용도. * * @param key - 폴링할 캐시 키 * @param ttlMs - 유효 캐시 판정 기준 * @param timeoutMs - 최대 대기 시간 (기본 25초) * @returns 타임아웃 전에 캐시를 얻으면 값, 실패하면 `null`. */ async function waitForCache(key: string, ttlMs: number, timeoutMs = 25_000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { await new Promise((r) => setTimeout(r, 500)); const cached = await getCached(key, ttlMs); if (cached !== null) return cached; } return null; } /** * 캐시 우선 조회, 없으면 `fetcher` 실행 후 결과를 캐싱한다. * * 동일 키로 동시에 여러 요청이 들어오면 락으로 직렬화하여 외부 API 중복 호출을 방지한다. * 락 획득에 실패한 요청은 캐시를 폴링하고, 타임아웃 시엔 개별적으로 fetch (캐싱은 하지 않음). * * @param key - 캐시 키 * @param ttlMs - 캐시 유효 시간(ms) * @param fetcher - 캐시가 없을 때 실제 데이터를 얻는 함수 * @returns 캐시 또는 새로 fetch한 데이터 */ export async function getOrFetch( key: string, ttlMs: number, fetcher: () => Promise ): Promise { const cached = await getCached(key, ttlMs); if (cached !== null) return cached; const locked = await acquireLock(key); if (!locked) { const waited = await waitForCache(key, ttlMs); if (waited !== null) return waited; return fetcher(); } try { const fresh = await fetcher(); await setCached(key, fresh, ttlMs); return fresh; } finally { await releaseLock(key); } } export { acquireLock, releaseLock };