fromis_9/docs/frontend-refactoring.md

1654 lines
51 KiB
Markdown
Raw Normal View History

# Frontend Refactoring Plan
프론트엔드 완전 리팩토링 계획서
## 개요
- **대상**: `/docker/fromis_9/frontend/src/`
- **현재 규모**: 75개 파일, ~18,600 LOC
- **목표**: 완전한 코드 재구성, 중복 제거, 유지보수성 극대화
- **방식**: `frontend-temp/` 폴더에 새 구조 구축 후 마이그레이션
- **예상 효과**: LOC 40% 감소, 중복 코드 90% 제거
---
## 1. 마이그레이션 전략
### Strangler Fig Pattern
기존 코드를 건드리지 않고 새 구조를 먼저 구축한 후, 검증 완료 시 교체하는 방식
```
frontend/ (기존 - 운영 중)
frontend-temp/ (신규 - 개발 중)
↓ 검증 완료 후
frontend-backup/ (기존 백업)
frontend/ (신규로 교체)
```
### 장점
- 기존 코드 안전 유지 (작업 중에도 사이트 정상 동작)
- 깔끔한 새 구조로 시작
- 페이지 단위로 점진적 마이그레이션 및 검증
- 문제 발생 시 즉시 롤백 가능
### 코딩 가이드라인
#### useEffect 대신 useQuery 사용
**데이터 페칭에는 반드시 React Query (useQuery)를 사용**
```javascript
// ❌ BAD - useEffect로 데이터 페칭
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchData()
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
// ✅ GOOD - useQuery로 데이터 페칭
const { data, isLoading, error } = useQuery({
queryKey: ['dataKey'],
queryFn: fetchData,
});
```
**useEffect 사용이 허용되는 경우:**
- DOM 이벤트 리스너 등록/해제 (resize, scroll, keydown 등)
- 외부 라이브러리 초기화/정리
- 브라우저 API 동기화 (document.title, localStorage 등)
- 타이머 설정 (setTimeout, setInterval)
**useQuery 사용의 장점:**
- 자동 캐싱 및 재검증
- 로딩/에러 상태 자동 관리
- 중복 요청 방지
- 백그라운드 리프레시
- 개발자 도구 지원
---
## 2. 현재 구조 분석
### 디렉토리 구조
```
src/
├── api/ # API 호출 (12개 파일, ~300 LOC)
├── components/ # UI 컴포넌트 (~900 LOC)
│ ├── pc/ # PC 전용 (Header, Layout, Footer)
│ ├── mobile/ # 모바일 전용 (Layout)
│ ├── admin/ # 관리자 전용 (6개)
│ └── common/ # 공통 (Lightbox, Toast, Tooltip)
├── hooks/ # 커스텀 훅 (3개, ~100 LOC)
├── pages/ # 페이지 (~15,000 LOC) ⚠️ 대부분 여기 집중
│ ├── pc/public/ # PC 공개 (14개)
│ ├── mobile/public/ # 모바일 공개 (8개)
│ └── pc/admin/ # 관리자 (~20개)
├── stores/ # Zustand (1개, 42 LOC)
├── utils/ # 유틸리티 (1개, 107 LOC)
└── App.jsx # 라우팅 (122 LOC)
```
### 주요 문제 파일 (LOC 기준)
| 파일 | LOC | 문제점 |
|------|-----|--------|
| AdminAlbumPhotos.jsx | 1,538 | SSE 스트리밍, 복잡한 상태 |
| Mobile Schedule.jsx | 1,530 | PC와 70%+ 중복 |
| AdminSchedule.jsx | 1,469 | 검색, 필터링 로직 복잡 |
| PC Schedule.jsx | 1,419 | Mobile과 70%+ 중복 |
| AdminScheduleForm.jsx | 1,173 | 폼 검증, 다양한 입력 |
| AdminScheduleDict.jsx | 664 | 사전 관리 |
| AdminAlbumForm.jsx | 666 | 앨범 폼 |
| AlbumDetail.jsx (PC) | 593 | Mobile과 60% 중복 |
| AlbumDetail.jsx (Mobile) | 465 | PC와 60% 중복 |
| AlbumGallery.jsx (PC) | 381 | Mobile과 70% 중복 |
| AlbumGallery.jsx (Mobile) | 351 | PC와 70% 중복 |
### 중복 코드 분석
| 영역 | PC | Mobile | 중복도 | 절감 가능 LOC |
|------|----|----|-------|--------------|
| Schedule.jsx | 1,419 | 1,530 | 70%+ | ~1,000 |
| AlbumDetail.jsx | 593 | 465 | 60% | ~300 |
| AlbumGallery.jsx | 381 | 351 | 70% | ~250 |
| Home.jsx | ~300 | ~250 | 50% | ~130 |
| Members.jsx | ~200 | ~180 | 60% | ~110 |
| 유틸리티 함수 | 다수 | 다수 | 100% | ~200 |
| **총계** | | | | **~2,000** |
---
## 3. 목표 구조
```
frontend-temp/src/
├── api/ # API 계층
│ ├── index.js # 공통 fetch 래퍼
│ ├── normalizers.js # 응답 정규화
│ ├── public/
│ │ ├── schedules.js
│ │ ├── albums.js
│ │ └── members.js
│ └── admin/
│ ├── schedules.js
│ ├── albums.js
│ ├── members.js
│ ├── auth.js
│ └── ...
├── components/ # UI 컴포넌트
│ ├── common/ # 공통 컴포넌트
│ │ ├── ErrorBoundary.jsx
│ │ ├── QueryErrorHandler.jsx
│ │ ├── Loading.jsx
│ │ ├── Toast.jsx
│ │ ├── Tooltip.jsx
│ │ ├── Lightbox/
│ │ │ ├── index.jsx
│ │ │ └── Indicator.jsx
│ │ └── ScrollToTop.jsx
│ │
│ ├── layout/ # 레이아웃 컴포넌트
│ │ ├── pc/
│ │ │ ├── Layout.jsx
│ │ │ ├── Header.jsx
│ │ │ └── Footer.jsx
│ │ ├── mobile/
│ │ │ ├── Layout.jsx
│ │ │ └── BottomNav.jsx
│ │ └── admin/
│ │ ├── AdminLayout.jsx
│ │ └── AdminHeader.jsx
│ │
│ ├── schedule/ # 일정 관련 (PC/Mobile 공유)
│ │ ├── ScheduleCard.jsx # 일정 카드
│ │ ├── ScheduleList.jsx # 일정 목록
│ │ ├── BirthdayCard.jsx # 생일 카드
│ │ ├── CalendarPicker.jsx # 달력 선택기
│ │ ├── CategoryFilter.jsx # 카테고리 필터
│ │ ├── SearchBar.jsx # 검색바
│ │ └── ScheduleDetail/ # 상세 섹션
│ │ ├── DefaultSection.jsx
│ │ ├── YouTubeSection.jsx
│ │ └── XSection.jsx
│ │
│ ├── album/ # 앨범 관련 (PC/Mobile 공유)
│ │ ├── AlbumCard.jsx
│ │ ├── AlbumGrid.jsx
│ │ ├── TrackList.jsx
│ │ ├── PhotoGallery.jsx
│ │ └── TeaserList.jsx
│ │
│ ├── member/ # 멤버 관련 (PC/Mobile 공유)
│ │ ├── MemberCard.jsx
│ │ └── MemberGrid.jsx
│ │
│ └── admin/ # 관리자 전용
│ ├── ConfirmDialog.jsx
│ ├── CustomDatePicker.jsx
│ ├── CustomTimePicker.jsx
│ ├── NumberPicker.jsx
│ └── forms/
│ ├── ScheduleForm/
│ │ ├── index.jsx
│ │ ├── YouTubeForm.jsx
│ │ └── XForm.jsx
│ └── AlbumForm.jsx
├── hooks/ # 커스텀 훅
│ ├── useToast.js
│ ├── useAdminAuth.js
│ ├── useMediaQuery.js # PC/Mobile 감지
│ ├── useInfiniteScroll.js # 무한 스크롤
│ ├── schedule/
│ │ ├── useScheduleData.js # 일정 데이터 페칭
│ │ ├── useScheduleSearch.js # 검색 로직
│ │ ├── useScheduleFiltering.js # 필터링 로직
│ │ └── useCalendar.js # 달력 로직
│ └── album/
│ ├── useAlbumData.js
│ └── useAlbumGallery.js
├── pages/ # 페이지 (렌더링만 담당)
│ ├── pc/
│ │ ├── public/
│ │ │ ├── Home.jsx
│ │ │ ├── Schedule.jsx # 훅 + 컴포넌트 조합만
│ │ │ ├── ScheduleDetail.jsx
│ │ │ ├── Album.jsx
│ │ │ ├── AlbumDetail.jsx
│ │ │ ├── AlbumGallery.jsx
│ │ │ ├── Members.jsx
│ │ │ ├── TrackDetail.jsx
│ │ │ └── NotFound.jsx
│ │ └── admin/
│ │ ├── Dashboard.jsx
│ │ ├── Schedule.jsx
│ │ ├── ScheduleForm.jsx
│ │ ├── Albums.jsx
│ │ ├── AlbumForm.jsx
│ │ ├── AlbumPhotos.jsx
│ │ ├── Members.jsx
│ │ └── ...
│ └── mobile/
│ └── public/
│ ├── Home.jsx
│ ├── Schedule.jsx # PC와 같은 훅 사용, UI만 다름
│ ├── ScheduleDetail.jsx
│ ├── Album.jsx
│ ├── AlbumDetail.jsx
│ ├── AlbumGallery.jsx
│ └── Members.jsx
├── stores/ # Zustand 상태 관리
│ ├── useAuthStore.js # 인증 상태
│ ├── useScheduleStore.js # 일정 UI 상태
│ └── useUIStore.js # 전역 UI 상태
├── utils/ # 유틸리티 함수
│ ├── date.js # 날짜 처리
│ ├── schedule.js # 일정 헬퍼
│ ├── format.js # 포맷팅
│ ├── confetti.js # 폭죽 애니메이션
│ └── cn.js # className 유틸 (clsx)
├── constants/ # 상수 정의
│ ├── schedule.js
│ ├── album.js
│ └── routes.js
├── types/ # JSDoc 타입 정의
│ └── index.js
├── styles/ # 스타일
│ ├── index.css # Tailwind 기본
│ ├── pc.css
│ └── mobile.css
├── App.jsx # 라우팅
└── main.jsx # 엔트리포인트
```
---
## 4. 리팩토링 단계
### Phase 1: 프로젝트 기반 설정
**목표**: frontend-temp 폴더 생성 및 기본 설정
#### 작업 내용
- [ ] `frontend-temp/` 디렉토리 생성
- [ ] `package.json` 복사 및 의존성 정리
- [ ] `vite.config.js` 설정
- [ ] `tailwind.config.js` 설정
- [ ] `index.html` 복사
- [ ] 디렉토리 구조 생성
#### 정리할 의존성
```json
{
"dependencies": {
"@tanstack/react-query": "^5.x",
"@tanstack/react-virtual": "^3.x",
"framer-motion": "^11.x",
"lucide-react": "^0.x",
"zustand": "^5.x",
"dayjs": "^1.x",
"canvas-confetti": "^1.x",
"react-router-dom": "^6.x",
"clsx": "^2.x"
}
}
```
**제거 검토 대상**:
- `react-device-detect``useMediaQuery` 훅으로 대체
---
### Phase 2: 유틸리티 및 상수
**목표**: 공통 유틸리티와 상수 정의
#### 2.1 `constants/schedule.js`
```javascript
export const BIRTHDAY_CONFETTI_COLORS = [
'#ff69b4', '#ff1493', '#da70d6', '#ba55d3',
'#9370db', '#8a2be2', '#ffd700', '#ff6347'
];
export const SEARCH_LIMIT = 20;
export const ESTIMATED_ITEM_HEIGHT = 120;
export const MIN_YEAR = 2017;
export const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
};
export const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
export const MONTH_NAMES = ['1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'];
```
#### 2.2 `utils/schedule.js`
```javascript
/**
* @typedef {Object} Schedule
* @property {number} id
* @property {string} title
* @property {string} [date]
* @property {string} [datetime]
* @property {string} [time]
* @property {Object} [category]
* @property {number} [category_id]
*/
/** HTML 엔티티 디코딩 */
export const decodeHtmlEntities = (text) => {
if (!text) return '';
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
};
/** 멤버 리스트 추출 */
export const getMemberList = (schedule) => {
if (schedule.member_names) {
return schedule.member_names.split(',').map(n => n.trim()).filter(Boolean);
}
if (Array.isArray(schedule.members) && schedule.members.length > 0) {
if (typeof schedule.members[0] === 'string') {
return schedule.members.filter(Boolean);
}
return schedule.members.map(m => m.name).filter(Boolean);
}
return [];
};
/** 일정 날짜 추출 */
export const getScheduleDate = (schedule) => {
if (schedule.datetime) return new Date(schedule.datetime);
if (schedule.date) return new Date(schedule.date);
return new Date();
};
/** 일정 시간 추출 */
export const getScheduleTime = (schedule) => {
if (schedule.time) return schedule.time.slice(0, 5);
if (schedule.datetime?.includes('T')) {
const timePart = schedule.datetime.split('T')[1];
return timePart ? timePart.slice(0, 5) : null;
}
return null;
};
/** 카테고리 ID 추출 */
export const getCategoryId = (schedule) => {
if (schedule.category_id !== undefined) return schedule.category_id;
if (schedule.category?.id !== undefined) return schedule.category.id;
return null;
};
/** 카테고리 정보 추출 */
export const getCategoryInfo = (schedule, categories) => {
const catId = getCategoryId(schedule);
if (schedule.category?.name && schedule.category?.color) {
return schedule.category;
}
return categories.find(c => c.id === catId) ||
{ id: catId, name: '미분류', color: '#6b7280' };
};
/** 생일 여부 확인 */
export const isBirthdaySchedule = (schedule) => {
return schedule.is_birthday || String(schedule.id).startsWith('birthday-');
};
```
#### 2.3 `utils/confetti.js`
```javascript
import confetti from 'canvas-confetti';
import { BIRTHDAY_CONFETTI_COLORS } from '../constants/schedule';
export const fireBirthdayConfetti = () => {
const duration = 3000;
const end = Date.now() + duration;
const frame = () => {
confetti({
particleCount: 3,
angle: 60,
spread: 55,
origin: { x: 0 },
colors: BIRTHDAY_CONFETTI_COLORS,
});
confetti({
particleCount: 3,
angle: 120,
spread: 55,
origin: { x: 1 },
colors: BIRTHDAY_CONFETTI_COLORS,
});
if (Date.now() < end) {
requestAnimationFrame(frame);
}
};
frame();
};
```
#### 2.4 `utils/cn.js`
```javascript
import { clsx } from 'clsx';
/** className 병합 유틸리티 */
export function cn(...inputs) {
return clsx(inputs);
}
```
---
### Phase 3: Stores 구축
**목표**: Zustand 스토어 정의
#### 3.1 `stores/useAuthStore.js`
```javascript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useAuthStore = create(
persist(
(set, get) => ({
token: null,
user: null,
get isAuthenticated() {
return !!get().token;
},
setAuth: (token, user) => set({ token, user }),
logout: () => set({ token: null, user: null }),
getToken: () => get().token,
}),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token }),
}
)
);
export default useAuthStore;
```
#### 3.2 `stores/useScheduleStore.js`
```javascript
import { create } from 'zustand';
const useScheduleStore = create((set) => ({
// 검색 상태
searchInput: '',
searchTerm: '',
isSearchMode: false,
// 필터 상태
selectedCategories: [],
selectedDate: undefined,
currentDate: new Date(),
// UI 상태
scrollPosition: 0,
// Actions
setSearchInput: (value) => set({ searchInput: value }),
setSearchTerm: (value) => set({ searchTerm: value }),
setIsSearchMode: (value) => set({ isSearchMode: value }),
setSelectedCategories: (value) => set({ selectedCategories: value }),
setSelectedDate: (value) => set({ selectedDate: value }),
setCurrentDate: (value) => set({ currentDate: value }),
setScrollPosition: (value) => set({ scrollPosition: value }),
// 복합 Actions
enterSearchMode: () => set({ isSearchMode: true }),
exitSearchMode: () => set({
isSearchMode: false,
searchInput: '',
searchTerm: ''
}),
reset: () => set({
searchInput: '',
searchTerm: '',
isSearchMode: false,
selectedCategories: [],
selectedDate: undefined,
currentDate: new Date(),
scrollPosition: 0,
}),
}));
export default useScheduleStore;
```
#### 3.3 `stores/useUIStore.js`
```javascript
import { create } from 'zustand';
const useUIStore = create((set) => ({
// Toast
toast: null,
setToast: (toast) => set({ toast }),
clearToast: () => set({ toast: null }),
// Global loading
isLoading: false,
setIsLoading: (value) => set({ isLoading: value }),
}));
export default useUIStore;
```
---
### Phase 4: API 계층
**목표**: API 호출 및 응답 정규화
#### 4.1 `api/index.js`
```javascript
import useAuthStore from '../stores/useAuthStore';
const BASE_URL = '';
export class ApiError extends Error {
constructor(message, status, data) {
super(message);
this.status = status;
this.data = data;
}
}
export async function fetchApi(url, options = {}) {
const headers = { ...options.headers };
if (options.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${BASE_URL}${url}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: '요청 실패' }));
throw new ApiError(
error.error || `HTTP ${response.status}`,
response.status,
error
);
}
return response.json();
}
export async function fetchAdminApi(url, options = {}) {
const token = useAuthStore.getState().getToken();
return fetchApi(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
});
}
export async function fetchAdminFormData(url, formData, method = 'POST') {
const token = useAuthStore.getState().getToken();
const response = await fetch(`${BASE_URL}${url}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: '요청 실패' }));
throw new ApiError(
error.error || `HTTP ${response.status}`,
response.status,
error
);
}
return response.json();
}
```
#### 4.2 `api/normalizers.js`
```javascript
/**
* 일정 응답 정규화 (날짜별 그룹 → 플랫 배열)
*/
export function normalizeSchedulesResponse(data) {
const schedules = [];
for (const [date, dayData] of Object.entries(data)) {
for (const schedule of dayData.schedules) {
const category = schedule.category || {};
schedules.push({
...schedule,
date,
categoryId: category.id,
categoryName: category.name,
categoryColor: category.color,
// 하위 호환성
category_id: category.id,
category_name: category.name,
category_color: category.color,
});
}
}
return schedules;
}
/**
* 검색 결과 정규화
*/
export function normalizeSearchResult(schedule) {
return {
...schedule,
categoryId: schedule.category?.id,
categoryName: schedule.category?.name,
categoryColor: schedule.category?.color,
category_id: schedule.category?.id,
category_name: schedule.category?.name,
category_color: schedule.category?.color,
};
}
```
---
### Phase 5: 커스텀 훅
**목표**: 재사용 가능한 로직을 훅으로 추출
#### 5.1 `hooks/useMediaQuery.js`
```javascript
import { useState, useEffect } from 'react';
export function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches;
}
return false;
});
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);
return matches;
}
export const useIsMobile = () => useMediaQuery('(max-width: 768px)');
export const useIsDesktop = () => useMediaQuery('(min-width: 769px)');
```
#### 5.2 `hooks/schedule/useScheduleData.js`
```javascript
import { useQuery } from '@tanstack/react-query';
import { fetchApi } from '../../api';
import { normalizeSchedulesResponse } from '../../api/normalizers';
export function useScheduleData(year, month) {
return useQuery({
queryKey: ['schedules', year, month],
queryFn: async () => {
const data = await fetchApi(`/api/schedules?year=${year}&month=${month}`);
return normalizeSchedulesResponse(data);
},
});
}
export function useScheduleDetail(id) {
return useQuery({
queryKey: ['schedule', id],
queryFn: () => fetchApi(`/api/schedules/${id}`),
enabled: !!id,
});
}
```
#### 5.3 `hooks/schedule/useScheduleSearch.js`
```javascript
import { useState, useMemo } from 'react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { fetchApi } from '../../api';
import { normalizeSearchResult } from '../../api/normalizers';
import { SEARCH_LIMIT } from '../../constants/schedule';
export function useScheduleSearch() {
const [searchInput, setSearchInput] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [isSearchMode, setIsSearchMode] = useState(false);
// 검색 결과 (무한 스크롤)
const searchQuery = useInfiniteQuery({
queryKey: ['scheduleSearch', searchTerm],
queryFn: async ({ pageParam = 0, signal }) => {
const response = await fetch(
`/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}`,
{ signal }
);
if (!response.ok) throw new Error('Search failed');
const data = await response.json();
return {
...data,
schedules: data.schedules.map(normalizeSearchResult),
};
},
getNextPageParam: (lastPage) => {
return lastPage.hasMore
? lastPage.offset + lastPage.schedules.length
: undefined;
},
enabled: !!searchTerm && isSearchMode,
});
// 검색어 추천
const suggestionsQuery = useQuery({
queryKey: ['scheduleSuggestions', searchInput],
queryFn: async ({ signal }) => {
const response = await fetch(
`/api/schedules/suggestions?q=${encodeURIComponent(searchInput)}&limit=10`,
{ signal }
);
if (!response.ok) return [];
const data = await response.json();
return data.suggestions || [];
},
enabled: !!searchInput && searchInput.trim().length > 0,
staleTime: 5 * 60 * 1000,
});
const searchResults = useMemo(() => {
return searchQuery.data?.pages?.flatMap(page => page.schedules) || [];
}, [searchQuery.data]);
const searchTotal = searchQuery.data?.pages?.[0]?.total || 0;
const executeSearch = () => {
if (searchInput.trim()) {
setSearchTerm(searchInput);
}
};
const clearSearch = () => {
setSearchInput('');
setSearchTerm('');
setIsSearchMode(false);
};
return {
// 상태
searchInput,
searchTerm,
isSearchMode,
searchResults,
searchTotal,
suggestions: suggestionsQuery.data || [],
isLoading: searchQuery.isLoading,
hasNextPage: searchQuery.hasNextPage,
isFetchingNextPage: searchQuery.isFetchingNextPage,
// 액션
setSearchInput,
setIsSearchMode,
executeSearch,
clearSearch,
fetchNextPage: searchQuery.fetchNextPage,
};
}
```
#### 5.4 `hooks/schedule/useScheduleFiltering.js`
```javascript
import { useMemo } from 'react';
import { formatDate } from '../../utils/date';
import { getCategoryId, isBirthdaySchedule } from '../../utils/schedule';
export function useScheduleFiltering(schedules, options = {}) {
const {
categoryIds = [],
selectedDate,
birthdayFirst = true,
} = options;
return useMemo(() => {
let result = schedules.filter(schedule => {
// 카테고리 필터
if (categoryIds.length > 0) {
const catId = getCategoryId(schedule);
if (!categoryIds.includes(catId)) return false;
}
// 날짜 필터
if (selectedDate) {
const scheduleDate = formatDate(schedule.date || schedule.datetime);
if (scheduleDate !== selectedDate) return false;
}
return true;
});
// 생일 우선 정렬
if (birthdayFirst) {
result.sort((a, b) => {
const aIsBirthday = isBirthdaySchedule(a);
const bIsBirthday = isBirthdaySchedule(b);
if (aIsBirthday && !bIsBirthday) return -1;
if (!aIsBirthday && bIsBirthday) return 1;
return 0;
});
}
return result;
}, [schedules, categoryIds, selectedDate, birthdayFirst]);
}
```
#### 5.5 `hooks/schedule/useCalendar.js`
```javascript
import { useState, useMemo, useCallback } from 'react';
import { MIN_YEAR, WEEKDAYS, MONTH_NAMES } from '../../constants/schedule';
import { getTodayKST } from '../../utils/date';
export function useCalendar(initialDate = new Date()) {
const [currentDate, setCurrentDate] = useState(initialDate);
const [selectedDate, setSelectedDate] = useState(getTodayKST());
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const calendarData = useMemo(() => {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const prevMonthDays = new Date(year, month, 0).getDate();
return {
year,
month,
firstDay,
daysInMonth,
prevMonthDays,
weekdays: WEEKDAYS,
monthNames: MONTH_NAMES,
};
}, [year, month]);
const canGoPrevMonth = !(year === MIN_YEAR && month === 0);
const goToPrevMonth = useCallback(() => {
if (!canGoPrevMonth) return;
const newDate = new Date(year, month - 1, 1);
setCurrentDate(newDate);
updateSelectedDate(newDate);
}, [year, month, canGoPrevMonth]);
const goToNextMonth = useCallback(() => {
const newDate = new Date(year, month + 1, 1);
setCurrentDate(newDate);
updateSelectedDate(newDate);
}, [year, month]);
const goToMonth = useCallback((newYear, newMonth) => {
const newDate = new Date(newYear, newMonth, 1);
setCurrentDate(newDate);
updateSelectedDate(newDate);
}, []);
const updateSelectedDate = (newDate) => {
const today = new Date();
if (newDate.getFullYear() === today.getFullYear() &&
newDate.getMonth() === today.getMonth()) {
setSelectedDate(getTodayKST());
} else {
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
setSelectedDate(firstDay);
}
};
const selectDate = useCallback((day) => {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
setSelectedDate(dateStr);
}, [year, month]);
return {
...calendarData,
currentDate,
selectedDate,
canGoPrevMonth,
goToPrevMonth,
goToNextMonth,
goToMonth,
selectDate,
setSelectedDate,
};
}
```
---
### Phase 6: 공통 컴포넌트
**목표**: PC/Mobile 공유 가능한 컴포넌트 구축
#### 6.1 `components/common/ErrorBoundary.jsx`
```javascript
import { Component } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
class ErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center min-h-[200px]">
<AlertTriangle size={48} className="text-red-500 mb-4" />
<h2 className="text-xl font-bold text-gray-900 mb-2">
문제가 발생했습니다
</h2>
<p className="text-gray-500 mb-4">
{this.state.error?.message || '알 수 없는 오류'}
</p>
<button
onClick={() => window.location.reload()}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
>
<RefreshCw size={16} />
새로고침
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
```
#### 6.2 `components/common/Loading.jsx`
```javascript
export default 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 (
<div className={`flex justify-center items-center ${className}`}>
<div className={`animate-spin rounded-full border-primary border-t-transparent ${sizeClasses[size]}`} />
</div>
);
}
```
#### 6.3 `components/schedule/ScheduleCard.jsx`
```javascript
import { memo } from 'react';
import { Clock, Tag, Link2 } from 'lucide-react';
import { cn } from '../../utils/cn';
import {
decodeHtmlEntities,
getMemberList,
getScheduleDate,
getScheduleTime,
getCategoryInfo,
} from '../../utils/schedule';
import { WEEKDAYS } from '../../constants/schedule';
const ScheduleCard = memo(function ScheduleCard({
schedule,
categories = [],
variant = 'card', // 'card' | 'list' | 'compact'
showYear = false,
onClick,
actions,
className,
}) {
const scheduleDate = getScheduleDate(schedule);
const categoryInfo = getCategoryInfo(schedule, categories);
const timeStr = getScheduleTime(schedule);
const memberList = getMemberList(schedule);
const title = decodeHtmlEntities(schedule.title);
const MemberBadges = () => {
if (memberList.length === 0) return null;
if (memberList.length >= 5) {
return (
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
</span>
);
}
return memberList.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
>
{name}
</span>
));
};
if (variant === 'list') {
return (
<div
onClick={onClick}
className={cn(
"p-5 hover:bg-gray-50 transition-colors group cursor-pointer",
className
)}
>
<div className="flex items-start gap-4">
<div className="w-20 text-center flex-shrink-0">
{showYear && (
<div className="text-xs text-gray-400 mb-0.5">
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
</div>
)}
<div className="text-2xl font-bold text-gray-900">
{scheduleDate.getDate()}
</div>
<div className="text-sm text-gray-500">
{WEEKDAYS[scheduleDate.getDay()]}요일
</div>
</div>
<div
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
style={{ backgroundColor: categoryInfo.color }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 line-clamp-1">{title}</h3>
<div className="flex items-center flex-wrap gap-3 mt-1 text-sm text-gray-500">
{timeStr && (
<span className="flex items-center gap-1">
<Clock size={14} />
{timeStr}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={14} />
{categoryInfo.name}
</span>
{schedule.source?.name && (
<span className="flex items-center gap-1">
<Link2 size={14} />
{schedule.source.name}
</span>
)}
</div>
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
<MemberBadges />
</div>
)}
</div>
{actions && (
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{actions}
</div>
)}
</div>
</div>
);
}
// Card variant (default)
return (
<div
onClick={onClick}
className={cn(
"flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer",
className
)}
>
<div
className="w-24 flex flex-col items-center justify-center text-white py-4"
style={{ backgroundColor: categoryInfo.color }}
>
{showYear && (
<span className="text-xs font-medium opacity-60">
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
</span>
)}
<span className="text-3xl font-bold">{scheduleDate.getDate()}</span>
<span className="text-sm font-medium opacity-80">
{WEEKDAYS[scheduleDate.getDay()]}
</span>
</div>
<div className="flex-1 p-4 flex flex-col justify-center">
<h3 className="font-bold text-lg mb-1 line-clamp-1">{title}</h3>
<div className="flex flex-wrap gap-3 text-sm text-gray-500">
{timeStr && (
<span className="flex items-center gap-1">
<Clock size={14} />
{timeStr}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={14} />
{categoryInfo.name}
</span>
</div>
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{memberList.slice(0, 3).map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full"
>
{name}
</span>
))}
{memberList.length > 3 && (
<span className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-full">
+{memberList.length - 3}
</span>
)}
</div>
)}
</div>
</div>
);
});
export default ScheduleCard;
```
#### 6.4 `components/schedule/BirthdayCard.jsx`
```javascript
import { memo } from 'react';
import { Cake } from 'lucide-react';
import { cn } from '../../utils/cn';
import { decodeHtmlEntities } from '../../utils/schedule';
const BirthdayCard = memo(function BirthdayCard({
schedule,
variant = 'default', // 'default' | 'compact'
onClick,
className,
}) {
const title = decodeHtmlEntities(schedule.title);
const memberName = title.replace(' 생일', '').replace('의', '');
if (variant === 'compact') {
return (
<div
onClick={onClick}
className={cn(
"flex items-center gap-3 p-4 bg-gradient-to-r from-pink-50 to-purple-50 rounded-xl cursor-pointer hover:shadow-md transition-shadow",
className
)}
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-400 to-purple-400 flex items-center justify-center">
<Cake size={20} className="text-white" />
</div>
<div>
<p className="font-bold text-gray-900">{memberName}</p>
<p className="text-sm text-pink-500">생일 축하해요!</p>
</div>
</div>
);
}
return (
<div
onClick={onClick}
className={cn(
"relative overflow-hidden rounded-2xl bg-gradient-to-br from-pink-100 via-purple-50 to-blue-100 p-6 cursor-pointer hover:shadow-lg transition-shadow",
className
)}
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-pink-200/50 to-purple-200/50 rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="relative">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-pink-400 to-purple-400 flex items-center justify-center shadow-lg">
<Cake size={24} className="text-white" />
</div>
<div>
<p className="text-sm text-purple-600 font-medium">오늘은</p>
<h3 className="text-xl font-bold text-gray-900">{memberName} 생일!</h3>
</div>
</div>
<p className="text-pink-600 font-medium">생일 축하해요!</p>
</div>
</div>
);
});
export default BirthdayCard;
```
---
### Phase 7: Schedule 페이지 마이그레이션
**목표**: 가장 큰 중복 영역인 Schedule 페이지 통합
#### 페이지 구조
```
pages/
├── pc/public/Schedule.jsx # PC UI + 공통 훅
└── mobile/public/Schedule.jsx # Mobile UI + 공통 훅 (동일)
```
#### 7.1 PC Schedule 페이지 예시
```javascript
// pages/pc/public/Schedule.jsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
// Hooks
import { useScheduleData } from '../../../hooks/schedule/useScheduleData';
import { useScheduleSearch } from '../../../hooks/schedule/useScheduleSearch';
import { useScheduleFiltering } from '../../../hooks/schedule/useScheduleFiltering';
import { useCalendar } from '../../../hooks/schedule/useCalendar';
import useScheduleStore from '../../../stores/useScheduleStore';
// Components
import Layout from '../../../components/layout/pc/Layout';
import ScheduleCard from '../../../components/schedule/ScheduleCard';
import BirthdayCard from '../../../components/schedule/BirthdayCard';
import CalendarPicker from '../../../components/schedule/CalendarPicker';
import CategoryFilter from '../../../components/schedule/CategoryFilter';
import SearchBar from '../../../components/schedule/SearchBar';
import Loading from '../../../components/common/Loading';
// Utils
import { fireBirthdayConfetti } from '../../../utils/confetti';
import { isBirthdaySchedule } from '../../../utils/schedule';
export default function Schedule() {
const navigate = useNavigate();
const { selectedCategories } = useScheduleStore();
// 달력 훅
const calendar = useCalendar();
// 데이터 페칭
const { data: schedules = [], isLoading } = useScheduleData(
calendar.year,
calendar.month + 1
);
// 검색 훅
const search = useScheduleSearch();
// 필터링 훅
const filteredSchedules = useScheduleFiltering(
search.isSearchMode ? search.searchResults : schedules,
{
categoryIds: selectedCategories,
selectedDate: search.isSearchMode ? undefined : calendar.selectedDate,
}
);
// 카테고리 추출
const categories = useMemo(() => {
const map = new Map();
schedules.forEach(s => {
if (s.category_id && !map.has(s.category_id)) {
map.set(s.category_id, {
id: s.category_id,
name: s.category_name,
color: s.category_color,
});
}
});
return Array.from(map.values());
}, [schedules]);
// 생일 폭죽
useEffect(() => {
const hasBirthday = filteredSchedules.some(isBirthdaySchedule);
if (hasBirthday && !search.isSearchMode) {
fireBirthdayConfetti();
}
}, [calendar.selectedDate]);
const handleScheduleClick = (schedule) => {
if (isBirthdaySchedule(schedule)) return;
navigate(`/schedule/${schedule.id}`);
};
return (
<Layout>
<div className="h-full flex">
{/* 왼쪽: 달력 + 카테고리 */}
<aside className="w-80 flex-shrink-0 p-6 space-y-6">
<CalendarPicker
{...calendar}
schedules={schedules}
disabled={search.isSearchMode}
/>
<CategoryFilter
categories={categories}
disabled={search.isSearchMode && !search.searchTerm}
/>
</aside>
{/* 오른쪽: 일정 목록 */}
<main className="flex-1 flex flex-col min-h-0">
<SearchBar {...search} />
{isLoading ? (
<Loading className="flex-1" />
) : (
<div className="flex-1 overflow-y-auto p-6 space-y-4">
<AnimatePresence mode="popLayout">
{filteredSchedules.map((schedule, index) => (
<motion.div
key={schedule.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ delay: index * 0.02 }}
>
{isBirthdaySchedule(schedule) ? (
<BirthdayCard
schedule={schedule}
onClick={() => handleScheduleClick(schedule)}
/>
) : (
<ScheduleCard
schedule={schedule}
categories={categories}
onClick={() => handleScheduleClick(schedule)}
/>
)}
</motion.div>
))}
</AnimatePresence>
</div>
)}
</main>
</div>
</Layout>
);
}
```
---
### Phase 8: Album 페이지 마이그레이션
**목표**: Album 관련 페이지 통합
- [ ] `hooks/album/useAlbumData.js` 생성
- [ ] `hooks/album/useAlbumGallery.js` 생성
- [ ] `components/album/AlbumCard.jsx` 생성
- [ ] `components/album/AlbumGrid.jsx` 생성
- [ ] `components/album/TrackList.jsx` 생성
- [ ] `components/album/PhotoGallery.jsx` 생성
- [ ] PC/Mobile Album.jsx 마이그레이션
- [ ] PC/Mobile AlbumDetail.jsx 마이그레이션
- [ ] PC/Mobile AlbumGallery.jsx 마이그레이션
---
### Phase 9: 기타 Public 페이지 마이그레이션
- [ ] `components/member/MemberCard.jsx` 생성
- [ ] `components/member/MemberGrid.jsx` 생성
- [ ] PC/Mobile Home.jsx 마이그레이션
- [ ] PC/Mobile Members.jsx 마이그레이션
- [ ] PC/Mobile TrackDetail.jsx 마이그레이션
- [ ] PC/Mobile ScheduleDetail.jsx 마이그레이션
---
### Phase 10: Admin 페이지 마이그레이션
- [ ] AdminLayout 마이그레이션
- [ ] AdminSchedule.jsx 마이그레이션
- [ ] AdminScheduleForm.jsx 마이그레이션
- [ ] AdminAlbums.jsx 마이그레이션
- [ ] AdminAlbumForm.jsx 마이그레이션
- [ ] AdminAlbumPhotos.jsx 마이그레이션
- [ ] AdminMembers.jsx 마이그레이션
- [ ] 기타 관리자 페이지 마이그레이션
---
### Phase 11: 최종 검증 및 교체
#### 검증 체크리스트
**Public 페이지**
- [ ] 홈 페이지 (PC/Mobile)
- [ ] 일정 페이지 - 달력 탐색 (PC/Mobile)
- [ ] 일정 페이지 - 검색 (PC/Mobile)
- [ ] 일정 페이지 - 카테고리 필터 (PC/Mobile)
- [ ] 일정 상세 페이지 (PC/Mobile)
- [ ] 앨범 목록 페이지 (PC/Mobile)
- [ ] 앨범 상세 페이지 (PC/Mobile)
- [ ] 앨범 갤러리 페이지 (PC/Mobile)
- [ ] 트랙 상세 페이지 (PC/Mobile)
- [ ] 멤버 페이지 (PC/Mobile)
**Admin 페이지**
- [ ] 로그인/로그아웃
- [ ] 대시보드
- [ ] 일정 목록/검색
- [ ] 일정 생성/수정/삭제
- [ ] YouTube/X 일정 등록
- [ ] 앨범 목록
- [ ] 앨범 생성/수정/삭제
- [ ] 앨범 사진 업로드
- [ ] 멤버 관리
#### 교체 절차
```bash
# 1. 기존 frontend 백업
mv frontend frontend-backup
# 2. 새 frontend로 교체
mv frontend-temp frontend
# 3. Docker 재빌드
docker compose up -d --build fromis9-frontend
# 4. 검증 후 백업 삭제
rm -rf frontend-backup
```
---
## 5. 예상 효과
| 항목 | Before | After | 개선 |
|------|--------|-------|------|
| **총 LOC** | ~18,600 | ~11,000 | **-40%** |
| **중복 코드** | 60-70% | <10% | **-60%p** |
| **파일 수** | 75개 | ~65개 | -10개 |
| **Store 수** | 1개 | 3개 | 명확한 책임 분리 |
| **커스텀 훅** | 3개 | 12개+ | 재사용성 극대화 |
| **공유 컴포넌트** | 5개 | 20개+ | 일관성 확보 |
---
## 6. 진행 상황
| Phase | 작업 | 상태 |
|-------|------|------|
| 1 | 프로젝트 기반 설정 | ✅ 완료 |
| 2 | 유틸리티 및 상수 | ✅ 완료 |
| 3 | Stores 구축 | ✅ 완료 |
| 4 | API 계층 (공개 API) | ✅ 완료 |
| 5 | 커스텀 훅 | ✅ 완료 |
| 6 | 공통 컴포넌트 | ✅ 완료 |
| 7 | Schedule 페이지 | ✅ 완료 |
| 8 | Album 페이지 | ✅ 완료 |
| 9 | 기타 Public 페이지 (Home, Members) | ✅ 완료 |
| 9-1 | NotFound 페이지 | ✅ 완료 |
| 9-2 | 코드 품질 개선 (Critical/High) | 🔄 진행 중 |
| 10 | Admin 페이지 | ⬜ 대기 |
| 11 | 최종 검증 및 교체 | ⬜ 대기 |
### 세부 완료 현황
**레이아웃 컴포넌트**
- ✅ PC: Layout, Header, Footer
- ✅ Mobile: Layout
**공개 페이지**
- ✅ Home (PC/Mobile)
- ✅ Members (PC/Mobile)
- ✅ Album, AlbumDetail, AlbumGallery, TrackDetail (PC/Mobile)
- ✅ Schedule, ScheduleDetail, Birthday (PC/Mobile)
- ✅ NotFound (PC/Mobile)
**일정 컴포넌트**
- ✅ Calendar, MobileCalendar
- ✅ ScheduleCard, MobileScheduleCard, MobileScheduleListCard, MobileScheduleSearchCard
- ✅ BirthdayCard, CategoryFilter, AdminScheduleCard
**공통 컴포넌트**
- ✅ Loading, ErrorBoundary, Toast, Lightbox
- ✅ LightboxIndicator, Tooltip, ScrollToTop
**훅**
- ✅ useAlbumData, useMemberData, useScheduleData, useScheduleSearch
- ✅ useScheduleFiltering, useCalendar, useMediaQuery, useAdminAuth
- ⬜ useToast (관리자 영역 마이그레이션 시 진행)
- ⬜ useLightbox (코드 품질 개선 시 추가 예정)
**스토어**
- ✅ useAuthStore, useScheduleStore, useUIStore
**관리자 (별도 요청 시 진행)**
- ⬜ 관리자 API 전체
- ⬜ 관리자 컴포넌트 전체
- ⬜ 관리자 페이지 전체
---
## 7. 참고 사항
### 의존성 정리
**유지**:
- `@tanstack/react-query` - 데이터 페칭
- `@tanstack/react-virtual` - 가상 스크롤
- `framer-motion` - 애니메이션
- `lucide-react` - 아이콘
- `zustand` - 상태 관리
- `dayjs` - 날짜 처리
- `canvas-confetti` - 폭죽
- `react-router-dom` - 라우팅
- `clsx` - className 유틸 (신규 추가)
**제거 검토**:
- `react-device-detect``useMediaQuery` 훅으로 대체
### 하위 호환성
- API 응답 필드명은 snake_case와 camelCase 모두 제공
- 기존 URL 구조 유지
### 롤백 계획
- `frontend-backup/` 폴더로 즉시 복원 가능
- Docker 이미지 태그로 이전 버전 배포 가능
---
## 8. 코드 품질 개선 (Phase 9-2)
공개 영역 마이그레이션 완료 후 코드 품질 검토를 통해 발견된 개선 사항입니다.
상세 내용은 [code-improvements.md](./code-improvements.md) 참조.
### Critical (즉시 수정)
| 항목 | 파일 | 상태 |
|-----|------|------|
| useAdminAuth 무한 루프 위험 | `hooks/useAdminAuth.js` | ⬜ 대기 |
| queryKey 충돌 | `hooks/useAdminAuth.js` | ⬜ 대기 |
| 카드 컴포넌트 memo 적용 | `components/*/ScheduleCard.jsx` 등 | ⬜ 대기 |
| 접근성(a11y) 개선 | 모든 컴포넌트 | ⬜ 대기 |
### High (높은 우선순위)
| 항목 | 파일 | 상태 |
|-----|------|------|
| 중복 함수 유틸리티화 | `utils/youtube.js`, `hooks/useLightbox.js` | ⬜ 대기 |
| 에러 상태 처리 | 모든 페이지 | ⬜ 대기 |
| 로딩 스피너 통일 | 모든 페이지 | ⬜ 대기 |
| 스토어 미사용 코드 삭제 | `stores/useAuthStore.js`, `stores/useUIStore.js` | ⬜ 대기 |