diff --git a/src/ScheduleContext.tsx b/src/ScheduleContext.tsx index 529f0dd..8954ef6 100644 --- a/src/ScheduleContext.tsx +++ b/src/ScheduleContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, PropsWithChildren, useContext, useState } from "react"; +import React, { createContext, PropsWithChildren, useContext, useMemo, useState } from "react"; import { Schedule } from "./types.ts"; import dummyScheduleMap from "./dummyScheduleMap.ts"; @@ -20,8 +20,11 @@ export const useScheduleContext = () => { export const ScheduleProvider = ({ children }: PropsWithChildren) => { const [schedulesMap, setSchedulesMap] = useState>(dummyScheduleMap); + // Context 값을 메모이제이션하여 불필요한 리렌더링 방지 + const value = useMemo(() => ({ schedulesMap, setSchedulesMap }), [schedulesMap]); + return ( - + {children} ); diff --git a/src/ScheduleTable.tsx b/src/ScheduleTable.tsx index ea17b6a..f9cb327 100644 --- a/src/ScheduleTable.tsx +++ b/src/ScheduleTable.tsx @@ -17,7 +17,7 @@ import { Schedule } from "./types.ts"; import { fill2, parseHnM } from "./utils.ts"; import { useDndContext, useDraggable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; -import { ComponentProps, Fragment } from "react"; +import { ComponentProps, Fragment, memo, useMemo } from "react"; interface Props { tableId: string; @@ -39,24 +39,30 @@ const TIMES = [ ] as const; const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { + const dndContext = useDndContext(); - const getColor = (lectureId: string): string => { + // 강의 ID에 따른 색상 매핑을 메모이제이션 + const colorMap = useMemo(() => { const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; - return colors[lectures.indexOf(lectureId) % colors.length]; - }; + const map: Record = {}; + lectures.forEach((lectureId, index) => { + map[lectureId] = colors[index % colors.length]; + }); + return map; + }, [schedules]); - const dndContext = useDndContext(); + const getColor = (lectureId: string): string => { + return colorMap[lectureId]; + }; - const getActiveTableId = () => { + const activeTableId = useMemo(() => { const activeId = dndContext.active?.id; if (activeId) { return String(activeId).split(":")[0]; } return null; - } - - const activeTableId = getActiveTableId(); + }, [dndContext.active?.id]); return ( ); -} +}); export default ScheduleTable; diff --git a/src/ScheduleTables.tsx b/src/ScheduleTables.tsx index 44dbd7a..de28c5f 100644 --- a/src/ScheduleTables.tsx +++ b/src/ScheduleTables.tsx @@ -2,7 +2,7 @@ import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react"; import ScheduleTable from "./ScheduleTable.tsx"; import { useScheduleContext } from "./ScheduleContext.tsx"; import SearchDialog from "./SearchDialog.tsx"; -import { useState } from "react"; +import { useCallback, useMemo, useState } from "react"; export const ScheduleTables = () => { const { schedulesMap, setSchedulesMap } = useScheduleContext(); @@ -14,19 +14,34 @@ export const ScheduleTables = () => { const disabledRemoveButton = Object.keys(schedulesMap).length === 1; - const duplicate = (targetId: string) => { + const duplicate = useCallback((targetId: string) => { setSchedulesMap(prev => ({ ...prev, [`schedule-${Date.now()}`]: [...prev[targetId]] })) - }; + }, [setSchedulesMap]); - const remove = (targetId: string) => { + const remove = useCallback((targetId: string) => { setSchedulesMap(prev => { delete prev[targetId]; return { ...prev }; }) - }; + }, [setSchedulesMap]); + + const handleScheduleTimeClick = useCallback((tableId: string, timeInfo: { day: string; time: number }) => { + setSearchInfo({ tableId, ...timeInfo }); + }, []); + + const handleDeleteButtonClick = useCallback((tableId: string, { day, time }: { day: string; time: number }) => { + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: prev[tableId].filter(schedule => schedule.day !== day || !schedule.range.includes(time)) + })); + }, [setSchedulesMap]); + + const handleCloseSearch = useCallback(() => { + setSearchInfo(null); + }, []); return ( <> @@ -46,16 +61,13 @@ export const ScheduleTables = () => { key={`schedule-table-${index}`} schedules={schedules} tableId={tableId} - onScheduleTimeClick={(timeInfo) => setSearchInfo({ tableId, ...timeInfo })} - onDeleteButtonClick={({ day, time }) => setSchedulesMap((prev) => ({ - ...prev, - [tableId]: prev[tableId].filter(schedule => schedule.day !== day || !schedule.range.includes(time)) - }))} + onScheduleTimeClick={(timeInfo) => handleScheduleTimeClick(tableId, timeInfo)} + onDeleteButtonClick={(timeInfo) => handleDeleteButtonClick(tableId, timeInfo)} /> ))} - setSearchInfo(null)}/> + ); } diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx index 593951f..16f401a 100644 --- a/src/SearchDialog.tsx +++ b/src/SearchDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, @@ -45,12 +45,12 @@ interface Props { } interface SearchOption { - query?: string, - grades: number[], - days: string[], - times: number[], - majors: string[], - credits?: number, + query?: string; + grades: number[]; + days: string[]; + times: number[]; + majors: string[]; + credits?: number; } const TIME_SLOTS = [ @@ -82,18 +82,36 @@ const TIME_SLOTS = [ const PAGE_SIZE = 100; -const fetchMajors = () => axios.get('/schedules-majors.json'); -const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json'); +const createCachedFetcher = (fetcher: () => Promise) => { + let cache: Promise | null = null; -// TODO: 이 코드를 개선해서 API 호출을 최소화 해보세요 + Promise.all이 현재 잘못 사용되고 있습니다. 같이 개선해주세요. -const fetchAllLectures = async () => await Promise.all([ - (console.log('API Call 1', performance.now()), await fetchMajors()), - (console.log('API Call 2', performance.now()), await fetchLiberalArts()), - (console.log('API Call 3', performance.now()), await fetchMajors()), - (console.log('API Call 4', performance.now()), await fetchLiberalArts()), - (console.log('API Call 5', performance.now()), await fetchMajors()), - (console.log('API Call 6', performance.now()), await fetchLiberalArts()), -]); + return () => { + if (!cache) { + cache = fetcher(); + } + return cache; + }; +}; + +const fetchMajors = createCachedFetcher(() => + axios.get("/schedules-majors.json") +); +const fetchLiberalArts = createCachedFetcher(() => + axios.get("/schedules-liberal-arts.json") +); + +const fetchAllLectures = async () => { + const results = await Promise.all([ + (console.log("API Call 1", performance.now()), fetchMajors()), + (console.log("API Call 2", performance.now()), fetchLiberalArts()), + (console.log("API Call 3", performance.now()), fetchMajors()), + (console.log("API Call 4", performance.now()), fetchLiberalArts()), + (console.log("API Call 5", performance.now()), fetchMajors()), + (console.log("API Call 6", performance.now()), fetchLiberalArts()), + ]); + + return results; +}; // TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. const SearchDialog = ({ searchInfo, onClose }: Props) => { @@ -104,77 +122,102 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { const [lectures, setLectures] = useState([]); const [page, setPage] = useState(1); const [searchOptions, setSearchOptions] = useState({ - query: '', + query: "", grades: [], days: [], times: [], majors: [], }); - const getFilteredLectures = () => { - const { query = '', credits, grades, days, times, majors } = searchOptions; + // useMemo를 사용하여 searchOptions와 lectures가 변경될 때만 필터링 재실행 + const filteredLectures = useMemo(() => { + const { query = "", credits, grades, days, times, majors } = searchOptions; return lectures - .filter(lecture => - lecture.title.toLowerCase().includes(query.toLowerCase()) || - lecture.id.toLowerCase().includes(query.toLowerCase()) + .filter( + (lecture) => + lecture.title.toLowerCase().includes(query.toLowerCase()) || + lecture.id.toLowerCase().includes(query.toLowerCase()) + ) + .filter( + (lecture) => grades.length === 0 || grades.includes(lecture.grade) + ) + .filter( + (lecture) => majors.length === 0 || majors.includes(lecture.major) ) - .filter(lecture => grades.length === 0 || grades.includes(lecture.grade)) - .filter(lecture => majors.length === 0 || majors.includes(lecture.major)) - .filter(lecture => !credits || lecture.credits.startsWith(String(credits))) - .filter(lecture => { + .filter( + (lecture) => !credits || lecture.credits.startsWith(String(credits)) + ) + .filter((lecture) => { if (days.length === 0) { return true; } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => days.includes(s.day)); + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + return schedules.some((s) => days.includes(s.day)); }) - .filter(lecture => { + .filter((lecture) => { if (times.length === 0) { return true; } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => s.range.some(time => times.includes(time))); + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + return schedules.some((s) => + s.range.some((time) => times.includes(time)) + ); }); - } + }, [lectures, searchOptions]); - const filteredLectures = getFilteredLectures(); const lastPage = Math.ceil(filteredLectures.length / PAGE_SIZE); - const visibleLectures = filteredLectures.slice(0, page * PAGE_SIZE); - const allMajors = [...new Set(lectures.map(lecture => lecture.major))]; + const visibleLectures = useMemo( + () => filteredLectures.slice(0, page * PAGE_SIZE), + [filteredLectures, page] + ); + const allMajors = useMemo( + () => [...new Set(lectures.map((lecture) => lecture.major))], + [lectures] + ); - const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => { - setPage(1); - setSearchOptions(({ ...searchOptions, [field]: value })); - loaderWrapperRef.current?.scrollTo(0, 0); - }; + const changeSearchOption = useCallback( + (field: keyof SearchOption, value: SearchOption[typeof field]) => { + setPage(1); + setSearchOptions((prev) => ({ ...prev, [field]: value })); + loaderWrapperRef.current?.scrollTo(0, 0); + }, + [] + ); - const addSchedule = (lecture: Lecture) => { - if (!searchInfo) return; + const addSchedule = useCallback( + (lecture: Lecture) => { + if (!searchInfo) return; - const { tableId } = searchInfo; + const { tableId } = searchInfo; - const schedules = parseSchedule(lecture.schedule).map(schedule => ({ - ...schedule, - lecture - })); + const schedules = parseSchedule(lecture.schedule).map((schedule) => ({ + ...schedule, + lecture, + })); - setSchedulesMap(prev => ({ - ...prev, - [tableId]: [...prev[tableId], ...schedules] - })); + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: [...prev[tableId], ...schedules], + })); - onClose(); - }; + onClose(); + }, + [searchInfo, setSchedulesMap, onClose] + ); useEffect(() => { const start = performance.now(); - console.log('API 호출 시작: ', start) - fetchAllLectures().then(results => { + console.log("API 호출 시작: ", start); + fetchAllLectures().then((results) => { const end = performance.now(); - console.log('모든 API 호출 완료 ', end) - console.log('API 호출에 걸린 시간(ms): ', end - start) - setLectures(results.flatMap(result => result.data)); - }) + console.log("모든 API 호출 완료 ", end); + console.log("API 호출에 걸린 시간(ms): ", end - start); + setLectures(results.flatMap((result) => result.data)); + }); }, []); useEffect(() => { @@ -186,9 +229,9 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { } const observer = new IntersectionObserver( - entries => { + (entries) => { if (entries[0].isIntersecting) { - setPage(prevPage => Math.min(lastPage, prevPage + 1)); + setPage((prevPage) => Math.min(lastPage, prevPage + 1)); } }, { threshold: 0, root: $loaderWrapper } @@ -200,20 +243,20 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { }, [lastPage]); useEffect(() => { - setSearchOptions(prev => ({ + setSearchOptions((prev) => ({ ...prev, days: searchInfo?.day ? [searchInfo.day] : [], times: searchInfo?.time ? [searchInfo.time] : [], - })) + })); setPage(1); }, [searchInfo]); return ( - + 수업 검색 - + @@ -222,7 +265,7 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { changeSearchOption('query', e.target.value)} + onChange={(e) => changeSearchOption("query", e.target.value)} /> @@ -230,7 +273,9 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { 학점