# 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 (

문제가 발생했습니다

{this.state.error?.message || '알 수 없는 오류'}

); } 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 (
); } ``` #### 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 ( 프로미스나인 ); } return memberList.map((name, i) => ( {name} )); }; if (variant === 'list') { return (
{showYear && (
{scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1}
)}
{scheduleDate.getDate()}
{WEEKDAYS[scheduleDate.getDay()]}요일

{title}

{timeStr && ( {timeStr} )} {categoryInfo.name} {schedule.source?.name && ( {schedule.source.name} )}
{memberList.length > 0 && (
)}
{actions && (
{actions}
)}
); } // Card variant (default) return (
{showYear && ( {scheduleDate.getFullYear()}.{scheduleDate.getMonth() + 1} )} {scheduleDate.getDate()} {WEEKDAYS[scheduleDate.getDay()]}

{title}

{timeStr && ( {timeStr} )} {categoryInfo.name}
{memberList.length > 0 && (
{memberList.slice(0, 3).map((name, i) => ( {name} ))} {memberList.length > 3 && ( +{memberList.length - 3} )}
)}
); }); 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 (

{memberName}

생일 축하해요!

); } return (

오늘은

{memberName} 생일!

생일 축하해요!

); }); 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 (
{/* 왼쪽: 달력 + 카테고리 */} {/* 오른쪽: 일정 목록 */}
{isLoading ? ( ) : (
{filteredSchedules.map((schedule, index) => ( {isBirthdaySchedule(schedule) ? ( handleScheduleClick(schedule)} /> ) : ( handleScheduleClick(schedule)} /> )} ))}
)}
); } ``` --- ### 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 | ✅ 완료 | | 9-3 | 코드 품질 개선 - 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) 개선 | 모든 컴포넌트 | ✅ 완료 | | 스토어 미사용 코드 삭제 | `stores/useAuthStore.js`, `stores/useUIStore.js` | ✅ 완료 | ### High (높은 우선순위) | 항목 | 파일 | 상태 | |-----|------|------| | 중복 함수 유틸리티화 | `utils/youtube.js`, `utils/format.js` | ✅ 완료 | | useLightbox 훅 생성 | `hooks/useLightbox.js` | ✅ 완료 | | ErrorMessage 컴포넌트 | `components/common/ErrorMessage.jsx` | ✅ 완료 | | Loading 컴포넌트 size prop | `components/common/Loading.jsx` | ✅ 완료 |