- TypeScript 소스 파일 내 모든 import 구문에서 불필요한 `.js` 확장자를 제거하여 모듈 참조 방식을 표준화했습니다. - 핸들러, 서비스, 리포지토리 및 테스트 코드를 포함한 프로젝트 전반의 import 경로를 일관성 있게 정리했습니다.
169 lines
5.4 KiB
TypeScript
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 };
|