fromis_9/docs/frontend-refactoring.md
caadiq fe067ca8c8 feat(frontend): Phase 4 - API 계층 구현
- api/client.js: fetch 래퍼, ApiError, 헬퍼 함수 (get/post/put/del)
- api/auth.js: 로그인, 토큰 검증
- api/schedules.js: 스케줄/카테고리 API (공개 + 어드민)
- api/albums.js: 앨범 API (공개 + 어드민)
- api/members.js: 멤버 API (공개 + 어드민)
- docs: useQuery 사용 가이드라인 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:17:56 +09:00

49 KiB

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)를 사용

// ❌ 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 복사
  • 디렉토리 구조 생성

정리할 의존성

{
  "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-detectuseMediaQuery 훅으로 대체

Phase 2: 유틸리티 및 상수

목표: 공통 유틸리티와 상수 정의

2.1 constants/schedule.js

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

/**
 * @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

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

import { clsx } from 'clsx';

/** className 병합 유틸리티 */
export function cn(...inputs) {
    return clsx(inputs);
}

Phase 3: Stores 구축

목표: Zustand 스토어 정의

3.1 stores/useAuthStore.js

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

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

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

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

/**
 * 일정 응답 정규화 (날짜별 그룹 → 플랫 배열)
 */
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

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

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

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

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

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

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

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

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

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 페이지 예시

// 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 일정 등록
  • 앨범 목록
  • 앨범 생성/수정/삭제
  • 앨범 사진 업로드
  • 멤버 관리

교체 절차

# 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-detectuseMediaQuery 훅으로 대체

하위 호환성

  • API 응답 필드명은 snake_case와 camelCase 모두 제공
  • 기존 URL 구조 유지

롤백 계획

  • frontend-backup/ 폴더로 즉시 복원 가능
  • Docker 이미지 태그로 이전 버전 배포 가능