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/architecture.md](docs/architecture.md) - 프로젝트 구조
- [docs/api.md](docs/api.md) - API 명세 - [docs/api.md](docs/api.md) - API 명세
- [docs/development.md](docs/development.md) - 개발/배포 가이드 - [docs/development.md](docs/development.md) - 개발/배포 가이드
## 작업 시 주의사항
- **문서 업데이트 필수**: 작업이 완료되면 항상 `docs/` 폴더의 관련 문서를 업데이트할 것

View file

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

View file

@ -1,10 +1,30 @@
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import { fetchRecentVideos, fetchAllVideos } from './api.js'; import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js';
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
const YOUTUBE_CATEGORY_ID = 2; const YOUTUBE_CATEGORY_ID = 2;
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
async function youtubeBotPlugin(fastify, opts) { 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) { 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; let addedCount = 0;
for (const video of videos) { for (const video of videos) {
@ -106,7 +127,8 @@ async function youtubeBotPlugin(fastify, opts) {
* 전체 영상 동기화 (초기화) * 전체 영상 동기화 (초기화)
*/ */
async function syncAllVideos(bot) { 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; let addedCount = 0;
for (const video of videos) { for (const video of videos) {
@ -137,5 +159,5 @@ async function youtubeBotPlugin(fastify, opts) {
export default fp(youtubeBotPlugin, { export default fp(youtubeBotPlugin, {
name: 'youtubeBot', name: 'youtubeBot',
dependencies: ['db'], dependencies: ['db', 'redis'],
}); });

View file

@ -67,7 +67,7 @@ Base URL: `/api`
**source 객체 (카테고리별):** **source 객체 (카테고리별):**
- YouTube (category_id=2): `{ name: "채널명", url: "https://www.youtube.com/..." }` - 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 없음 - 기타 카테고리: source 없음
**검색 응답:** **검색 응답:**

View file

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