diff --git a/backend/src/config/index.js b/backend/src/config/index.js index d1c7924..a6a4b28 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -5,6 +5,14 @@ export const CATEGORY_IDS = { BIRTHDAY: 8, }; +// 필수 환경변수 검증 +const requiredEnvVars = ['JWT_SECRET']; +for (const envVar of requiredEnvVars) { + if (!process.env[envVar]) { + throw new Error(`필수 환경변수 ${envVar}가 설정되지 않았습니다.`); + } +} + export default { server: { port: parseInt(process.env.PORT) || 80, @@ -34,7 +42,7 @@ export default { apiKey: process.env.YOUTUBE_API_KEY, }, jwt: { - secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026', + secret: process.env.JWT_SECRET, expiresIn: '30d', }, s3: { diff --git a/backend/src/services/album.js b/backend/src/services/album.js index b3bdb54..f755a75 100644 --- a/backend/src/services/album.js +++ b/backend/src/services/album.js @@ -2,7 +2,7 @@ * 앨범 서비스 * 앨범 관련 비즈니스 로직 */ -import { uploadAlbumCover, deleteAlbumCover } from './image.js'; +import { uploadAlbumCover, deleteAlbumCover, deleteAlbumPhoto, deleteAlbumVideo } from './image.js'; import { withTransaction } from '../utils/transaction.js'; import { getOrSet, invalidate, invalidatePattern, cacheKeys, TTL } from '../utils/cache.js'; @@ -283,6 +283,17 @@ export async function updateAlbum(db, id, data, coverBuffer) { }); } +/** + * URL에서 파일명 추출 + * @param {string} url - S3 URL + * @returns {string|null} 파일명 + */ +function extractFilenameFromUrl(url) { + if (!url) return null; + const parts = url.split('/'); + return parts[parts.length - 1]; +} + /** * 앨범 삭제 * @param {object} db - 데이터베이스 연결 풀 @@ -297,14 +308,52 @@ export async function deleteAlbum(db, id) { } const album = existingAlbums[0]; + const folderName = album.folder_name; + + // S3 파일 삭제를 위해 사진/티저 목록 조회 (트랜잭션 외부) + const [[photos], [teasers]] = await Promise.all([ + db.query('SELECT original_url FROM album_photos WHERE album_id = ?', [id]), + db.query('SELECT original_url, video_url FROM album_teasers WHERE album_id = ?', [id]), + ]); return withTransaction(db, async (connection) => { + // S3 파일 삭제 (병렬 처리) + const s3DeletePromises = []; + // 커버 이미지 삭제 - if (album.cover_original_url && album.folder_name) { - await deleteAlbumCover(album.folder_name); + if (album.cover_original_url && folderName) { + s3DeletePromises.push(deleteAlbumCover(folderName)); } - // 관련 데이터 삭제 + // 사진 삭제 + for (const photo of photos) { + const filename = extractFilenameFromUrl(photo.original_url); + if (filename && folderName) { + s3DeletePromises.push(deleteAlbumPhoto(folderName, 'photo', filename)); + } + } + + // 티저 삭제 (이미지 + 비디오) + for (const teaser of teasers) { + const filename = extractFilenameFromUrl(teaser.original_url); + if (filename && folderName) { + s3DeletePromises.push(deleteAlbumPhoto(folderName, 'teaser', filename)); + } + const videoFilename = extractFilenameFromUrl(teaser.video_url); + if (videoFilename && folderName) { + s3DeletePromises.push(deleteAlbumVideo(folderName, videoFilename)); + } + } + + await Promise.all(s3DeletePromises); + + // DB 관련 데이터 삭제 (순서 중요: FK 제약조건) + await connection.query( + 'DELETE FROM album_photo_members WHERE photo_id IN (SELECT id FROM album_photos WHERE album_id = ?)', + [id] + ); + await connection.query('DELETE FROM album_photos WHERE album_id = ?', [id]); + await connection.query('DELETE FROM album_teasers WHERE album_id = ?', [id]); await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); await connection.query('DELETE FROM albums WHERE id = ?', [id]); diff --git a/docker-compose.yml b/docker-compose.yml index 95c2840..cbe6fd1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ services: restart: unless-stopped meilisearch: - image: getmeili/meilisearch:v1.6 + image: getmeili/meilisearch:latest container_name: fromis9-meilisearch environment: - MEILI_MASTER_KEY=${MEILI_MASTER_KEY} diff --git a/docs/code-review.md b/docs/code-review.md new file mode 100644 index 0000000..7ed12c6 --- /dev/null +++ b/docs/code-review.md @@ -0,0 +1,193 @@ +# 코드 리뷰 결과 + +> 작성일: 2025-01-23 +> 범위: app 폴더 제외한 backend, frontend, docker 설정 + +## 잘 된 점 + +### 백엔드 구조 +- Fastify 플러그인 패턴을 적절히 활용 +- 서비스/라우트 계층 분리가 명확함 +- `withTransaction` 유틸리티로 트랜잭션 처리 일관성 확보 +- N+1 쿼리 최적화 적용 (`getAlbumsWithTracks`) + +### 캐싱 +- Redis 캐시 유틸리티(`utils/cache.js`)가 깔끔하게 구현됨 +- TTL 상수화로 관리 용이 +- 캐시 키 생성 헬퍼 함수 제공 + +### 프론트엔드 +- API 클라이언트의 에러 처리와 인증 토큰 관리가 잘 구현됨 +- PC/Mobile 분리 구조 적용 +- Zustand를 활용한 상태 관리 + +--- + +## 개선 필요 사항 + +### 1. 보안 (우선순위: 높음) + +#### 1.1 JWT Secret 하드코딩 +**파일**: `backend/src/config/index.js:37` + +```javascript +// 현재 - 기본값 노출 위험 +secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026', + +// 권장 - 환경변수 필수화 +secret: process.env.JWT_SECRET, +``` + +서버 시작 시 JWT_SECRET 환경변수 존재 여부를 검증하는 로직 추가 필요. + +### 2. Docker 설정 (우선순위: 중간) + +#### 2.1 Meilisearch 버전 +~~현재 `v1.6` 사용 중. 최신 버전으로 업데이트 권장.~~ ✅ `latest`로 변경 완료 (v1.33.1) + +#### 2.2 리소스 제한 없음 +```yaml +redis: + # ... 기존 설정 + deploy: + resources: + limits: + memory: 256M + +meilisearch: + # ... 기존 설정 + deploy: + resources: + limits: + memory: 512M +``` + +#### 2.3 프로덕션 Dockerfile +현재 개발용 Dockerfile만 활성화됨. 배포 시 주석 해제 또는 multi-stage build 적용 필요. + +--- + +### 3. 백엔드 코드 (우선순위: 중간) + +#### 3.1 에러 유틸리티 미활용 +`src/utils/error.js`에 에러 헬퍼가 있지만 라우트에서 직접 처리. + +```javascript +// 현재 - routes/albums/index.js +return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); + +// 권장 +import { notFound } from '../../utils/error.js'; +return notFound(reply, '앨범을 찾을 수 없습니다.'); +``` + +#### 3.2 SELECT * 사용 +**파일**: `services/album.js:16-19` + +```javascript +// 현재 +'SELECT * FROM albums WHERE folder_name = ? OR title = ?' + +// 권장 +'SELECT id, title, folder_name, album_type, album_type_short, release_date, cover_original_url, cover_medium_url, cover_thumb_url, description FROM albums WHERE folder_name = ? OR title = ?' +``` + +#### 3.3 앨범 삭제 시 관련 데이터 누락 +**파일**: `services/album.js:301-312` + +```javascript +// 현재 +await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); +await connection.query('DELETE FROM albums WHERE id = ?', [id]); + +// 수정 필요 - 관련 테이블 모두 삭제 +await connection.query( + 'DELETE FROM album_photo_members WHERE photo_id IN (SELECT id FROM album_photos WHERE album_id = ?)', + [id] +); +await connection.query('DELETE FROM album_photos WHERE album_id = ?', [id]); +await connection.query('DELETE FROM album_teasers WHERE album_id = ?', [id]); +await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); +await connection.query('DELETE FROM albums WHERE id = ?', [id]); +``` + +### 4. 프론트엔드 코드 (우선순위: 낮음) + +#### 4.1 App.jsx 라우트 분리 +현재 194줄로 너무 큼. 라우트 분리 권장. + +``` +src/ +├── routes/ +│ ├── index.jsx # 라우트 통합 +│ ├── pc.jsx # PC 라우트 +│ ├── mobile.jsx # Mobile 라우트 +│ └── admin.jsx # Admin 라우트 +└── App.jsx # 간소화 +``` + +#### 4.2 레거시 export 정리 +**파일**: `api/client.js:147-155` + +```javascript +// 삭제 대상 (마이그레이션 완료 후) +export const get = api.get; +export const post = api.post; +export const put = api.put; +export const del = api.del; +export const authGet = authApi.get; +export const authPost = authApi.post; +export const authPut = authApi.put; +export const authDel = authApi.del; +``` + +#### 4.3 개발 도구 미설정 +**파일**: `frontend/package.json` + +```json +{ + "scripts": { + "lint": "eslint src --ext .js,.jsx", + "lint:fix": "eslint src --ext .js,.jsx --fix", + "test": "vitest", + "test:coverage": "vitest --coverage" + }, + "devDependencies": { + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.0", + "eslint-plugin-react-hooks": "^4.6.0", + "vitest": "^1.6.0" + } +} +``` + +--- + +### 5. 문서 (우선순위: 낮음) + +#### 5.1 migration.md 파일 누락 +`CLAUDE.md`와 `README.md`에서 `docs/migration.md`를 참조하지만 실제 파일이 없음. + +#### 5.2 환경별 설정 문서화 +개발/스테이징/프로덕션 환경별 설정 가이드 필요. + +--- + +## 개선 우선순위 요약 + +| 순위 | 항목 | 파일 | 난이도 | +|:----:|------|------|:------:| +| 1 | JWT Secret 기본값 제거 | `config/index.js` | 낮음 | +| 2 | 앨범 삭제 시 관련 테이블 정리 | `services/album.js` | 중간 | +| 3 | 에러 유틸리티 통일 | 라우트 파일들 | 중간 | +| 4 | App.jsx 라우트 분리 | `App.jsx` | 중간 | +| 5 | 레거시 export 정리 | `api/client.js` | 낮음 | +| 6 | ~~Meilisearch 버전 업데이트~~ | `docker-compose.yml` | ✅ 완료 | +| 7 | 테스트 코드 작성 | 전체 | 높음 | + +--- + +## 참고 + +- 이 문서는 app 폴더를 제외한 코드 리뷰 결과입니다. +- 보안 관련 항목은 가능한 빨리 적용을 권장합니다.