diff --git a/docs/api.md b/docs/api.md index 9bef46a..d5ece60 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7,6 +7,8 @@ Base URL: `/api` ### POST /auth/login 로그인 (JWT 토큰 발급) +**Rate Limit:** 1분당 5회 (IP 기준) + ### GET /auth/verify 토큰 검증 및 사용자 정보 (인증 필요) @@ -207,16 +209,36 @@ Meilisearch 전체 동기화 (인증 필요) "name": "fromis_9", "type": "youtube", "status": "running", - "last_check_at": "2026-01-18T10:30:00Z", + "last_check_at": "2026-01-18T19:30:00+09:00", "last_added_count": 2, + "last_sync_duration": 1234, "schedules_added": 150, "check_interval": 2, "error_message": null, "enabled": true + }, + { + "id": "meilisearch-sync", + "name": "Meilisearch 동기화", + "type": "meilisearch", + "status": "running", + "last_check_at": "2026-01-18T04:00:00+09:00", + "last_added_count": 500, + "last_sync_duration": 2500, + "schedules_added": 500, + "check_interval": 0, + "error_message": null, + "enabled": true, + "version": "1.6.0" } ] ``` +**필드 설명:** +- `last_check_at`: 마지막 동기화 시간 (KST, +09:00) +- `last_sync_duration`: 마지막 동기화 소요 시간 (ms) +- `version`: Meilisearch 버전 (meilisearch 타입만) + ### POST /admin/bots/:id/start 봇 시작 @@ -243,7 +265,7 @@ YouTube API 할당량 경고 조회 { "active": true, "message": "YouTube API 할당량 초과", - "timestamp": "2026-01-18T10:00:00Z" + "timestamp": "2026-01-18T19:00:00+09:00" } ``` diff --git a/docs/architecture.md b/docs/architecture.md index d9ed2bc..f920afb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,12 @@ fromis_9/ │ │ │ ├── x/ # X(Twitter) 봇 │ │ │ ├── meilisearch/ # 검색 서비스 │ │ │ └── suggestions/ # 추천 검색어 +│ │ ├── utils/ # 유틸리티 +│ │ │ ├── cache.js # Redis 캐시 헬퍼 (SCAN 사용) +│ │ │ ├── date.js # 날짜 유틸 (KST 변환) +│ │ │ ├── error.js # 에러 응답 헬퍼 +│ │ │ ├── logger.js # 로깅 유틸 +│ │ │ └── transaction.js # DB 트랜잭션 래퍼 │ │ ├── app.js # Fastify 앱 설정 │ │ └── server.js # 진입점 │ ├── Dockerfile # 백엔드 컨테이너 diff --git a/docs/development.md b/docs/development.md index 6ec18fa..2b97ebc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -271,6 +271,6 @@ docker compose down && docker compose up -d --build curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \ -H "Authorization: Bearer " -# Redis 확인 -docker exec fromis9-redis redis-cli KEYS "*" +# Redis 확인 (SCAN 사용 권장) +docker exec fromis9-redis redis-cli SCAN 0 MATCH "*" COUNT 100 ``` diff --git a/docs/improvements.md b/docs/improvements.md deleted file mode 100644 index faba54a..0000000 --- a/docs/improvements.md +++ /dev/null @@ -1,563 +0,0 @@ -# 코드 개선 사항 - -코드 리뷰를 통해 발견된 개선 필요 사항 목록입니다. - -## 페이즈 개요 - -| 페이즈 | 목표 | 이슈 수 | 예상 영향도 | -|--------|------|---------|------------| -| **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 동기화 개선 - -### 문제점 - -1. `syncAllSchedules`에서 `deleteAllDocuments()` 후 `addDocuments()` 하는 방식은 비효율적 -2. DB에서 일정 삭제 시 Meilisearch에 반영되지 않음 -3. 플러그인 초기화 시 설정 변경 완료를 기다리지 않음 - -### 영향받는 파일 - -| 파일 | 라인 | 설명 | -|------|------|------| -| `backend/src/plugins/meilisearch.js` | 45-81 | 인덱스 설정 | -| `backend/src/services/meilisearch/index.js` | 254-272 | `syncAllSchedules` - 전체 동기화 | -| `backend/src/routes/schedules/index.js` | DELETE | 일정 삭제 시 Meilisearch 반영 필요 | - -### 권장 해결책 - -**1. syncAllSchedules 개선** - -`deleteAllDocuments()` 제거. Meilisearch의 `addDocuments()`는 같은 ID면 자동 업데이트(upsert). - -```javascript -// 변경 전 -await index.deleteAllDocuments(); -await index.addDocuments(documents); - -// 변경 후 -await index.addDocuments(documents); // upsert 방식 -``` - -**2. 일정 삭제 시 Meilisearch 동기화** - -```javascript -// routes/schedules/index.js DELETE 핸들러 -await meilisearchService.deleteSchedule(id); -``` - -**3. 플러그인 초기화 시 waitForTask (선택)** - -서버 시작 시 1회만 실행되므로 우선순위 낮음. - -### 우선순위 - -**중간** - 동기화 안정성 및 효율성 - ---- - -# 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 동기화 개선 | Medium | ✅ 해결됨 | - -## Phase 4: 성능 최적화 - -| 이슈 | 우선순위 | 상태 | -|------|---------|------| -| Redis KEYS → SCAN | Low | ✅ 해결됨 | -| 동기식 파일 I/O | Low | ✅ 해결됨 | - ---- - -## 상태 범례 - -- ⬜ 미해결 -- 🔄 진행중 -- ✅ 해결됨 -- ⏭️ 보류 - ---- - -*마지막 업데이트: 2026-01-23*