From 3ee41beb46c51dbf029652b623392c2ac57ce4c2 Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 21 Jan 2026 16:16:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20Redis=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=ED=99=95=EB=8C=80=20-=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC,?= =?UTF-8?q?=20=EC=95=A8=EB=B2=94=20=EB=AA=A9=EB=A1=9D/=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=BA=90=EC=8B=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캐시 적용: - 카테고리 목록: 1시간 TTL - 앨범 목록: 10분 TTL - 앨범 상세: 10분 TTL 캐시 무효화: - 앨범 생성/수정/삭제 시 자동 무효화 - invalidateAlbumCache 함수 추가 utils/cache.js: - TTL 상수 추가 (SHORT, MEDIUM, LONG, VERY_LONG) - 앨범 관련 캐시 키 추가 Co-Authored-By: Claude Opus 4.5 --- backend/src/routes/albums/index.js | 15 ++- backend/src/routes/schedules/index.js | 2 +- backend/src/services/album.js | 185 +++++++++++++++----------- backend/src/services/schedule.js | 21 ++- backend/src/utils/cache.js | 15 +++ docs/refactoring.md | 10 +- 6 files changed, 159 insertions(+), 89 deletions(-) diff --git a/backend/src/routes/albums/index.js b/backend/src/routes/albums/index.js index e78f08e..0616918 100644 --- a/backend/src/routes/albums/index.js +++ b/backend/src/routes/albums/index.js @@ -6,6 +6,7 @@ import { createAlbum, updateAlbum, deleteAlbum, + invalidateAlbumCache, } from '../../services/album.js'; import photosRoutes from './photos.js'; import teasersRoutes from './teasers.js'; @@ -16,7 +17,7 @@ import { errorResponse, successResponse, idParam } from '../../schemas/index.js' * GET: 공개, POST/PUT/DELETE: 인증 필요 */ export default async function albumsRoutes(fastify) { - const { db } = fastify; + const { db, redis } = fastify; // 하위 라우트 등록 fastify.register(photosRoutes); @@ -37,7 +38,7 @@ export default async function albumsRoutes(fastify) { }, }, }, async () => { - return await getAlbumsWithTracks(db); + return await getAlbumsWithTracks(db, redis); }); /** @@ -124,7 +125,7 @@ export default async function albumsRoutes(fastify) { if (!album) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } - return getAlbumDetails(db, album); + return getAlbumDetails(db, album, redis); }); /** @@ -145,7 +146,7 @@ export default async function albumsRoutes(fastify) { if (!album) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } - return getAlbumDetails(db, album); + return getAlbumDetails(db, album, redis); }); // ==================== POST/PUT/DELETE (인증 필요) ==================== @@ -195,7 +196,9 @@ export default async function albumsRoutes(fastify) { return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' }); } - return await createAlbum(db, data, coverBuffer); + const result = await createAlbum(db, data, coverBuffer); + await invalidateAlbumCache(redis); + return result; }); /** @@ -238,6 +241,7 @@ export default async function albumsRoutes(fastify) { if (!result) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } + await invalidateAlbumCache(redis, id); return result; }); @@ -263,6 +267,7 @@ export default async function albumsRoutes(fastify) { if (!result) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } + await invalidateAlbumCache(redis, id); return result; }); } diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 34ee7db..8f51ebf 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -39,7 +39,7 @@ export default async function schedulesRoutes(fastify) { }, }, async (request, reply) => { try { - return await getCategories(db); + return await getCategories(db, redis); } catch (err) { fastify.log.error(err); return reply.code(500).send({ error: '카테고리 목록 조회 실패' }); diff --git a/backend/src/services/album.js b/backend/src/services/album.js index ceaa89b..b3bdb54 100644 --- a/backend/src/services/album.js +++ b/backend/src/services/album.js @@ -4,6 +4,7 @@ */ import { uploadAlbumCover, deleteAlbumCover } from './image.js'; import { withTransaction } from '../utils/transaction.js'; +import { getOrSet, invalidate, invalidatePattern, cacheKeys, TTL } from '../utils/cache.js'; /** * 앨범명 또는 폴더명으로 앨범 조회 @@ -31,102 +32,134 @@ export async function getAlbumById(db, id) { } /** - * 앨범 상세 정보 조회 (트랙, 티저, 컨셉포토 포함) + * 앨범 상세 정보 조회 (트랙, 티저, 컨셉포토 포함, 캐시 적용) * @param {object} db - 데이터베이스 연결 * @param {object} album - 앨범 기본 정보 + * @param {object} redis - Redis 클라이언트 (선택적) * @returns {object} 상세 정보가 포함된 앨범 */ -export async function getAlbumDetails(db, album) { - // 트랙, 티저, 포토 병렬 조회 - const [[tracks], [teasers], [photos]] = await Promise.all([ - db.query( - 'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number', - [album.id] - ), - db.query( - `SELECT original_url, medium_url, thumb_url, video_url, media_type - FROM album_teasers WHERE album_id = ? ORDER BY sort_order`, - [album.id] - ), - db.query( - `SELECT - p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order, - p.width, p.height, - GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members - FROM album_photos p - LEFT JOIN album_photo_members pm ON p.id = pm.photo_id - LEFT JOIN members m ON pm.member_id = m.id - WHERE p.album_id = ? - GROUP BY p.id - ORDER BY p.sort_order`, - [album.id] - ), - ]); +export async function getAlbumDetails(db, album, redis = null) { + const fetchDetails = async () => { + // 트랙, 티저, 포토 병렬 조회 + const [[tracks], [teasers], [photos]] = await Promise.all([ + db.query( + 'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number', + [album.id] + ), + db.query( + `SELECT original_url, medium_url, thumb_url, video_url, media_type + FROM album_teasers WHERE album_id = ? ORDER BY sort_order`, + [album.id] + ), + db.query( + `SELECT + p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order, + p.width, p.height, + GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members + FROM album_photos p + LEFT JOIN album_photo_members pm ON p.id = pm.photo_id + LEFT JOIN members m ON pm.member_id = m.id + WHERE p.album_id = ? + GROUP BY p.id + ORDER BY p.sort_order`, + [album.id] + ), + ]); - album.tracks = tracks; - album.teasers = teasers; + const result = { ...album }; + result.tracks = tracks; + result.teasers = teasers; - const conceptPhotos = {}; - for (const photo of photos) { - const concept = photo.concept_name || 'Default'; - if (!conceptPhotos[concept]) { - conceptPhotos[concept] = []; + const conceptPhotos = {}; + for (const photo of photos) { + const concept = photo.concept_name || 'Default'; + if (!conceptPhotos[concept]) { + conceptPhotos[concept] = []; + } + conceptPhotos[concept].push({ + id: photo.id, + original_url: photo.original_url, + medium_url: photo.medium_url, + thumb_url: photo.thumb_url, + width: photo.width, + height: photo.height, + type: photo.photo_type, + members: photo.members, + sortOrder: photo.sort_order, + }); } - conceptPhotos[concept].push({ - id: photo.id, - original_url: photo.original_url, - medium_url: photo.medium_url, - thumb_url: photo.thumb_url, - width: photo.width, - height: photo.height, - type: photo.photo_type, - members: photo.members, - sortOrder: photo.sort_order, - }); - } - album.conceptPhotos = conceptPhotos; + result.conceptPhotos = conceptPhotos; - return album; + return result; + }; + + if (redis) { + return getOrSet(redis, cacheKeys.albumDetail(album.id), fetchDetails, TTL.LONG); + } + return fetchDetails(); } /** - * 앨범 목록과 트랙 조회 (N+1 최적화) + * 앨범 목록과 트랙 조회 (N+1 최적화, 캐시 적용) * @param {object} db - 데이터베이스 연결 + * @param {object} redis - Redis 클라이언트 (선택적) * @returns {array} 트랙 포함된 앨범 목록 */ -export async function getAlbumsWithTracks(db) { - const [albums] = await db.query(` - SELECT id, title, folder_name, album_type, album_type_short, release_date, - cover_original_url, cover_medium_url, cover_thumb_url, description - FROM albums - ORDER BY release_date DESC - `); +export async function getAlbumsWithTracks(db, redis = null) { + const fetchAlbums = async () => { + const [albums] = await db.query(` + SELECT id, title, folder_name, album_type, album_type_short, release_date, + cover_original_url, cover_medium_url, cover_thumb_url, description + FROM albums + ORDER BY release_date DESC + `); - if (albums.length === 0) return albums; + if (albums.length === 0) return albums; - // 모든 트랙을 한 번에 조회 - const albumIds = albums.map(a => a.id); - const [allTracks] = await db.query( - `SELECT id, album_id, track_number, title, is_title_track, duration, lyricist, composer, arranger - FROM album_tracks WHERE album_id IN (?) ORDER BY album_id, track_number`, - [albumIds] - ); + // 모든 트랙을 한 번에 조회 + const albumIds = albums.map(a => a.id); + const [allTracks] = await db.query( + `SELECT id, album_id, track_number, title, is_title_track, duration, lyricist, composer, arranger + FROM album_tracks WHERE album_id IN (?) ORDER BY album_id, track_number`, + [albumIds] + ); - // 앨범 ID별로 트랙 그룹화 - const tracksByAlbum = {}; - for (const track of allTracks) { - if (!tracksByAlbum[track.album_id]) { - tracksByAlbum[track.album_id] = []; + // 앨범 ID별로 트랙 그룹화 + const tracksByAlbum = {}; + for (const track of allTracks) { + if (!tracksByAlbum[track.album_id]) { + tracksByAlbum[track.album_id] = []; + } + tracksByAlbum[track.album_id].push(track); } - tracksByAlbum[track.album_id].push(track); - } - // 각 앨범에 트랙 할당 - for (const album of albums) { - album.tracks = tracksByAlbum[album.id] || []; - } + // 각 앨범에 트랙 할당 + for (const album of albums) { + album.tracks = tracksByAlbum[album.id] || []; + } - return albums; + return albums; + }; + + if (redis) { + return getOrSet(redis, cacheKeys.albums, fetchAlbums, TTL.LONG); + } + return fetchAlbums(); +} + +/** + * 앨범 캐시 무효화 + * @param {object} redis - Redis 클라이언트 + * @param {number} albumId - 앨범 ID (선택적, 특정 앨범만 무효화) + */ +export async function invalidateAlbumCache(redis, albumId = null) { + const keys = [cacheKeys.albums]; + if (albumId) { + keys.push(cacheKeys.albumDetail(albumId)); + } + await invalidate(redis, keys); + // 앨범 이름 기반 캐시도 무효화 (패턴 매칭) + await invalidatePattern(redis, 'album:name:*'); } /** diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 637f3ee..6463d0e 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -3,17 +3,26 @@ * 스케줄 관련 비즈니스 로직 */ import config, { CATEGORY_IDS } from '../config/index.js'; +import { getOrSet, cacheKeys, TTL } from '../utils/cache.js'; /** - * 카테고리 목록 조회 + * 카테고리 목록 조회 (캐시 적용) * @param {object} db - 데이터베이스 연결 + * @param {object} redis - Redis 클라이언트 (선택적) * @returns {array} 카테고리 목록 */ -export async function getCategories(db) { - const [categories] = await db.query( - 'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC' - ); - return categories; +export async function getCategories(db, redis = null) { + const fetchCategories = async () => { + const [categories] = await db.query( + 'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC' + ); + return categories; + }; + + if (redis) { + return getOrSet(redis, cacheKeys.categories, fetchCategories, TTL.VERY_LONG); + } + return fetchCategories(); } /** diff --git a/backend/src/utils/cache.js b/backend/src/utils/cache.js index b55b4bb..db30612 100644 --- a/backend/src/utils/cache.js +++ b/backend/src/utils/cache.js @@ -54,8 +54,23 @@ export async function invalidatePattern(redis, pattern) { // 캐시 키 생성 헬퍼 export const cacheKeys = { + // 멤버 members: 'members:all', member: (name) => `member:${name}`, + // 일정 + categories: 'categories:all', scheduleDetail: (id) => `schedule:${id}`, scheduleMonthly: (year, month) => `schedule:monthly:${year}:${month}`, + // 앨범 + albums: 'albums:all', + albumDetail: (id) => `album:${id}`, + albumByName: (name) => `album:name:${name}`, +}; + +// TTL 상수 (초) +export const TTL = { + SHORT: 60, // 1분 + MEDIUM: 300, // 5분 + LONG: 600, // 10분 + VERY_LONG: 3600, // 1시간 }; diff --git a/docs/refactoring.md b/docs/refactoring.md index 29564a9..fa57d1c 100644 --- a/docs/refactoring.md +++ b/docs/refactoring.md @@ -200,14 +200,22 @@ - [x] 캐시 유틸리티 생성 (`src/utils/cache.js`) - [x] 멤버 목록 캐싱 (10분 TTL) - [x] 멤버 수정 시 캐시 무효화 +- [x] 카테고리 목록 캐싱 (1시간 TTL) +- [x] 앨범 목록 캐싱 (10분 TTL) +- [x] 앨범 상세 캐싱 (10분 TTL) +- [x] 앨범 생성/수정/삭제 시 캐시 무효화 - [ ] 일정 상세 조회 - X 프로필이 별도 캐시 사용하므로 보류 **생성된 파일:** -- `src/utils/cache.js` - getOrSet, invalidate, invalidatePattern, cacheKeys +- `src/utils/cache.js` - getOrSet, invalidate, invalidatePattern, cacheKeys, TTL 상수 **수정된 파일:** - `src/services/member.js` - getAllMembers에 캐시 적용, invalidateMemberCache 추가 +- `src/services/schedule.js` - getCategories에 캐시 적용 +- `src/services/album.js` - getAlbumsWithTracks, getAlbumDetails에 캐시 적용, invalidateAlbumCache 추가 - `src/routes/members/index.js` - Redis 캐시 사용, 수정 시 캐시 무효화 +- `src/routes/schedules/index.js` - 카테고리 조회 시 캐시 사용 +- `src/routes/albums/index.js` - 캐시 사용, 생성/수정/삭제 시 캐시 무효화 ---