From c7b0a519240cb82c92e50a7cab4b8ec477a17296 Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 19 Jan 2026 12:32:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20YouTube=20API=20=ED=95=A0=EB=8B=B9?= =?UTF-8?q?=EB=9F=89=20=EC=A0=88=EA=B0=90=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?playlist=20ID=20=EC=BA=90=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uploads playlist ID를 Redis에 영구 캐싱 (불변값) - 일일 API 사용량 6,480 → 4,320 units (33% 절감) - 문서 업데이트 (컨테이너 분리 구조, X source.name 빈 문자열) - CLAUDE.md에 문서 업데이트 필수 안내 추가 Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 4 +++ backend/src/services/youtube/api.js | 15 ++++++++---- backend/src/services/youtube/index.js | 30 ++++++++++++++++++++--- docs/api.md | 2 +- docs/architecture.md | 35 +++++++++++++++------------ 5 files changed, 61 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9232fd6..4478b7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` 폴더의 관련 문서를 업데이트할 것 diff --git a/backend/src/services/youtube/api.js b/backend/src/services/youtube/api.js index e754038..168956f 100644 --- a/backend/src/services/youtube/api.js +++ b/backend/src/services/youtube/api.js @@ -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 = ''; diff --git a/backend/src/services/youtube/index.js b/backend/src/services/youtube/index.js index eae87d6..4e8b73c 100644 --- a/backend/src/services/youtube/index.js +++ b/backend/src/services/youtube/index.js @@ -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'], }); diff --git a/docs/api.md b/docs/api.md index f515af6..f5c910b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 없음 **검색 응답:** diff --git a/docs/architecture.md b/docs/architecture.md index 0c05868..f99e2be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 │ │ -│ └─────────────────┘ └──────────┬──────────────────┘ │ -└─────────────────────────────────────┼───────────────────┘ - │ - ┌────────────────────────────┼────────────────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ MariaDB │ │ Meilisearch │ │ Redis │ -│ (외부 DB망) │ │ (검색 엔진) │ │ (캐시) │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ +│ fromis9-frontend (:80) │ +│ Vite 개발서버 │ +│ (프록시: /api → backend) │ +└─────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ fromis9-backend (:80) │ +│ Fastify API │ +└─────────────────────┬───────────────────────────────────┘ + │ + ┌────────────┼────────────┬────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌───────────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ +│ MariaDB │ │Meilisearch│ │ Redis │ │ Nitter │ +│ (외부 DB망) │ │ (검색엔진) │ │ (캐시) │ │ (X 스크랩) │ +└───────────────┘ └───────────┘ └───────────┘ └───────────┘ ``` ## 데이터베이스