fromis_9/docs/improvements.md
caadiq e852f215a3 feat: 보안 강화 및 인증 개선 (Phase 2)
- 로그인 Rate Limit 추가 (5회/분, 마지막 시도 기준 리셋)
- Multipart JSON 파싱 에러 처리 추가
- 로그아웃 시 무한 리다이렉트 버그 수정
- 인증 라우트 가드(RequireAuth) 추가로 비로그인 접근 차단
- Zustand hydration 대기로 페이지 깜빡임 해결
- admin/public 라우트 조건부 렌더링으로 경로 매칭 경고 해결

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:47:05 +09:00

15 KiB

코드 개선 사항

코드 리뷰를 통해 발견된 개선 필요 사항 목록입니다.

페이즈 개요

페이즈 목표 이슈 수 예상 영향도
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)

// 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)

// 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)

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)

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 설치

npm install @fastify/rate-limit

2. 플러그인 등록 (app.js)

import rateLimit from '@fastify/rate-limit';

await app.register(rateLimit, {
  global: false,  // 전역 적용 안 함
});

3. 로그인 라우트에 적용 (auth.js)

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 파싱

현재 코드

// albums/index.js:186
} else if (part.fieldname === 'data') {
  data = JSON.parse(part.value);  // 에러 처리 없음
}

권장 해결책

} else if (part.fieldname === 'data') {
  try {
    data = JSON.parse(part.value);
  } catch (err) {
    return badRequest(reply, '잘못된 JSON 형식입니다.');
  }
}

photos.js의 경우 SSE 응답이므로:

} 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 전체 페이지 수집

현재 코드

// 197-200줄
export async function fetchTweets(nitterUrl, username) {
  const url = `${nitterUrl}/${username}`;
  const res = await fetch(url);      // 타임아웃 없음
  const html = await res.text();     // res.ok 체크 없음
  // ...
}

권장 해결책

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)

// 45-57줄: 설정 변경 후 완료 대기 없음
await index.updateSearchableAttributes([...]);
await index.updateFilterableAttributes([...]);
await index.updateSortableAttributes([...]);
// Meilisearch는 비동기 task로 처리하므로 즉시 반환됨

현재 코드 (syncAllSchedules)

// 255-272줄
await index.deleteAllDocuments();     // task 완료 대기 없음
await index.addDocuments(documents);  // 삭제 완료 전 추가될 수 있음

권장 해결책

// 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를 블로킹하여 대규모 키에서 성능 문제를 일으킬 수 있습니다.

현재 상황: invalidatePatternalbum:name:* 패턴에만 사용됨. 앨범 수가 적어 실질적 문제 없음.

영향받는 파일

파일 라인 함수
backend/src/utils/cache.js 48-52 invalidatePattern

현재 코드

export async function invalidatePattern(redis, pattern) {
  const keys = await redis.keys(pattern);  // 블로킹 명령
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

권장 해결책

SCAN 명령으로 점진적 조회:

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.jsreadFileSync는 서버 시작 시 1회만 실행되는 초기화 코드이므로 제외

영향받는 파일

파일 라인 함수 설명
backend/src/routes/schedules/suggestions.js 142 GET /dict readFileSync 사용
backend/src/routes/schedules/suggestions.js 183 PUT /dict writeFileSync 사용

현재 코드

import { readFileSync, writeFileSync } from 'fs';

// 142줄
const content = readFileSync(dictPath, 'utf-8');

// 183줄
writeFileSync(dictPath, content, 'utf-8');

권장 해결책

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