From 54e014c83300e7e708b0c6efa2fd0d5dfd8df45d Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 7 Jun 2026 16:39:40 +0900 Subject: [PATCH] =?UTF-8?q?perf(schedule):=20=EC=9B=94=EB=B3=84=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=20Redis=20=EC=BA=90=EC=8B=9C=20+=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캘린더(최다 호출 공개 엔드포인트) getMonthlySchedules를 getOrSet으로 캐시(TTL 60s). 무효화는 모든 쓰기가 수렴하는 3개 meili 동기화 함수 (addOrUpdateSchedule/syncScheduleById/deleteSchedule)에 redis 전달 시 schedule:monthly:* 무효화. 관리자 라우트·봇 경로에서 redis 전달(즉시 반영), festival/누락 경로는 60s TTL로 자동 치유. Co-Authored-By: Claude Opus 4.7 --- backend/src/routes/admin/concert.js | 4 ++-- backend/src/routes/admin/events.js | 6 +++--- backend/src/routes/admin/variety.js | 4 ++-- backend/src/routes/admin/x.js | 6 +++--- backend/src/routes/admin/youtube.js | 4 ++-- backend/src/routes/schedules/index.js | 6 +++--- backend/src/services/meilisearch/index.js | 21 ++++++++++++++++++--- backend/src/services/schedule.js | 13 +++++++++++-- backend/src/services/x/index.js | 6 +++--- backend/src/services/youtube/index.js | 10 +++++----- 10 files changed, 52 insertions(+), 28 deletions(-) diff --git a/backend/src/routes/admin/concert.js b/backend/src/routes/admin/concert.js index 1f4ac5b..17070b7 100644 --- a/backend/src/routes/admin/concert.js +++ b/backend/src/routes/admin/concert.js @@ -339,7 +339,7 @@ export default async function concertRoutes(fastify) { category_name: category.name || '', category_color: category.color || '', member_names: memberNames, - }); + }, fastify.redis); } } @@ -540,7 +540,7 @@ export default async function concertRoutes(fastify) { category_name: category.name || '', category_color: category.color || '', member_names: memberNames, - }); + }, fastify.redis); } } diff --git a/backend/src/routes/admin/events.js b/backend/src/routes/admin/events.js index 4aa741e..f1fd531 100644 --- a/backend/src/routes/admin/events.js +++ b/backend/src/routes/admin/events.js @@ -206,8 +206,8 @@ export default async function eventsRoutes(fastify) { ); } - // Meilisearch 동기화 - await syncScheduleById(meilisearch, db, scheduleId); + // Meilisearch 동기화 + 월별 캐시 무효화 + await syncScheduleById(meilisearch, db, scheduleId, fastify.redis); logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', @@ -291,7 +291,7 @@ export default async function eventsRoutes(fastify) { [finalIds.length > 0 ? JSON.stringify(finalIds) : null, id] ); - await syncScheduleById(meilisearch, db, parseInt(id)); + await syncScheduleById(meilisearch, db, parseInt(id), fastify.redis); logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', diff --git a/backend/src/routes/admin/variety.js b/backend/src/routes/admin/variety.js index 63e1439..9a507af 100644 --- a/backend/src/routes/admin/variety.js +++ b/backend/src/routes/admin/variety.js @@ -125,7 +125,7 @@ export default async function varietyRoutes(fastify) { category_name: category.name || '', category_color: category.color || '', member_names: memberNames, - }); + }, redis); // 방송사 캐시 무효화 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 syncScheduleById(meilisearch, db, parseInt(id)); + await syncScheduleById(meilisearch, db, parseInt(id), redis); await redis.del(BROADCASTER_KEY); logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'variety_schedule', targetId: parseInt(id), summary: `예능 일정 수정: ${title.trim()}` }); return { success: true }; diff --git a/backend/src/routes/admin/x.js b/backend/src/routes/admin/x.js index 0a8de88..f3c51af 100644 --- a/backend/src/routes/admin/x.js +++ b/backend/src/routes/admin/x.js @@ -152,7 +152,7 @@ export default async function xRoutes(fastify) { category_name: category.name || '', category_color: category.color || '', source_name: '', - }); + }, fastify.redis); logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'x_schedule', targetId: scheduleId, summary: `X 일정 생성: ${title}` }); return { success: true, scheduleId }; @@ -251,8 +251,8 @@ export default async function xRoutes(fastify) { [fetchUsername, newContent, newImageUrls, row.schedule_id] ); - // Meilisearch 동기화 - await syncScheduleById(meilisearch, db, row.schedule_id); + // Meilisearch 동기화 + 월별 캐시 무효화 + await syncScheduleById(meilisearch, db, row.schedule_id, fastify.redis); updated++; fastify.log.info(`리트윗 재수집 완료: schedule_id=${row.schedule_id}, post_id=${row.post_id}`); diff --git a/backend/src/routes/admin/youtube.js b/backend/src/routes/admin/youtube.js index c4b4285..eb992cd 100644 --- a/backend/src/routes/admin/youtube.js +++ b/backend/src/routes/admin/youtube.js @@ -148,7 +148,7 @@ export default async function youtubeRoutes(fastify) { category_name: category.name || '', category_color: category.color || '', source_name: channelName || '', - }); + }, fastify.redis); logActivity(db, { actor: 'admin', action: 'create', category: 'schedule', targetType: 'youtube_schedule', targetId: scheduleId, summary: `YouTube 일정 생성: ${title}` }); return { success: true, scheduleId }; @@ -252,7 +252,7 @@ export default async function youtubeRoutes(fastify) { category_color: category.color || '', member_names: memberNames, source_name: channelName, - }); + }, fastify.redis); logActivity(db, { actor: 'admin', action: 'update', category: 'schedule', targetType: 'youtube_schedule', targetId: parseInt(id), summary: `YouTube 일정 수정: ${schedules[0].title}` }); return { success: true }; diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 6df0c27..ac29e97 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -142,7 +142,7 @@ export default async function schedulesRoutes(fastify) { 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) { fastify.log.error(err); return serverError(reply, '일정 조회 실패'); @@ -271,8 +271,8 @@ export default async function schedulesRoutes(fastify) { await connection.query('DELETE FROM schedules WHERE id = ?', [id]); }); - // Meilisearch에서도 삭제 (트랜잭션 외부, 실패해도 무시) - await deleteSchedule(meilisearch, id); + // Meilisearch에서도 삭제 + 월별 캐시 무효화 (트랜잭션 외부, 실패해도 무시) + await deleteSchedule(meilisearch, id, redis); logActivity(db, { actor: 'admin', action: 'delete', category: 'schedule', targetType: null, targetId: parseInt(id), summary: `일정 삭제: ${id}` }); return { success: true }; diff --git a/backend/src/services/meilisearch/index.js b/backend/src/services/meilisearch/index.js index 938b029..fe06210 100644 --- a/backend/src/services/meilisearch/index.js +++ b/backend/src/services/meilisearch/index.js @@ -8,6 +8,17 @@ import Inko from 'inko'; import config, { CATEGORY_IDS } from '../../config/index.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 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 { const index = meilisearch.index(INDEX_NAME); @@ -223,12 +234,13 @@ export async function addOrUpdateSchedule(meilisearch, schedule) { } catch (err) { logger.error(`문서 추가 오류: ${err.message}`); } + await invalidateMonthlyCache(redis); } /** * 일정 ID로 DB에서 조회 후 Meilisearch에 동기화 */ -export async function syncScheduleById(meilisearch, db, scheduleId) { +export async function syncScheduleById(meilisearch, db, scheduleId, redis = null) { try { const [rows] = await db.query(` SELECT @@ -273,9 +285,11 @@ export async function syncScheduleById(meilisearch, db, scheduleId) { const index = meilisearch.index(INDEX_NAME); await index.addDocuments([document]); logger.info(`일정 동기화: ${scheduleId}`); + await invalidateMonthlyCache(redis); return true; } catch (err) { logger.error(`일정 동기화 오류 (${scheduleId}): ${err.message}`); + await invalidateMonthlyCache(redis); 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 { const index = meilisearch.index(INDEX_NAME); await index.deleteDocument(scheduleId); @@ -291,6 +305,7 @@ export async function deleteSchedule(meilisearch, scheduleId) { } catch (err) { logger.error(`문서 삭제 오류: ${err.message}`); } + await invalidateMonthlyCache(redis); } /** diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index d8113cc..adb81d4 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -500,7 +500,8 @@ const SCHEDULE_LIST_SQL = ` * @param {number} month - 월 * @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 endDate = new Date(year, month, 0).toISOString().split('T')[0]; @@ -619,7 +620,15 @@ export async function getMonthlySchedules(db, year, month) { return 0; }); - return { schedules }; + return { schedules }; + }; + + // 캘린더(최다 호출 공개 엔드포인트) 캐시. 쓰기 시 schedule:monthly:* 무효화 + + // 짧은 TTL 안전망으로 혹시 빠진 경로도 자동 치유. + if (redis) { + return getOrSet(redis, cacheKeys.scheduleMonthly(year, month), run, TTL.SHORT); + } + return run(); } /** diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index 49aeaf1..9c65025 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -212,7 +212,7 @@ async function xBotPlugin(fastify, opts) { const scheduleId = await saveYoutubeFromTweet(video); if (scheduleId) { // Meilisearch 동기화 - await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); + await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis); addedCount++; } } catch (err) { @@ -254,7 +254,7 @@ async function xBotPlugin(fastify, opts) { const scheduleId = await saveTweet(tweet, bot.username); if (scheduleId) { // Meilisearch 동기화 - await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); + await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis); const title = extractTitle(tweet.text); logActivity(fastify.db, { actor: bot.id, @@ -296,7 +296,7 @@ async function xBotPlugin(fastify, opts) { const scheduleId = await saveTweet(tweet, bot.username); if (scheduleId) { // Meilisearch 동기화 - await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); + await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis); addedCount++; // YouTube 링크 처리 (옵션이 켜져 있을 때만) if (bot.extractYoutube === true) { diff --git a/backend/src/services/youtube/index.js b/backend/src/services/youtube/index.js index a77bf4b..a7d1c80 100644 --- a/backend/src/services/youtube/index.js +++ b/backend/src/services/youtube/index.js @@ -115,7 +115,7 @@ async function youtubeBotPlugin(fastify) { // Meilisearch 동기화 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}`); } @@ -141,7 +141,7 @@ async function youtubeBotPlugin(fastify) { }); // 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}`); return scheduledEntry.schedule_id; @@ -159,7 +159,7 @@ async function youtubeBotPlugin(fastify) { }); // Meilisearch에서도 삭제 - await deleteSchedule(fastify.meilisearch, scheduleId); + await deleteSchedule(fastify.meilisearch, scheduleId, fastify.redis); fastify.log.info(`[${bot.id}] 예정 일정 삭제 (영상 미업로드)`); // 다음 주 예정 일정 생성 @@ -367,7 +367,7 @@ async function youtubeBotPlugin(fastify) { const scheduleId = await saveVideo(video, bot); if (scheduleId) { - await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); + await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis); logActivity(fastify.db, { actor: bot.id, action: 'create', @@ -394,7 +394,7 @@ async function youtubeBotPlugin(fastify) { const scheduleId = await saveVideo(video, bot); if (scheduleId) { // Meilisearch 동기화 - await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); + await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId, fastify.redis); addedCount++; } }