Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 20 additions & 52 deletions academic/handler.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import https from "https";
import { EmbedBuilder, Message, ChatInputCommandInteraction } from "discord.js";
import {
kstNow,
NEIS_KEY,
ATPT_CODE,
SCHOOL_CODE,
fetchWithRetry,
getFallbackSchedule,
} from "../utils.js";
import { kstNow, fetchWithRetry, fetchNeis, getFallbackSchedule } from "../utils.js";
import { Ctx, ctxFromMessage, ctxFromInteraction } from "../ctx.js";
import { TtlCache } from "../cache.js";

interface ScheduleRow {
AA_YMD: string;
Expand All @@ -17,61 +10,36 @@ interface ScheduleRow {

// 학사일정은 월 단위 조회라 재요청이 많고, 등록된 일정은 당일 바뀌는 일이 거의 없어 TTL을 길게 둔다.
const CACHE_TTL = 3 * 60 * 60 * 1000;
const academicCache = new Map<string, { rows: ScheduleRow[]; cachedAt: number }>();
const academicCache = new TtlCache<ScheduleRow[]>(24);

function fetchAcademicSchedule(year: number, month: number): Promise<ScheduleRow[]> {
async function fetchAcademicSchedule(year: number, month: number): Promise<ScheduleRow[]> {
const mm = String(month).padStart(2, "0");
const lastDay = new Date(year, month, 0).getDate();
const fromDate = `${year}${mm}01`;
const toDate = `${year}${mm}${String(lastDay).padStart(2, "0")}`;

const url =
`https://open.neis.go.kr/hub/SchoolSchedule` +
`?KEY=${NEIS_KEY}&Type=json` +
`&ATPT_OFCDC_SC_CODE=${ATPT_CODE}` +
`&SD_SCHUL_CODE=${SCHOOL_CODE}` +
`&AA_FROM_YMD=${fromDate}&AA_TO_YMD=${toDate}`;

return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode}`));
}
res.setEncoding("utf8");
let raw = "";
res.on("data", (chunk: string) => (raw += chunk));
res.on("end", () => {
try {
const json = JSON.parse(raw);
if (json.RESULT) {
if (json.RESULT.CODE === "INFO-200") return resolve([]);
return reject(new Error(`${json.RESULT.CODE}: ${json.RESULT.MESSAGE}`));
}
resolve(json.SchoolSchedule?.[1]?.row ?? []);
} catch (e) {
reject(e);
}
});
});
req.setTimeout(4000, () => req.destroy(new Error("NEIS API timeout")));
req.on("error", reject);
const json = await fetchNeis("SchoolSchedule", {
AA_FROM_YMD: `${year}${mm}01`,
AA_TO_YMD: `${year}${mm}${String(lastDay).padStart(2, "0")}`,
});
if (json.RESULT) {
if (json.RESULT.CODE === "INFO-200") return [];
throw new Error(`${json.RESULT.CODE}: ${json.RESULT.MESSAGE}`);
}
return json.SchoolSchedule?.[1]?.row ?? [];
}

async function executeAcademic(ctx: Ctx, year: number, month: number): Promise<void> {
try {
const cacheKey = `${year}-${month}`;
const cached = academicCache.get(cacheKey);

let rows: ScheduleRow[] = [];
let rows = cached ?? [];
let isFallback = false;
if (cached && Date.now() - cached.cachedAt < CACHE_TTL) {
rows = cached.rows;
} else {
if (cached === undefined) {
await ctx.defer();
try {
rows = await fetchWithRetry(() => fetchAcademicSchedule(year, month));
academicCache.set(cacheKey, { rows, cachedAt: Date.now() });
rows = await academicCache.getOrLoad(
cacheKey,
() => fetchWithRetry(() => fetchAcademicSchedule(year, month)),
() => CACHE_TTL,
);
} catch (err) {
console.warn(
`[NEIS] 학사일정 API 호출 실패, fallback 데이터 사용을 시도합니다: ${(err as Error).message}`,
Expand Down
85 changes: 85 additions & 0 deletions cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import { TtlCache } from "./cache.js";

test("동시에 들어온 같은 키 요청은 로더를 한 번만 부른다", async () => {
const cache = new TtlCache<number>();
let calls = 0;
const loader = async () => {
calls++;
await new Promise((r) => setTimeout(r, 10));
return 42;
};
const results = await Promise.all([
cache.getOrLoad("k", loader, () => 1000),
cache.getOrLoad("k", loader, () => 1000),
cache.getOrLoad("k", loader, () => 1000),
]);
assert.deepEqual(results, [42, 42, 42]);
assert.equal(calls, 1);
assert.equal(await cache.getOrLoad("k", loader, () => 1000), 42);
assert.equal(calls, 1);
});

test("null도 캐시된다 (결과 없음 캐싱)", async () => {
const cache = new TtlCache<number | null>();
let calls = 0;
const loader = async () => {
calls++;
return null;
};
await cache.getOrLoad("k", loader, () => 1000);
assert.equal(cache.get("k"), null);
await cache.getOrLoad("k", loader, () => 1000);
assert.equal(calls, 1);
});

test("TTL이 지나면 다시 불러온다", async () => {
const cache = new TtlCache<number>();
cache.set("k", 1, 5);
await new Promise((r) => setTimeout(r, 15));
assert.equal(cache.get("k"), undefined);
});

test("로더가 실패하면 캐시하지 않고 다음 요청에서 다시 시도한다", async () => {
const cache = new TtlCache<number>();
await assert.rejects(
cache.getOrLoad(
"k",
() => Promise.reject(new Error("boom")),
() => 1000,
),
);
assert.equal(
await cache.getOrLoad(
"k",
async () => 7,
() => 1000,
),
7,
);
});

test("load는 캐시를 무시하고 새로 불러온다 (예열)", async () => {
const cache = new TtlCache<number>();
cache.set("k", 1, 1000);
assert.equal(
await cache.load(
"k",
async () => 2,
() => 1000,
),
2,
);
assert.equal(cache.get("k"), 2);
});

test("최대 개수를 넘으면 오래된 것부터 버린다", () => {
const cache = new TtlCache<number>(2);
cache.set("a", 1, 1000);
cache.set("b", 2, 1000);
cache.set("c", 3, 1000);
assert.equal(cache.get("a"), undefined);
assert.equal(cache.get("b"), 2);
assert.equal(cache.get("c"), 3);
});
57 changes: 57 additions & 0 deletions cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// 조회 결과를 TTL 동안 보관하고, 같은 키로 동시에 들어온 요청은 진행 중인 호출 하나를 공유한다.
// (점심시간에 여러 명이 동시에 !밥을 쳐도 외부 API는 한 번만 부른다)
export class TtlCache<V> {
private readonly entries = new Map<string, { value: V; expiresAt: number }>();
private readonly inflight = new Map<string, Promise<V>>();

constructor(private readonly maxEntries = 500) {}

// 캐시 미스는 undefined — null은 "결과 없음"을 캐시한 정상 값으로 취급한다
get(key: string): V | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key);
return undefined;
}
return entry.value;
}

set(key: string, value: V, ttlMs: number): void {
if (!this.entries.has(key) && this.entries.size >= this.maxEntries) this.prune();
this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
}

// 캐시를 무시하고 새로 불러온다 (예열용). 이미 불러오는 중이면 그 결과를 같이 기다린다.
load(key: string, loader: () => Promise<V>, ttlMs: (value: V) => number): Promise<V> {
const running = this.inflight.get(key);
if (running) return running;

const promise = loader()
.then((value) => {
this.set(key, value, ttlMs(value));
return value;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, promise);
return promise;
}

getOrLoad(key: string, loader: () => Promise<V>, ttlMs: (value: V) => number): Promise<V> {
const hit = this.get(key);
return hit !== undefined ? Promise.resolve(hit) : this.load(key, loader, ttlMs);
}

private prune(): void {
const now = Date.now();
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key);
}
// 만료된 게 없으면 가장 오래 전에 넣은 것부터 버린다 (Map은 삽입 순서를 유지)
while (this.entries.size >= this.maxEntries) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
}
}
Loading
Loading