refactor: 보안 강화 및 앨범 삭제 로직 개선
- JWT_SECRET 환경변수 필수화 (기본값 제거) - 앨범 삭제 시 S3 파일(사진, 티저, 비디오) 함께 삭제 - 앨범 삭제 시 관련 DB 테이블 정리 (album_photo_members, album_photos, album_teasers) - Meilisearch latest 태그로 변경 (v1.6 → latest) - 코드 리뷰 문서 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
980ae3fe1d
commit
b8137935c2
4 changed files with 256 additions and 6 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
193
docs/code-review.md
Normal file
193
docs/code-review.md
Normal file
|
|
@ -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 폴더를 제외한 코드 리뷰 결과입니다.
|
||||
- 보안 관련 항목은 가능한 빨리 적용을 권장합니다.
|
||||
Loading…
Add table
Reference in a new issue