feat(backend): Redis 캐시 확대 - 카테고리, 앨범 목록/상세 캐싱
캐시 적용: - 카테고리 목록: 1시간 TTL - 앨범 목록: 10분 TTL - 앨범 상세: 10분 TTL 캐시 무효화: - 앨범 생성/수정/삭제 시 자동 무효화 - invalidateAlbumCache 함수 추가 utils/cache.js: - TTL 상수 추가 (SHORT, MEDIUM, LONG, VERY_LONG) - 앨범 관련 캐시 키 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a62cf7142b
commit
3ee41beb46
6 changed files with 159 additions and 89 deletions
|
|
@ -6,6 +6,7 @@ import {
|
||||||
createAlbum,
|
createAlbum,
|
||||||
updateAlbum,
|
updateAlbum,
|
||||||
deleteAlbum,
|
deleteAlbum,
|
||||||
|
invalidateAlbumCache,
|
||||||
} from '../../services/album.js';
|
} from '../../services/album.js';
|
||||||
import photosRoutes from './photos.js';
|
import photosRoutes from './photos.js';
|
||||||
import teasersRoutes from './teasers.js';
|
import teasersRoutes from './teasers.js';
|
||||||
|
|
@ -16,7 +17,7 @@ import { errorResponse, successResponse, idParam } from '../../schemas/index.js'
|
||||||
* GET: 공개, POST/PUT/DELETE: 인증 필요
|
* GET: 공개, POST/PUT/DELETE: 인증 필요
|
||||||
*/
|
*/
|
||||||
export default async function albumsRoutes(fastify) {
|
export default async function albumsRoutes(fastify) {
|
||||||
const { db } = fastify;
|
const { db, redis } = fastify;
|
||||||
|
|
||||||
// 하위 라우트 등록
|
// 하위 라우트 등록
|
||||||
fastify.register(photosRoutes);
|
fastify.register(photosRoutes);
|
||||||
|
|
@ -37,7 +38,7 @@ export default async function albumsRoutes(fastify) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async () => {
|
}, async () => {
|
||||||
return await getAlbumsWithTracks(db);
|
return await getAlbumsWithTracks(db, redis);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -124,7 +125,7 @@ export default async function albumsRoutes(fastify) {
|
||||||
if (!album) {
|
if (!album) {
|
||||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
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) {
|
if (!album) {
|
||||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
return getAlbumDetails(db, album);
|
return getAlbumDetails(db, album, redis);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== POST/PUT/DELETE (인증 필요) ====================
|
// ==================== POST/PUT/DELETE (인증 필요) ====================
|
||||||
|
|
@ -195,7 +196,9 @@ export default async function albumsRoutes(fastify) {
|
||||||
return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' });
|
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) {
|
if (!result) {
|
||||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
await invalidateAlbumCache(redis, id);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -263,6 +267,7 @@ export default async function albumsRoutes(fastify) {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
|
||||||
}
|
}
|
||||||
|
await invalidateAlbumCache(redis, id);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export default async function schedulesRoutes(fastify) {
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
return await getCategories(db);
|
return await getCategories(db, redis);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fastify.log.error(err);
|
fastify.log.error(err);
|
||||||
return reply.code(500).send({ error: '카테고리 목록 조회 실패' });
|
return reply.code(500).send({ error: '카테고리 목록 조회 실패' });
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
import { uploadAlbumCover, deleteAlbumCover } from './image.js';
|
import { uploadAlbumCover, deleteAlbumCover } from './image.js';
|
||||||
import { withTransaction } from '../utils/transaction.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} db - 데이터베이스 연결
|
||||||
* @param {object} album - 앨범 기본 정보
|
* @param {object} album - 앨범 기본 정보
|
||||||
|
* @param {object} redis - Redis 클라이언트 (선택적)
|
||||||
* @returns {object} 상세 정보가 포함된 앨범
|
* @returns {object} 상세 정보가 포함된 앨범
|
||||||
*/
|
*/
|
||||||
export async function getAlbumDetails(db, album) {
|
export async function getAlbumDetails(db, album, redis = null) {
|
||||||
// 트랙, 티저, 포토 병렬 조회
|
const fetchDetails = async () => {
|
||||||
const [[tracks], [teasers], [photos]] = await Promise.all([
|
// 트랙, 티저, 포토 병렬 조회
|
||||||
db.query(
|
const [[tracks], [teasers], [photos]] = await Promise.all([
|
||||||
'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number',
|
db.query(
|
||||||
[album.id]
|
'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
|
db.query(
|
||||||
FROM album_teasers WHERE album_id = ? ORDER BY sort_order`,
|
`SELECT original_url, medium_url, thumb_url, video_url, media_type
|
||||||
[album.id]
|
FROM album_teasers WHERE album_id = ? ORDER BY sort_order`,
|
||||||
),
|
[album.id]
|
||||||
db.query(
|
),
|
||||||
`SELECT
|
db.query(
|
||||||
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order,
|
`SELECT
|
||||||
p.width, p.height,
|
p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order,
|
||||||
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
|
p.width, p.height,
|
||||||
FROM album_photos p
|
GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members
|
||||||
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
FROM album_photos p
|
||||||
LEFT JOIN members m ON pm.member_id = m.id
|
LEFT JOIN album_photo_members pm ON p.id = pm.photo_id
|
||||||
WHERE p.album_id = ?
|
LEFT JOIN members m ON pm.member_id = m.id
|
||||||
GROUP BY p.id
|
WHERE p.album_id = ?
|
||||||
ORDER BY p.sort_order`,
|
GROUP BY p.id
|
||||||
[album.id]
|
ORDER BY p.sort_order`,
|
||||||
),
|
[album.id]
|
||||||
]);
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
album.tracks = tracks;
|
const result = { ...album };
|
||||||
album.teasers = teasers;
|
result.tracks = tracks;
|
||||||
|
result.teasers = teasers;
|
||||||
|
|
||||||
const conceptPhotos = {};
|
const conceptPhotos = {};
|
||||||
for (const photo of photos) {
|
for (const photo of photos) {
|
||||||
const concept = photo.concept_name || 'Default';
|
const concept = photo.concept_name || 'Default';
|
||||||
if (!conceptPhotos[concept]) {
|
if (!conceptPhotos[concept]) {
|
||||||
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({
|
result.conceptPhotos = conceptPhotos;
|
||||||
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;
|
|
||||||
|
|
||||||
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} db - 데이터베이스 연결
|
||||||
|
* @param {object} redis - Redis 클라이언트 (선택적)
|
||||||
* @returns {array} 트랙 포함된 앨범 목록
|
* @returns {array} 트랙 포함된 앨범 목록
|
||||||
*/
|
*/
|
||||||
export async function getAlbumsWithTracks(db) {
|
export async function getAlbumsWithTracks(db, redis = null) {
|
||||||
const [albums] = await db.query(`
|
const fetchAlbums = async () => {
|
||||||
SELECT id, title, folder_name, album_type, album_type_short, release_date,
|
const [albums] = await db.query(`
|
||||||
cover_original_url, cover_medium_url, cover_thumb_url, description
|
SELECT id, title, folder_name, album_type, album_type_short, release_date,
|
||||||
FROM albums
|
cover_original_url, cover_medium_url, cover_thumb_url, description
|
||||||
ORDER BY release_date DESC
|
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 albumIds = albums.map(a => a.id);
|
||||||
const [allTracks] = await db.query(
|
const [allTracks] = await db.query(
|
||||||
`SELECT id, album_id, track_number, title, is_title_track, duration, lyricist, composer, arranger
|
`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`,
|
FROM album_tracks WHERE album_id IN (?) ORDER BY album_id, track_number`,
|
||||||
[albumIds]
|
[albumIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 앨범 ID별로 트랙 그룹화
|
// 앨범 ID별로 트랙 그룹화
|
||||||
const tracksByAlbum = {};
|
const tracksByAlbum = {};
|
||||||
for (const track of allTracks) {
|
for (const track of allTracks) {
|
||||||
if (!tracksByAlbum[track.album_id]) {
|
if (!tracksByAlbum[track.album_id]) {
|
||||||
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) {
|
for (const album of albums) {
|
||||||
album.tracks = tracksByAlbum[album.id] || [];
|
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:*');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,26 @@
|
||||||
* 스케줄 관련 비즈니스 로직
|
* 스케줄 관련 비즈니스 로직
|
||||||
*/
|
*/
|
||||||
import config, { CATEGORY_IDS } from '../config/index.js';
|
import config, { CATEGORY_IDS } from '../config/index.js';
|
||||||
|
import { getOrSet, cacheKeys, TTL } from '../utils/cache.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 카테고리 목록 조회
|
* 카테고리 목록 조회 (캐시 적용)
|
||||||
* @param {object} db - 데이터베이스 연결
|
* @param {object} db - 데이터베이스 연결
|
||||||
|
* @param {object} redis - Redis 클라이언트 (선택적)
|
||||||
* @returns {array} 카테고리 목록
|
* @returns {array} 카테고리 목록
|
||||||
*/
|
*/
|
||||||
export async function getCategories(db) {
|
export async function getCategories(db, redis = null) {
|
||||||
const [categories] = await db.query(
|
const fetchCategories = async () => {
|
||||||
'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC'
|
const [categories] = await db.query(
|
||||||
);
|
'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC'
|
||||||
return categories;
|
);
|
||||||
|
return categories;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (redis) {
|
||||||
|
return getOrSet(redis, cacheKeys.categories, fetchCategories, TTL.VERY_LONG);
|
||||||
|
}
|
||||||
|
return fetchCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,23 @@ export async function invalidatePattern(redis, pattern) {
|
||||||
|
|
||||||
// 캐시 키 생성 헬퍼
|
// 캐시 키 생성 헬퍼
|
||||||
export const cacheKeys = {
|
export const cacheKeys = {
|
||||||
|
// 멤버
|
||||||
members: 'members:all',
|
members: 'members:all',
|
||||||
member: (name) => `member:${name}`,
|
member: (name) => `member:${name}`,
|
||||||
|
// 일정
|
||||||
|
categories: 'categories:all',
|
||||||
scheduleDetail: (id) => `schedule:${id}`,
|
scheduleDetail: (id) => `schedule:${id}`,
|
||||||
scheduleMonthly: (year, month) => `schedule:monthly:${year}:${month}`,
|
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시간
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -200,14 +200,22 @@
|
||||||
- [x] 캐시 유틸리티 생성 (`src/utils/cache.js`)
|
- [x] 캐시 유틸리티 생성 (`src/utils/cache.js`)
|
||||||
- [x] 멤버 목록 캐싱 (10분 TTL)
|
- [x] 멤버 목록 캐싱 (10분 TTL)
|
||||||
- [x] 멤버 수정 시 캐시 무효화
|
- [x] 멤버 수정 시 캐시 무효화
|
||||||
|
- [x] 카테고리 목록 캐싱 (1시간 TTL)
|
||||||
|
- [x] 앨범 목록 캐싱 (10분 TTL)
|
||||||
|
- [x] 앨범 상세 캐싱 (10분 TTL)
|
||||||
|
- [x] 앨범 생성/수정/삭제 시 캐시 무효화
|
||||||
- [ ] 일정 상세 조회 - 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/member.js` - getAllMembers에 캐시 적용, invalidateMemberCache 추가
|
||||||
|
- `src/services/schedule.js` - getCategories에 캐시 적용
|
||||||
|
- `src/services/album.js` - getAlbumsWithTracks, getAlbumDetails에 캐시 적용, invalidateAlbumCache 추가
|
||||||
- `src/routes/members/index.js` - Redis 캐시 사용, 수정 시 캐시 무효화
|
- `src/routes/members/index.js` - Redis 캐시 사용, 수정 시 캐시 무효화
|
||||||
|
- `src/routes/schedules/index.js` - 카테고리 조회 시 캐시 사용
|
||||||
|
- `src/routes/albums/index.js` - 캐시 사용, 생성/수정/삭제 시 캐시 무효화
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue