# 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 계층 | ⬜ 대기 |
| 5 | 커스텀 훅 | ⬜ 대기 |
| 6 | 공통 컴포넌트 | ⬜ 대기 |
| 7 | Schedule 페이지 | ⬜ 대기 |
| 8 | Album 페이지 | ⬜ 대기 |
| 9 | 기타 Public 페이지 | ⬜ 대기 |
| 10 | Admin 페이지 | ⬜ 대기 |
| 11 | 최종 검증 및 교체 | ⬜ 대기 |

---

## 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 이미지 태그로 이전 버전 배포 가능
