docs: 문서 업데이트 및 improvements.md 삭제
- improvements.md: 모든 개선 작업 완료로 삭제 - api.md: - 로그인 Rate Limit 정보 추가 - 봇 API에 last_sync_duration, version 필드 추가 - 타임스탬프 KST 형식으로 업데이트 - architecture.md: backend/utils 폴더 구조 추가 - development.md: Redis KEYS → SCAN 반영 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bdd2dfcd84
commit
8bc09e7c0d
4 changed files with 32 additions and 567 deletions
26
docs/api.md
26
docs/api.md
|
|
@ -7,6 +7,8 @@ Base URL: `/api`
|
||||||
### POST /auth/login
|
### POST /auth/login
|
||||||
로그인 (JWT 토큰 발급)
|
로그인 (JWT 토큰 발급)
|
||||||
|
|
||||||
|
**Rate Limit:** 1분당 5회 (IP 기준)
|
||||||
|
|
||||||
### GET /auth/verify
|
### GET /auth/verify
|
||||||
토큰 검증 및 사용자 정보 (인증 필요)
|
토큰 검증 및 사용자 정보 (인증 필요)
|
||||||
|
|
||||||
|
|
@ -207,16 +209,36 @@ Meilisearch 전체 동기화 (인증 필요)
|
||||||
"name": "fromis_9",
|
"name": "fromis_9",
|
||||||
"type": "youtube",
|
"type": "youtube",
|
||||||
"status": "running",
|
"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_added_count": 2,
|
||||||
|
"last_sync_duration": 1234,
|
||||||
"schedules_added": 150,
|
"schedules_added": 150,
|
||||||
"check_interval": 2,
|
"check_interval": 2,
|
||||||
"error_message": null,
|
"error_message": null,
|
||||||
"enabled": true
|
"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
|
### POST /admin/bots/:id/start
|
||||||
봇 시작
|
봇 시작
|
||||||
|
|
||||||
|
|
@ -243,7 +265,7 @@ YouTube API 할당량 경고 조회
|
||||||
{
|
{
|
||||||
"active": true,
|
"active": true,
|
||||||
"message": "YouTube API 할당량 초과",
|
"message": "YouTube API 할당량 초과",
|
||||||
"timestamp": "2026-01-18T10:00:00Z"
|
"timestamp": "2026-01-18T19:00:00+09:00"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ fromis_9/
|
||||||
│ │ │ ├── x/ # X(Twitter) 봇
|
│ │ │ ├── x/ # X(Twitter) 봇
|
||||||
│ │ │ ├── meilisearch/ # 검색 서비스
|
│ │ │ ├── meilisearch/ # 검색 서비스
|
||||||
│ │ │ └── suggestions/ # 추천 검색어
|
│ │ │ └── suggestions/ # 추천 검색어
|
||||||
|
│ │ ├── utils/ # 유틸리티
|
||||||
|
│ │ │ ├── cache.js # Redis 캐시 헬퍼 (SCAN 사용)
|
||||||
|
│ │ │ ├── date.js # 날짜 유틸 (KST 변환)
|
||||||
|
│ │ │ ├── error.js # 에러 응답 헬퍼
|
||||||
|
│ │ │ ├── logger.js # 로깅 유틸
|
||||||
|
│ │ │ └── transaction.js # DB 트랜잭션 래퍼
|
||||||
│ │ ├── app.js # Fastify 앱 설정
|
│ │ ├── app.js # Fastify 앱 설정
|
||||||
│ │ └── server.js # 진입점
|
│ │ └── server.js # 진입점
|
||||||
│ ├── Dockerfile # 백엔드 컨테이너
|
│ ├── Dockerfile # 백엔드 컨테이너
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,6 @@ docker compose down && docker compose up -d --build
|
||||||
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
|
curl -X POST https://fromis9.caadiq.co.kr/api/schedules/sync-search \
|
||||||
-H "Authorization: Bearer <token>"
|
-H "Authorization: Bearer <token>"
|
||||||
|
|
||||||
# Redis 확인
|
# Redis 확인 (SCAN 사용 권장)
|
||||||
docker exec fromis9-redis redis-cli KEYS "*"
|
docker exec fromis9-redis redis-cli SCAN 0 MATCH "*" COUNT 100
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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*
|
|
||||||
Loading…
Add table
Reference in a new issue