From 0ce67d57e87f38a01edab00b1fc2c838c7bfc907 Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 22 Jan 2026 12:22:08 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EC=BD=94=EB=93=9C=20=ED=92=88=EC=A7=88?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20=EC=82=AC=ED=95=AD=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - code-improvements.md 생성: 전체 코드 검토 결과 정리 - Critical/High/Medium/Low 우선순위별 개선 사항 분류 - 구체적인 문제점과 해결방법 코드 예시 포함 - migration.md에서 useToast를 관리자 영역으로 이동 Co-Authored-By: Claude Opus 4.5 --- docs/code-improvements.md | 1028 +++++++++++++++++++++++++++++++++++++ docs/migration.md | 7 +- 2 files changed, 1033 insertions(+), 2 deletions(-) create mode 100644 docs/code-improvements.md diff --git a/docs/code-improvements.md b/docs/code-improvements.md new file mode 100644 index 0000000..a0753d4 --- /dev/null +++ b/docs/code-improvements.md @@ -0,0 +1,1028 @@ +# 프론트엔드 코드 개선 사항 + +> 작성일: 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` + +**문제**: +```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) + +**문제**: +```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) 심각한 문제 + +**파일**: 모든 컴포넌트 + +**문제**: +- `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 | + +**접근성 개선 체크리스트**: +- [ ] 모든 아이콘 버튼에 `aria-label` 추가 +- [ ] 장식용 아이콘에 `aria-hidden="true"` 추가 +- [ ] 모달/다이얼로그에 `role="dialog"`, `aria-modal="true"` 추가 +- [ ] 알림에 `role="alert"` 추가 +- [ ] 선택 가능한 요소에 `aria-selected` 추가 +- [ ] 토글 버튼에 `aria-pressed` 추가 +- [ ] 색상 외에 텍스트/아이콘으로 정보 보완 + +--- + +### 1.4 카드 컴포넌트 메모이제이션 부족 + +**파일**: `components/pc/ScheduleCard.jsx`, `components/pc/BirthdayCard.jsx`, `components/mobile/ScheduleCard.jsx` 등 + +**문제**: +```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; + } +); +``` + +**메모이제이션이 필요한 컴포넌트**: +- [ ] `components/pc/ScheduleCard.jsx` +- [ ] `components/pc/BirthdayCard.jsx` +- [ ] `components/mobile/ScheduleCard.jsx` +- [ ] `components/mobile/ScheduleListCard.jsx` +- [ ] `components/mobile/ScheduleSearchCard.jsx` +- [ ] `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` + +**중복 코드**: +```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` + +**중복 코드**: +```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` + +**중복 코드**: +```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 라이트박스 로직 중복 + +**파일**: +- `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 에러 상태 처리 미흡 + +**파일**: 대부분의 페이지 컴포넌트 + +**문제**: +```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 로딩 스피너 불일치 + +**파일**: 여러 페이지 + +**문제**: +```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` + +#### useAuthStore 미사용 메서드 + +```javascript +// 삭제 대상 +getToken: () => get().token, // 직접 token 접근으로 대체 가능 +checkAuth: () => !!token, // isAuthenticated로 대체 가능 +``` + +#### useUIStore 미사용 코드 (약 30줄) + +```javascript +// 삭제 대상 - 전체 confirmDialog 관련 코드 +confirmDialog: null, + +showConfirm: (options) => + set({ + confirmDialog: { + isOpen: true, + title: options.title || '확인', + message: options.message || '', + confirmText: options.confirmText || '확인', + cancelText: options.cancelText || '취소', + onConfirm: options.onConfirm, + onCancel: options.onCancel, + variant: options.variant || 'default', + }, + }), + +closeConfirm: () => + set({ + confirmDialog: null, + }), +``` + +--- + +## 3. 중간 우선순위 (Medium) + +### 3.1 API 에러 처리 불일치 + +**파일**: `api/client.js` + +**문제**: +```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` + +**문제**: +```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` + +**문제**: +```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` + +**문제**: +```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` + +**중복 함수**: +```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` + +**문제**: +```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 사용 + +**파일**: 여러 컴포넌트 + +**문제**: +```javascript +// LightboxIndicator.jsx +{Array.from({ length: count }).map((_, i) => ( +