diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index f499f74..e2d3b78 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -18,6 +18,7 @@ import { idParam, } from '../../schemas/index.js'; import { badRequest, notFound, serverError } from '../../utils/error.js'; +import { withTransaction } from '../../utils/transaction.js'; export default async function schedulesRoutes(fastify) { const { db, meilisearch, redis } = fastify; @@ -183,22 +184,25 @@ export default async function schedulesRoutes(fastify) { try { const { id } = request.params; - // 일정 존재 확인 + // 일정 존재 확인 - 트랜잭션 전에 수행 const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]); if (existing.length === 0) { return notFound(reply, '일정을 찾을 수 없습니다.'); } - // 관련 테이블 삭제 (외래 키) - 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]); + // 트랜잭션으로 DELETE 작업 수행 + 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 db.query('DELETE FROM schedules WHERE id = ?', [id]); + // 메인 테이블 삭제 + await connection.query('DELETE FROM schedules WHERE id = ?', [id]); + }); - // Meilisearch에서도 삭제 + // Meilisearch에서도 삭제 (트랜잭션 외부) try { const { deleteSchedule } = await import('../../services/meilisearch/index.js'); await deleteSchedule(meilisearch, id); diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index 699ac19..7a786fa 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -3,6 +3,7 @@ import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extr import { fetchVideoInfo } from '../youtube/api.js'; import { formatDate, formatTime } from '../../utils/date.js'; import bots from '../../config/bots.js'; +import { withTransaction } from '../../utils/transaction.js'; const X_CATEGORY_ID = 3; const YOUTUBE_CATEGORY_ID = 2; @@ -53,7 +54,7 @@ async function xBotPlugin(fastify, opts) { * 트윗을 DB에 저장 */ async function saveTweet(tweet) { - // 중복 체크 (post_id로) + // 중복 체크 (post_id로) - 트랜잭션 전에 수행 const [existing] = await fastify.db.query( 'SELECT id FROM schedule_x WHERE post_id = ?', [tweet.id] @@ -66,32 +67,35 @@ async function xBotPlugin(fastify, opts) { const time = formatTime(tweet.time); const title = extractTitle(tweet.text); - // schedules 테이블에 저장 - const [result] = await fastify.db.query( - 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', - [X_CATEGORY_ID, title, date, time] - ); - const scheduleId = result.insertId; + // 트랜잭션으로 INSERT 작업 수행 + return withTransaction(fastify.db, async (connection) => { + // schedules 테이블에 저장 + const [result] = await connection.query( + 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', + [X_CATEGORY_ID, title, date, time] + ); + const scheduleId = result.insertId; - // schedule_x 테이블에 저장 - await fastify.db.query( - 'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)', - [ - scheduleId, - tweet.id, - tweet.text, - tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null, - ] - ); + // schedule_x 테이블에 저장 + await connection.query( + 'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)', + [ + scheduleId, + tweet.id, + tweet.text, + tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null, + ] + ); - return scheduleId; + return scheduleId; + }); } /** * YouTube 영상을 DB에 저장 (트윗에서 감지된 링크) */ async function saveYoutubeFromTweet(video) { - // 중복 체크 + // 중복 체크 - 트랜잭션 전에 수행 const [existing] = await fastify.db.query( 'SELECT id FROM schedule_youtube WHERE video_id = ?', [video.videoId] @@ -100,20 +104,23 @@ async function xBotPlugin(fastify, opts) { return null; } - // schedules 테이블에 저장 - 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; + // 트랜잭션으로 INSERT 작업 수행 + 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 fastify.db.query( - 'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)', - [scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle] - ); + // 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, video.channelTitle] + ); - return scheduleId; + return scheduleId; + }); } /** diff --git a/backend/src/services/youtube/index.js b/backend/src/services/youtube/index.js index 3693a86..28d9696 100644 --- a/backend/src/services/youtube/index.js +++ b/backend/src/services/youtube/index.js @@ -2,6 +2,7 @@ import fp from 'fastify-plugin'; import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.js'; import bots from '../../config/bots.js'; import { CATEGORY_IDS } from '../../config/index.js'; +import { withTransaction } from '../../utils/transaction.js'; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; const PLAYLIST_CACHE_PREFIX = 'yt_uploads:'; @@ -56,7 +57,7 @@ async function youtubeBotPlugin(fastify, opts) { * 영상을 DB에 저장 */ async function saveVideo(video, bot) { - // 중복 체크 (video_id로) + // 중복 체크 (video_id로) - 트랜잭션 전에 수행 const [existing] = await fastify.db.query( 'SELECT id FROM schedule_youtube WHERE video_id = ?', [video.videoId] @@ -70,40 +71,48 @@ async function youtubeBotPlugin(fastify, opts) { return null; } - // schedules 테이블에 저장 - 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; - - // schedule_youtube 테이블에 저장 - await fastify.db.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 fastify.db.query( - 'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', - [values] - ); - } + // 멤버 이름 맵 미리 조회 (트랜잭션 전에) + let nameMap = null; + if (bot.extractMembersFromDesc) { + nameMap = await getMemberNameMap(); } - return scheduleId; + // 트랜잭션으로 INSERT 작업 수행 + 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 (nameMap) { + 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; + }); } /** diff --git a/docs/improvements.md b/docs/improvements.md new file mode 100644 index 0000000..05a2905 --- /dev/null +++ b/docs/improvements.md @@ -0,0 +1,570 @@ +# 코드 개선 사항 + +코드 리뷰를 통해 발견된 개선 필요 사항 목록입니다. + +## 페이즈 개요 + +| 페이즈 | 목표 | 이슈 수 | 예상 영향도 | +|--------|------|---------|------------| +| **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*