diff --git a/docs/admin-migration.md b/docs/admin-migration.md new file mode 100644 index 0000000..f5a680f --- /dev/null +++ b/docs/admin-migration.md @@ -0,0 +1,542 @@ +# 관리자 페이지 마이그레이션 계획서 + +## 개요 + +- **목표**: `frontend/src/pages/pc/admin/` → `frontend-temp/src/pages/admin/` +- **현재 규모**: 12개 파일, 약 7,535 LOC +- **방식**: 일반 페이지와 동일한 Strangler Fig Pattern 적용 +- **특이사항**: PC 전용 (모바일 관리자 페이지 없음) + +--- + +## 현재 구조 분석 + +### 관리자 페이지 (12개) + +| 파일 | LOC | 설명 | 복잡도 | +|------|-----|------|--------| +| AdminLogin.jsx | 168 | 로그인 폼 | 낮음 | +| AdminDashboard.jsx | 167 | 대시보드 (통계) | 낮음 | +| AdminMembers.jsx | 180 | 멤버 목록 | 낮음 | +| AdminMemberEdit.jsx | 382 | 멤버 수정 폼 | 중간 | +| AdminAlbums.jsx | 222 | 앨범 목록 | 낮음 | +| AdminAlbumForm.jsx | 666 | 앨범 생성/수정 폼 | 높음 | +| AdminAlbumPhotos.jsx | 1,538 | 사진 업로드 (SSE) | 매우 높음 | +| AdminSchedule.jsx | 1,465 | 일정 목록/검색 | 매우 높음 | +| AdminScheduleForm.jsx | 1,173 | 일정 생성/수정 폼 | 높음 | +| AdminScheduleCategory.jsx | 463 | 카테고리 관리 | 중간 | +| AdminScheduleDict.jsx | 664 | 일정 사전 관리 | 중간 | +| AdminScheduleBots.jsx | 447 | 봇 관리 | 중간 | + +### 관리자 컴포넌트 (3개) + +| 파일 | 설명 | +|------|------| +| AdminLayout.jsx | 관리자 레이아웃 (헤더 + 본문) | +| AdminHeader.jsx | 관리자 헤더 (네비게이션) | +| NumberPicker.jsx | 숫자 입력 컴포넌트 | + +### 관리자 API (8개) + +| 파일 | 설명 | +|------|------| +| auth.js | 로그인/인증 | +| albums.js | 앨범 CRUD | +| members.js | 멤버 CRUD | +| schedules.js | 일정 CRUD | +| categories.js | 카테고리 CRUD | +| suggestions.js | 검색어 추천 관리 | +| bots.js | 봇 관리 | +| stats.js | 통계 조회 | + +--- + +## 마이그레이션 전략 + +### 우선순위 + +복잡도와 의존성을 고려하여 순차적으로 진행: + +1. **1단계**: 폴더 구조 재편 (기존 파일을 새 구조로 이동) +2. **2단계**: 관리자 기반 설정 (레이아웃, API, 인증) +3. **3단계**: 간단한 페이지 (로그인, 대시보드, 멤버) +4. **4단계**: 앨범 관리 +5. **5단계**: 일정 관리 (가장 복잡) + +### 코딩 가이드라인 + +공개 영역과 동일한 패턴 적용: +- `useQuery` / `useMutation` 사용 +- 서비스 레이어 분리 +- 공통 컴포넌트 재사용 +- TypeScript JSDoc 타입 정의 + +--- + +## 1단계: 폴더 구조 재편 + +기존 frontend-temp 파일들을 새로운 구조로 이동합니다. + +### 1.1 현재 구조 → 새 구조 + +``` +현재 → 새 구조 +───────────────────────────────────────────────────────── +api/ +├── client.js → api/common/client.js +├── index.js → (삭제, 각 폴더별 index.js로 대체) +├── auth.js → api/pc/admin/auth.js +├── schedules.js → api/pc/public/schedules.js +├── albums.js → api/pc/public/albums.js +└── members.js → api/pc/common/members.js + +components/ +├── common/ → components/common/ +│ ├── Loading.jsx +│ ├── ErrorBoundary.jsx +│ ├── ErrorMessage.jsx +│ ├── Toast.jsx +│ ├── Lightbox.jsx +│ └── ... +├── pc/ → components/pc/public/ +│ ├── Layout.jsx +│ ├── Header.jsx +│ ├── Footer.jsx +│ ├── Calendar.jsx +│ └── ScheduleCard.jsx +└── mobile/ → components/mobile/ + ├── Layout.jsx + ├── Header.jsx + ├── BottomNav.jsx + └── ... + +hooks/ +├── useMediaQuery.js → hooks/common/useMediaQuery.js +├── useLightbox.js → hooks/common/useLightbox.js +├── useCalendar.js → hooks/common/useCalendar.js +├── useAdminAuth.js → hooks/pc/admin/useAdminAuth.js +└── ... + +pages/ +├── home/pc/ → pages/pc/public/home/ +├── home/mobile/ → pages/mobile/home/ +├── schedule/pc/ → pages/pc/public/schedule/ +├── schedule/mobile/ → pages/mobile/schedule/ +├── album/pc/ → pages/pc/public/album/ +├── album/mobile/ → pages/mobile/album/ +├── members/pc/ → pages/pc/public/members/ +└── members/mobile/ → pages/mobile/members/ +``` + +### 1.2 import 경로 업데이트 + +파일 이동 후 모든 import 경로를 새 구조에 맞게 수정합니다. + +```javascript +// Before +import { fetchApi } from '@/api/client'; +import Loading from '@/components/common/Loading'; +import Calendar from '@/components/pc/Calendar'; + +// After +import { fetchApi } from '@/api/common/client'; +import Loading from '@/components/common/Loading'; +import Calendar from '@/components/pc/public/Calendar'; +``` + +### 1.3 index.js 파일 생성 + +각 폴더에 re-export용 index.js 생성: + +```javascript +// api/pc/public/index.js +export * from './schedules'; +export * from './albums'; + +// api/pc/admin/index.js +export * from './auth'; +export * from './schedules'; +// ... +``` + +--- + +## 2단계: 관리자 기반 설정 + +### 2.1 관리자 API 추가 + +**원칙**: 조회(GET)는 공개 API 재사용, CUD(Create/Update/Delete)는 관리자 API + +``` +api/ +├── common/ # PC, Mobile 공통 +│ └── client.js # fetch 래퍼 (fetchApi, fetchAuthApi) +│ +├── pc/ +│ ├── common/ # PC 내 공통 (public + admin 공유) +│ │ └── members.js # 멤버 조회 (일정 폼에서도 사용) +│ ├── public/ # PC 공개 API +│ │ ├── schedules.js +│ │ └── albums.js +│ └── admin/ # PC 관리자 API +│ ├── auth.js +│ ├── schedules.js +│ ├── albums.js +│ ├── members.js +│ ├── categories.js +│ ├── suggestions.js +│ ├── bots.js +│ └── stats.js +│ +└── mobile/ # 모바일 (pc/public과 동일 API 사용 가능) +``` + +**사용 예시**: +```javascript +// PC 공개 페이지 +import { fetchSchedules } from '@/api/pc/public'; + +// PC 관리자 페이지 - 조회는 pc/common 또는 pc/public 재사용 +import { fetchMembers } from '@/api/pc/common'; +import { createSchedule } from '@/api/pc/admin'; + +// 모바일 페이지 +import { fetchSchedules } from '@/api/pc/public'; // 동일 API 재사용 +``` + +### 2.2 관리자 레이아웃 + +`components/pc/admin/` 폴더 구조: + +``` +components/pc/admin/ +├── Layout.jsx # AdminLayout +├── Header.jsx # AdminHeader (네비게이션) +└── NumberPicker.jsx +``` + +### 2.3 인증 훅 수정 + +현재 `useAdminAuth.js`를 관리자 전용으로 확장: +- 토큰 검증 +- 자동 로그아웃 +- 권한 체크 + +--- + +## 3단계: 간단한 페이지 + +### 3.1 AdminLogin + +- 로그인 폼 컴포넌트화 +- `useMutation`으로 로그인 처리 +- 에러 처리 표준화 + +### 3.2 AdminDashboard + +- 통계 API 호출 (`useQuery`) +- 차트/카드 컴포넌트화 +- 로딩/에러 상태 처리 + +### 3.3 AdminMembers / AdminMemberEdit + +- 멤버 목록 테이블 +- 멤버 수정 폼 +- 이미지 업로드 처리 + +--- + +## 4단계: 앨범 관리 + +### 4.1 AdminAlbums + +- 앨범 목록 테이블 +- 삭제 확인 모달 +- 정렬/필터링 + +### 4.2 AdminAlbumForm + +폼 필드: +- 기본 정보 (이름, 발매일, 타입) +- 트랙 목록 (동적 추가/삭제) +- 티저 목록 (동적 추가/삭제) +- 앨범 커버 업로드 + +컴포넌트 분리: +``` +components/pc/admin/album/ +├── AlbumBasicForm.jsx # 기본 정보 +├── TrackListForm.jsx # 트랙 관리 +├── TeaserListForm.jsx # 티저 관리 +└── CoverUploader.jsx # 커버 업로드 +``` + +### 4.3 AdminAlbumPhotos + +가장 복잡한 페이지: +- SSE 스트리밍 업로드 +- 진행률 표시 +- 멤버 태깅 +- 드래그 앤 드롭 + +훅 분리: +``` +hooks/pc/admin/ +├── usePhotoUpload.js # SSE 업로드 로직 +├── usePhotoList.js # 사진 목록 관리 +└── useMemberTag.js # 멤버 태깅 +``` + +--- + +## 5단계: 일정 관리 + +### 5.1 AdminSchedule + +기능: +- 일정 목록 (가상 스크롤) +- 검색/필터링 +- 일괄 삭제 + +훅 재사용: +- `useScheduleSearch` (공개 영역과 동일) +- `useScheduleFiltering` (공개 영역과 동일) + +### 5.2 AdminScheduleForm + +폼 필드: +- 기본 정보 (제목, 날짜, 시간) +- 카테고리 선택 +- 멤버 선택 +- 출처 정보 +- 카테고리별 추가 필드 (YouTube, X) + +컴포넌트 분리: +``` +components/pc/admin/schedule/ +├── ScheduleBasicForm.jsx # 기본 정보 +├── CategorySelect.jsx # 카테고리 선택 +├── MemberSelect.jsx # 멤버 다중 선택 +├── SourceInput.jsx # 출처 입력 +├── YouTubeForm.jsx # YouTube 영상 정보 +└── XForm.jsx # X 게시글 정보 +``` + +### 5.3 AdminScheduleCategory + +- 카테고리 CRUD +- 색상 선택기 +- 순서 변경 (드래그) + +### 5.4 AdminScheduleDict + +- 일정 사전 관리 +- 자동완성 데이터 + +### 5.5 AdminScheduleBots + +- 봇 목록/상태 +- 동기화 실행 +- 로그 확인 + +--- + +## 목표 구조 + +``` +frontend-temp/src/ +├── api/ +│ ├── common/ # PC, Mobile 공통 +│ │ └── client.js # fetch 래퍼 +│ ├── pc/ +│ │ ├── common/ # PC 내 공통 (public + admin 공유) +│ │ │ └── members.js # 멤버 조회 (폼에서 공유) +│ │ ├── public/ # PC 공개 API +│ │ │ ├── schedules.js +│ │ │ └── albums.js +│ │ └── admin/ # PC 관리자 API +│ │ ├── auth.js +│ │ ├── schedules.js +│ │ ├── albums.js +│ │ ├── members.js +│ │ ├── categories.js +│ │ ├── suggestions.js +│ │ ├── bots.js +│ │ └── stats.js +│ └── mobile/ # 모바일 API +│ └── (public과 동일하게 사용) +│ +├── components/ +│ ├── common/ # PC, Mobile 공통 +│ │ ├── Loading.jsx +│ │ ├── ErrorBoundary.jsx +│ │ ├── ErrorMessage.jsx +│ │ ├── Toast.jsx +│ │ └── Lightbox/ +│ ├── pc/ +│ │ ├── common/ # PC 내 공통 +│ │ │ └── (필요시) +│ │ ├── public/ # PC 공개 컴포넌트 +│ │ │ ├── Layout.jsx +│ │ │ ├── Header.jsx +│ │ │ ├── Footer.jsx +│ │ │ ├── Calendar.jsx +│ │ │ └── ScheduleCard.jsx +│ │ └── admin/ # PC 관리자 컴포넌트 +│ │ ├── Layout.jsx +│ │ ├── Header.jsx +│ │ ├── NumberPicker.jsx +│ │ ├── ConfirmDialog.jsx +│ │ ├── album/ +│ │ │ ├── AlbumBasicForm.jsx +│ │ │ ├── TrackListForm.jsx +│ │ │ ├── TeaserListForm.jsx +│ │ │ └── CoverUploader.jsx +│ │ └── schedule/ +│ │ ├── ScheduleBasicForm.jsx +│ │ ├── CategorySelect.jsx +│ │ ├── MemberSelect.jsx +│ │ ├── SourceInput.jsx +│ │ ├── YouTubeForm.jsx +│ │ └── XForm.jsx +│ └── mobile/ # 모바일 컴포넌트 +│ ├── Layout.jsx +│ ├── Header.jsx +│ ├── BottomNav.jsx +│ ├── Calendar.jsx +│ └── ScheduleCard.jsx +│ +├── hooks/ +│ ├── common/ # PC, Mobile 공통 +│ │ ├── useMediaQuery.js +│ │ ├── useLightbox.js +│ │ └── useCalendar.js +│ ├── pc/ +│ │ ├── common/ # PC 내 공통 +│ │ │ └── (필요시) +│ │ ├── public/ # PC 공개 훅 +│ │ │ └── (필요시) +│ │ └── admin/ # PC 관리자 훅 +│ │ ├── useAdminAuth.js +│ │ ├── usePhotoUpload.js +│ │ ├── usePhotoList.js +│ │ └── useMemberTag.js +│ └── mobile/ # 모바일 훅 +│ └── (필요시) +│ +├── pages/ +│ ├── pc/ +│ │ ├── public/ # PC 공개 페이지 +│ │ │ ├── Home.jsx +│ │ │ ├── Schedule.jsx +│ │ │ ├── Album.jsx +│ │ │ └── Members.jsx +│ │ └── admin/ # PC 관리자 페이지 +│ │ ├── Login.jsx +│ │ ├── Dashboard.jsx +│ │ ├── Members.jsx +│ │ ├── MemberEdit.jsx +│ │ ├── Albums.jsx +│ │ ├── AlbumForm.jsx +│ │ ├── AlbumPhotos.jsx +│ │ ├── Schedule.jsx +│ │ ├── ScheduleForm.jsx +│ │ ├── ScheduleCategory.jsx +│ │ ├── ScheduleDict.jsx +│ │ └── ScheduleBots.jsx +│ └── mobile/ # 모바일 페이지 +│ ├── Home.jsx +│ ├── Schedule.jsx +│ ├── Album.jsx +│ └── Members.jsx +│ +├── stores/ # 전역 상태 (플랫) +│ ├── useAuthStore.js +│ ├── useScheduleStore.js +│ └── useUIStore.js +│ +├── utils/ # 유틸리티 (플랫) +│ ├── date.js +│ ├── format.js +│ └── schedule.js +│ +└── constants/ # 상수 (플랫) + └── index.js +``` + +--- + +## 작업 체크리스트 + +### 1단계: 폴더 구조 재편 +- [ ] api/ 구조 변경 (common/, pc/common/, pc/public/, pc/admin/) +- [ ] components/ 구조 변경 (common/, pc/public/, pc/admin/, mobile/) +- [ ] hooks/ 구조 변경 (common/, pc/admin/) +- [ ] pages/ 구조 변경 (pc/public/, pc/admin/, mobile/) +- [ ] 모든 import 경로 업데이트 +- [ ] 각 폴더에 index.js 생성 (re-export) +- [ ] 빌드 및 동작 확인 + +### 2단계: 관리자 기반 설정 +- [ ] 관리자 API 마이그레이션 (api/pc/admin/) +- [ ] AdminLayout 마이그레이션 (components/pc/admin/) +- [ ] AdminHeader 마이그레이션 (components/pc/admin/) +- [ ] useAdminAuth 훅 이동 (hooks/pc/admin/) +- [ ] 관리자 라우트 설정 (App.jsx) + +### 3단계: 간단한 페이지 +- [ ] AdminLogin 마이그레이션 +- [ ] AdminDashboard 마이그레이션 +- [ ] AdminMembers 마이그레이션 +- [ ] AdminMemberEdit 마이그레이션 + +### 4단계: 앨범 관리 +- [ ] AdminAlbums 마이그레이션 +- [ ] AdminAlbumForm 마이그레이션 +- [ ] AdminAlbumPhotos 마이그레이션 (SSE 처리 포함) + +### 5단계: 일정 관리 +- [ ] AdminSchedule 마이그레이션 +- [ ] AdminScheduleForm 마이그레이션 +- [ ] AdminScheduleCategory 마이그레이션 +- [ ] AdminScheduleDict 마이그레이션 +- [ ] AdminScheduleBots 마이그레이션 + +### 6단계: 검증 +- [ ] 공개 페이지 동작 확인 (PC/Mobile) +- [ ] 로그인/로그아웃 테스트 +- [ ] 멤버 CRUD 테스트 +- [ ] 앨범 CRUD 테스트 +- [ ] 앨범 사진 업로드 테스트 +- [ ] 일정 CRUD 테스트 +- [ ] YouTube/X 일정 등록 테스트 +- [ ] 카테고리 관리 테스트 +- [ ] 봇 관리 테스트 + +--- + +## 예상 효과 + +| 항목 | Before | After | 개선 | +|------|--------|-------|------| +| 총 LOC | ~7,535 | ~5,500 | -27% | +| 중복 코드 | 30%+ | <10% | 공통 훅/컴포넌트 활용 | +| 컴포넌트 분리 | 12개 대형 파일 | 30개+ 작은 파일 | 유지보수성 향상 | + +--- + +## 주의사항 + +1. **SSE 스트리밍**: AdminAlbumPhotos의 SSE 업로드는 별도 훅으로 분리하여 처리 +2. **인증 처리**: 모든 관리자 API 호출에 토큰 자동 첨부 +3. **에러 처리**: 401 에러 시 자동 로그아웃 및 리다이렉트 +4. **폼 검증**: React Hook Form 또는 자체 검증 로직 사용 +5. **낙관적 업데이트**: 목록 삭제 등에 useMutation의 onMutate 활용 + +--- + +## 참고 + +- 기존 문서: [frontend-refactoring.md](./frontend-refactoring.md) +- API 명세: [api.md](./api.md) +- 개발 가이드: [development.md](./development.md) diff --git a/docs/backend-refactoring.md b/docs/backend-refactoring.md deleted file mode 100644 index fa57d1c..0000000 --- a/docs/backend-refactoring.md +++ /dev/null @@ -1,276 +0,0 @@ -# Backend Refactoring Plan - -백엔드 코드 품질 개선을 위한 리팩토링 계획서 - -## 완료된 작업 - -### 1단계: 설정 통합 (config 정리) ✅ 완료 -- [x] 카테고리 ID 상수 통합 (`CATEGORY_IDS`) - -**수정된 파일:** -- `src/config/index.js` - `CATEGORY_IDS` 상수 추가 -- `src/routes/admin/youtube.js` - config에서 import -- `src/routes/admin/x.js` - config에서 import -- `src/routes/schedules/index.js` - 하드코딩된 2, 3, 8 → 상수로 변경 - ---- - -### 2단계: N+1 쿼리 최적화 ✅ 완료 -- [x] 앨범 목록 조회 시 트랙 한 번에 조회로 변경 -- [x] 스케줄 멤버 조회 - 이미 최적화됨 (확인 완료) - -**수정된 파일:** -- `src/routes/albums/index.js` - GET /api/albums에서 트랙 조회 최적화 - ---- - -### 3단계: 서비스 레이어 분리 ✅ 완료 -- [x] `src/services/album.js` 생성 -- [x] `src/services/schedule.js` 생성 -- [x] 라우트에서 서비스 호출로 변경 - ---- - -### 4단계: 에러 처리 통일 ✅ 완료 -- [x] 에러 응답 유틸리티 생성 (`src/utils/error.js`) -- [x] `reply.status()` → `reply.code()` 통일 - ---- - -### 5단계: 중복 코드 제거 ✅ 완료 -- [x] 스케줄러 상태 업데이트 로직 통합 (handleSyncResult 함수) -- [x] youtube/index.js 하드코딩된 카테고리 ID → config 사용 - ---- - -## 추가 작업 목록 - -### 6단계: 매직 넘버 config 이동 ✅ 완료 -- [x] 이미지 크기/품질 설정 (`services/image.js`) -- [x] X 기본 사용자명 (`routes/admin/x.js`, `routes/schedules/index.js`, `services/schedule.js`) -- [x] Meilisearch 최소 점수 (`services/meilisearch/index.js`) - -**수정된 파일:** -- `src/config/index.js` - `image`, `x`, `meilisearch.minScore` 추가 -- `src/services/image.js` - config에서 이미지 크기/품질 참조 -- `src/services/meilisearch/index.js` - config에서 minScore 참조 -- `src/routes/admin/x.js` - config에서 defaultUsername 참조 -- `src/routes/schedules/index.js` - config에서 defaultUsername 참조 -- `src/services/schedule.js` - config에서 defaultUsername 참조 - ---- - -### 7단계: 순차 쿼리 → 병렬 처리 ✅ 완료 -- [x] `services/album.js` getAlbumDetails - tracks, teasers, photos 병렬 조회 -- [x] `routes/albums/photos.js` - 멤버 INSERT 배치 처리 - -**수정된 파일:** -- `src/services/album.js` - Promise.all로 3개 쿼리 병렬 실행 -- `src/routes/albums/photos.js` - for loop → VALUES ? 배치 INSERT - ---- - -### 8단계: meilisearch 카테고리 ID 상수화 ✅ 완료 -- [x] `services/meilisearch/index.js` - 하드코딩된 2, 3 → CATEGORY_IDS 사용 - -**수정된 파일:** -- `src/services/meilisearch/index.js` - CATEGORY_IDS.YOUTUBE, CATEGORY_IDS.X 사용 - ---- - -### 9단계: 응답 형식 통일 ✅ 완료 -- [x] `routes/schedules/suggestions.js` - `{success, message}` → `{error}` 또는 `{message}` 형식으로 통일 - -**수정된 파일:** -- `src/routes/schedules/suggestions.js` - 응답 형식 통일 - ---- - -### 10단계: 로거 통일 ✅ 완료 -- [x] `src/utils/logger.js` 생성 -- [x] 모든 `console.error/log` → logger 또는 fastify.log 사용 - -**수정된 파일:** -- `src/utils/logger.js` - 로거 유틸리티 생성 (createLogger) -- `src/services/image.js` - logger 사용 -- `src/services/meilisearch/index.js` - logger 사용 -- `src/services/suggestions/index.js` - logger 사용 -- `src/services/suggestions/morpheme.js` - logger 사용 -- `src/routes/albums/photos.js` - fastify.log 사용 -- `src/routes/schedules/index.js` - fastify.log 사용 -- `src/routes/schedules/suggestions.js` - fastify.log 사용 - ---- - -### 11단계: 대형 핸들러 분리 ✅ 완료 -- [x] `routes/albums/index.js` POST/PUT/DELETE → 서비스 함수로 분리 -- [ ] `routes/albums/photos.js` POST - SSE 스트리밍으로 인해 분리 보류 - -**수정된 파일:** -- `src/services/album.js` - createAlbum, updateAlbum, deleteAlbum, insertTracks 추가 -- `src/routes/albums/index.js` - 서비스 함수 호출로 변경 (80줄 감소) - ---- - -### 12단계: 트랜잭션 헬퍼 추상화 ✅ 완료 -- [x] `src/utils/transaction.js` 생성 - withTransaction 함수 -- [x] 반복되는 트랜잭션 패턴 추상화 적용 - -**수정된 파일:** -- `src/utils/transaction.js` - 트랜잭션 헬퍼 유틸리티 생성 -- `src/services/album.js` - createAlbum, updateAlbum, deleteAlbum에 withTransaction 적용 -- `src/routes/albums/photos.js` - DELETE 핸들러에 withTransaction 적용 -- `src/routes/albums/teasers.js` - DELETE 핸들러에 withTransaction 적용 - ---- - -### 13단계: Swagger/OpenAPI 문서화 개선 ✅ 완료 -- [x] `src/schemas/index.js` 생성 - 공통 스키마 정의 -- [x] `src/app.js` - Swagger components에 스키마 등록 -- [x] 태그 추가 (admin/youtube, admin/x, admin/bots) - -**수정된 파일:** -- `src/schemas/index.js` - JSON Schema 정의 (Error, Success, Album, Schedule 등) -- `src/app.js` - Swagger 설정에 스키마 컴포넌트 추가 - ---- - -### 14단계: 입력 검증 강화 (JSON Schema) ✅ 완료 -- [x] 라우트에 params, querystring, body, response 스키마 추가 -- [x] 상세 description 추가로 API 문서 품질 향상 - -**수정된 파일:** -- `src/routes/albums/index.js` - GET/POST/PUT/DELETE 스키마 추가 -- `src/routes/schedules/index.js` - 검색/조회/삭제 스키마 추가 -- `src/routes/admin/youtube.js` - 영상 조회/일정 등록/수정 스키마 추가 -- `src/routes/admin/x.js` - 게시글 조회/일정 등록 스키마 추가 -- `src/routes/admin/bots.js` - 봇 관리 스키마 추가 - ---- - -### 15단계: 스키마 파일 분리 ✅ 완료 -- [x] 단일 스키마 파일을 도메인별로 분리 -- [x] Re-export 패턴으로 기존 import 호환성 유지 - -**생성된 파일:** -- `src/schemas/common.js` - 공통 스키마 (errorResponse, successResponse, paginationQuery, idParam) -- `src/schemas/album.js` - 앨범 관련 스키마 -- `src/schemas/schedule.js` - 일정 관련 스키마 -- `src/schemas/admin.js` - 관리자 API 스키마 (YouTube, X) -- `src/schemas/member.js` - 멤버 스키마 -- `src/schemas/auth.js` - 인증 스키마 -- `src/schemas/index.js` - 모든 스키마 re-export - ---- - -### 16단계: 에러 처리 일관성 ✅ 완료 -- [x] 모든 라우트에 try/catch 적용 -- [x] 에러 응답 패턴 통일 - -**수정된 파일:** -- `src/routes/schedules/index.js` - 모든 핸들러에 try/catch 추가 - ---- - -### 17단계: 중복 코드 제거 (멤버 조회) ✅ 완료 -- [x] 멤버 조회 로직을 서비스로 분리 -- [x] 앨범 존재 확인 로직 통합 - -**생성된 파일:** -- `src/services/member.js` - getAllMembers, getMemberByName, getMemberBasicByName - -**수정된 파일:** -- `src/services/album.js` - getAlbumByName, getAlbumById 추가 -- `src/routes/members/index.js` - 서비스 호출로 변경 (약 50줄 감소) -- `src/routes/albums/index.js` - 서비스 호출로 변경 - ---- - -### 18단계: 이미지 처리 최적화 ✅ 완료 -- [x] 이미지 메타데이터 중복 처리 제거 -- [x] processImage에서 메타데이터 함께 반환 -- [x] sharp 인스턴스 재사용 (clone() 사용) - -**수정된 파일:** -- `src/services/image.js` - processImage 함수 개선, uploadAlbumPhoto 중복 조회 제거 - ---- - -### 19단계: Redis 캐시 확대 ✅ 완료 -- [x] 캐시 유틸리티 생성 (`src/utils/cache.js`) -- [x] 멤버 목록 캐싱 (10분 TTL) -- [x] 멤버 수정 시 캐시 무효화 -- [x] 카테고리 목록 캐싱 (1시간 TTL) -- [x] 앨범 목록 캐싱 (10분 TTL) -- [x] 앨범 상세 캐싱 (10분 TTL) -- [x] 앨범 생성/수정/삭제 시 캐시 무효화 -- [ ] 일정 상세 조회 - X 프로필이 별도 캐시 사용하므로 보류 - -**생성된 파일:** -- `src/utils/cache.js` - getOrSet, invalidate, invalidatePattern, cacheKeys, TTL 상수 - -**수정된 파일:** -- `src/services/member.js` - getAllMembers에 캐시 적용, invalidateMemberCache 추가 -- `src/services/schedule.js` - getCategories에 캐시 적용 -- `src/services/album.js` - getAlbumsWithTracks, getAlbumDetails에 캐시 적용, invalidateAlbumCache 추가 -- `src/routes/members/index.js` - Redis 캐시 사용, 수정 시 캐시 무효화 -- `src/routes/schedules/index.js` - 카테고리 조회 시 캐시 사용 -- `src/routes/albums/index.js` - 캐시 사용, 생성/수정/삭제 시 캐시 무효화 - ---- - -### 20단계: 서비스 레이어 확대 ✅ 완료 -- [x] schedules 라우트의 DB 쿼리를 서비스로 분리 -- [x] 일관된 서비스 패턴 적용 - -**수정된 파일:** -- `src/services/schedule.js` - getCategories, getScheduleDetail 함수 추가 -- `src/routes/schedules/index.js` - 서비스 호출로 변경 (약 70줄 감소) - ---- - -### 21단계: 검색 페이징 최적화 ✅ 완료 -- [x] 라우트에서 중복 slice 제거 -- [x] 내부 검색 한도와 페이징 파라미터 분리 -- [x] searchSchedules에 offset/limit 직접 전달 - -**수정된 파일:** -- `src/services/meilisearch/index.js` - SEARCH_LIMIT 분리, 기본 limit 100으로 변경 -- `src/routes/schedules/index.js` - handleSearch에서 중복 slice 제거 - ---- - -## 진행 상황 - -| 단계 | 작업 | 상태 | -|------|------|------| -| 1단계 | 설정 통합 | ✅ 완료 | -| 2단계 | N+1 쿼리 최적화 | ✅ 완료 | -| 3단계 | 서비스 레이어 분리 | ✅ 완료 | -| 4단계 | 에러 처리 통일 | ✅ 완료 | -| 5단계 | 중복 코드 제거 | ✅ 완료 | -| 6단계 | 매직 넘버 config 이동 | ✅ 완료 | -| 7단계 | 순차→병렬 쿼리 | ✅ 완료 | -| 8단계 | meilisearch 카테고리 ID | ✅ 완료 | -| 9단계 | 응답 형식 통일 | ✅ 완료 | -| 10단계 | 로거 통일 | ✅ 완료 | -| 11단계 | 대형 핸들러 분리 | ✅ 완료 | -| 12단계 | 트랜잭션 헬퍼 추상화 | ✅ 완료 | -| 13단계 | Swagger/OpenAPI 문서화 | ✅ 완료 | -| 14단계 | 입력 검증 강화 (JSON Schema) | ✅ 완료 | -| 15단계 | 스키마 파일 분리 | ✅ 완료 | -| 16단계 | 에러 처리 일관성 | ✅ 완료 | -| 17단계 | 중복 코드 제거 (멤버 조회) | ✅ 완료 | -| 18단계 | 이미지 처리 최적화 | ✅ 완료 | -| 19단계 | Redis 캐시 확대 | ✅ 완료 | -| 20단계 | 서비스 레이어 확대 | ✅ 완료 | -| 21단계 | 검색 페이징 최적화 | ✅ 완료 | - ---- - -## 참고사항 - -- 각 단계별로 커밋 후 다음 단계 진행 -- 기존 API 응답 형식은 유지 -- 프론트엔드 수정 불필요하도록 진행 -- API 문서는 `/docs`에서 확인 가능 (Scalar API Reference) diff --git a/docs/code-improvements.md b/docs/code-improvements.md deleted file mode 100644 index 1310689..0000000 --- a/docs/code-improvements.md +++ /dev/null @@ -1,1055 +0,0 @@ -# 프론트엔드 코드 개선 사항 - -> 작성일: 2026-01-22 -> 대상: `frontend-temp/src/` -> 상태: 공개 영역 마이그레이션 완료 후 코드 품질 검토 - ---- - -## 목차 - -1. [즉시 수정 필요 (Critical)](#1-즉시-수정-필요-critical) -2. [높은 우선순위 (High)](#2-높은-우선순위-high) -3. [중간 우선순위 (Medium)](#3-중간-우선순위-medium) -4. [낮은 우선순위 (Low)](#4-낮은-우선순위-low) -5. [품질 점수 요약](#5-품질-점수-요약) - ---- - -## 1. 즉시 수정 필요 (Critical) ✅ 완료 - -### 1.1 useAdminAuth 무한 루프 위험 ✅ - -**파일**: `hooks/useAdminAuth.js:35` - -**상태**: ✅ 완료 - `useRef`로 logout 함수 안정화 - -**문제**: -```javascript -useEffect(() => { - if (required && (!token || isError)) { - logout(); - navigate(redirectTo); - } -}, [token, isError, required, logout, navigate, redirectTo]); -``` - -`logout` 함수가 Zustand store에서 오기 때문에 참조가 변경될 수 있어 무한 루프 발생 위험. - -**해결방법**: -```javascript -// 방법 1: useRef로 logout 함수 안정화 -const logoutRef = useRef(logout); -logoutRef.current = logout; - -useEffect(() => { - if (required && (!token || isError)) { - logoutRef.current(); - navigate(redirectTo); - } -}, [token, isError, required, navigate, redirectTo]); - -// 방법 2: Zustand에서 logout을 useCallback으로 메모이제이션 -// stores/useAuthStore.js -logout: useCallback(() => { - set({ token: null, user: null, isAuthenticated: false }); - localStorage.removeItem('admin_token'); -}, []), -``` - ---- - -### 1.2 인증 queryKey 충돌 ✅ - -**파일**: `hooks/useAdminAuth.js`, `hooks/useAdminAuth.js` (useRedirectIfAuthenticated) - -**상태**: ✅ 완료 - 고유 queryKey 적용 (`['admin', 'auth', 'verify']`, `['admin', 'auth', 'redirect-check']`) - -**문제**: -```javascript -// useAdminAuth (라인 19) -const { data, isLoading, isError } = useQuery({ - queryKey: ['admin', 'auth'], - queryFn: authApi.verifyToken, - // ... -}); - -// useRedirectIfAuthenticated (라인 56) -const { data, isLoading } = useQuery({ - queryKey: ['admin', 'auth'], // 동일한 queryKey! - queryFn: authApi.verifyToken, - // ... -}); -``` - -두 훅이 동일한 queryKey를 사용하여 캐시 충돌 발생 가능. - -**해결방법**: -```javascript -// useAdminAuth -queryKey: ['admin', 'auth', 'verify'], - -// useRedirectIfAuthenticated -queryKey: ['admin', 'auth', 'redirect-check'], - -// 또는 하나의 훅으로 통합 -export function useAuthStatus(options = {}) { - const { required = false, redirectTo = '/admin', redirectIfAuthenticated = false } = options; - // 통합 로직 -} -``` - ---- - -### 1.3 접근성(a11y) 심각한 문제 ✅ - -**파일**: 모든 컴포넌트 - -**상태**: ✅ 완료 - Toast, Lightbox, LightboxIndicator, Calendar (PC/Mobile)에 aria 속성 추가 - -**문제**: -- `aria-label` 전무 -- `role` 속성 미사용 -- `aria-selected`, `aria-expanded` 미사용 -- 키보드 네비게이션 미흡 -- 색상만으로 정보 전달 (색약자 고려 안 함) - -**예시 - Calendar.jsx**: -```javascript -// 현재 코드 (문제) - - -// 개선된 코드 - -``` - -**개선이 필요한 컴포넌트 목록**: - -| 컴포넌트 | 필요한 접근성 속성 | -|---------|------------------| -| `pc/Calendar.jsx` | aria-label (버튼), aria-selected (날짜), role="grid" | -| `mobile/Calendar.jsx` | aria-label (버튼), aria-selected (날짜) | -| `pc/CategoryFilter.jsx` | aria-pressed (토글), role="group" | -| `common/Lightbox.jsx` | aria-label (닫기/이전/다음), role="dialog", aria-modal | -| `common/LightboxIndicator.jsx` | aria-label (각 인디케이터), aria-current | -| `common/Toast.jsx` | role="alert", aria-live="polite" | -| `common/Tooltip.jsx` | role="tooltip", aria-describedby | - -**접근성 개선 체크리스트**: -- [x] 모든 아이콘 버튼에 `aria-label` 추가 -- [x] 장식용 아이콘에 `aria-hidden="true"` 추가 -- [x] 모달/다이얼로그에 `role="dialog"`, `aria-modal="true"` 추가 -- [x] 알림에 `role="alert"` 추가 -- [x] 선택 가능한 요소에 `aria-selected` 추가 -- [x] 토글 버튼에 `aria-pressed` 추가 -- [ ] 색상 외에 텍스트/아이콘으로 정보 보완 (향후 개선) - ---- - -### 1.4 카드 컴포넌트 메모이제이션 부족 ✅ - -**파일**: `components/pc/ScheduleCard.jsx`, `components/pc/BirthdayCard.jsx`, `components/mobile/ScheduleCard.jsx` 등 - -**상태**: ✅ 완료 - 6개 카드 컴포넌트에 `React.memo` 적용 - -**문제**: -```javascript -// 현재 코드 - memo 없음 -function ScheduleCard({ schedule, onClick, className = '' }) { - // ... -} -export default ScheduleCard; -``` - -리스트 렌더링 시 부모가 리렌더되면 모든 카드가 불필요하게 재렌더링됨. - -**해결방법**: -```javascript -import { memo } from 'react'; - -const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className = '' }) { - // ... -}); - -export default ScheduleCard; - -// 또는 커스텀 비교 함수 사용 -const ScheduleCard = memo( - function ScheduleCard({ schedule, onClick, className = '' }) { - // ... - }, - (prevProps, nextProps) => { - return prevProps.schedule.id === nextProps.schedule.id && - prevProps.className === nextProps.className; - } -); -``` - -**메모이제이션이 필요한 컴포넌트**: -- [x] `components/pc/ScheduleCard.jsx` -- [x] `components/pc/BirthdayCard.jsx` -- [x] `components/mobile/ScheduleCard.jsx` -- [x] `components/mobile/ScheduleListCard.jsx` -- [x] `components/mobile/ScheduleSearchCard.jsx` -- [x] `components/mobile/BirthdayCard.jsx` - ---- - -## 2. 높은 우선순위 (High) ✅ 완료 - -### 2.1 PC/Mobile 간 중복 코드 ✅ - -#### 2.1.1 YouTube URL 파싱 함수 중복 ✅ - -**파일**: `pages/album/pc/TrackDetail.jsx`, `pages/album/mobile/TrackDetail.jsx` - -**상태**: ✅ 완료 - `utils/youtube.js` 생성, TrackDetail에서 유틸리티 사용으로 변경 - -**중복 코드**: -```javascript -// 두 파일에서 동일하게 존재 -function getYoutubeVideoId(url) { - if (!url) return null; - const patterns = [ - /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, - ]; - for (const pattern of patterns) { - const match = url.match(pattern); - if (match) return match[1]; - } - return null; -} -``` - -**해결방법**: `utils/youtube.js` 생성 -```javascript -// utils/youtube.js -/** - * YouTube URL에서 비디오 ID 추출 - * @param {string} url - YouTube URL - * @returns {string|null} 비디오 ID 또는 null - */ -export function getYoutubeVideoId(url) { - if (!url) return null; - const patterns = [ - /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, - ]; - for (const pattern of patterns) { - const match = url.match(pattern); - if (match) return match[1]; - } - return null; -} - -/** - * YouTube 썸네일 URL 생성 - * @param {string} videoId - YouTube 비디오 ID - * @param {string} quality - 썸네일 품질 (default, hqdefault, mqdefault, sddefault, maxresdefault) - * @returns {string} 썸네일 URL - */ -export function getYoutubeThumbnail(videoId, quality = 'hqdefault') { - return `https://img.youtube.com/vi/${videoId}/${quality}.jpg`; -} -``` - ---- - -#### 2.1.2 Credit 포맷팅 함수 중복 ✅ - -**파일**: `pages/album/pc/TrackDetail.jsx`, `pages/album/mobile/TrackDetail.jsx` - -**상태**: ✅ 완료 - `utils/format.js`에 `parseCredits` 추가, TrackDetail에서 사용 - -**중복 코드**: -```javascript -function formatCredit(text) { - if (!text) return null; - return text.split(',').map((item, index) => ( - - {item.trim()} - {index < text.split(',').length - 1 && ', '} - - )); -} -``` - -**해결방법**: `utils/format.js`에 추가 -```javascript -// utils/format.js에 추가 - -/** - * 크레딧 텍스트를 배열로 분리 - * @param {string} text - 쉼표로 구분된 크레딧 텍스트 - * @returns {string[]} 크레딧 배열 - */ -export function parseCredits(text) { - if (!text) return []; - return text.split(',').map(item => item.trim()).filter(Boolean); -} -``` - -컴포넌트에서 사용: -```javascript -import { parseCredits } from '@/utils'; - -// 컴포넌트 내부 -{parseCredits(track.composer).map((name, index, arr) => ( - - {name} - {index < arr.length - 1 && ', '} - -))} -``` - ---- - -#### 2.1.3 재생 시간 계산 함수 중복 ✅ - -**파일**: `pages/album/pc/AlbumDetail.jsx`, `pages/album/mobile/AlbumDetail.jsx` - -**상태**: ✅ 완료 - `utils/format.js`에 `calculateTotalDuration` 추가, AlbumDetail에서 사용 - -**중복 코드**: -```javascript -const getTotalDuration = () => { - if (!album?.tracks) return ''; - let totalSeconds = 0; - album.tracks.forEach((track) => { - if (track.duration) { - const parts = track.duration.split(':'); - totalSeconds += parseInt(parts[0]) * 60 + parseInt(parts[1]); - } - }); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -}; -``` - -**해결방법**: `utils/format.js`에 추가 -```javascript -// utils/format.js에 추가 - -/** - * 트랙 목록의 총 재생 시간 계산 - * @param {Array<{duration?: string}>} tracks - 트랙 배열 (duration: "MM:SS" 형식) - * @returns {string} 총 재생 시간 ("MM:SS" 형식) - */ -export function calculateTotalDuration(tracks) { - if (!tracks || !Array.isArray(tracks)) return ''; - - const totalSeconds = tracks.reduce((acc, track) => { - if (!track.duration) return acc; - const parts = track.duration.split(':'); - if (parts.length !== 2) return acc; - return acc + parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); - }, 0); - - if (totalSeconds === 0) return ''; - - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -} -``` - ---- - -#### 2.1.4 라이트박스 로직 중복 ✅ - -**상태**: ✅ 완료 - `hooks/useLightbox.js` 생성 (향후 점진적 적용) - -**파일**: -- `pages/album/pc/AlbumDetail.jsx` -- `pages/album/mobile/AlbumDetail.jsx` -- `pages/album/pc/AlbumGallery.jsx` -- `pages/album/mobile/AlbumGallery.jsx` - -**중복 로직**: -- 이미지 프리로딩 -- 뒤로가기 처리 (popstate) -- 이미지 다운로드 -- body 스크롤 방지 - -**해결방법**: `hooks/useLightbox.js` 생성 -```javascript -// hooks/useLightbox.js -import { useState, useEffect, useCallback } from 'react'; - -/** - * 라이트박스 상태 및 동작 관리 훅 - * @param {Object} options - * @param {Array<{url: string, thumb_url?: string}>} options.images - 이미지 배열 - * @param {Function} options.onClose - 닫기 콜백 (optional) - * @returns {Object} 라이트박스 상태 및 메서드 - */ -export function useLightbox({ images = [], onClose } = {}) { - const [isOpen, setIsOpen] = useState(false); - const [currentIndex, setCurrentIndex] = useState(0); - - // 라이트박스 열기 - const open = useCallback((index = 0) => { - setCurrentIndex(index); - setIsOpen(true); - window.history.pushState({ lightbox: true }, ''); - }, []); - - // 라이트박스 닫기 - const close = useCallback(() => { - setIsOpen(false); - onClose?.(); - }, [onClose]); - - // 이전 이미지 - const goToPrev = useCallback(() => { - setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1)); - }, [images.length]); - - // 다음 이미지 - const goToNext = useCallback(() => { - setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0)); - }, [images.length]); - - // 특정 인덱스로 이동 - const goToIndex = useCallback((index) => { - if (index >= 0 && index < images.length) { - setCurrentIndex(index); - } - }, [images.length]); - - // 뒤로가기 처리 - useEffect(() => { - if (!isOpen) return; - - const handlePopState = () => { - close(); - }; - - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, [isOpen, close]); - - // body 스크롤 방지 - useEffect(() => { - if (isOpen) { - document.body.style.overflow = 'hidden'; - } else { - document.body.style.overflow = ''; - } - return () => { - document.body.style.overflow = ''; - }; - }, [isOpen]); - - // 이미지 프리로딩 - useEffect(() => { - if (!isOpen || images.length === 0) return; - - // 현재, 이전, 다음 이미지 프리로드 - const indicesToPreload = [ - currentIndex, - (currentIndex + 1) % images.length, - (currentIndex - 1 + images.length) % images.length, - ]; - - indicesToPreload.forEach((index) => { - const img = new Image(); - img.src = images[index]?.url || images[index]; - }); - }, [isOpen, currentIndex, images]); - - // 이미지 다운로드 - const downloadImage = useCallback(async () => { - const imageUrl = images[currentIndex]?.url || images[currentIndex]; - if (!imageUrl) return; - - try { - const response = await fetch(imageUrl); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `image_${currentIndex + 1}.jpg`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - } catch (error) { - console.error('이미지 다운로드 실패:', error); - } - }, [images, currentIndex]); - - return { - isOpen, - currentIndex, - currentImage: images[currentIndex], - totalCount: images.length, - open, - close, - goToPrev, - goToNext, - goToIndex, - downloadImage, - }; -} -``` - ---- - -### 2.2 에러 상태 처리 미흡 ✅ - -**파일**: 대부분의 페이지 컴포넌트 - -**상태**: ✅ 완료 - `components/common/ErrorMessage.jsx` 생성 (향후 점진적 적용) - -**문제**: -```javascript -// 현재 - 에러 처리 없음 -const { data: albums = [], isLoading: loading } = useQuery({...}); - -if (loading) return ; - -// 에러 발생 시 빈 화면 또는 예상치 못한 동작 -``` - -**해결방법**: 공통 에러 UI 컴포넌트 생성 -```javascript -// components/common/ErrorMessage.jsx -import { motion } from 'framer-motion'; -import { AlertCircle, RefreshCw } from 'lucide-react'; - -function ErrorMessage({ - message = '데이터를 불러오는데 실패했습니다.', - onRetry, - className = '' -}) { - return ( - - -

{message}

- {onRetry && ( - - )} -
- ); -} - -export default ErrorMessage; -``` - -페이지에서 사용: -```javascript -const { data, isLoading, isError, refetch } = useQuery({...}); - -if (isLoading) return ; -if (isError) return ; -``` - ---- - -### 2.3 로딩 스피너 불일치 ✅ - -**파일**: 여러 페이지 - -**상태**: ✅ 완료 - `components/common/Loading.jsx`에 `size` prop 추가 (sm, md, lg) - -**문제**: -```javascript -// PC - 큰 스피너 -
- -// Mobile - 작은 스피너 -
- -// 또 다른 곳 -
-``` - -**해결방법**: Loading 컴포넌트에 size prop 추가 -```javascript -// components/common/Loading.jsx -function Loading({ size = 'md', className = '' }) { - const sizeClasses = { - sm: 'h-6 w-6 border-2', - md: 'h-10 w-10 border-3', - lg: 'h-12 w-12 border-4', - }; - - return ( -
-
-
- ); -} -``` - -모든 페이지에서 통일: -```javascript -// PC -if (loading) return ; - -// Mobile -if (loading) return ; -``` - ---- - -### 2.4 스토어 미사용 코드 ✅ - -**파일**: `stores/useAuthStore.js`, `stores/useUIStore.js` - -**상태**: ✅ 완료 - 미사용 메서드 및 confirmDialog 코드 삭제 - -#### useAuthStore 미사용 메서드 (삭제됨) - -```javascript -// 삭제 완료 -getToken: () => get().token, // 직접 token 접근으로 대체 -checkAuth: () => !!token, // isAuthenticated로 대체 -``` - -#### useUIStore 미사용 코드 (삭제됨, 약 25줄) - -```javascript -// 삭제 완료 - 전체 confirmDialog 관련 코드 -confirmDialog: null, -showConfirm: (options) => ..., -closeConfirm: () => ..., -``` - ---- - -## 3. 중간 우선순위 (Medium) ✅ 완료 - -### 3.1 API 에러 처리 불일치 ✅ - -**파일**: `api/client.js` - -**상태**: ✅ 완료 - `fetchFormData`에 `requireAuth` 옵션 추가 - -**문제**: -```javascript -// fetchAuthApi - 토큰 없으면 에러 발생 -export async function fetchAuthApi(endpoint, options = {}) { - const token = useAuthStore.getState().token; - if (!token) { - throw new ApiError('인증이 필요합니다.', 401); - } - // ... -} - -// fetchFormData - 토큰 없어도 에러 미발생 -export async function fetchFormData(endpoint, formData, method = 'POST') { - const token = useAuthStore.getState().token; - const headers = {}; - if (token) { - headers.Authorization = `Bearer ${token}`; // 조건부 설정만 - } - // 토큰 없어도 계속 진행 -} -``` - -**해결방법**: -```javascript -// 인증 필요 여부를 명시적으로 지정 -export async function fetchFormData(endpoint, formData, method = 'POST', { requireAuth = true } = {}) { - const token = useAuthStore.getState().token; - - if (requireAuth && !token) { - throw new ApiError('인증이 필요합니다.', 401); - } - - const headers = {}; - if (token) { - headers.Authorization = `Bearer ${token}`; - } - // ... -} -``` - ---- - -### 3.2 HTTP 헬퍼 함수 중복 ✅ - -**파일**: `api/client.js` - -**상태**: ✅ 완료 - `createMethodHelpers` 팩토리 함수로 통합, `api`/`authApi` 객체 export - -**문제**: -```javascript -// 8개의 유사한 헬퍼 함수 -export const get = (endpoint) => fetchApi(endpoint); -export const post = (endpoint, data) => fetchApi(endpoint, { method: 'POST', body: JSON.stringify(data) }); -export const put = (endpoint, data) => fetchApi(endpoint, { method: 'PUT', body: JSON.stringify(data) }); -export const del = (endpoint) => fetchApi(endpoint, { method: 'DELETE' }); -export const authGet = (endpoint) => fetchAuthApi(endpoint); -export const authPost = (endpoint, data) => fetchAuthApi(endpoint, { method: 'POST', body: JSON.stringify(data) }); -export const authPut = (endpoint, data) => fetchAuthApi(endpoint, { method: 'PUT', body: JSON.stringify(data) }); -export const authDel = (endpoint) => fetchAuthApi(endpoint, { method: 'DELETE' }); -``` - -**해결방법**: 팩토리 함수로 통합 -```javascript -// 메서드 헬퍼 생성기 -function createMethodHelpers(baseFetch) { - return { - get: (endpoint) => baseFetch(endpoint), - post: (endpoint, data) => baseFetch(endpoint, { - method: 'POST', - body: JSON.stringify(data) - }), - put: (endpoint, data) => baseFetch(endpoint, { - method: 'PUT', - body: JSON.stringify(data) - }), - del: (endpoint) => baseFetch(endpoint, { method: 'DELETE' }), - }; -} - -// 공개 API 헬퍼 -export const api = createMethodHelpers(fetchApi); - -// 인증 API 헬퍼 -export const authApi = createMethodHelpers(fetchAuthApi); - -// 사용 -// api.get('/albums') -// authApi.post('/admin/schedules', data) -``` - ---- - -### 3.3 useMediaQuery 리스너 메모이제이션 ✅ - -**파일**: `hooks/useMediaQuery.js` - -**상태**: ✅ 완료 - `useCallback`으로 핸들러 메모이제이션 - -**문제**: -```javascript -useEffect(() => { - const media = window.matchMedia(query); - - const handler = (e) => setMatches(e.matches); // 매번 새 함수 생성 - - media.addEventListener('change', handler); - return () => media.removeEventListener('change', handler); -}, [query]); -``` - -**해결방법**: -```javascript -import { useState, useEffect, useCallback } from 'react'; - -export function useMediaQuery(query) { - const [matches, setMatches] = useState(() => { - if (typeof window === 'undefined') return false; - return window.matchMedia(query).matches; - }); - - const handler = useCallback((e) => { - setMatches(e.matches); - }, []); - - useEffect(() => { - const media = window.matchMedia(query); - setMatches(media.matches); - - media.addEventListener('change', handler); - return () => media.removeEventListener('change', handler); - }, [query, handler]); - - return matches; -} -``` - ---- - -### 3.4 useCalendar 반환 객체 메모이제이션 ✅ - -**파일**: `hooks/useCalendar.js` - -**상태**: ✅ 완료 - `useMemo`로 반환 객체 메모이제이션 - -**문제**: -```javascript -return { - ...calendarData, - currentDate, - selectedDate, - goToPrevMonth, - goToNextMonth, - // ... 매번 새 객체 생성 -}; -``` - -**해결방법**: -```javascript -return useMemo(() => ({ - ...calendarData, - currentDate, - selectedDate, - goToPrevMonth, - goToNextMonth, - goToMonth, - canGoPrevMonth, - canGoNextMonth, - selectDate, - updateSelectedDate, -}), [ - calendarData, - currentDate, - selectedDate, - goToPrevMonth, - goToNextMonth, - goToMonth, - canGoPrevMonth, - canGoNextMonth, - selectDate, - updateSelectedDate, -]); -``` - ---- - -### 3.5 날짜/시간 추출 함수 중복 ✅ - -**파일**: `utils/date.js`, `utils/schedule.js` - -**상태**: ✅ 완료 - `schedule.js`에서 `date.js`의 `extractDate`, `extractTime` 재사용 - -**중복 함수**: -```javascript -// date.js -export function extractDate(datetime) { ... } -export function extractTime(datetime) { ... } - -// schedule.js -export function getScheduleDate(schedule) { ... } // extractDate와 유사 -export function getScheduleTime(schedule) { ... } // extractTime과 유사 -``` - -**해결방법**: 기본 함수를 재사용 -```javascript -// schedule.js 수정 -import { extractDate, extractTime } from './date'; - -export function getScheduleDate(schedule) { - if (!schedule) return null; - return schedule.date || extractDate(schedule.datetime); -} - -export function getScheduleTime(schedule) { - if (!schedule) return null; - return schedule.time || extractTime(schedule.datetime); -} -``` - ---- - -### 3.6 decodeHtmlEntities DOM 조작 ✅ - -**파일**: `utils/format.js` - -**상태**: ✅ 완료 - 순수 함수로 변경 (정규식 + 매핑 객체), SSR 호환 - -**문제**: -```javascript -export function decodeHtmlEntities(text) { - if (!text) return ''; - const textarea = document.createElement('textarea'); // DOM 조작 - textarea.innerHTML = text; - return textarea.value; -} -``` - -서버 사이드 렌더링(SSR) 환경에서 오류 발생. - -**해결방법**: -```javascript -// 순수 함수 버전 -const htmlEntities = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - ''': "'", - ''': "'", - ''': "'", - ' ': ' ', -}; - -export function decodeHtmlEntities(text) { - if (!text) return ''; - return text.replace( - /&(?:amp|lt|gt|quot|#39|apos|#x27|nbsp);/g, - (match) => htmlEntities[match] || match - ); -} -``` - ---- - -### 3.7 리스트 key에 index 사용 ✅ (검토 완료) - -**파일**: 여러 컴포넌트 - -**상태**: ✅ 검토 완료 - 정적 리스트는 index 사용 유지, 동적 리스트는 이미 고유 ID 사용 중 - -**문제**: -```javascript -// LightboxIndicator.jsx -{Array.from({ length: count }).map((_, i) => ( -