# 프론트엔드 코드 개선 사항 > 작성일: 2026-01-22 > 대상: `frontend-temp/src/` > 상태: 공개 영역 마이그레이션 완료 후 코드 품질 검토 --- ## 목차 1. [즉시 수정 필요 (Critical)](#1-즉시-수정-필요-critical) 2. [높은 우선순위 (High)](#2-높은-우선순위-high) 3. [중간 우선순위 (Medium)](#3-중간-우선순위-medium) 4. [낮은 우선순위 (Low)](#4-낮은-우선순위-low) 5. [품질 점수 요약](#5-품질-점수-요약) --- ## 1. 즉시 수정 필요 (Critical) ✅ 완료 ### 1.1 useAdminAuth 무한 루프 위험 ✅ **파일**: `hooks/useAdminAuth.js:35` **상태**: ✅ 완료 - `useRef`로 logout 함수 안정화 **문제**: ```javascript useEffect(() => { if (required && (!token || isError)) { logout(); navigate(redirectTo); } }, [token, isError, required, logout, navigate, redirectTo]); ``` `logout` 함수가 Zustand store에서 오기 때문에 참조가 변경될 수 있어 무한 루프 발생 위험. **해결방법**: ```javascript // 방법 1: useRef로 logout 함수 안정화 const logoutRef = useRef(logout); logoutRef.current = logout; useEffect(() => { if (required && (!token || isError)) { logoutRef.current(); navigate(redirectTo); } }, [token, isError, required, navigate, redirectTo]); // 방법 2: Zustand에서 logout을 useCallback으로 메모이제이션 // stores/useAuthStore.js logout: useCallback(() => { set({ token: null, user: null, isAuthenticated: false }); localStorage.removeItem('admin_token'); }, []), ``` --- ### 1.2 인증 queryKey 충돌 ✅ **파일**: `hooks/useAdminAuth.js`, `hooks/useAdminAuth.js` (useRedirectIfAuthenticated) **상태**: ✅ 완료 - 고유 queryKey 적용 (`['admin', 'auth', 'verify']`, `['admin', 'auth', 'redirect-check']`) **문제**: ```javascript // useAdminAuth (라인 19) const { data, isLoading, isError } = useQuery({ queryKey: ['admin', 'auth'], queryFn: authApi.verifyToken, // ... }); // useRedirectIfAuthenticated (라인 56) const { data, isLoading } = useQuery({ queryKey: ['admin', 'auth'], // 동일한 queryKey! queryFn: authApi.verifyToken, // ... }); ``` 두 훅이 동일한 queryKey를 사용하여 캐시 충돌 발생 가능. **해결방법**: ```javascript // useAdminAuth queryKey: ['admin', 'auth', 'verify'], // useRedirectIfAuthenticated queryKey: ['admin', 'auth', 'redirect-check'], // 또는 하나의 훅으로 통합 export function useAuthStatus(options = {}) { const { required = false, redirectTo = '/admin', redirectIfAuthenticated = false } = options; // 통합 로직 } ``` --- ### 1.3 접근성(a11y) 심각한 문제 ✅ **파일**: 모든 컴포넌트 **상태**: ✅ 완료 - Toast, Lightbox, LightboxIndicator, Calendar (PC/Mobile)에 aria 속성 추가 **문제**: - `aria-label` 전무 - `role` 속성 미사용 - `aria-selected`, `aria-expanded` 미사용 - 키보드 네비게이션 미흡 - 색상만으로 정보 전달 (색약자 고려 안 함) **예시 - Calendar.jsx**: ```javascript // 현재 코드 (문제) // 개선된 코드 ``` **개선이 필요한 컴포넌트 목록**: | 컴포넌트 | 필요한 접근성 속성 | |---------|------------------| | `pc/Calendar.jsx` | aria-label (버튼), aria-selected (날짜), role="grid" | | `mobile/Calendar.jsx` | aria-label (버튼), aria-selected (날짜) | | `pc/CategoryFilter.jsx` | aria-pressed (토글), role="group" | | `common/Lightbox.jsx` | aria-label (닫기/이전/다음), role="dialog", aria-modal | | `common/LightboxIndicator.jsx` | aria-label (각 인디케이터), aria-current | | `common/Toast.jsx` | role="alert", aria-live="polite" | | `common/Tooltip.jsx` | role="tooltip", aria-describedby | **접근성 개선 체크리스트**: - [x] 모든 아이콘 버튼에 `aria-label` 추가 - [x] 장식용 아이콘에 `aria-hidden="true"` 추가 - [x] 모달/다이얼로그에 `role="dialog"`, `aria-modal="true"` 추가 - [x] 알림에 `role="alert"` 추가 - [x] 선택 가능한 요소에 `aria-selected` 추가 - [x] 토글 버튼에 `aria-pressed` 추가 - [ ] 색상 외에 텍스트/아이콘으로 정보 보완 (향후 개선) --- ### 1.4 카드 컴포넌트 메모이제이션 부족 ✅ **파일**: `components/pc/ScheduleCard.jsx`, `components/pc/BirthdayCard.jsx`, `components/mobile/ScheduleCard.jsx` 등 **상태**: ✅ 완료 - 6개 카드 컴포넌트에 `React.memo` 적용 **문제**: ```javascript // 현재 코드 - memo 없음 function ScheduleCard({ schedule, onClick, className = '' }) { // ... } export default ScheduleCard; ``` 리스트 렌더링 시 부모가 리렌더되면 모든 카드가 불필요하게 재렌더링됨. **해결방법**: ```javascript import { memo } from 'react'; const ScheduleCard = memo(function ScheduleCard({ schedule, onClick, className = '' }) { // ... }); export default ScheduleCard; // 또는 커스텀 비교 함수 사용 const ScheduleCard = memo( function ScheduleCard({ schedule, onClick, className = '' }) { // ... }, (prevProps, nextProps) => { return prevProps.schedule.id === nextProps.schedule.id && prevProps.className === nextProps.className; } ); ``` **메모이제이션이 필요한 컴포넌트**: - [x] `components/pc/ScheduleCard.jsx` - [x] `components/pc/BirthdayCard.jsx` - [x] `components/mobile/ScheduleCard.jsx` - [x] `components/mobile/ScheduleListCard.jsx` - [x] `components/mobile/ScheduleSearchCard.jsx` - [x] `components/mobile/BirthdayCard.jsx` --- ## 2. 높은 우선순위 (High) ✅ 완료 ### 2.1 PC/Mobile 간 중복 코드 ✅ #### 2.1.1 YouTube URL 파싱 함수 중복 ✅ **파일**: `pages/album/pc/TrackDetail.jsx`, `pages/album/mobile/TrackDetail.jsx` **상태**: ✅ 완료 - `utils/youtube.js` 생성, TrackDetail에서 유틸리티 사용으로 변경 **중복 코드**: ```javascript // 두 파일에서 동일하게 존재 function getYoutubeVideoId(url) { if (!url) return null; const patterns = [ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, ]; for (const pattern of patterns) { const match = url.match(pattern); if (match) return match[1]; } return null; } ``` **해결방법**: `utils/youtube.js` 생성 ```javascript // utils/youtube.js /** * YouTube URL에서 비디오 ID 추출 * @param {string} url - YouTube URL * @returns {string|null} 비디오 ID 또는 null */ export function getYoutubeVideoId(url) { if (!url) return null; const patterns = [ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, ]; for (const pattern of patterns) { const match = url.match(pattern); if (match) return match[1]; } return null; } /** * YouTube 썸네일 URL 생성 * @param {string} videoId - YouTube 비디오 ID * @param {string} quality - 썸네일 품질 (default, hqdefault, mqdefault, sddefault, maxresdefault) * @returns {string} 썸네일 URL */ export function getYoutubeThumbnail(videoId, quality = 'hqdefault') { return `https://img.youtube.com/vi/${videoId}/${quality}.jpg`; } ``` --- #### 2.1.2 Credit 포맷팅 함수 중복 ✅ **파일**: `pages/album/pc/TrackDetail.jsx`, `pages/album/mobile/TrackDetail.jsx` **상태**: ✅ 완료 - `utils/format.js`에 `parseCredits` 추가, TrackDetail에서 사용 **중복 코드**: ```javascript function formatCredit(text) { if (!text) return null; return text.split(',').map((item, index) => ( {item.trim()} {index < text.split(',').length - 1 && ', '} )); } ``` **해결방법**: `utils/format.js`에 추가 ```javascript // utils/format.js에 추가 /** * 크레딧 텍스트를 배열로 분리 * @param {string} text - 쉼표로 구분된 크레딧 텍스트 * @returns {string[]} 크레딧 배열 */ export function parseCredits(text) { if (!text) return []; return text.split(',').map(item => item.trim()).filter(Boolean); } ``` 컴포넌트에서 사용: ```javascript import { parseCredits } from '@/utils'; // 컴포넌트 내부 {parseCredits(track.composer).map((name, index, arr) => ( {name} {index < arr.length - 1 && ', '} ))} ``` --- #### 2.1.3 재생 시간 계산 함수 중복 ✅ **파일**: `pages/album/pc/AlbumDetail.jsx`, `pages/album/mobile/AlbumDetail.jsx` **상태**: ✅ 완료 - `utils/format.js`에 `calculateTotalDuration` 추가, AlbumDetail에서 사용 **중복 코드**: ```javascript const getTotalDuration = () => { if (!album?.tracks) return ''; let totalSeconds = 0; album.tracks.forEach((track) => { if (track.duration) { const parts = track.duration.split(':'); totalSeconds += parseInt(parts[0]) * 60 + parseInt(parts[1]); } }); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; ``` **해결방법**: `utils/format.js`에 추가 ```javascript // utils/format.js에 추가 /** * 트랙 목록의 총 재생 시간 계산 * @param {Array<{duration?: string}>} tracks - 트랙 배열 (duration: "MM:SS" 형식) * @returns {string} 총 재생 시간 ("MM:SS" 형식) */ export function calculateTotalDuration(tracks) { if (!tracks || !Array.isArray(tracks)) return ''; const totalSeconds = tracks.reduce((acc, track) => { if (!track.duration) return acc; const parts = track.duration.split(':'); if (parts.length !== 2) return acc; return acc + parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); }, 0); if (totalSeconds === 0) return ''; const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; } ``` --- #### 2.1.4 라이트박스 로직 중복 ✅ **상태**: ✅ 완료 - `hooks/useLightbox.js` 생성 (향후 점진적 적용) **파일**: - `pages/album/pc/AlbumDetail.jsx` - `pages/album/mobile/AlbumDetail.jsx` - `pages/album/pc/AlbumGallery.jsx` - `pages/album/mobile/AlbumGallery.jsx` **중복 로직**: - 이미지 프리로딩 - 뒤로가기 처리 (popstate) - 이미지 다운로드 - body 스크롤 방지 **해결방법**: `hooks/useLightbox.js` 생성 ```javascript // hooks/useLightbox.js import { useState, useEffect, useCallback } from 'react'; /** * 라이트박스 상태 및 동작 관리 훅 * @param {Object} options * @param {Array<{url: string, thumb_url?: string}>} options.images - 이미지 배열 * @param {Function} options.onClose - 닫기 콜백 (optional) * @returns {Object} 라이트박스 상태 및 메서드 */ export function useLightbox({ images = [], onClose } = {}) { const [isOpen, setIsOpen] = useState(false); const [currentIndex, setCurrentIndex] = useState(0); // 라이트박스 열기 const open = useCallback((index = 0) => { setCurrentIndex(index); setIsOpen(true); window.history.pushState({ lightbox: true }, ''); }, []); // 라이트박스 닫기 const close = useCallback(() => { setIsOpen(false); onClose?.(); }, [onClose]); // 이전 이미지 const goToPrev = useCallback(() => { setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1)); }, [images.length]); // 다음 이미지 const goToNext = useCallback(() => { setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0)); }, [images.length]); // 특정 인덱스로 이동 const goToIndex = useCallback((index) => { if (index >= 0 && index < images.length) { setCurrentIndex(index); } }, [images.length]); // 뒤로가기 처리 useEffect(() => { if (!isOpen) return; const handlePopState = () => { close(); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, [isOpen, close]); // body 스크롤 방지 useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [isOpen]); // 이미지 프리로딩 useEffect(() => { if (!isOpen || images.length === 0) return; // 현재, 이전, 다음 이미지 프리로드 const indicesToPreload = [ currentIndex, (currentIndex + 1) % images.length, (currentIndex - 1 + images.length) % images.length, ]; indicesToPreload.forEach((index) => { const img = new Image(); img.src = images[index]?.url || images[index]; }); }, [isOpen, currentIndex, images]); // 이미지 다운로드 const downloadImage = useCallback(async () => { const imageUrl = images[currentIndex]?.url || images[currentIndex]; if (!imageUrl) return; try { const response = await fetch(imageUrl); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `image_${currentIndex + 1}.jpg`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } catch (error) { console.error('이미지 다운로드 실패:', error); } }, [images, currentIndex]); return { isOpen, currentIndex, currentImage: images[currentIndex], totalCount: images.length, open, close, goToPrev, goToNext, goToIndex, downloadImage, }; } ``` --- ### 2.2 에러 상태 처리 미흡 ✅ **파일**: 대부분의 페이지 컴포넌트 **상태**: ✅ 완료 - `components/common/ErrorMessage.jsx` 생성 (향후 점진적 적용) **문제**: ```javascript // 현재 - 에러 처리 없음 const { data: albums = [], isLoading: loading } = useQuery({...}); if (loading) return ; // 에러 발생 시 빈 화면 또는 예상치 못한 동작 ``` **해결방법**: 공통 에러 UI 컴포넌트 생성 ```javascript // components/common/ErrorMessage.jsx import { motion } from 'framer-motion'; import { AlertCircle, RefreshCw } from 'lucide-react'; function ErrorMessage({ message = '데이터를 불러오는데 실패했습니다.', onRetry, className = '' }) { return (

{message}

{onRetry && ( )}
); } export default ErrorMessage; ``` 페이지에서 사용: ```javascript const { data, isLoading, isError, refetch } = useQuery({...}); if (isLoading) return ; if (isError) return ; ``` --- ### 2.3 로딩 스피너 불일치 ✅ **파일**: 여러 페이지 **상태**: ✅ 완료 - `components/common/Loading.jsx`에 `size` prop 추가 (sm, md, lg) **문제**: ```javascript // PC - 큰 스피너
// Mobile - 작은 스피너
// 또 다른 곳
``` **해결방법**: Loading 컴포넌트에 size prop 추가 ```javascript // components/common/Loading.jsx 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 (
); } ``` 모든 페이지에서 통일: ```javascript // PC if (loading) return ; // Mobile if (loading) return ; ``` --- ### 2.4 스토어 미사용 코드 ✅ **파일**: `stores/useAuthStore.js`, `stores/useUIStore.js` **상태**: ✅ 완료 - 미사용 메서드 및 confirmDialog 코드 삭제 #### useAuthStore 미사용 메서드 (삭제됨) ```javascript // 삭제 완료 getToken: () => get().token, // 직접 token 접근으로 대체 checkAuth: () => !!token, // isAuthenticated로 대체 ``` #### useUIStore 미사용 코드 (삭제됨, 약 25줄) ```javascript // 삭제 완료 - 전체 confirmDialog 관련 코드 confirmDialog: null, showConfirm: (options) => ..., closeConfirm: () => ..., ``` --- ## 3. 중간 우선순위 (Medium) ✅ 완료 ### 3.1 API 에러 처리 불일치 ✅ **파일**: `api/client.js` **상태**: ✅ 완료 - `fetchFormData`에 `requireAuth` 옵션 추가 **문제**: ```javascript // fetchAuthApi - 토큰 없으면 에러 발생 export async function fetchAuthApi(endpoint, options = {}) { const token = useAuthStore.getState().token; if (!token) { throw new ApiError('인증이 필요합니다.', 401); } // ... } // fetchFormData - 토큰 없어도 에러 미발생 export async function fetchFormData(endpoint, formData, method = 'POST') { const token = useAuthStore.getState().token; const headers = {}; if (token) { headers.Authorization = `Bearer ${token}`; // 조건부 설정만 } // 토큰 없어도 계속 진행 } ``` **해결방법**: ```javascript // 인증 필요 여부를 명시적으로 지정 export async function fetchFormData(endpoint, formData, method = 'POST', { requireAuth = true } = {}) { const token = useAuthStore.getState().token; if (requireAuth && !token) { throw new ApiError('인증이 필요합니다.', 401); } const headers = {}; if (token) { headers.Authorization = `Bearer ${token}`; } // ... } ``` --- ### 3.2 HTTP 헬퍼 함수 중복 ✅ **파일**: `api/client.js` **상태**: ✅ 완료 - `createMethodHelpers` 팩토리 함수로 통합, `api`/`authApi` 객체 export **문제**: ```javascript // 8개의 유사한 헬퍼 함수 export const get = (endpoint) => fetchApi(endpoint); export const post = (endpoint, data) => fetchApi(endpoint, { method: 'POST', body: JSON.stringify(data) }); export const put = (endpoint, data) => fetchApi(endpoint, { method: 'PUT', body: JSON.stringify(data) }); export const del = (endpoint) => fetchApi(endpoint, { method: 'DELETE' }); export const authGet = (endpoint) => fetchAuthApi(endpoint); export const authPost = (endpoint, data) => fetchAuthApi(endpoint, { method: 'POST', body: JSON.stringify(data) }); export const authPut = (endpoint, data) => fetchAuthApi(endpoint, { method: 'PUT', body: JSON.stringify(data) }); export const authDel = (endpoint) => fetchAuthApi(endpoint, { method: 'DELETE' }); ``` **해결방법**: 팩토리 함수로 통합 ```javascript // 메서드 헬퍼 생성기 function createMethodHelpers(baseFetch) { return { get: (endpoint) => baseFetch(endpoint), post: (endpoint, data) => baseFetch(endpoint, { method: 'POST', body: JSON.stringify(data) }), put: (endpoint, data) => baseFetch(endpoint, { method: 'PUT', body: JSON.stringify(data) }), del: (endpoint) => baseFetch(endpoint, { method: 'DELETE' }), }; } // 공개 API 헬퍼 export const api = createMethodHelpers(fetchApi); // 인증 API 헬퍼 export const authApi = createMethodHelpers(fetchAuthApi); // 사용 // api.get('/albums') // authApi.post('/admin/schedules', data) ``` --- ### 3.3 useMediaQuery 리스너 메모이제이션 ✅ **파일**: `hooks/useMediaQuery.js` **상태**: ✅ 완료 - `useCallback`으로 핸들러 메모이제이션 **문제**: ```javascript useEffect(() => { const media = window.matchMedia(query); const handler = (e) => setMatches(e.matches); // 매번 새 함수 생성 media.addEventListener('change', handler); return () => media.removeEventListener('change', handler); }, [query]); ``` **해결방법**: ```javascript import { useState, useEffect, useCallback } from 'react'; export function useMediaQuery(query) { const [matches, setMatches] = useState(() => { if (typeof window === 'undefined') return false; return window.matchMedia(query).matches; }); const handler = useCallback((e) => { setMatches(e.matches); }, []); useEffect(() => { const media = window.matchMedia(query); setMatches(media.matches); media.addEventListener('change', handler); return () => media.removeEventListener('change', handler); }, [query, handler]); return matches; } ``` --- ### 3.4 useCalendar 반환 객체 메모이제이션 ✅ **파일**: `hooks/useCalendar.js` **상태**: ✅ 완료 - `useMemo`로 반환 객체 메모이제이션 **문제**: ```javascript return { ...calendarData, currentDate, selectedDate, goToPrevMonth, goToNextMonth, // ... 매번 새 객체 생성 }; ``` **해결방법**: ```javascript return useMemo(() => ({ ...calendarData, currentDate, selectedDate, goToPrevMonth, goToNextMonth, goToMonth, canGoPrevMonth, canGoNextMonth, selectDate, updateSelectedDate, }), [ calendarData, currentDate, selectedDate, goToPrevMonth, goToNextMonth, goToMonth, canGoPrevMonth, canGoNextMonth, selectDate, updateSelectedDate, ]); ``` --- ### 3.5 날짜/시간 추출 함수 중복 ✅ **파일**: `utils/date.js`, `utils/schedule.js` **상태**: ✅ 완료 - `schedule.js`에서 `date.js`의 `extractDate`, `extractTime` 재사용 **중복 함수**: ```javascript // date.js export function extractDate(datetime) { ... } export function extractTime(datetime) { ... } // schedule.js export function getScheduleDate(schedule) { ... } // extractDate와 유사 export function getScheduleTime(schedule) { ... } // extractTime과 유사 ``` **해결방법**: 기본 함수를 재사용 ```javascript // schedule.js 수정 import { extractDate, extractTime } from './date'; export function getScheduleDate(schedule) { if (!schedule) return null; return schedule.date || extractDate(schedule.datetime); } export function getScheduleTime(schedule) { if (!schedule) return null; return schedule.time || extractTime(schedule.datetime); } ``` --- ### 3.6 decodeHtmlEntities DOM 조작 ✅ **파일**: `utils/format.js` **상태**: ✅ 완료 - 순수 함수로 변경 (정규식 + 매핑 객체), SSR 호환 **문제**: ```javascript export function decodeHtmlEntities(text) { if (!text) return ''; const textarea = document.createElement('textarea'); // DOM 조작 textarea.innerHTML = text; return textarea.value; } ``` 서버 사이드 렌더링(SSR) 환경에서 오류 발생. **해결방법**: ```javascript // 순수 함수 버전 const htmlEntities = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ''': "'", ' ': ' ', }; export function decodeHtmlEntities(text) { if (!text) return ''; return text.replace( /&(?:amp|lt|gt|quot|#39|apos|#x27|nbsp);/g, (match) => htmlEntities[match] || match ); } ``` --- ### 3.7 리스트 key에 index 사용 ✅ (검토 완료) **파일**: 여러 컴포넌트 **상태**: ✅ 검토 완료 - 정적 리스트는 index 사용 유지, 동적 리스트는 이미 고유 ID 사용 중 **문제**: ```javascript // LightboxIndicator.jsx {Array.from({ length: count }).map((_, i) => (