mmday-firebase/src/repositories/kboCacheRepository.ts
윤정민 c94ce69e4f Remove redundant .js extensions from import paths.
- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다.
- 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
2026-05-06 16:17:51 +09:00

169 lines
5.4 KiB
TypeScript

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<T> {
data: T;
updatedAt: FirebaseFirestore.Timestamp;
ttlMs?: number;
}
interface LockDoc {
acquiredAt: FirebaseFirestore.Timestamp;
}
/**
* 캐시 키로 사용할 안전한 문자열을 생성한다.
*
* `undefined`/빈 문자열은 제외하고, 영숫자/하이픈/언더스코어 이외의 문자는 `_`로 치환한 뒤 `__`로 연결한다.
*
* @param parts - 키를 구성할 값의 배열
* @returns Firestore 문서 ID로 안전하게 쓸 수 있는 문자열
*/
export function encodeKey(parts: Array<string | number | undefined>): 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<T>(key: string, ttlMs: number): Promise<T | null> {
const snap = await firestore.collection(CACHE_COLLECTION).doc(key).get();
if (!snap.exists) return null;
const doc = snap.data() as CacheDoc<T>;
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<unknown>;
return { updatedAt: doc.updatedAt, ttlMs: doc.ttlMs };
}
/**
* 캐시 문서를 저장한다. `updatedAt`은 서버 타임스탬프로 기록된다.
*
* @param key - 캐시 키
* @param data - 저장할 데이터
*/
export async function setCached<T>(
key: string,
data: T,
ttlMs?: number
): Promise<void> {
const payload: Record<string, unknown> = {
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<boolean> {
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<void> {
await firestore.collection(LOCK_COLLECTION).doc(key).delete().catch(() => undefined);
}
/**
* 락이 걸려있는 동안 주기적으로 캐시를 폴링한다.
*
* 다른 요청이 fetch 중일 때 동일 키로 들어온 요청이 불필요하게 외부 API를 호출하지 않도록 대기시키는 용도.
*
* @param key - 폴링할 캐시 키
* @param ttlMs - 유효 캐시 판정 기준
* @param timeoutMs - 최대 대기 시간 (기본 25초)
* @returns 타임아웃 전에 캐시를 얻으면 값, 실패하면 `null`.
*/
async function waitForCache<T>(key: string, ttlMs: number, timeoutMs = 25_000): Promise<T | null> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
await new Promise((r) => setTimeout(r, 500));
const cached = await getCached<T>(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<T>(
key: string,
ttlMs: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = await getCached<T>(key, ttlMs);
if (cached !== null) return cached;
const locked = await acquireLock(key);
if (!locked) {
const waited = await waitForCache<T>(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 };