에러 유틸리티 함수를 모든 라우트에 적용

utils/error.js에 정의된 헬퍼 함수들(badRequest, unauthorized, notFound,
conflict, serverError)을 전체 라우트 파일에 적용하여 에러 응답 처리 일관성 확보

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-23 11:24:42 +09:00
parent 897bdc471c
commit 1c9b30b783
12 changed files with 78 additions and 96 deletions

View file

@ -1,6 +1,7 @@
import bots from '../../config/bots.js';
import { errorResponse } from '../../schemas/index.js';
import { syncAllSchedules } from '../../services/meilisearch/index.js';
import { badRequest, notFound, serverError } from '../../utils/error.js';
// 봇 관련 스키마
const botResponse = {
@ -113,7 +114,7 @@ export default async function botsRoutes(fastify) {
await scheduler.startBot(id);
return { success: true, message: '봇이 시작되었습니다.' };
} catch (err) {
return reply.code(400).send({ error: err.message });
return badRequest(reply, err.message);
}
});
@ -147,7 +148,7 @@ export default async function botsRoutes(fastify) {
await scheduler.stopBot(id);
return { success: true, message: '봇이 정지되었습니다.' };
} catch (err) {
return reply.code(400).send({ error: err.message });
return badRequest(reply, err.message);
}
});
@ -182,7 +183,7 @@ export default async function botsRoutes(fastify) {
const bot = bots.find(b => b.id === id);
if (!bot) {
return reply.code(404).send({ error: '봇을 찾을 수 없습니다.' });
return notFound(reply, '봇을 찾을 수 없습니다.');
}
try {
@ -195,7 +196,7 @@ export default async function botsRoutes(fastify) {
const count = await syncAllSchedules(fastify.meilisearch, fastify.db);
result = { addedCount: count, total: count };
} else {
return reply.code(400).send({ error: '지원하지 않는 봇 타입입니다.' });
return badRequest(reply, '지원하지 않는 봇 타입입니다.');
}
// 상태 업데이트
@ -215,7 +216,7 @@ export default async function botsRoutes(fastify) {
};
} catch (err) {
fastify.log.error(`[${id}] 전체 동기화 오류:`, err);
return reply.code(500).send({ error: err.message });
return serverError(reply, err.message);
}
});

View file

@ -7,6 +7,7 @@ import {
xPostInfoQuery,
xScheduleCreate,
} from '../../schemas/index.js';
import { badRequest, conflict, serverError } from '../../utils/error.js';
const X_CATEGORY_ID = CATEGORY_IDS.X;
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
@ -61,7 +62,7 @@ export default async function xRoutes(fastify) {
// 게시글 ID 유효성 검사
if (!/^\d+$/.test(postId)) {
return reply.code(400).send({ error: '유효하지 않은 게시글 ID입니다.' });
return badRequest(reply, '유효하지 않은 게시글 ID입니다.');
}
try {
@ -80,7 +81,7 @@ export default async function xRoutes(fastify) {
};
} catch (err) {
fastify.log.error(`X 게시글 조회 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
return serverError(reply, err.message);
}
});
@ -118,7 +119,7 @@ export default async function xRoutes(fastify) {
[postId]
);
if (existing.length > 0) {
return reply.code(409).send({ error: '이미 등록된 게시글입니다.' });
return conflict(reply, '이미 등록된 게시글입니다.');
}
// schedules 테이블에 저장
@ -155,7 +156,7 @@ export default async function xRoutes(fastify) {
return { success: true, scheduleId };
} catch (err) {
fastify.log.error(`X 일정 저장 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
return serverError(reply, err.message);
}
});
}

View file

@ -8,6 +8,7 @@ import {
youtubeScheduleUpdate,
idParam,
} from '../../schemas/index.js';
import { badRequest, notFound, conflict, serverError } from '../../utils/error.js';
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
@ -54,13 +55,13 @@ export default async function youtubeRoutes(fastify) {
// YouTube URL에서 video ID 추출
const videoId = extractVideoId(url);
if (!videoId) {
return reply.code(400).send({ error: '유효하지 않은 YouTube URL입니다.' });
return badRequest(reply, '유효하지 않은 YouTube URL입니다.');
}
try {
const video = await fetchVideoInfo(videoId);
if (!video) {
return reply.code(404).send({ error: '영상을 찾을 수 없습니다.' });
return notFound(reply, '영상을 찾을 수 없습니다.');
}
return {
@ -76,7 +77,7 @@ export default async function youtubeRoutes(fastify) {
};
} catch (err) {
fastify.log.error(`YouTube 영상 조회 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
return serverError(reply, err.message);
}
});
@ -114,7 +115,7 @@ export default async function youtubeRoutes(fastify) {
[videoId]
);
if (existing.length > 0) {
return reply.code(409).send({ error: '이미 등록된 영상입니다.' });
return conflict(reply, '이미 등록된 영상입니다.');
}
// schedules 테이블에 저장
@ -151,7 +152,7 @@ export default async function youtubeRoutes(fastify) {
return { success: true, scheduleId };
} catch (err) {
fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
return serverError(reply, err.message);
}
});
@ -190,7 +191,7 @@ export default async function youtubeRoutes(fastify) {
[id, YOUTUBE_CATEGORY_ID]
);
if (schedules.length === 0) {
return reply.code(404).send({ error: 'YouTube 일정을 찾을 수 없습니다.' });
return notFound(reply, 'YouTube 일정을 찾을 수 없습니다.');
}
// 영상 유형 수정
@ -254,7 +255,7 @@ export default async function youtubeRoutes(fastify) {
return { success: true };
} catch (err) {
fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
return serverError(reply, err.message);
}
});
}

View file

@ -11,6 +11,7 @@ import {
import photosRoutes from './photos.js';
import teasersRoutes from './teasers.js';
import { errorResponse, successResponse, idParam } from '../../schemas/index.js';
import { notFound, badRequest } from '../../utils/error.js';
/**
* 앨범 라우트
@ -67,7 +68,7 @@ export default async function albumsRoutes(fastify) {
const album = await getAlbumByName(db, albumName);
if (!album) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
return notFound(reply, '앨범을 찾을 수 없습니다.');
}
const [tracks] = await db.query(
@ -76,7 +77,7 @@ export default async function albumsRoutes(fastify) {
);
if (tracks.length === 0) {
return reply.code(404).send({ error: '트랙을 찾을 수 없습니다.' });
return notFound(reply, '트랙을 찾을 수 없습니다.');
}
const track = tracks[0];
@ -123,7 +124,7 @@ export default async function albumsRoutes(fastify) {
}, async (request, reply) => {
const album = await getAlbumByName(db, decodeURIComponent(request.params.name));
if (!album) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
return notFound(reply, '앨범을 찾을 수 없습니다.');
}
return getAlbumDetails(db, album, redis);
});
@ -144,7 +145,7 @@ export default async function albumsRoutes(fastify) {
}, async (request, reply) => {
const album = await getAlbumById(db, request.params.id);
if (!album) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
return notFound(reply, '앨범을 찾을 수 없습니다.');
}
return getAlbumDetails(db, album, redis);
});
@ -187,13 +188,13 @@ export default async function albumsRoutes(fastify) {
}
if (!data) {
return reply.code(400).send({ error: '데이터가 필요합니다.' });
return badRequest(reply, '데이터가 필요합니다.');
}
const { title, album_type, release_date, folder_name } = data;
if (!title || !album_type || !release_date || !folder_name) {
return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' });
return badRequest(reply, '필수 필드를 모두 입력해주세요.');
}
const result = await createAlbum(db, data, coverBuffer);
@ -234,12 +235,12 @@ export default async function albumsRoutes(fastify) {
}
if (!data) {
return reply.code(400).send({ error: '데이터가 필요합니다.' });
return badRequest(reply, '데이터가 필요합니다.');
}
const result = await updateAlbum(db, id, data, coverBuffer);
if (!result) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
return notFound(reply, '앨범을 찾을 수 없습니다.');
}
await invalidateAlbumCache(redis, id);
return result;
@ -265,7 +266,7 @@ export default async function albumsRoutes(fastify) {
const { id } = request.params;
const result = await deleteAlbum(db, id);
if (!result) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
return notFound(reply, '앨범을 찾을 수 없습니다.');
}
await invalidateAlbumCache(redis, id);
return result;

View file

@ -4,6 +4,7 @@ import {
uploadAlbumVideo,
} from '../../services/image.js';
import { withTransaction } from '../../utils/transaction.js';
import { notFound } from '../../utils/error.js';
/**
* 앨범 사진 라우트
@ -25,7 +26,7 @@ export default async function photosRoutes(fastify) {
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
if (albums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
return notFound(reply, '앨범을 찾을 수 없습니다.');
}
const [photos] = await db.query(
@ -227,7 +228,7 @@ export default async function photosRoutes(fastify) {
);
if (photos.length === 0) {
return reply.code(404).send({ error: '사진을 찾을 수 없습니다.' });
return notFound(reply, '사진을 찾을 수 없습니다.');
}
const photo = photos[0];

View file

@ -3,6 +3,7 @@ import {
deleteAlbumVideo,
} from '../../services/image.js';
import { withTransaction } from '../../utils/transaction.js';
import { notFound } from '../../utils/error.js';
/**
* 앨범 티저 라우트
@ -24,7 +25,7 @@ export default async function teasersRoutes(fastify) {
const [albums] = await db.query('SELECT folder_name FROM albums WHERE id = ?', [albumId]);
if (albums.length === 0) {
return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' });
return notFound(reply, '앨범을 찾을 수 없습니다.');
}
const [teasers] = await db.query(
@ -61,7 +62,7 @@ export default async function teasersRoutes(fastify) {
);
if (teasers.length === 0) {
return reply.code(404).send({ error: '티저를 찾을 수 없습니다.' });
return notFound(reply, '티저를 찾을 수 없습니다.');
}
const teaser = teasers[0];

View file

@ -1,4 +1,5 @@
import bcrypt from 'bcrypt';
import { badRequest, unauthorized, serverError } from '../utils/error.js';
/**
* 인증 라우트
@ -42,7 +43,7 @@ export default async function authRoutes(fastify, opts) {
const { username, password } = request.body || {};
if (!username || !password) {
return reply.code(400).send({ error: '아이디와 비밀번호를 입력해주세요.' });
return badRequest(reply, '아이디와 비밀번호를 입력해주세요.');
}
try {
@ -52,14 +53,14 @@ export default async function authRoutes(fastify, opts) {
);
if (users.length === 0) {
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
return unauthorized(reply, '아이디 또는 비밀번호가 올바르지 않습니다.');
}
const user = users[0];
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return reply.code(401).send({ error: '아이디 또는 비밀번호가 올바르지 않습니다.' });
return unauthorized(reply, '아이디 또는 비밀번호가 올바르지 않습니다.');
}
// JWT 토큰 생성
@ -75,7 +76,7 @@ export default async function authRoutes(fastify, opts) {
};
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '로그인 처리 중 오류가 발생했습니다.' });
return serverError(reply, '로그인 처리 중 오류가 발생했습니다.');
}
});

View file

@ -1,5 +1,6 @@
import { uploadMemberImage } from '../../services/image.js';
import { getAllMembers, getMemberByName, getMemberBasicByName, invalidateMemberCache } from '../../services/member.js';
import { notFound, serverError } from '../../utils/error.js';
/**
* 멤버 라우트
@ -22,7 +23,7 @@ export default async function membersRoutes(fastify, opts) {
return await getAllMembers(db, redis);
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '멤버 목록 조회 실패' });
return serverError(reply, '멤버 목록 조회 실패');
}
});
@ -45,12 +46,12 @@ export default async function membersRoutes(fastify, opts) {
try {
const member = await getMemberByName(db, decodeURIComponent(request.params.name));
if (!member) {
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' });
return notFound(reply, '멤버를 찾을 수 없습니다');
}
return member;
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '멤버 조회 실패' });
return serverError(reply, '멤버 조회 실패');
}
});
@ -80,7 +81,7 @@ export default async function membersRoutes(fastify, opts) {
// 기존 멤버 조회
const existing = await getMemberBasicByName(db, decodedName);
if (!existing) {
return reply.code(404).send({ error: '멤버를 찾을 수 없습니다' });
return notFound(reply, '멤버를 찾을 수 없습니다');
}
const memberId = existing.id;
@ -161,7 +162,7 @@ export default async function membersRoutes(fastify, opts) {
return { message: '멤버 정보가 수정되었습니다', id: memberId };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '멤버 수정 실패: ' + err.message });
return serverError(reply, '멤버 수정 실패: ' + err.message);
}
});
}

View file

@ -17,6 +17,7 @@ import {
scheduleSearchResponse,
idParam,
} from '../../schemas/index.js';
import { badRequest, notFound, serverError } from '../../utils/error.js';
export default async function schedulesRoutes(fastify) {
const { db, meilisearch, redis } = fastify;
@ -42,7 +43,7 @@ export default async function schedulesRoutes(fastify) {
return await getCategories(db, redis);
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '카테고리 목록 조회 실패' });
return serverError(reply, '카테고리 목록 조회 실패');
}
});
@ -82,13 +83,13 @@ export default async function schedulesRoutes(fastify) {
// 월별 조회 모드
if (!year || !month) {
return reply.code(400).send({ error: 'search, startDate, 또는 year/month는 필수입니다.' });
return badRequest(reply, 'search, startDate, 또는 year/month는 필수입니다.');
}
return await getMonthlySchedules(db, parseInt(year), parseInt(month));
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '일정 조회 실패' });
return serverError(reply, '일정 조회 실패');
}
});
@ -119,7 +120,7 @@ export default async function schedulesRoutes(fastify) {
return { success: true, synced: count };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '동기화 실패' });
return serverError(reply, '동기화 실패');
}
});
@ -146,13 +147,13 @@ export default async function schedulesRoutes(fastify) {
);
if (!result) {
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
return notFound(reply, '일정을 찾을 수 없습니다.');
}
return result;
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '일정 상세 조회 실패' });
return serverError(reply, '일정 상세 조회 실패');
}
});
@ -185,7 +186,7 @@ export default async function schedulesRoutes(fastify) {
// 일정 존재 확인
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
if (existing.length === 0) {
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
return notFound(reply, '일정을 찾을 수 없습니다.');
}
// 관련 테이블 삭제 (외래 키)
@ -208,7 +209,7 @@ export default async function schedulesRoutes(fastify) {
return { success: true };
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '일정 삭제 실패' });
return serverError(reply, '일정 삭제 실패');
}
});
}

View file

@ -4,6 +4,7 @@
import { readFileSync, writeFileSync } from 'fs';
import { SuggestionService } from '../../services/suggestions/index.js';
import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js';
import { badRequest, serverError } from '../../utils/error.js';
let suggestionService = null;
@ -109,7 +110,7 @@ export default async function suggestionsRoutes(fastify) {
const { query } = request.body;
if (!query || query.trim().length === 0) {
return reply.code(400).send({ error: '검색어가 필요합니다.' });
return badRequest(reply, '검색어가 필요합니다.');
}
await suggestionService.saveSearchQuery(query);
@ -187,7 +188,7 @@ export default async function suggestionsRoutes(fastify) {
return { message: '사전이 저장되었습니다.' };
} catch (error) {
fastify.log.error(`[Suggestions] 사전 저장 오류: ${error.message}`);
return reply.code(500).send({ error: '사전 저장 중 오류가 발생했습니다.' });
return serverError(reply, '사전 저장 중 오류가 발생했습니다.');
}
});
}

View file

@ -2,6 +2,8 @@
* 통계 라우트
* 인증 필요
*/
import { serverError } from '../../utils/error.js';
export default async function statsRoutes(fastify, opts) {
const { db } = fastify;
@ -70,7 +72,7 @@ export default async function statsRoutes(fastify, opts) {
};
} catch (err) {
fastify.log.error(err);
return reply.code(500).send({ error: '통계 조회 실패' });
return serverError(reply, '통계 조회 실패');
}
});
}

View file

@ -25,20 +25,12 @@
## 개선 필요 사항
### 1. 보안 (우선순위: 높음)
### ~~1. 보안 (우선순위: 높음)~~ ✅ 완료
#### 1.1 JWT Secret 하드코딩
**파일**: `backend/src/config/index.js:37`
#### ~~1.1 JWT Secret 하드코딩~~ ✅ 완료
**파일**: `backend/src/config/index.js`
```javascript
// 현재 - 기본값 노출 위험
secret: process.env.JWT_SECRET || 'fromis9-admin-secret-key-2026',
// 권장 - 환경변수 필수화
secret: process.env.JWT_SECRET,
```
서버 시작 시 JWT_SECRET 환경변수 존재 여부를 검증하는 로직 추가 필요.
기본값을 제거하고 서버 시작 시 필수 환경변수 검증 로직을 추가함.
### 2. Docker 설정 (우선순위: 중간)
@ -69,17 +61,9 @@ meilisearch:
### 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.1 에러 유틸리티 미활용~~ ✅ 완료
모든 라우트 파일에 에러 유틸리티(`utils/error.js`) 적용 완료.
- `badRequest`, `unauthorized`, `notFound`, `conflict`, `serverError` 함수 사용
#### 3.2 SELECT * 사용
**파일**: `services/album.js:16-19`
@ -92,24 +76,10 @@ return notFound(reply, '앨범을 찾을 수 없습니다.');
'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`
#### ~~3.3 앨범 삭제 시 관련 데이터 누락~~ ✅ 완료
**파일**: `services/album.js`
```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]);
```
앨범 삭제 시 관련 테이블(photos, teasers, photo_members) 및 S3 파일도 함께 삭제하도록 개선됨.
### 4. 프론트엔드 코드 (우선순위: 낮음)
@ -175,15 +145,15 @@ export const authDel = authApi.del;
## 개선 우선순위 요약
| 순위 | 항목 | 파일 | 난이도 |
| 순위 | 항목 | 파일 | 상태 |
|:----:|------|------|:------:|
| 1 | JWT Secret 기본값 제거 | `config/index.js` | 낮음 |
| 2 | 앨범 삭제 시 관련 테이블 정리 | `services/album.js` | 중간 |
| 3 | 에러 유틸리티 통일 | 라우트 파일들 | 중간 |
| 4 | App.jsx 라우트 분리 | `App.jsx` | 중간 |
| 5 | 레거시 export 정리 | `api/client.js` | 낮음 |
| 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 | 테스트 코드 작성 | 전체 | 높음 |
| 7 | 테스트 코드 작성 | 전체 | 미완료 |
---