refactor(backend): 트랜잭션 헬퍼, JSON 스키마 추가 및 스키마 파일 분리

- src/utils/transaction.js: withTransaction 헬퍼 함수 추가
- src/schemas/: 도메인별 스키마 파일 분리 (common, album, schedule, admin, member, auth)
- 라우트에 JSON Schema 검증 및 Swagger 문서화 적용
- 트랜잭션 패턴을 withTransaction 헬퍼로 추상화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 14:58:07 +09:00
parent 84113a8c48
commit f483f2cf53
20 changed files with 707 additions and 152 deletions

View file

@ -7,6 +7,7 @@ import fastifySwagger from '@fastify/swagger';
import scalarApiReference from '@scalar/fastify-api-reference';
import multipart from '@fastify/multipart';
import config from './config/index.js';
import * as schemas from './schemas/index.js';
// 플러그인
import dbPlugin from './plugins/db.js';
@ -50,6 +51,14 @@ export async function buildApp(opts = {}) {
await fastify.register(xBotPlugin);
await fastify.register(schedulerPlugin);
// 공유 스키마 등록 (라우트에서 $ref로 참조 가능)
fastify.addSchema({ $id: 'Album', ...schemas.albumResponse });
fastify.addSchema({ $id: 'AlbumTrack', ...schemas.albumTrack });
fastify.addSchema({ $id: 'Schedule', ...schemas.scheduleResponse });
fastify.addSchema({ $id: 'ScheduleCategory', ...schemas.scheduleCategory });
fastify.addSchema({ $id: 'Member', ...schemas.memberResponse });
fastify.addSchema({ $id: 'Photo', ...schemas.photoResponse });
// Swagger (OpenAPI) 설정
await fastify.register(fastifySwagger, {
openapi: {
@ -66,6 +75,9 @@ export async function buildApp(opts = {}) {
{ name: 'members', description: '멤버 API' },
{ name: 'albums', description: '앨범 API' },
{ name: 'schedules', description: '일정 API' },
{ name: 'admin/youtube', description: 'YouTube 관리 API' },
{ name: 'admin/x', description: 'X (Twitter) 관리 API' },
{ name: 'admin/bots', description: '봇 관리 API' },
{ name: 'stats', description: '통계 API' },
],
components: {

View file

@ -1,4 +1,30 @@
import bots from '../../config/bots.js';
import { errorResponse } from '../../schemas/index.js';
// 봇 관련 스키마
const botResponse = {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
type: { type: 'string', enum: ['youtube', 'x'] },
status: { type: 'string', enum: ['running', 'stopped', 'error'] },
last_check_at: { type: 'string', format: 'date-time' },
last_added_count: { type: 'integer' },
schedules_added: { type: 'integer' },
check_interval: { type: 'integer' },
error_message: { type: 'string' },
enabled: { type: 'boolean' },
},
};
const botIdParam = {
type: 'object',
properties: {
id: { type: 'string', description: '봇 ID' },
},
required: ['id'],
};
/**
* 관리 라우트
@ -16,7 +42,14 @@ export default async function botsRoutes(fastify) {
schema: {
tags: ['admin/bots'],
summary: '봇 목록 조회',
description: '등록된 모든 봇(YouTube, X)의 상태를 조회합니다.',
security: [{ bearerAuth: [] }],
response: {
200: {
type: 'array',
items: botResponse,
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@ -57,7 +90,19 @@ export default async function botsRoutes(fastify) {
schema: {
tags: ['admin/bots'],
summary: '봇 시작',
description: '지정된 봇의 스케줄러를 시작합니다.',
security: [{ bearerAuth: [] }],
params: botIdParam,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
},
},
400: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@ -79,7 +124,19 @@ export default async function botsRoutes(fastify) {
schema: {
tags: ['admin/bots'],
summary: '봇 정지',
description: '지정된 봇의 스케줄러를 정지합니다.',
security: [{ bearerAuth: [] }],
params: botIdParam,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
},
},
400: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@ -101,7 +158,22 @@ export default async function botsRoutes(fastify) {
schema: {
tags: ['admin/bots'],
summary: '봇 전체 동기화',
description: '봇이 관리하는 모든 콘텐츠를 다시 동기화합니다.',
security: [{ bearerAuth: [] }],
params: botIdParam,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
addedCount: { type: 'integer', description: '추가된 일정 수' },
total: { type: 'integer', description: '총 처리 수' },
},
},
400: errorResponse,
404: errorResponse,
500: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@ -151,7 +223,18 @@ export default async function botsRoutes(fastify) {
schema: {
tags: ['admin/bots'],
summary: '할당량 경고 조회',
description: 'YouTube API 할당량 경고 상태를 조회합니다.',
security: [{ bearerAuth: [] }],
response: {
200: {
type: 'object',
properties: {
active: { type: 'boolean' },
message: { type: 'string' },
timestamp: { type: 'string', format: 'date-time' },
},
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@ -170,7 +253,16 @@ export default async function botsRoutes(fastify) {
schema: {
tags: ['admin/bots'],
summary: '할당량 경고 해제',
description: 'YouTube API 할당량 경고를 해제합니다.',
security: [{ bearerAuth: [] }],
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
},
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {

View file

@ -2,6 +2,11 @@ import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
import { formatDate, formatTime } from '../../utils/date.js';
import config, { CATEGORY_IDS } from '../../config/index.js';
import {
errorResponse,
xPostInfoQuery,
xScheduleCreate,
} from '../../schemas/index.js';
const X_CATEGORY_ID = CATEGORY_IDS.X;
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
@ -21,14 +26,33 @@ export default async function xRoutes(fastify) {
schema: {
tags: ['admin/x'],
summary: 'X 게시글 정보 조회',
description: 'X(Twitter) 게시글 ID로 정보를 조회합니다.',
security: [{ bearerAuth: [] }],
querystring: {
querystring: xPostInfoQuery,
response: {
200: {
type: 'object',
properties: {
postId: { type: 'string', description: '게시글 ID' },
username: { type: 'string', description: '사용자명 (기본: realfromis_9)' },
postId: { type: 'string' },
username: { type: 'string' },
text: { type: 'string' },
title: { type: 'string' },
imageUrls: { type: 'array', items: { type: 'string' } },
date: { type: 'string' },
time: { type: 'string' },
postUrl: { type: 'string' },
profile: {
type: 'object',
properties: {
username: { type: 'string' },
displayName: { type: 'string' },
avatarUrl: { type: 'string' },
},
required: ['postId'],
},
},
},
400: errorResponse,
500: errorResponse,
},
},
preHandler: [fastify.authenticate],
@ -68,18 +92,19 @@ export default async function xRoutes(fastify) {
schema: {
tags: ['admin/x'],
summary: 'X 일정 저장',
description: 'X(Twitter) 게시글을 일정으로 등록합니다.',
security: [{ bearerAuth: [] }],
body: {
body: xScheduleCreate,
response: {
200: {
type: 'object',
properties: {
postId: { type: 'string' },
title: { type: 'string' },
content: { type: 'string' },
imageUrls: { type: 'array', items: { type: 'string' } },
date: { type: 'string' },
time: { type: 'string' },
success: { type: 'boolean' },
scheduleId: { type: 'integer' },
},
required: ['postId', 'title', 'date'],
},
409: errorResponse,
500: errorResponse,
},
},
preHandler: [fastify.authenticate],

View file

@ -1,6 +1,13 @@
import { fetchVideoInfo } from '../../services/youtube/api.js';
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
import { CATEGORY_IDS } from '../../config/index.js';
import {
errorResponse,
youtubeVideoInfo,
youtubeScheduleCreate,
youtubeScheduleUpdate,
idParam,
} from '../../schemas/index.js';
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
@ -17,13 +24,27 @@ export default async function youtubeRoutes(fastify) {
schema: {
tags: ['admin/youtube'],
summary: 'YouTube 영상 정보 조회',
description: 'YouTube URL에서 영상 정보를 추출합니다.',
security: [{ bearerAuth: [] }],
querystring: {
querystring: youtubeVideoInfo,
response: {
200: {
type: 'object',
properties: {
url: { type: 'string', description: 'YouTube URL' },
videoId: { type: 'string' },
title: { type: 'string' },
channelId: { type: 'string' },
channelName: { type: 'string' },
publishedAt: { type: 'string' },
date: { type: 'string' },
time: { type: 'string' },
videoType: { type: 'string' },
videoUrl: { type: 'string' },
},
required: ['url'],
},
400: errorResponse,
404: errorResponse,
500: errorResponse,
},
},
preHandler: [fastify.authenticate],
@ -67,19 +88,19 @@ export default async function youtubeRoutes(fastify) {
schema: {
tags: ['admin/youtube'],
summary: 'YouTube 일정 저장',
description: 'YouTube 영상을 일정으로 등록합니다.',
security: [{ bearerAuth: [] }],
body: {
body: youtubeScheduleCreate,
response: {
200: {
type: 'object',
properties: {
videoId: { type: 'string' },
title: { type: 'string' },
channelId: { type: 'string' },
channelName: { type: 'string' },
date: { type: 'string' },
time: { type: 'string' },
videoType: { type: 'string' },
success: { type: 'boolean' },
scheduleId: { type: 'integer' },
},
required: ['videoId', 'title', 'date'],
},
409: errorResponse,
500: errorResponse,
},
},
preHandler: [fastify.authenticate],
@ -142,20 +163,19 @@ export default async function youtubeRoutes(fastify) {
schema: {
tags: ['admin/youtube'],
summary: 'YouTube 일정 수정',
description: 'YouTube 일정의 멤버와 영상 유형을 수정합니다.',
security: [{ bearerAuth: [] }],
params: {
params: idParam,
body: youtubeScheduleUpdate,
response: {
200: {
type: 'object',
properties: {
id: { type: 'integer' },
success: { type: 'boolean' },
},
required: ['id'],
},
body: {
type: 'object',
properties: {
memberIds: { type: 'array', items: { type: 'integer' } },
videoType: { type: 'string', enum: ['video', 'shorts'] },
},
404: errorResponse,
500: errorResponse,
},
},
preHandler: [fastify.authenticate],

View file

@ -7,6 +7,7 @@ import {
} from '../../services/album.js';
import photosRoutes from './photos.js';
import teasersRoutes from './teasers.js';
import { errorResponse, successResponse, idParam } from '../../schemas/index.js';
/**
* 앨범 라우트
@ -28,6 +29,10 @@ export default async function albumsRoutes(fastify) {
schema: {
tags: ['albums'],
summary: '전체 앨범 목록 조회',
description: '모든 앨범과 트랙 목록을 조회합니다.',
response: {
200: { type: 'array', items: { type: 'object', additionalProperties: true } },
},
},
}, async () => {
return await getAlbumsWithTracks(db);
@ -40,6 +45,18 @@ export default async function albumsRoutes(fastify) {
schema: {
tags: ['albums'],
summary: '앨범명과 트랙명으로 트랙 조회',
description: '앨범명(또는 폴더명)과 트랙명으로 트랙 상세 정보를 조회합니다.',
params: {
type: 'object',
properties: {
albumName: { type: 'string', description: '앨범명 또는 폴더명' },
trackTitle: { type: 'string', description: '트랙 제목' },
},
required: ['albumName', 'trackTitle'],
},
response: {
404: errorResponse,
},
},
}, async (request, reply) => {
const albumName = decodeURIComponent(request.params.albumName);
@ -94,6 +111,17 @@ export default async function albumsRoutes(fastify) {
schema: {
tags: ['albums'],
summary: '앨범명으로 앨범 조회',
description: '앨범명(또는 폴더명)으로 앨범 상세 정보를 조회합니다.',
params: {
type: 'object',
properties: {
name: { type: 'string', description: '앨범명 또는 폴더명' },
},
required: ['name'],
},
response: {
200: { type: 'object', additionalProperties: true },
},
},
}, async (request, reply) => {
const name = decodeURIComponent(request.params.name);
@ -117,6 +145,11 @@ export default async function albumsRoutes(fastify) {
schema: {
tags: ['albums'],
summary: 'ID로 앨범 조회',
description: '앨범 ID로 상세 정보(트랙, 티저, 컨셉포토 포함)를 조회합니다.',
params: idParam,
response: {
200: { type: 'object', additionalProperties: true },
},
},
}, async (request, reply) => {
const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [
@ -139,7 +172,19 @@ export default async function albumsRoutes(fastify) {
schema: {
tags: ['albums'],
summary: '앨범 생성',
description: 'multipart/form-data로 앨범을 생성합니다. data 필드에 JSON, cover 필드에 이미지 파일.',
security: [{ bearerAuth: [] }],
consumes: ['multipart/form-data'],
response: {
200: {
type: 'object',
properties: {
message: { type: 'string' },
albumId: { type: 'integer' },
},
},
400: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@ -175,7 +220,15 @@ export default async function albumsRoutes(fastify) {
schema: {
tags: ['albums'],
summary: '앨범 수정',
description: 'multipart/form-data로 앨범을 수정합니다.',
security: [{ bearerAuth: [] }],
consumes: ['multipart/form-data'],
params: idParam,
response: {
200: successResponse,
400: errorResponse,
404: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@ -210,7 +263,13 @@ export default async function albumsRoutes(fastify) {
schema: {
tags: ['albums'],
summary: '앨범 삭제',
description: '앨범과 관련 데이터(트랙, 커버 이미지)를 삭제합니다.',
security: [{ bearerAuth: [] }],
params: idParam,
response: {
200: successResponse,
404: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {

View file

@ -3,6 +3,7 @@ import {
deleteAlbumPhoto,
uploadAlbumVideo,
} from '../../services/image.js';
import { withTransaction } from '../../utils/transaction.js';
/**
* 앨범 사진 라우트
@ -215,12 +216,9 @@ export default async function photosRoutes(fastify) {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { albumId, photoId } = request.params;
const connection = await db.getConnection();
try {
await connection.beginTransaction();
const [photos] = await connection.query(
// 사진 존재 여부 먼저 확인
const [photos] = await db.query(
`SELECT p.*, a.folder_name
FROM album_photos p
JOIN albums a ON p.album_id = a.id
@ -235,17 +233,12 @@ export default async function photosRoutes(fastify) {
const photo = photos[0];
const filename = photo.original_url.split('/').pop();
return withTransaction(db, async (connection) => {
await deleteAlbumPhoto(photo.folder_name, 'photo', filename);
await connection.query('DELETE FROM album_photo_members WHERE photo_id = ?', [photoId]);
await connection.query('DELETE FROM album_photos WHERE id = ?', [photoId]);
await connection.commit();
return { message: '사진이 삭제되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
});
}

View file

@ -2,6 +2,7 @@ import {
deleteAlbumPhoto,
deleteAlbumVideo,
} from '../../services/image.js';
import { withTransaction } from '../../utils/transaction.js';
/**
* 앨범 티저 라우트
@ -49,12 +50,9 @@ export default async function teasersRoutes(fastify) {
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { albumId, teaserId } = request.params;
const connection = await db.getConnection();
try {
await connection.beginTransaction();
const [teasers] = await connection.query(
// 티저 존재 여부 먼저 확인
const [teasers] = await db.query(
`SELECT t.*, a.folder_name
FROM album_teasers t
JOIN albums a ON t.album_id = a.id
@ -69,6 +67,7 @@ export default async function teasersRoutes(fastify) {
const teaser = teasers[0];
const filename = teaser.original_url.split('/').pop();
return withTransaction(db, async (connection) => {
await deleteAlbumPhoto(teaser.folder_name, 'teaser', filename);
if (teaser.video_url) {
@ -78,13 +77,7 @@ export default async function teasersRoutes(fastify) {
await connection.query('DELETE FROM album_teasers WHERE id = ?', [teaserId]);
await connection.commit();
return { message: '티저가 삭제되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
});
}

View file

@ -6,6 +6,12 @@ import suggestionsRoutes from './suggestions.js';
import { searchSchedules, syncAllSchedules } from '../../services/meilisearch/index.js';
import config, { CATEGORY_IDS } from '../../config/index.js';
import { getMonthlySchedules, getUpcomingSchedules } from '../../services/schedule.js';
import {
errorResponse,
scheduleSearchQuery,
scheduleSearchResponse,
idParam,
} from '../../schemas/index.js';
export default async function schedulesRoutes(fastify) {
const { db, meilisearch, redis } = fastify;
@ -21,6 +27,10 @@ export default async function schedulesRoutes(fastify) {
schema: {
tags: ['schedules'],
summary: '카테고리 목록 조회',
description: '일정 카테고리 목록을 조회합니다.',
response: {
200: { type: 'array', items: { type: 'object', additionalProperties: true } },
},
},
}, async (request, reply) => {
const [categories] = await db.query(
@ -38,16 +48,10 @@ export default async function schedulesRoutes(fastify) {
schema: {
tags: ['schedules'],
summary: '일정 조회 (검색 또는 월별)',
querystring: {
type: 'object',
properties: {
search: { type: 'string', description: '검색어' },
year: { type: 'integer', description: '년도' },
month: { type: 'integer', minimum: 1, maximum: 12, description: '월' },
startDate: { type: 'string', description: '시작 날짜 (YYYY-MM-DD)' },
offset: { type: 'integer', default: 0, description: '페이지 오프셋' },
limit: { type: 'integer', default: 100, description: '결과 개수' },
},
description: 'search 파라미터로 검색, year/month로 월별 조회, startDate로 다가오는 일정 조회',
querystring: scheduleSearchQuery,
response: {
200: { type: 'object', additionalProperties: true },
},
},
}, async (request, reply) => {
@ -79,7 +83,17 @@ export default async function schedulesRoutes(fastify) {
schema: {
tags: ['schedules'],
summary: 'Meilisearch 전체 동기화',
description: 'DB의 모든 일정을 Meilisearch에 동기화합니다.',
security: [{ bearerAuth: [] }],
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
synced: { type: 'integer', description: '동기화된 일정 수' },
},
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
@ -95,6 +109,11 @@ export default async function schedulesRoutes(fastify) {
schema: {
tags: ['schedules'],
summary: '일정 상세 조회',
description: '일정 ID로 상세 정보를 조회합니다. 카테고리에 따라 추가 정보(YouTube/X)가 포함됩니다.',
params: idParam,
response: {
200: { type: 'object', additionalProperties: true },
},
},
}, async (request, reply) => {
const { id } = request.params;
@ -191,7 +210,18 @@ export default async function schedulesRoutes(fastify) {
schema: {
tags: ['schedules'],
summary: '일정 삭제',
description: '일정과 관련 데이터(YouTube/X 정보, 멤버, 이미지)를 삭제합니다.',
security: [{ bearerAuth: [] }],
params: idParam,
response: {
200: {
type: 'object',
properties: {
success: { type: 'boolean' },
},
},
404: errorResponse,
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {

View file

@ -0,0 +1,59 @@
/**
* 관리자 API 스키마 (YouTube, X)
*/
// ==================== YouTube ====================
export const youtubeVideoInfo = {
type: 'object',
properties: {
url: { type: 'string', description: 'YouTube URL' },
},
required: ['url'],
};
export const youtubeScheduleCreate = {
type: 'object',
properties: {
videoId: { type: 'string', minLength: 11, maxLength: 11, description: 'YouTube 영상 ID' },
title: { type: 'string', minLength: 1, maxLength: 500, description: '제목' },
channelId: { type: 'string', description: '채널 ID' },
channelName: { type: 'string', maxLength: 200, description: '채널명' },
date: { type: 'string', format: 'date', description: '날짜 (YYYY-MM-DD)' },
time: { type: 'string', pattern: '^\\d{2}:\\d{2}(:\\d{2})?$', description: '시간 (HH:MM 또는 HH:MM:SS)' },
videoType: { type: 'string', enum: ['video', 'shorts'], default: 'video', description: '영상 유형' },
},
required: ['videoId', 'title', 'date'],
};
export const youtubeScheduleUpdate = {
type: 'object',
properties: {
memberIds: { type: 'array', items: { type: 'integer' }, description: '멤버 ID 목록' },
videoType: { type: 'string', enum: ['video', 'shorts'], description: '영상 유형' },
},
};
// ==================== X (Twitter) ====================
export const xPostInfoQuery = {
type: 'object',
properties: {
postId: { type: 'string', pattern: '^\\d+$', description: '게시글 ID' },
username: { type: 'string', default: 'realfromis_9', description: '사용자명' },
},
required: ['postId'],
};
export const xScheduleCreate = {
type: 'object',
properties: {
postId: { type: 'string', pattern: '^\\d+$', description: '게시글 ID' },
title: { type: 'string', minLength: 1, maxLength: 500, description: '제목' },
content: { type: 'string', maxLength: 5000, description: '게시글 내용' },
imageUrls: { type: 'array', items: { type: 'string', format: 'uri' }, description: '이미지 URL 목록' },
date: { type: 'string', format: 'date', description: '날짜 (YYYY-MM-DD)' },
time: { type: 'string', pattern: '^\\d{2}:\\d{2}(:\\d{2})?$', description: '시간' },
},
required: ['postId', 'title', 'date'],
};

View file

@ -0,0 +1,75 @@
/**
* 앨범 스키마
*/
export const albumTrack = {
type: 'object',
properties: {
track_number: { type: 'integer', minimum: 1, description: '트랙 번호' },
title: { type: 'string', minLength: 1, maxLength: 200, description: '트랙 제목' },
duration: { type: 'string', pattern: '^\\d{1,2}:\\d{2}$', description: '재생 시간 (M:SS 또는 MM:SS)' },
is_title_track: { type: 'boolean', description: '타이틀곡 여부' },
lyricist: { type: 'string', maxLength: 500, description: '작사가' },
composer: { type: 'string', maxLength: 500, description: '작곡가' },
arranger: { type: 'string', maxLength: 500, description: '편곡가' },
lyrics: { type: 'string', description: '가사' },
music_video_url: { type: 'string', format: 'uri', description: '뮤직비디오 URL' },
},
required: ['track_number', 'title'],
};
export const albumCreate = {
type: 'object',
properties: {
title: { type: 'string', minLength: 1, maxLength: 200, description: '앨범 제목' },
album_type: { type: 'string', description: '앨범 유형 (정규, 미니, 싱글 등)' },
album_type_short: { type: 'string', maxLength: 20, description: '앨범 유형 약자' },
release_date: { type: 'string', format: 'date', description: '발매일 (YYYY-MM-DD)' },
folder_name: { type: 'string', pattern: '^[a-zA-Z0-9_-]+$', description: '폴더명 (영문, 숫자, -, _만 허용)' },
description: { type: 'string', maxLength: 2000, description: '앨범 설명' },
tracks: { type: 'array', items: albumTrack, description: '트랙 목록' },
},
required: ['title', 'album_type', 'release_date', 'folder_name'],
};
export const albumResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
title: { type: 'string' },
album_type: { type: 'string' },
album_type_short: { type: 'string' },
release_date: { type: 'string' },
folder_name: { type: 'string' },
cover_original_url: { type: 'string' },
cover_medium_url: { type: 'string' },
cover_thumb_url: { type: 'string' },
description: { type: 'string' },
tracks: { type: 'array', items: albumTrack },
},
};
export const photoMetadata = {
type: 'object',
properties: {
conceptName: { type: 'string', maxLength: 100, description: '컨셉 이름' },
groupType: { type: 'string', enum: ['group', 'unit', 'solo'], description: '사진 유형' },
members: { type: 'array', items: { type: 'integer' }, description: '멤버 ID 목록' },
},
};
export const photoResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
original_url: { type: 'string' },
medium_url: { type: 'string' },
thumb_url: { type: 'string' },
photo_type: { type: 'string' },
concept_name: { type: 'string' },
sort_order: { type: 'integer' },
width: { type: 'integer' },
height: { type: 'integer' },
members: { type: 'array', items: { type: 'integer' } },
},
};

View file

@ -0,0 +1,20 @@
/**
* 인증 스키마
*/
export const loginRequest = {
type: 'object',
properties: {
username: { type: 'string', minLength: 1, maxLength: 50, description: '사용자명' },
password: { type: 'string', minLength: 1, maxLength: 100, description: '비밀번호' },
},
required: ['username', 'password'],
};
export const loginResponse = {
type: 'object',
properties: {
token: { type: 'string', description: 'JWT 토큰' },
expiresAt: { type: 'string', format: 'date-time', description: '만료 시간' },
},
};

View file

@ -0,0 +1,34 @@
/**
* 공통 스키마
*/
export const errorResponse = {
type: 'object',
properties: {
error: { type: 'string', description: '에러 메시지' },
},
required: ['error'],
};
export const successResponse = {
type: 'object',
properties: {
message: { type: 'string', description: '성공 메시지' },
},
};
export const paginationQuery = {
type: 'object',
properties: {
offset: { type: 'integer', default: 0, minimum: 0, description: '페이지 오프셋' },
limit: { type: 'integer', default: 20, minimum: 1, maximum: 100, description: '결과 개수' },
},
};
export const idParam = {
type: 'object',
properties: {
id: { type: 'integer', minimum: 1, description: 'ID' },
},
required: ['id'],
};

View file

@ -0,0 +1,11 @@
/**
* JSON Schema 정의
* 입력 검증 Swagger 문서화에 사용
*/
export * from './common.js';
export * from './album.js';
export * from './schedule.js';
export * from './admin.js';
export * from './member.js';
export * from './auth.js';

View file

@ -0,0 +1,15 @@
/**
* 멤버 스키마
*/
export const memberResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
name_en: { type: 'string' },
birth_date: { type: 'string' },
position: { type: 'string' },
is_former: { type: 'boolean' },
},
};

View file

@ -0,0 +1,57 @@
/**
* 일정 스키마
*/
export const scheduleCategory = {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
color: { type: 'string' },
sort_order: { type: 'integer' },
},
};
export const scheduleMember = {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
},
};
export const scheduleResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
title: { type: 'string' },
datetime: { type: 'string' },
category: scheduleCategory,
members: { type: 'array', items: scheduleMember },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
};
export const scheduleSearchQuery = {
type: 'object',
properties: {
search: { type: 'string', description: '검색어' },
year: { type: 'integer', minimum: 2000, maximum: 2100, description: '년도' },
month: { type: 'integer', minimum: 1, maximum: 12, description: '월' },
startDate: { type: 'string', format: 'date', description: '시작 날짜' },
offset: { type: 'integer', default: 0, minimum: 0, description: '페이지 오프셋' },
limit: { type: 'integer', default: 100, minimum: 1, maximum: 1000, description: '결과 개수' },
},
};
export const scheduleSearchResponse = {
type: 'object',
properties: {
schedules: { type: 'array', items: scheduleResponse },
total: { type: 'integer' },
offset: { type: 'integer' },
limit: { type: 'integer' },
hasMore: { type: 'boolean' },
},
};

View file

@ -3,6 +3,7 @@
* 앨범 관련 비즈니스 로직
*/
import { uploadAlbumCover, deleteAlbumCover } from './image.js';
import { withTransaction } from '../utils/transaction.js';
/**
* 앨범 상세 정보 조회 (트랙, 티저, 컨셉포토 포함)
@ -143,11 +144,7 @@ async function insertTracks(connection, albumId, tracks) {
export async function createAlbum(db, data, coverBuffer) {
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
const connection = await db.getConnection();
try {
await connection.beginTransaction();
return withTransaction(db, async (connection) => {
// 커버 이미지 업로드
let coverOriginalUrl = null;
let coverMediumUrl = null;
@ -174,14 +171,8 @@ export async function createAlbum(db, data, coverBuffer) {
// 트랙 일괄 삽입
await insertTracks(connection, albumId, tracks);
await connection.commit();
return { message: '앨범이 생성되었습니다.', albumId };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
}
/**
@ -190,25 +181,20 @@ export async function createAlbum(db, data, coverBuffer) {
* @param {number} id - 앨범 ID
* @param {object} data - 앨범 데이터
* @param {Buffer|null} coverBuffer - 커버 이미지 버퍼
* @returns {object} 결과 메시지
* @returns {object|null} 결과 메시지 또는 null(앨범 없음)
*/
export async function updateAlbum(db, id, data, coverBuffer) {
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
const connection = await db.getConnection();
try {
await connection.beginTransaction();
// 기존 앨범 확인
const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]);
// 앨범 존재 여부 먼저 확인 (트랜잭션 외부)
const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
if (existingAlbums.length === 0) {
connection.release();
return null; // 앨범 없음
return null;
}
const existing = existingAlbums[0];
return withTransaction(db, async (connection) => {
// 커버 이미지 처리
let coverOriginalUrl = existing.cover_original_url;
let coverMediumUrl = existing.cover_medium_url;
@ -235,14 +221,8 @@ export async function updateAlbum(db, id, data, coverBuffer) {
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
await insertTracks(connection, id, tracks);
await connection.commit();
return { message: '앨범이 수정되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
}
/**
@ -252,19 +232,15 @@ export async function updateAlbum(db, id, data, coverBuffer) {
* @returns {object|null} 결과 메시지 또는 null(앨범 없음)
*/
export async function deleteAlbum(db, id) {
const connection = await db.getConnection();
try {
await connection.beginTransaction();
const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]);
// 앨범 존재 여부 먼저 확인 (트랜잭션 외부)
const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
if (existingAlbums.length === 0) {
connection.release();
return null;
}
const album = existingAlbums[0];
return withTransaction(db, async (connection) => {
// 커버 이미지 삭제
if (album.cover_original_url && album.folder_name) {
await deleteAlbumCover(album.folder_name);
@ -274,12 +250,6 @@ export async function deleteAlbum(db, id) {
await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]);
await connection.query('DELETE FROM albums WHERE id = ?', [id]);
await connection.commit();
return { message: '앨범이 삭제되었습니다.' };
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
});
}

View file

@ -0,0 +1,34 @@
/**
* 트랜잭션 헬퍼 유틸리티
* 반복되는 트랜잭션 패턴 추상화
*/
/**
* 트랜잭션 래퍼 함수
* @param {object} db - 데이터베이스 연결
* @param {function} callback - 트랜잭션 내에서 실행할 함수 (connection을 인자로 받음)
* @returns {Promise<any>} callback의 반환값
* @throws callback에서 발생한 에러 (자동 롤백 재throw)
*
* @example
* const result = await withTransaction(db, async (connection) => {
* await connection.query('INSERT INTO ...');
* await connection.query('UPDATE ...');
* return { success: true };
* });
*/
export async function withTransaction(db, callback) {
const connection = await db.getConnection();
try {
await connection.beginTransaction();
const result = await callback(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}

View file

@ -112,6 +112,57 @@
---
### 12단계: 트랜잭션 헬퍼 추상화 ✅ 완료
- [x] `src/utils/transaction.js` 생성 - withTransaction 함수
- [x] 반복되는 트랜잭션 패턴 추상화 적용
**수정된 파일:**
- `src/utils/transaction.js` - 트랜잭션 헬퍼 유틸리티 생성
- `src/services/album.js` - createAlbum, updateAlbum, deleteAlbum에 withTransaction 적용
- `src/routes/albums/photos.js` - DELETE 핸들러에 withTransaction 적용
- `src/routes/albums/teasers.js` - DELETE 핸들러에 withTransaction 적용
---
### 13단계: Swagger/OpenAPI 문서화 개선 ✅ 완료
- [x] `src/schemas/index.js` 생성 - 공통 스키마 정의
- [x] `src/app.js` - Swagger components에 스키마 등록
- [x] 태그 추가 (admin/youtube, admin/x, admin/bots)
**수정된 파일:**
- `src/schemas/index.js` - JSON Schema 정의 (Error, Success, Album, Schedule 등)
- `src/app.js` - Swagger 설정에 스키마 컴포넌트 추가
---
### 14단계: 입력 검증 강화 (JSON Schema) ✅ 완료
- [x] 라우트에 params, querystring, body, response 스키마 추가
- [x] 상세 description 추가로 API 문서 품질 향상
**수정된 파일:**
- `src/routes/albums/index.js` - GET/POST/PUT/DELETE 스키마 추가
- `src/routes/schedules/index.js` - 검색/조회/삭제 스키마 추가
- `src/routes/admin/youtube.js` - 영상 조회/일정 등록/수정 스키마 추가
- `src/routes/admin/x.js` - 게시글 조회/일정 등록 스키마 추가
- `src/routes/admin/bots.js` - 봇 관리 스키마 추가
---
### 15단계: 스키마 파일 분리 ✅ 완료
- [x] 단일 스키마 파일을 도메인별로 분리
- [x] Re-export 패턴으로 기존 import 호환성 유지
**생성된 파일:**
- `src/schemas/common.js` - 공통 스키마 (errorResponse, successResponse, paginationQuery, idParam)
- `src/schemas/album.js` - 앨범 관련 스키마
- `src/schemas/schedule.js` - 일정 관련 스키마
- `src/schemas/admin.js` - 관리자 API 스키마 (YouTube, X)
- `src/schemas/member.js` - 멤버 스키마
- `src/schemas/auth.js` - 인증 스키마
- `src/schemas/index.js` - 모든 스키마 re-export
---
## 진행 상황
| 단계 | 작업 | 상태 |
@ -127,6 +178,10 @@
| 9단계 | 응답 형식 통일 | ✅ 완료 |
| 10단계 | 로거 통일 | ✅ 완료 |
| 11단계 | 대형 핸들러 분리 | ✅ 완료 |
| 12단계 | 트랜잭션 헬퍼 추상화 | ✅ 완료 |
| 13단계 | Swagger/OpenAPI 문서화 | ✅ 완료 |
| 14단계 | 입력 검증 강화 (JSON Schema) | ✅ 완료 |
| 15단계 | 스키마 파일 분리 | ✅ 완료 |
---
@ -135,3 +190,4 @@
- 각 단계별로 커밋 후 다음 단계 진행
- 기존 API 응답 형식은 유지
- 프론트엔드 수정 불필요하도록 진행
- API 문서는 `/docs`에서 확인 가능 (Scalar API Reference)

View file

@ -352,7 +352,7 @@ function AlbumDetail() {
transition={{ delay: 0.2, duration: 0.4 }}
>
<h2 className="text-xl font-bold mb-4">수록곡</h2>
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
<div className="bg-white rounded-2xl shadow-md overflow-hidden">
{album.tracks?.map((track, index) => (
<div
key={track.id}

View file

@ -621,7 +621,7 @@ function Schedule() {
return (
<div className="h-[calc(100vh-64px)] overflow-hidden flex flex-col">
<div className="flex-1 flex flex-col overflow-hidden max-w-7xl mx-auto px-6 py-8 w-full">
<div className="flex-1 flex flex-col overflow-hidden max-w-7xl mx-auto px-6 pt-16 pb-8 w-full">
{/* 헤더 */}
<div className="flex-shrink-0 text-center mb-8">
<motion.h1