diff --git a/backend/src/routes/albums/index.js b/backend/src/routes/albums/index.js index 8f3d244..ee976fd 100644 --- a/backend/src/routes/albums/index.js +++ b/backend/src/routes/albums/index.js @@ -2,6 +2,7 @@ import { uploadAlbumCover, deleteAlbumCover, } from '../../services/image.js'; +import { getAlbumDetails, getAlbumsWithTracks } from '../../services/album.js'; import photosRoutes from './photos.js'; import teasersRoutes from './teasers.js'; @@ -16,60 +17,6 @@ export default async function albumsRoutes(fastify) { fastify.register(photosRoutes); fastify.register(teasersRoutes); - /** - * 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함) - */ - async function getAlbumDetails(album) { - const [tracks] = await db.query( - 'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number', - [album.id] - ); - album.tracks = tracks; - - const [teasers] = await 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] - ); - album.teasers = teasers; - - const [photos] = await 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] - ); - - 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, - }); - } - album.conceptPhotos = conceptPhotos; - - return album; - } - // ==================== GET (공개) ==================== /** @@ -81,38 +28,7 @@ export default async function albumsRoutes(fastify) { summary: '전체 앨범 목록 조회', }, }, 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; - - // N+1 쿼리 최적화: 모든 트랙을 한 번에 조회 - 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] = []; - } - tracksByAlbum[track.album_id].push(track); - } - - // 각 앨범에 트랙 할당 - for (const album of albums) { - album.tracks = tracksByAlbum[album.id] || []; - } - - return albums; + return await getAlbumsWithTracks(db); }); /** @@ -189,7 +105,7 @@ export default async function albumsRoutes(fastify) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } - return getAlbumDetails(albums[0]); + return getAlbumDetails(db, albums[0]); }); /** @@ -209,7 +125,7 @@ export default async function albumsRoutes(fastify) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } - return getAlbumDetails(albums[0]); + return getAlbumDetails(db, albums[0]); }); // ==================== POST/PUT/DELETE (인증 필요) ==================== diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index dfe2836..63351c6 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -5,6 +5,7 @@ import suggestionsRoutes from './suggestions.js'; import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js'; import { CATEGORY_IDS } from '../../config/index.js'; +import { getMonthlySchedules, getUpcomingSchedules } from '../../services/schedule.js'; export default async function schedulesRoutes(fastify) { const { db, meilisearch, redis } = fastify; @@ -59,7 +60,7 @@ export default async function schedulesRoutes(fastify) { // 다가오는 일정 조회 (startDate부터) if (startDate) { - return await handleUpcomingSchedules(db, startDate, parseInt(limit)); + return await getUpcomingSchedules(db, startDate, parseInt(limit)); } // 월별 조회 모드 @@ -67,7 +68,7 @@ export default async function schedulesRoutes(fastify) { return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' }); } - return await handleMonthlySchedules(db, parseInt(year), parseInt(month)); + return await getMonthlySchedules(db, parseInt(year), parseInt(month)); }); /** @@ -263,243 +264,3 @@ async function saveSearchQueryAsync(fastify, query) { console.error('[Search] 검색어 저장 실패:', err.message); } } - -/** - * 월별 일정 조회 (생일 포함) - */ -async function handleMonthlySchedules(db, year, month) { - const startDate = `${year}-${String(month).padStart(2, '0')}-01`; - const endDate = new Date(year, month, 0).toISOString().split('T')[0]; - - // 일정 조회 (YouTube, X 소스 정보 포함) - const [schedules] = await db.query(` - SELECT - s.id, - s.title, - s.date, - s.time, - s.category_id, - c.name as category_name, - c.color as category_color, - sy.channel_name as youtube_channel, - sy.video_id as youtube_video_id, - sy.video_type as youtube_video_type, - sx.post_id as x_post_id - FROM schedules s - LEFT JOIN schedule_categories c ON s.category_id = c.id - LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id - LEFT JOIN schedule_x sx ON s.id = sx.schedule_id - WHERE s.date BETWEEN ? AND ? - ORDER BY s.date ASC, s.time ASC - `, [startDate, endDate]); - - // 일정 멤버 조회 - const scheduleIds = schedules.map(s => s.id); - let memberMap = {}; - - if (scheduleIds.length > 0) { - const [scheduleMembers] = await db.query(` - SELECT sm.schedule_id, m.name - FROM schedule_members sm - JOIN members m ON sm.member_id = m.id - WHERE sm.schedule_id IN (?) - ORDER BY m.id - `, [scheduleIds]); - - // 일정별 멤버 그룹화 - for (const sm of scheduleMembers) { - if (!memberMap[sm.schedule_id]) { - memberMap[sm.schedule_id] = []; - } - memberMap[sm.schedule_id].push({ name: sm.name }); - } - } - - // 생일 조회 - const [birthdays] = await db.query(` - SELECT m.id, m.name, m.name_en, m.birth_date, - i.thumb_url as image_url - FROM members m - LEFT JOIN images i ON m.image_id = i.id - WHERE m.is_former = 0 AND MONTH(m.birth_date) = ? - `, [month]); - - // 날짜별로 그룹화 - const grouped = {}; - - // 일정 추가 - for (const s of schedules) { - const dateKey = s.date instanceof Date - ? s.date.toISOString().split('T')[0] - : s.date; - - if (!grouped[dateKey]) { - grouped[dateKey] = { - categories: [], - schedules: [], - }; - } - - // 멤버 정보 (5명 이상이면 프로미스나인) - const scheduleMembers = memberMap[s.id] || []; - const members = scheduleMembers.length >= 5 - ? [{ name: '프로미스나인' }] - : scheduleMembers; - - const schedule = { - id: s.id, - title: s.title, - time: s.time, - category: { - id: s.category_id, - name: s.category_name, - color: s.category_color, - }, - members, - }; - - // source 정보 추가 - if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) { - const videoUrl = s.youtube_video_type === 'shorts' - ? `https://www.youtube.com/shorts/${s.youtube_video_id}` - : `https://www.youtube.com/watch?v=${s.youtube_video_id}`; - schedule.source = { - name: s.youtube_channel || 'YouTube', - url: videoUrl, - }; - } else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) { - schedule.source = { - name: '', - url: `https://x.com/realfromis_9/status/${s.x_post_id}`, - }; - } - - grouped[dateKey].schedules.push(schedule); - - // 카테고리 카운트 - const existingCategory = grouped[dateKey].categories.find(c => c.id === s.category_id); - if (existingCategory) { - existingCategory.count++; - } else { - grouped[dateKey].categories.push({ - id: s.category_id, - name: s.category_name, - color: s.category_color, - count: 1, - }); - } - } - - // 생일 일정 추가 - for (const member of birthdays) { - const birthDate = new Date(member.birth_date); - const birthYear = birthDate.getFullYear(); - - // 조회 연도가 생년보다 이전이면 스킵 - if (year < birthYear) continue; - - const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate()); - const dateKey = birthdayThisYear.toISOString().split('T')[0]; - - if (!grouped[dateKey]) { - grouped[dateKey] = { - categories: [], - schedules: [], - }; - } - - // 생일 카테고리 - const BIRTHDAY_CATEGORY = { - id: CATEGORY_IDS.BIRTHDAY, - name: '생일', - color: '#f472b6', - }; - - const birthdaySchedule = { - id: `birthday-${member.id}`, - title: `HAPPY ${member.name_en} DAY`, - time: null, - category: BIRTHDAY_CATEGORY, - is_birthday: true, - member_name: member.name, - member_image: member.image_url, - }; - - grouped[dateKey].schedules.push(birthdaySchedule); - - // 생일 카테고리 카운트 - const existingBirthdayCategory = grouped[dateKey].categories.find(c => c.id === CATEGORY_IDS.BIRTHDAY); - if (existingBirthdayCategory) { - existingBirthdayCategory.count++; - } else { - grouped[dateKey].categories.push({ - ...BIRTHDAY_CATEGORY, - count: 1, - }); - } - } - - return grouped; -} - -/** - * 다가오는 일정 조회 (startDate부터 limit개) - */ -async function handleUpcomingSchedules(db, startDate, limit) { - // 일정 조회 - const [schedules] = await db.query(` - SELECT - s.id, - s.title, - s.date, - s.time, - s.category_id, - c.name as category_name, - c.color as category_color - FROM schedules s - LEFT JOIN schedule_categories c ON s.category_id = c.id - WHERE s.date >= ? - ORDER BY s.date ASC, s.time ASC - LIMIT ? - `, [startDate, limit]); - - // 멤버 정보 조회 - const scheduleIds = schedules.map(s => s.id); - let memberMap = {}; - - if (scheduleIds.length > 0) { - const [scheduleMembers] = await db.query(` - SELECT sm.schedule_id, m.name - FROM schedule_members sm - JOIN members m ON sm.member_id = m.id - WHERE sm.schedule_id IN (?) - ORDER BY m.id - `, [scheduleIds]); - - for (const sm of scheduleMembers) { - if (!memberMap[sm.schedule_id]) { - memberMap[sm.schedule_id] = []; - } - memberMap[sm.schedule_id].push({ name: sm.name }); - } - } - - // 결과 포맷팅 - return schedules.map(s => { - const scheduleMembers = memberMap[s.id] || []; - const members = scheduleMembers.length >= 5 - ? [{ name: '프로미스나인' }] - : scheduleMembers; - - return { - id: s.id, - title: s.title, - date: s.date, - time: s.time, - category_id: s.category_id, - category_name: s.category_name, - category_color: s.category_color, - members, - }; - }); -} diff --git a/backend/src/services/album.js b/backend/src/services/album.js new file mode 100644 index 0000000..3ff87d6 --- /dev/null +++ b/backend/src/services/album.js @@ -0,0 +1,101 @@ +/** + * 앨범 서비스 + * 앨범 관련 비즈니스 로직 + */ + +/** + * 앨범 상세 정보 조회 (트랙, 티저, 컨셉포토 포함) + * @param {object} db - 데이터베이스 연결 + * @param {object} album - 앨범 기본 정보 + * @returns {object} 상세 정보가 포함된 앨범 + */ +export async function getAlbumDetails(db, album) { + const [tracks] = await db.query( + 'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number', + [album.id] + ); + album.tracks = tracks; + + const [teasers] = await 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] + ); + album.teasers = teasers; + + const [photos] = await 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] + ); + + 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, + }); + } + album.conceptPhotos = conceptPhotos; + + return album; +} + +/** + * 앨범 목록과 트랙 조회 (N+1 최적화) + * @param {object} db - 데이터베이스 연결 + * @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 + `); + + 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] + ); + + // 앨범 ID별로 트랙 그룹화 + const tracksByAlbum = {}; + for (const track of allTracks) { + if (!tracksByAlbum[track.album_id]) { + tracksByAlbum[track.album_id] = []; + } + tracksByAlbum[track.album_id].push(track); + } + + // 각 앨범에 트랙 할당 + for (const album of albums) { + album.tracks = tracksByAlbum[album.id] || []; + } + + return albums; +} diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js new file mode 100644 index 0000000..d49edf9 --- /dev/null +++ b/backend/src/services/schedule.js @@ -0,0 +1,251 @@ +/** + * 스케줄 서비스 + * 스케줄 관련 비즈니스 로직 + */ +import { CATEGORY_IDS } from '../config/index.js'; + +/** + * 월별 일정 조회 (생일 포함) + * @param {object} db - 데이터베이스 연결 + * @param {number} year - 연도 + * @param {number} month - 월 + * @returns {object} 날짜별로 그룹화된 일정 + */ +export async function getMonthlySchedules(db, year, month) { + const startDate = `${year}-${String(month).padStart(2, '0')}-01`; + const endDate = new Date(year, month, 0).toISOString().split('T')[0]; + + // 일정 조회 (YouTube, X 소스 정보 포함) + const [schedules] = await db.query(` + SELECT + s.id, + s.title, + s.date, + s.time, + s.category_id, + c.name as category_name, + c.color as category_color, + sy.channel_name as youtube_channel, + sy.video_id as youtube_video_id, + sy.video_type as youtube_video_type, + sx.post_id as x_post_id + FROM schedules s + LEFT JOIN schedule_categories c ON s.category_id = c.id + LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id + LEFT JOIN schedule_x sx ON s.id = sx.schedule_id + WHERE s.date BETWEEN ? AND ? + ORDER BY s.date ASC, s.time ASC + `, [startDate, endDate]); + + // 일정 멤버 조회 + const scheduleIds = schedules.map(s => s.id); + let memberMap = {}; + + if (scheduleIds.length > 0) { + const [scheduleMembers] = await db.query(` + SELECT sm.schedule_id, m.name + FROM schedule_members sm + JOIN members m ON sm.member_id = m.id + WHERE sm.schedule_id IN (?) + ORDER BY m.id + `, [scheduleIds]); + + for (const sm of scheduleMembers) { + if (!memberMap[sm.schedule_id]) { + memberMap[sm.schedule_id] = []; + } + memberMap[sm.schedule_id].push({ name: sm.name }); + } + } + + // 생일 조회 + const [birthdays] = await db.query(` + SELECT m.id, m.name, m.name_en, m.birth_date, + i.thumb_url as image_url + FROM members m + LEFT JOIN images i ON m.image_id = i.id + WHERE m.is_former = 0 AND MONTH(m.birth_date) = ? + `, [month]); + + // 날짜별로 그룹화 + const grouped = {}; + + // 일정 추가 + for (const s of schedules) { + const dateKey = s.date instanceof Date + ? s.date.toISOString().split('T')[0] + : s.date; + + if (!grouped[dateKey]) { + grouped[dateKey] = { + categories: [], + schedules: [], + }; + } + + // 멤버 정보 (5명 이상이면 프로미스나인) + const scheduleMembers = memberMap[s.id] || []; + const members = scheduleMembers.length >= 5 + ? [{ name: '프로미스나인' }] + : scheduleMembers; + + const schedule = { + id: s.id, + title: s.title, + time: s.time, + category: { + id: s.category_id, + name: s.category_name, + color: s.category_color, + }, + members, + }; + + // source 정보 추가 + if (s.category_id === CATEGORY_IDS.YOUTUBE && s.youtube_video_id) { + const videoUrl = s.youtube_video_type === 'shorts' + ? `https://www.youtube.com/shorts/${s.youtube_video_id}` + : `https://www.youtube.com/watch?v=${s.youtube_video_id}`; + schedule.source = { + name: s.youtube_channel || 'YouTube', + url: videoUrl, + }; + } else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) { + schedule.source = { + name: '', + url: `https://x.com/realfromis_9/status/${s.x_post_id}`, + }; + } + + grouped[dateKey].schedules.push(schedule); + + // 카테고리 카운트 + const existingCategory = grouped[dateKey].categories.find(c => c.id === s.category_id); + if (existingCategory) { + existingCategory.count++; + } else { + grouped[dateKey].categories.push({ + id: s.category_id, + name: s.category_name, + color: s.category_color, + count: 1, + }); + } + } + + // 생일 일정 추가 + for (const member of birthdays) { + const birthDate = new Date(member.birth_date); + const birthYear = birthDate.getFullYear(); + + // 조회 연도가 생년보다 이전이면 스킵 + if (year < birthYear) continue; + + const birthdayThisYear = new Date(year, birthDate.getMonth(), birthDate.getDate()); + const dateKey = birthdayThisYear.toISOString().split('T')[0]; + + if (!grouped[dateKey]) { + grouped[dateKey] = { + categories: [], + schedules: [], + }; + } + + // 생일 카테고리 + const BIRTHDAY_CATEGORY = { + id: CATEGORY_IDS.BIRTHDAY, + name: '생일', + color: '#f472b6', + }; + + const birthdaySchedule = { + id: `birthday-${member.id}`, + title: `HAPPY ${member.name_en} DAY`, + time: null, + category: BIRTHDAY_CATEGORY, + is_birthday: true, + member_name: member.name, + member_image: member.image_url, + }; + + grouped[dateKey].schedules.push(birthdaySchedule); + + // 생일 카테고리 카운트 + const existingBirthdayCategory = grouped[dateKey].categories.find(c => c.id === CATEGORY_IDS.BIRTHDAY); + if (existingBirthdayCategory) { + existingBirthdayCategory.count++; + } else { + grouped[dateKey].categories.push({ + ...BIRTHDAY_CATEGORY, + count: 1, + }); + } + } + + return grouped; +} + +/** + * 다가오는 일정 조회 (startDate부터 limit개) + * @param {object} db - 데이터베이스 연결 + * @param {string} startDate - 시작 날짜 + * @param {number} limit - 조회 개수 + * @returns {array} 일정 목록 + */ +export async function getUpcomingSchedules(db, startDate, limit) { + const [schedules] = await db.query(` + SELECT + s.id, + s.title, + s.date, + s.time, + s.category_id, + c.name as category_name, + c.color as category_color + FROM schedules s + LEFT JOIN schedule_categories c ON s.category_id = c.id + WHERE s.date >= ? + ORDER BY s.date ASC, s.time ASC + LIMIT ? + `, [startDate, limit]); + + // 멤버 정보 조회 + const scheduleIds = schedules.map(s => s.id); + let memberMap = {}; + + if (scheduleIds.length > 0) { + const [scheduleMembers] = await db.query(` + SELECT sm.schedule_id, m.name + FROM schedule_members sm + JOIN members m ON sm.member_id = m.id + WHERE sm.schedule_id IN (?) + ORDER BY m.id + `, [scheduleIds]); + + for (const sm of scheduleMembers) { + if (!memberMap[sm.schedule_id]) { + memberMap[sm.schedule_id] = []; + } + memberMap[sm.schedule_id].push({ name: sm.name }); + } + } + + // 결과 포맷팅 + return schedules.map(s => { + const scheduleMembers = memberMap[s.id] || []; + const members = scheduleMembers.length >= 5 + ? [{ name: '프로미스나인' }] + : scheduleMembers; + + return { + id: s.id, + title: s.title, + date: s.date, + time: s.time, + category_id: s.category_id, + category_name: s.category_name, + category_color: s.category_color, + members, + }; + }); +} diff --git a/docs/refactoring.md b/docs/refactoring.md index 8faec80..e39e201 100644 --- a/docs/refactoring.md +++ b/docs/refactoring.md @@ -28,14 +28,18 @@ --- -### 3단계: 서비스 레이어 분리 -- [ ] `src/services/album.js` 생성 - 앨범 관련 비즈니스 로직 -- [ ] `src/services/schedule.js` 생성 - 스케줄 관련 비즈니스 로직 -- [ ] 라우트에서 서비스 호출로 변경 +### 3단계: 서비스 레이어 분리 ✅ 완료 +- [x] `src/services/album.js` 생성 - 앨범 관련 비즈니스 로직 +- [x] `src/services/schedule.js` 생성 - 스케줄 관련 비즈니스 로직 +- [x] 라우트에서 서비스 호출로 변경 -**관련 파일:** -- `src/routes/albums/index.js` -- `src/routes/schedules/index.js` +**생성된 파일:** +- `src/services/album.js` - getAlbumDetails, getAlbumsWithTracks +- `src/services/schedule.js` - getMonthlySchedules, getUpcomingSchedules + +**수정된 파일:** +- `src/routes/albums/index.js` - 서비스 import 및 사용 +- `src/routes/schedules/index.js` - 서비스 import 및 기존 함수 제거 (240줄 감소) --- @@ -67,7 +71,7 @@ |------|------|------| | 1단계 | 설정 통합 | ✅ 완료 | | 2단계 | N+1 쿼리 최적화 | ✅ 완료 | -| 3단계 | 서비스 레이어 분리 | 대기 | +| 3단계 | 서비스 레이어 분리 | ✅ 완료 | | 4단계 | 에러 처리 통일 | 대기 | | 5단계 | 중복 코드 제거 | 대기 |