feat: YouTube API 할당량 절감을 위한 playlist ID 캐싱

- uploads playlist ID를 Redis에 영구 캐싱 (불변값)
- 일일 API 사용량 6,480 → 4,320 units (33% 절감)
- 문서 업데이트 (컨테이너 분리 구조, X source.name 빈 문자열)
- CLAUDE.md에 문서 업데이트 필수 안내 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-19 12:32:04 +09:00
parent 149e85ebd9
commit c7b0a51924
5 changed files with 61 additions and 25 deletions

View file

@ -29,3 +29,7 @@ DB 및 외부 서비스 접근 정보는 `.env` 파일 참조:
- [docs/architecture.md](docs/architecture.md) - 프로젝트 구조
- [docs/api.md](docs/api.md) - API 명세
- [docs/development.md](docs/development.md) - 개발/배포 가이드
## 작업 시 주의사항
- **문서 업데이트 필수**: 작업이 완료되면 항상 `docs/` 폴더의 관련 문서를 업데이트할 것

View file

@ -29,7 +29,7 @@ function getVideoUrl(videoId, isShorts) {
/**
* 채널의 업로드 플레이리스트 ID 조회
*/
async function getUploadsPlaylistId(channelId) {
export async function getUploadsPlaylistId(channelId) {
const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`;
const res = await fetch(url);
const data = await res.json();
@ -64,9 +64,12 @@ async function getVideoDurations(videoIds) {
/**
* 최근 N개 영상 조회
* @param {string} channelId - 채널 ID
* @param {number} maxResults - 최대 결과
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
*/
export async function fetchRecentVideos(channelId, maxResults = 10) {
const uploadsId = await getUploadsPlaylistId(channelId);
export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlaylistId = null) {
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`;
const res = await fetch(url);
@ -102,9 +105,11 @@ export async function fetchRecentVideos(channelId, maxResults = 10) {
/**
* 전체 영상 조회 (페이지네이션)
* @param {string} channelId - 채널 ID
* @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택)
*/
export async function fetchAllVideos(channelId) {
const uploadsId = await getUploadsPlaylistId(channelId);
export async function fetchAllVideos(channelId, uploadsPlaylistId = null) {
const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId);
const videos = [];
let pageToken = '';

View file

@ -1,10 +1,30 @@
import fp from 'fastify-plugin';
import { fetchRecentVideos, fetchAllVideos } from './api.js';
import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
import bots from '../../config/bots.js';
const YOUTUBE_CATEGORY_ID = 2;
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
async function youtubeBotPlugin(fastify, opts) {
/**
* uploads playlist ID 조회 (Redis 캐싱)
*/
async function getCachedUploadsPlaylistId(channelId) {
const cacheKey = `${PLAYLIST_CACHE_PREFIX}${channelId}`;
// Redis 캐시 확인
const cached = await fastify.redis.get(cacheKey);
if (cached) {
return cached;
}
// API 호출 후 캐싱 (영구 저장 - 값이 변하지 않음)
const playlistId = await getUploadsPlaylistId(channelId);
await fastify.redis.set(cacheKey, playlistId);
return playlistId;
}
/**
* 멤버 이름 조회
*/
@ -89,7 +109,8 @@ async function youtubeBotPlugin(fastify, opts) {
* 최근 영상 동기화 (정기 실행)
*/
async function syncNewVideos(bot) {
const videos = await fetchRecentVideos(bot.channelId, 10);
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
let addedCount = 0;
for (const video of videos) {
@ -106,7 +127,8 @@ async function youtubeBotPlugin(fastify, opts) {
* 전체 영상 동기화 (초기화)
*/
async function syncAllVideos(bot) {
const videos = await fetchAllVideos(bot.channelId);
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
const videos = await fetchAllVideos(bot.channelId, uploadsPlaylistId);
let addedCount = 0;
for (const video of videos) {
@ -137,5 +159,5 @@ async function youtubeBotPlugin(fastify, opts) {
export default fp(youtubeBotPlugin, {
name: 'youtubeBot',
dependencies: ['db'],
dependencies: ['db', 'redis'],
});

View file

@ -67,7 +67,7 @@ Base URL: `/api`
**source 객체 (카테고리별):**
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }`
- X (category_id=3): `{ name: "X", url: "https://x.com/realfromis_9/status/..." }`
- X (category_id=3): `{ name: "", url: "https://x.com/realfromis_9/status/..." }` (name 빈 문자열)
- 기타 카테고리: source 없음
**검색 응답:**

View file

@ -30,6 +30,7 @@ fromis_9/
│ │ │ └── suggestions/ # 추천 검색어
│ │ ├── app.js # Fastify 앱 설정
│ │ └── server.js # 진입점
│ ├── Dockerfile # 백엔드 컨테이너
│ └── package.json
├── backend-backup/ # Express 백엔드 (참조용, 마이그레이션 원본)
@ -47,9 +48,9 @@ fromis_9/
│ │ ├── stores/ # Zustand 스토어
│ │ └── App.jsx
│ ├── vite.config.js
│ ├── Dockerfile # 프론트엔드 컨테이너
│ └── package.json
├── Dockerfile # 개발/배포 통합 (주석 전환)
├── docker-compose.yml
└── .env
```
@ -64,20 +65,24 @@ fromis_9/
┌─────────────────────────────────────────────────────────┐
│ fromis9-frontend (Docker) │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ Vite (:80) │───▶│ Fastify (:3000) │ │
│ │ 프론트엔드 │ │ 백엔드 API │ │
│ └─────────────────┘ └──────────┬──────────────────┘ │
└─────────────────────────────────────┼───────────────────┘
│ fromis9-frontend (:80) │
│ Vite 개발서버 │
│ (프록시: /api → backend) │
└─────────────────────┬───────────────────────────────────┘
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ MariaDB │ │ Meilisearch │ │ Redis │
│ (외부 DB망) │ │ (검색 엔진) │ │ (캐시) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
┌─────────────────────────────────────────────────────────┐
│ fromis9-backend (:80) │
│ Fastify API │
└─────────────────────┬───────────────────────────────────┘
┌────────────┼────────────┬────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌───────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ MariaDB │ │Meilisearch│ │ Redis │ │ Nitter │
│ (외부 DB망) │ │ (검색엔진) │ │ (캐시) │ │ (X 스크랩) │
└───────────────┘ └───────────┘ └───────────┘ └───────────┘
```
## 데이터베이스