perf(schedule): 월별 일정 Redis 캐시 + 쓰기 시 무효화

캘린더(최다 호출 공개 엔드포인트) getMonthlySchedules를 getOrSet으로
캐시(TTL 60s). 무효화는 모든 쓰기가 수렴하는 3개 meili 동기화 함수
(addOrUpdateSchedule/syncScheduleById/deleteSchedule)에 redis 전달 시
schedule:monthly:* 무효화. 관리자 라우트·봇 경로에서 redis 전달(즉시
반영), festival/누락 경로는 60s TTL로 자동 치유.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-06-07 16:39:40 +09:00
parent f50463f394
commit 54e014c833
10 changed files with 52 additions and 28 deletions

View file

@ -339,7 +339,7 @@ export default async function concertRoutes(fastify) {
category_name: category.name || '', category_name: category.name || '',
category_color: category.color || '', category_color: category.color || '',
member_names: memberNames, member_names: memberNames,
}); }, fastify.redis);
} }
} }
@ -540,7 +540,7 @@ export default async function concertRoutes(fastify) {
category_name: category.name || '', category_name: category.name || '',
category_color: category.color || '', category_color: category.color || '',
member_names: memberNames, member_names: memberNames,
}); }, fastify.redis);
} }
} }

View file

@ -206,8 +206,8 @@ export default async function eventsRoutes(fastify) {
); );
} }
// Meilisearch 동기화 // Meilisearch 동기화 + 월별 캐시 무효화
await syncScheduleById(meilisearch, db, scheduleId); await syncScheduleById(meilisearch, db, scheduleId, fastify.redis);
logActivity(db, { logActivity(db, {
actor: 'admin', action: 'create', category: 'schedule', actor: 'admin', action: 'create', category: 'schedule',
@ -291,7 +291,7 @@ export default async function eventsRoutes(fastify) {
[finalIds.length > 0 ? JSON.stringify(finalIds) : null, id] [finalIds.length > 0 ? JSON.stringify(finalIds) : null, id]
); );
await syncScheduleById(meilisearch, db, parseInt(id)); await syncScheduleById(meilisearch, db, parseInt(id), fastify.redis);
logActivity(db, { logActivity(db, {
actor: 'admin', action: 'update', category: 'schedule', actor: 'admin', action: 'update', category: 'schedule',

View file

@ -125,7 +125,7 @@ export default async function varietyRoutes(fastify) {
category_name: category.name || '', category_name: category.name || '',
category_color: category.color || '', category_color: category.color || '',
member_names: memberNames, member_names: memberNames,
}); }, redis);
// 방송사 캐시 무효화 // 방송사 캐시 무효화
await redis.del(BROADCASTER_KEY); await redis.del(BROADCASTER_KEY);
@ -214,7 +214,7 @@ export default async function varietyRoutes(fastify) {
await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]);
} }
await syncScheduleById(meilisearch, db, parseInt(id)); await syncScheduleById(meilisearch, db, parseInt(id), redis);
await redis.del(BROADCASTER_KEY); await redis.del(BROADCASTER_KEY);
logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'variety_schedule', targetId: parseInt(id), summary: `예능 일정 수정: ${title.trim()}` }); logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'variety_schedule', targetId: parseInt(id), summary: `예능 일정 수정: ${title.trim()}` });
return { success: true }; return { success: true };

View file

@ -152,7 +152,7 @@ export default async function xRoutes(fastify) {
category_name: category.name || '', category_name: category.name || '',
category_color: category.color || '', category_color: category.color || '',
source_name: '', source_name: '',
}); }, fastify.redis);
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'x_schedule', targetId: scheduleId, summary: `X 일정 생성: ${title}` }); logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'x_schedule', targetId: scheduleId, summary: `X 일정 생성: ${title}` });
return { success: true, scheduleId }; return { success: true, scheduleId };
@ -251,8 +251,8 @@ export default async function xRoutes(fastify) {
[fetchUsername, newContent, newImageUrls, row.schedule_id] [fetchUsername, newContent, newImageUrls, row.schedule_id]
); );
// Meilisearch 동기화 // Meilisearch 동기화 + 월별 캐시 무효화
await syncScheduleById(meilisearch, db, row.schedule_id); await syncScheduleById(meilisearch, db, row.schedule_id, fastify.redis);
updated++; updated++;
fastify.log.info(`리트윗 재수집 완료: schedule_id=${row.schedule_id}, post_id=${row.post_id}`); fastify.log.info(`리트윗 재수집 완료: schedule_id=${row.schedule_id}, post_id=${row.post_id}`);

View file

@ -148,7 +148,7 @@ export default async function youtubeRoutes(fastify) {
category_name: category.name || '', category_name: category.name || '',
category_color: category.color || '', category_color: category.color || '',
source_name: channelName || '', source_name: channelName || '',
}); }, fastify.redis);
logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'youtube_schedule', targetId: scheduleId, summary: `YouTube 일정 생성: ${title}` }); logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'youtube_schedule', targetId: scheduleId, summary: `YouTube 일정 생성: ${title}` });
return { success: true, scheduleId }; return { success: true, scheduleId };
@ -252,7 +252,7 @@ export default async function youtubeRoutes(fastify) {
category_color: category.color || '', category_color: category.color || '',
member_names: memberNames, member_names: memberNames,
source_name: channelName, source_name: channelName,
}); }, fastify.redis);
logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'youtube_schedule', targetId: parseInt(id), summary: `YouTube 일정 수정: ${schedules[0].title}` }); logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'youtube_schedule', targetId: parseInt(id), summary: `YouTube 일정 수정: ${schedules[0].title}` });
return { success: true }; return { success: true };

View file

@ -142,7 +142,7 @@ export default async function schedulesRoutes(fastify) {
return badRequest(reply, 'search, startDate, 또는 year/month는 필수입니다.'); return badRequest(reply, 'search, startDate, 또는 year/month는 필수입니다.');
} }
return await getMonthlySchedules(db, parseInt(year), parseInt(month)); return await getMonthlySchedules(db, parseInt(year), parseInt(month), redis);
} catch (err) { } catch (err) {
fastify.log.error(err); fastify.log.error(err);
return serverError(reply, '일정 조회 실패'); return serverError(reply, '일정 조회 실패');
@ -271,8 +271,8 @@ export default async function schedulesRoutes(fastify) {
await connection.query('DELETE FROM schedules WHERE id = ?', [id]); await connection.query('DELETE FROM schedules WHERE id = ?', [id]);
}); });
// Meilisearch에서도 삭제 (트랜잭션 외부, 실패해도 무시) // Meilisearch에서도 삭제 + 월별 캐시 무효화 (트랜잭션 외부, 실패해도 무시)
await deleteSchedule(meilisearch, id); await deleteSchedule(meilisearch, id, redis);
logActivity(db, { actor: 'admin', action: 'delete', category: 'schedule', targetType: null, targetId: parseInt(id), summary: `일정 삭제: ${id}` }); logActivity(db, { actor: 'admin', action: 'delete', category: 'schedule', targetType: null, targetId: parseInt(id), summary: `일정 삭제: ${id}` });
return { success: true }; return { success: true };

View file

@ -8,6 +8,17 @@
import Inko from 'inko'; import Inko from 'inko';
import config, { CATEGORY_IDS } from '../../config/index.js'; import config, { CATEGORY_IDS } from '../../config/index.js';
import { createLogger } from '../../utils/logger.js'; import { createLogger } from '../../utils/logger.js';
import { invalidatePattern } from '../../utils/cache.js';
// 일정 쓰기 시 월별 일정 캐시 무효화 (redis가 주어진 호출에 한함)
async function invalidateMonthlyCache(redis) {
if (!redis) return;
try {
await invalidatePattern(redis, 'schedule:monthly:*');
} catch {
// 캐시 무효화 실패는 치명적이지 않음 (TTL로 자동 만료)
}
}
const inko = new Inko(); const inko = new Inko();
const logger = createLogger('Meilisearch'); const logger = createLogger('Meilisearch');
@ -202,7 +213,7 @@ function formatScheduleResponse(hit) {
/** /**
* 일정 추가/업데이트 (데이터 직접 전달) * 일정 추가/업데이트 (데이터 직접 전달)
*/ */
export async function addOrUpdateSchedule(meilisearch, schedule) { export async function addOrUpdateSchedule(meilisearch, schedule, redis = null) {
try { try {
const index = meilisearch.index(INDEX_NAME); const index = meilisearch.index(INDEX_NAME);
@ -223,12 +234,13 @@ export async function addOrUpdateSchedule(meilisearch, schedule) {
} catch (err) { } catch (err) {
logger.error(`문서 추가 오류: ${err.message}`); logger.error(`문서 추가 오류: ${err.message}`);
} }
await invalidateMonthlyCache(redis);
} }
/** /**
* 일정 ID로 DB에서 조회 Meilisearch에 동기화 * 일정 ID로 DB에서 조회 Meilisearch에 동기화
*/ */
export async function syncScheduleById(meilisearch, db, scheduleId) { export async function syncScheduleById(meilisearch, db, scheduleId, redis = null) {
try { try {
const [rows] = await db.query(` const [rows] = await db.query(`
SELECT SELECT
@ -273,9 +285,11 @@ export async function syncScheduleById(meilisearch, db, scheduleId) {
const index = meilisearch.index(INDEX_NAME); const index = meilisearch.index(INDEX_NAME);
await index.addDocuments([document]); await index.addDocuments([document]);
logger.info(`일정 동기화: ${scheduleId}`); logger.info(`일정 동기화: ${scheduleId}`);
await invalidateMonthlyCache(redis);
return true; return true;
} catch (err) { } catch (err) {
logger.error(`일정 동기화 오류 (${scheduleId}): ${err.message}`); logger.error(`일정 동기화 오류 (${scheduleId}): ${err.message}`);
await invalidateMonthlyCache(redis);
return false; return false;
} }
} }
@ -283,7 +297,7 @@ export async function syncScheduleById(meilisearch, db, scheduleId) {
/** /**
* 일정 삭제 * 일정 삭제
*/ */
export async function deleteSchedule(meilisearch, scheduleId) { export async function deleteSchedule(meilisearch, scheduleId, redis = null) {
try { try {
const index = meilisearch.index(INDEX_NAME); const index = meilisearch.index(INDEX_NAME);
await index.deleteDocument(scheduleId); await index.deleteDocument(scheduleId);
@ -291,6 +305,7 @@ export async function deleteSchedule(meilisearch, scheduleId) {
} catch (err) { } catch (err) {
logger.error(`문서 삭제 오류: ${err.message}`); logger.error(`문서 삭제 오류: ${err.message}`);
} }
await invalidateMonthlyCache(redis);
} }
/** /**

View file

@ -500,7 +500,8 @@ const SCHEDULE_LIST_SQL = `
* @param {number} month - * @param {number} month -
* @returns {object} { schedules: [] } * @returns {object} { schedules: [] }
*/ */
export async function getMonthlySchedules(db, year, month) { export async function getMonthlySchedules(db, year, month, redis = null) {
const run = async () => {
const startDate = `${year}-${String(month).padStart(2, '0')}-01`; const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const endDate = new Date(year, month, 0).toISOString().split('T')[0]; const endDate = new Date(year, month, 0).toISOString().split('T')[0];
@ -619,7 +620,15 @@ export async function getMonthlySchedules(db, year, month) {
return 0; return 0;
}); });
return { schedules }; return { schedules };
};
// 캘린더(최다 호출 공개 엔드포인트) 캐시. 쓰기 시 schedule:monthly:* 무효화 +
// 짧은 TTL 안전망으로 혹시 빠진 경로도 자동 치유.
if (redis) {
return getOrSet(redis, cacheKeys.scheduleMonthly(year, month), run, TTL.SHORT);
}
return run();
} }
/** /**

View file

@ -212,7 +212,7 @@ async function xBotPlugin(fastify, opts) {
const scheduleId = await saveYoutubeFromTweet(video); const scheduleId = await saveYoutubeFromTweet(video);
if (scheduleId) { if (scheduleId) {
// Meilisearch 동기화 // Meilisearch 동기화
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
addedCount++; addedCount++;
} }
} catch (err) { } catch (err) {
@ -254,7 +254,7 @@ async function xBotPlugin(fastify, opts) {
const scheduleId = await saveTweet(tweet, bot.username); const scheduleId = await saveTweet(tweet, bot.username);
if (scheduleId) { if (scheduleId) {
// Meilisearch 동기화 // Meilisearch 동기화
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
const title = extractTitle(tweet.text); const title = extractTitle(tweet.text);
logActivity(fastify.db, { logActivity(fastify.db, {
actor: bot.id, actor: bot.id,
@ -296,7 +296,7 @@ async function xBotPlugin(fastify, opts) {
const scheduleId = await saveTweet(tweet, bot.username); const scheduleId = await saveTweet(tweet, bot.username);
if (scheduleId) { if (scheduleId) {
// Meilisearch 동기화 // Meilisearch 동기화
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
addedCount++; addedCount++;
// YouTube 링크 처리 (옵션이 켜져 있을 때만) // YouTube 링크 처리 (옵션이 켜져 있을 때만)
if (bot.extractYoutube === true) { if (bot.extractYoutube === true) {

View file

@ -115,7 +115,7 @@ async function youtubeBotPlugin(fastify) {
// Meilisearch 동기화 // Meilisearch 동기화
if (scheduleId) { if (scheduleId) {
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
fastify.log.info(`[${bot.id}] 다음 주 예정 일정 생성: ${nextDate} - ${title}`); fastify.log.info(`[${bot.id}] 다음 주 예정 일정 생성: ${nextDate} - ${title}`);
} }
@ -141,7 +141,7 @@ async function youtubeBotPlugin(fastify) {
}); });
// Meilisearch 동기화 // Meilisearch 동기화
await syncScheduleById(fastify.meilisearch, fastify.db, scheduledEntry.schedule_id); await syncScheduleById(fastify.meilisearch, fastify.db, scheduledEntry.schedule_id, fastify.redis);
fastify.log.info(`[${bot.id}] 예정 일정 업데이트: ${video.title}`); fastify.log.info(`[${bot.id}] 예정 일정 업데이트: ${video.title}`);
return scheduledEntry.schedule_id; return scheduledEntry.schedule_id;
@ -159,7 +159,7 @@ async function youtubeBotPlugin(fastify) {
}); });
// Meilisearch에서도 삭제 // Meilisearch에서도 삭제
await deleteSchedule(fastify.meilisearch, scheduleId); await deleteSchedule(fastify.meilisearch, scheduleId, fastify.redis);
fastify.log.info(`[${bot.id}] 예정 일정 삭제 (영상 미업로드)`); fastify.log.info(`[${bot.id}] 예정 일정 삭제 (영상 미업로드)`);
// 다음 주 예정 일정 생성 // 다음 주 예정 일정 생성
@ -367,7 +367,7 @@ async function youtubeBotPlugin(fastify) {
const scheduleId = await saveVideo(video, bot); const scheduleId = await saveVideo(video, bot);
if (scheduleId) { if (scheduleId) {
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
logActivity(fastify.db, { logActivity(fastify.db, {
actor: bot.id, actor: bot.id,
action: 'create', action: 'create',
@ -394,7 +394,7 @@ async function youtubeBotPlugin(fastify) {
const scheduleId = await saveVideo(video, bot); const scheduleId = await saveVideo(video, bot);
if (scheduleId) { if (scheduleId) {
// Meilisearch 동기화 // Meilisearch 동기화
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis);
addedCount++; addedCount++;
} }
} }