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_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);
}
}

View file

@ -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',

View file

@ -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 };

View file

@ -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}`);

View file

@ -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 };

View file

@ -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 };

View file

@ -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);
}
/**

View file

@ -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();
}
/**

View file

@ -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) {

View file

@ -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++;
}
}