diff --git a/docs/frontend-refactoring.md b/docs/frontend-refactoring.md new file mode 100644 index 0000000..8e3c9e0 --- /dev/null +++ b/docs/frontend-refactoring.md @@ -0,0 +1,1590 @@ +# 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 || '알 수 없는 오류'} +
+ +{memberName}
+생일 축하해요!
+오늘은
+생일 축하해요!
+Phase 3 완료 - 스토어
+Phase 4 완료 - API 계층
디바이스: {isMobile ? "모바일" : "PC"}
오늘: {formatFullDate(today)}
+인증: {isAuthenticated ? "✅" : "❌"}
useAuthStore 테스트
-인증 상태: {isAuthenticated ? "✅ 로그인됨" : "❌ 로그아웃"}
-API 테스트 (React Query)
+ ++ 멤버:{" "} + {membersLoading ? "로딩 중..." : `${members?.length || 0}명`} +
+ {members && ( ++ 카테고리:{" "} + {categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개`} +
+ {categories && ( +useScheduleStore 테스트
-뷰 모드: {viewMode}
-선택된 카테고리: {selectedCategories.length > 0 ? selectedCategories.join(", ") : "없음"}
-useUIStore 토스트
-활성 토스트: {toasts.length}개
+직접 API 호출 테스트
+ + {apiTest &&{apiTest}
}