From fc35a359876376527fe4ce8cc3724b903db46186 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 9 Jan 2026 19:26:52 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20AdminSchedule=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filteredSchedules useMemo로 불필요한 재계산 방지 - ScheduleItem React.memo 컴포넌트로 분리하여 리렌더링 방지 - categoryCounts useMemo 맵으로 O(1) 카테고리 카운트 조회 - 카테고리 카운트를 선택된 날짜 기준으로 계산 - useDeferredValue로 달력 점 표시 지연 처리하여 UI 응답성 향상 - selectedDate 변경 시 스크롤 맨 위로 초기화 --- frontend/src/pages/pc/admin/AdminSchedule.jsx | 279 ++++++++++-------- 1 file changed, 163 insertions(+), 116 deletions(-) diff --git a/frontend/src/pages/pc/admin/AdminSchedule.jsx b/frontend/src/pages/pc/admin/AdminSchedule.jsx index d29e14e..69b6f0a 100644 --- a/frontend/src/pages/pc/admin/AdminSchedule.jsx +++ b/frontend/src/pages/pc/admin/AdminSchedule.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, memo, useDeferredValue } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { @@ -13,6 +13,112 @@ import Tooltip from '../../../components/Tooltip'; import useScheduleStore from '../../../stores/useScheduleStore'; import { getTodayKST, formatDate } from '../../../utils/date'; +// 일정 아이템 컴포넌트 - React.memo로 불필요한 리렌더링 방지 +const ScheduleItem = memo(function ScheduleItem({ + schedule, + index, + selectedDate, + categories, + getColorStyle, + navigate, + openDeleteDialog +}) { + const scheduleDate = new Date(schedule.date); + const categoryColor = getColorStyle(categories.find(c => c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280'; + const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류'; + const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; + const memberList = memberNames.split(',').filter(name => name.trim()); + + return ( + +
+
+
+ {scheduleDate.getDate()} +
+
+ {['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}요일 +
+
+ +
+ +
+

{schedule.title}

+
+ {schedule.time && ( + + + {schedule.time.slice(0, 5)} + + )} + + + {categoryName} + + {schedule.source_name && ( + + + {schedule.source_name} + + )} +
+ {memberList.length > 0 && ( +
+ {memberList.length >= 5 ? ( + + 프로미스나인 + + ) : ( + memberList.map((name, i) => ( + + {name.trim()} + + )) + )} +
+ )} +
+ +
+ {schedule.source_url && ( + e.stopPropagation()} + className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" + > + + + )} + + +
+
+ + ); +}); + function AdminSchedule() { const navigate = useNavigate(); @@ -166,17 +272,20 @@ function AdminSchedule() { return colors[color] || 'bg-gray-100 text-gray-700'; }; - // 일정 날짜별 맵 (O(1) 조회용) - 성능 최적화 + // 일정 데이터를 지연 처리하여 달력 UI 응답성 향상 + const deferredSchedules = useDeferredValue(schedules); + + // 일정 날짜별 맵 (O(1) 조회용) - 지연된 데이터로 점 표시 const scheduleDateMap = useMemo(() => { const map = new Map(); - schedules.forEach(s => { + deferredSchedules.forEach(s => { const dateStr = formatDate(s.date); if (!map.has(dateStr)) { map.set(dateStr, s); } }); return map; - }, [schedules]); + }, [deferredSchedules]); // 해당 날짜에 일정이 있는지 확인 (O(1)) const hasSchedule = (day) => { @@ -245,6 +354,13 @@ function AdminSchedule() { } }, [loading]); // 로딩이 끝나면 스크롤 복원 + // 날짜 변경 시 스크롤 맨 위로 초기화 + useEffect(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }, [selectedDate]); + // 스크롤 위치 저장 const handleScroll = (e) => { setScrollPosition(e.target.scrollTop); @@ -415,38 +531,50 @@ function AdminSchedule() { return (str || '').toLowerCase().replace(/[\s\-_.,!?#@]/g, ''); }; - // 일정 목록 (검색 모드일 때 searchResults, 일반 모드일 때 로컬 필터링) - const filteredSchedules = isSearchMode - ? (searchTerm ? searchResults : []) // 검색 모드: 검색 전엔 빈 목록, 검색 후엔 API 결과 - : schedules.filter(schedule => { // 일반 모드: 로컬 필터링 + // 일정 목록 (검색 모드일 때 searchResults, 일반 모드일 때 로컬 필터링) - useMemo로 최적화 + const filteredSchedules = useMemo(() => { + if (isSearchMode) { + return searchTerm ? searchResults : []; + } + // 일반 모드: 로컬 필터링 + return schedules.filter(schedule => { const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(schedule.category_id); const scheduleDate = formatDate(schedule.date); const matchesDate = !selectedDate || scheduleDate === selectedDate; return matchesCategory && matchesDate; }); + }, [isSearchMode, searchTerm, searchResults, schedules, selectedCategories, selectedDate]); - // 검색 모드일 때 카테고리별 검색 결과 카운트 계산 - const getSearchCategoryCount = (categoryId) => { - if (!isSearchMode || !searchTerm) { - return schedules.filter(s => s.category_id === categoryId).length; - } - return searchResults.filter(s => s.category_id === categoryId).length; - }; - - // 검색 모드일 때 전체 일정 수 - const getTotalCount = () => { - if (!isSearchMode || !searchTerm) { - return schedules.length; - } - return searchResults.length; - }; + // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준 + const categoryCounts = useMemo(() => { + const source = (isSearchMode && searchTerm) ? searchResults : schedules; + const counts = new Map(); + let total = 0; + + source.forEach(s => { + // 선택된 날짜가 있으면 해당 날짜만 카운트 + if (selectedDate) { + const scheduleDate = formatDate(s.date); + if (scheduleDate !== selectedDate) return; + } + + const catId = s.category_id; + counts.set(catId, (counts.get(catId) || 0) + 1); + total++; + }); + + counts.set('total', total); + return counts; + }, [schedules, searchResults, isSearchMode, searchTerm, selectedDate]); // 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지) const sortedCategories = useMemo(() => { + const total = categoryCounts.get('total') || 0; + return categories .map(category => ({ ...category, - count: category.id === 'all' ? getTotalCount() : getSearchCategoryCount(category.id) + count: category.id === 'all' ? total : (categoryCounts.get(category.id) || 0) })) .filter(category => category.id === 'all' || category.count > 0) .sort((a, b) => { @@ -456,7 +584,7 @@ function AdminSchedule() { if (b.name === '기타') return -1; return b.count - a.count; }); - }, [categories, schedules, searchResults, isSearchMode, searchTerm]); + }, [categories, categoryCounts]); return (
@@ -1154,99 +1282,18 @@ function AdminSchedule() {
) : ( - /* 일반 모드: 기존 렌더링 */ + /* 일반 모드: ScheduleItem 컴포넌트 사용 */ filteredSchedules.map((schedule, index) => ( - -
-
-
- {new Date(schedule.date).getDate()} -
-
- {['일', '월', '화', '수', '목', '금', '토'][new Date(schedule.date).getDay()]}요일 -
-
- -
c.id === schedule.category_id)?.color)?.style?.backgroundColor || '#6b7280' }} - /> - -
-

{schedule.title}

-
- {schedule.time && ( - - - {schedule.time.slice(0, 5)} - - )} - - - {categories.find(c => c.id === schedule.category_id)?.name || '미분류'} - - {schedule.source_name && ( - - - {schedule.source_name} - - )} -
- {(() => { - const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; - const memberList = memberNames.split(',').filter(name => name.trim()); - if (memberList.length === 0) return null; - return ( -
- {memberList.length >= 5 ? ( - - 프로미스나인 - - ) : ( - memberList.map((name, i) => ( - - {name.trim()} - - )) - )} -
- ); - })()} -
- -
- {schedule.source_url && ( - e.stopPropagation()} - className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" - > - - - )} - - -
-
- + schedule={schedule} + index={index} + selectedDate={selectedDate} + categories={categories} + getColorStyle={getColorStyle} + navigate={navigate} + openDeleteDialog={openDeleteDialog} + /> )) )}