# 코드 개선 사항 코드 리뷰를 통해 발견된 개선 필요 사항 목록입니다. ## 페이즈 개요 | 페이즈 | 목표 | 이슈 수 | 예상 영향도 | |--------|------|---------|------------| | **Phase 1** | 데이터 무결성 | 1개 | 높음 | | **Phase 2** | 보안 강화 | 2개 | 중간 | | **Phase 3** | 외부 서비스 안정성 | 2개 | 중간 | | **Phase 4** | 성능 최적화 | 2개 | 낮음 | ## 목차 ### Phase 1: 데이터 무결성 - [High] 트랜잭션 부재 ### Phase 2: 보안 강화 - [Low→Medium] 로그인 Rate Limit - [Medium] Multipart JSON 파싱 에러 처리 ### Phase 3: 외부 서비스 안정성 - [Medium] Nitter 요청 안정성 - [Medium] Meilisearch Task 완료 대기 ### Phase 4: 성능 최적화 - [Low] Redis KEYS 명령어 - [Low] 동기식 파일 I/O --- # Phase 1: 데이터 무결성 > **목표**: 데이터베이스 작업의 원자성 보장 > **위험**: 중간 실패 시 orphan 데이터, 데이터 불일치 > **영향 범위**: 일정 저장/삭제 --- ## [High] 트랜잭션 부재 다중 테이블 작업 시 트랜잭션이 없어 중간 실패 시 orphan 데이터나 부분 삭제가 발생할 수 있습니다. ### 영향받는 파일 | 파일 | 라인 | 함수 | 설명 | |------|------|------|------| | `backend/src/services/youtube/index.js` | 73-103 | `saveVideo` | schedules → schedule_youtube → schedule_members 순차 INSERT | | `backend/src/services/x/index.js` | 69-85 | `saveTweet` | schedules → schedule_x 순차 INSERT | | `backend/src/routes/schedules/index.js` | 192-199 | DELETE 핸들러 | 5개 테이블 순차 DELETE | ### 현재 코드 (youtube/index.js) ```javascript // 73-103줄: 트랜잭션 없이 3개 테이블에 순차 INSERT const [result] = await fastify.db.query( 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', [YOUTUBE_CATEGORY_ID, video.title, video.date, video.time] ); const scheduleId = result.insertId; await fastify.db.query( 'INSERT INTO schedule_youtube (schedule_id, video_id, ...) VALUES (...)', [scheduleId, ...] ); // schedule_members INSERT도 동일 ``` ### 현재 코드 (schedules/index.js DELETE) ```javascript // 192-199줄: 트랜잭션 없이 순차 삭제 await db.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]); await db.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]); await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); await db.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]); await db.query('DELETE FROM schedules WHERE id = ?', [id]); ``` ### 해결책: withTransaction 유틸리티 사용 기존 프로젝트에 `withTransaction` 유틸리티가 있으므로 이를 활용합니다. (`backend/src/utils/transaction.js`) **saveVideo (youtube/index.js)** ```javascript import { withTransaction } from '../../utils/transaction.js'; async function saveVideo(video, bot) { // 중복 체크 (트랜잭션 외부에서 수행) const [existing] = await fastify.db.query( 'SELECT id FROM schedule_youtube WHERE video_id = ?', [video.videoId] ); if (existing.length > 0) return null; // 커스텀 설정 적용 if (bot.titleFilter && !video.title.includes(bot.titleFilter)) { return null; } return withTransaction(fastify.db, async (connection) => { // schedules 테이블에 저장 const [result] = await connection.query( 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', [YOUTUBE_CATEGORY_ID, video.title, video.date, video.time] ); const scheduleId = result.insertId; // schedule_youtube 테이블에 저장 await connection.query( 'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)', [scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName] ); // 멤버 연결 (커스텀 설정) if (bot.defaultMemberId || bot.extractMembersFromDesc) { const memberIds = []; if (bot.defaultMemberId) { memberIds.push(bot.defaultMemberId); } if (bot.extractMembersFromDesc) { const nameMap = await getMemberNameMap(); memberIds.push(...extractMemberIds(video.description, nameMap)); } if (memberIds.length > 0) { const uniqueIds = [...new Set(memberIds)]; const values = uniqueIds.map(id => [scheduleId, id]); await connection.query( 'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values] ); } } return scheduleId; }); } ``` **saveTweet (x/index.js)** - 동일한 패턴 적용 **DELETE 핸들러 (schedules/index.js)** ```javascript import { withTransaction } from '../../utils/transaction.js'; // DELETE /api/schedules/:id fastify.delete('/:id', { ... }, async (request, reply) => { const { id } = request.params; // 일정 존재 확인 const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]); if (existing.length === 0) { return notFound(reply, '일정을 찾을 수 없습니다.'); } await withTransaction(db, async (connection) => { // 관련 테이블 삭제 (외래 키) await connection.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]); await connection.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]); await connection.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); await connection.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]); // 메인 테이블 삭제 await connection.query('DELETE FROM schedules WHERE id = ?', [id]); }); // Meilisearch에서도 삭제 (트랜잭션 외부) try { const { deleteSchedule } = await import('../../services/meilisearch/index.js'); await deleteSchedule(meilisearch, id); } catch (meiliErr) { fastify.log.error(`Meilisearch 삭제 오류: ${meiliErr.message}`); } return { success: true }; }); ``` ### 우선순위 **높음** - 데이터 무결성에 직접적인 영향 --- # Phase 2: 보안 강화 > **목표**: 보안 취약점 해결 및 입력 검증 강화 > **위험**: 브루트포스 공격, 잘못된 입력으로 인한 서버 에러 > **영향 범위**: 인증, 앨범/사진 업로드 --- ## [Medium] 로그인 Rate Limit 로그인 엔드포인트에 rate limit이 없어 브루트포스 공격에 취약합니다. ### 영향받는 파일 | 파일 | 라인 | 설명 | |------|------|------| | `backend/src/routes/auth.js` | 13-81 | POST /api/auth/login | ### 현재 코드 rate limit 설정 없음. ### 권장 해결책 **1. @fastify/rate-limit 설치** ```bash npm install @fastify/rate-limit ``` **2. 플러그인 등록 (app.js)** ```javascript import rateLimit from '@fastify/rate-limit'; await app.register(rateLimit, { global: false, // 전역 적용 안 함 }); ``` **3. 로그인 라우트에 적용 (auth.js)** ```javascript fastify.post('/login', { config: { rateLimit: { max: 5, // 최대 5회 timeWindow: '1 minute', keyGenerator: (request) => request.ip, errorResponseBuilder: () => ({ statusCode: 429, error: 'Too Many Requests', message: '너무 많은 로그인 시도입니다. 잠시 후 다시 시도해주세요.', }), }, }, schema: { ... }, }, async (request, reply) => { // ... }); ``` ### 우선순위 **중간** - 보안 취약점 --- ## [Medium] Multipart JSON 파싱 에러 처리 multipart에서 JSON을 파싱할 때 try-catch가 없어 잘못된 입력 시 500 에러로 종료됩니다. ### 영향받는 파일 | 파일 | 라인 | 설명 | |------|------|------| | `backend/src/routes/albums/index.js` | 186 | POST /api/albums - data 필드 파싱 | | `backend/src/routes/albums/index.js` | 233 | PUT /api/albums/:id - data 필드 파싱 | | `backend/src/routes/albums/photos.js` | 100 | POST /api/albums/:albumId/photos - metadata 파싱 | ### 현재 코드 ```javascript // albums/index.js:186 } else if (part.fieldname === 'data') { data = JSON.parse(part.value); // 에러 처리 없음 } ``` ### 권장 해결책 ```javascript } else if (part.fieldname === 'data') { try { data = JSON.parse(part.value); } catch (err) { return badRequest(reply, '잘못된 JSON 형식입니다.'); } } ``` photos.js의 경우 SSE 응답이므로: ```javascript } else if (part.fieldname === 'metadata') { try { metadata = JSON.parse(part.value); } catch (err) { reply.raw.write(`data: ${JSON.stringify({ error: '잘못된 metadata JSON 형식입니다.' })}\n\n`); reply.raw.end(); return; } } ``` ### 우선순위 **중간** - 사용자 경험 및 디버깅 용이성 --- # Phase 3: 외부 서비스 안정성 > **목표**: 외부 서비스 장애 시에도 안정적인 동작 보장 > **위험**: Nitter/Meilisearch 장애 시 서버 행, 검색 누락 > **영향 범위**: X(트위터) 동기화, 검색 기능 --- ## [Medium] Nitter 요청 안정성 Nitter 요청에서 상태 코드 검증과 타임아웃이 없어 장애 시 에러 페이지를 파싱하거나 무기한 대기할 수 있습니다. ### 영향받는 파일 | 파일 | 라인 | 함수 | 설명 | |------|------|------|------| | `backend/src/services/x/scraper.js` | 197-209 | `fetchTweets` | 첫 페이지 수집 | | `backend/src/services/x/scraper.js` | 214-258 | `fetchAllTweets` | 전체 페이지 수집 | ### 현재 코드 ```javascript // 197-200줄 export async function fetchTweets(nitterUrl, username) { const url = `${nitterUrl}/${username}`; const res = await fetch(url); // 타임아웃 없음 const html = await res.text(); // res.ok 체크 없음 // ... } ``` ### 권장 해결책 ```javascript const FETCH_TIMEOUT = 10000; // 10초 export async function fetchTweets(nitterUrl, username) { const url = `${nitterUrl}/${username}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT); try { const res = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); if (!res.ok) { throw new Error(`Nitter 요청 실패: ${res.status}`); } const html = await res.text(); // ... } catch (err) { clearTimeout(timeoutId); if (err.name === 'AbortError') { throw new Error('Nitter 요청 타임아웃'); } throw err; } } ``` ### 우선순위 **중간** - 외부 서비스 장애 시 서버 안정성 --- ## [Medium] Meilisearch Task 완료 대기 Meilisearch 설정/문서 작업이 task 완료를 기다리지 않아 즉시 검색 시 누락/지연이 발생할 수 있습니다. ### 영향받는 파일 | 파일 | 라인 | 설명 | |------|------|------| | `backend/src/plugins/meilisearch.js` | 45-81 | 인덱스 설정 (searchable, filterable, sortable 등) | | `backend/src/services/meilisearch/index.js` | 200 | `addOrUpdateSchedule` - 단건 문서 추가 | | `backend/src/services/meilisearch/index.js` | 255-272 | `syncAllSchedules` - 전체 동기화 | ### 현재 코드 (plugins/meilisearch.js) ```javascript // 45-57줄: 설정 변경 후 완료 대기 없음 await index.updateSearchableAttributes([...]); await index.updateFilterableAttributes([...]); await index.updateSortableAttributes([...]); // Meilisearch는 비동기 task로 처리하므로 즉시 반환됨 ``` ### 현재 코드 (syncAllSchedules) ```javascript // 255-272줄 await index.deleteAllDocuments(); // task 완료 대기 없음 await index.addDocuments(documents); // 삭제 완료 전 추가될 수 있음 ``` ### 권장 해결책 ```javascript // plugins/meilisearch.js const searchableTask = await index.updateSearchableAttributes([...]); await client.waitForTask(searchableTask.taskUid); const filterableTask = await index.updateFilterableAttributes([...]); await client.waitForTask(filterableTask.taskUid); // ... // syncAllSchedules const deleteTask = await index.deleteAllDocuments(); await meilisearch.waitForTask(deleteTask.taskUid); const addTask = await index.addDocuments(documents); await meilisearch.waitForTask(addTask.taskUid); ``` ### 우선순위 **중간** - 동기화 안정성 (개별 문서 추가는 fire-and-forget 허용 가능) --- # Phase 4: 성능 최적화 > **목표**: 이벤트 루프 블로킹 방지 및 스케일 대비 > **위험**: 대규모 데이터 시 성능 저하 > **영향 범위**: 캐시 무효화, 사전 파일 관리 --- ## [Low] Redis KEYS 명령어 `redis.keys(pattern)` 명령은 Redis를 블로킹하여 대규모 키에서 성능 문제를 일으킬 수 있습니다. > **현재 상황**: `invalidatePattern`은 `album:name:*` 패턴에만 사용됨. 앨범 수가 적어 실질적 문제 없음. ### 영향받는 파일 | 파일 | 라인 | 함수 | |------|------|------| | `backend/src/utils/cache.js` | 48-52 | `invalidatePattern` | ### 현재 코드 ```javascript export async function invalidatePattern(redis, pattern) { const keys = await redis.keys(pattern); // 블로킹 명령 if (keys.length > 0) { await redis.del(...keys); } } ``` ### 권장 해결책 SCAN 명령으로 점진적 조회: ```javascript export async function invalidatePattern(redis, pattern) { let cursor = '0'; do { const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); cursor = nextCursor; if (keys.length > 0) { await redis.del(...keys); } } while (cursor !== '0'); } ``` ### 우선순위 **낮음** - 현재 키 수가 적으면 즉시 문제 아님. 스케일 시 필요. --- ## [Low] 동기식 파일 I/O 동기식 파일 I/O는 이벤트 루프를 블로킹할 수 있습니다. > **참고**: `morpheme.js`의 `readFileSync`는 서버 시작 시 1회만 실행되는 초기화 코드이므로 제외 ### 영향받는 파일 | 파일 | 라인 | 함수 | 설명 | |------|------|------|------| | `backend/src/routes/schedules/suggestions.js` | 142 | GET /dict | `readFileSync` 사용 | | `backend/src/routes/schedules/suggestions.js` | 183 | PUT /dict | `writeFileSync` 사용 | ### 현재 코드 ```javascript import { readFileSync, writeFileSync } from 'fs'; // 142줄 const content = readFileSync(dictPath, 'utf-8'); // 183줄 writeFileSync(dictPath, content, 'utf-8'); ``` ### 권장 해결책 ```javascript import { readFile, writeFile } from 'fs/promises'; // GET /dict const content = await readFile(dictPath, 'utf-8'); // PUT /dict await writeFile(dictPath, content, 'utf-8'); ``` ### 우선순위 **낮음** - 관리자 전용 기능이고 파일이 작음 --- # 진행 상황 요약 ## Phase 1: 데이터 무결성 | 이슈 | 우선순위 | 상태 | |------|---------|------| | 트랜잭션 부재 | High | ✅ 해결됨 | ## Phase 2: 보안 강화 | 이슈 | 우선순위 | 상태 | |------|---------|------| | 로그인 Rate Limit | Medium | ✅ 해결됨 | | Multipart JSON 파싱 | Medium | ✅ 해결됨 | ## Phase 3: 외부 서비스 안정성 | 이슈 | 우선순위 | 상태 | |------|---------|------| | Nitter 요청 안정성 | Medium | ⬜ 미해결 | | Meilisearch Task 대기 | Medium | ⬜ 미해결 | ## Phase 4: 성능 최적화 | 이슈 | 우선순위 | 상태 | |------|---------|------| | Redis KEYS → SCAN | Low | ⬜ 미해결 | | 동기식 파일 I/O | Low | ⬜ 미해결 | --- ## 상태 범례 - ⬜ 미해결 - 🔄 진행중 - ✅ 해결됨 - ⏭️ 보류 --- *마지막 업데이트: 2025-01*