
// (이미 호출한 api는 다시 호출하지 않도록 - 클로저를 이용하여 캐시 구성)
const createLectureFetcher = () => {
let lectureCache: Lecture[] | null = null; // 클로저에서만 유지되는 캐시
const fetchAllLectures = async () => {
if (lectureCache) return lectureCache; // 캐시가 있으면 api 호출 생략
const res = 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()),
]);
lectureCache = res.flatMap((r) => r.data); // 전공과 교양을 평탄화하여 하나의 배열로 처리
return lectureCache;
};
return { fetchAllLectures };
};
// fetcher 생성
const lectureFetcher = createLectureFetcher();
lectureCache는 createLectureFetcher 내부에만 존재하는 클로저 캐시이며, api를 호출한 후 데이터를 저장한다. 최초 호출 이후에 또 호출하게 되면 이를 즉시 반환하며, 외부에서는 캐시에 접근할 수 없다. 모달 컴포넌트 외부에 한 번만 생성하여 모달의 리렌더링에 관계 없이 캐시 유지가 가능하다!!
모달을 열어보면 최고 한 번 열었을 때만 api를 호출하고, 그 이후엔 캐싱된 값을 사용하여 api를 호출하지 않는 것을 확인할 수 있다 👍
// 각 컴포넌트로 분리 후 메모이제이션 적용
export default React.memo(LectureHeadItem);
export default React.memo(LectureItem);
export default React.memo(ScheduleTemplate);
강의 헤더 아이템과 단일 아이템, 시간표 템플릿 컴포넌트 등을 분리한 뒤 memo를 사용하여 리렌더링을 방지했다. 이 외에도 filteredLectures, visibleLectures, allMajor, changeSearchOption 등에 useMemo, useCallback을 사용하여 최적화했다.
// 각 필터 분리 후 메모이제이션 적용
export default React.memo(CreditFilter);
export default React.memo(DayFilter);
export default React.memo(GradeFilter);
export default React.memo(MajorFilter);
export default React.memo(QueryFilter);
export default React.memo(TimeFilter);
// src/components/filter/SearchOptionFilter.tsx
interface SearchOptionFilterProps {
searchOptions: SearchOption;
allMajors: string[];
changeSearchOption: (field: keyof SearchOption, value: SearchOption[typeof field]) => void;
}
const SearchOptionFilter = ({ searchOptions, allMajors, changeSearchOption }: SearchOptionFilterProps) => {
return (
<>
<HStack spacing={4}>
{/* 검색 필터링 */}
<QueryFilter query={searchOptions.query} changeSearchOption={changeSearchOption} />
{/* 학점 필터링 */}
<CreditFilter credits={searchOptions.credits} changeSearchOption={changeSearchOption} />
</HStack>
<HStack spacing={4}>
{/* 학년 필터링 */}
<GradeFilter grades={searchOptions.grades} changeSearchOption={changeSearchOption} />
{/* 요일 필터링 */}
<DayFilter days={searchOptions.days} changeSearchOption={changeSearchOption} />
</HStack>
<HStack spacing={4}>
{/* 시간 필터링 */}
<TimeFilter times={searchOptions.times} changeSearchOption={changeSearchOption} />
{/* 전공 필터링 */}
<MajorFilter majors={searchOptions.majors} allMajors={allMajors} changeSearchOption={changeSearchOption} />
</HStack>
</>
);
};
export default SearchOptionFilter;
필터링 컴포넌트의 경우는 각 필터를 컴포넌트로 분리하여 React.memo로 감싸주었다.
이를 통해 props의 값이 변경되지 않으면 리렌더링이 발생하지 않도록 개선했다!
const ScheduleTable = React.memo(({ index, disabled, tableId, initialSchedule, onDuplicate, onRemove }: Props) => {
// 시간표 개별 상태 관리
const [schedules, setSchedules] = useState<Schedule[]>(initialSchedule);
// 현재 선택된 강의 정보 - 시간표 id, 요일, 시간
const [searchInfo, setSearchInfo] = useState<{
tableId: string;
day?: string;
time?: number;
} | null>(null);
const [isActive, setIsActive] = useState<Active | null>(null);
const getColor = (lectureId: string): string => {
const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))];
const colors = ['#fdd', '#ffd', '#dff', '#ddf', '#fdf', '#dfd'];
return colors[lectures.indexOf(lectureId) % colors.length];
};
// 드래그 시작 시 active 업데이트
const handleDragStart = ({ active }: { active: Active }) => {
setIsActive(active);
};
// 드래그 종료 시 호출
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleDragEnd = (event: any) => {
// active - 드래그한 아이템
// delta - 이동 거리
const { active, delta } = event;
// 드래그 종료 시 active 제거
setIsActive(null);
const { x, y } = delta;
const [, index] = active.id.split(':');
const schedule = schedules[index];
const nowDayIndex = DAY_LABELS.indexOf(schedule.day as (typeof DAY_LABELS)[number]);
// 이동한 그리드 계산
const moveDayIndex = Math.floor(x / 80);
const moveTimeIndex = Math.floor(y / 30);
setSchedules((prev) =>
prev.map((schedule, idx) =>
idx === Number(index)
? {
...schedule,
day: DAY_LABELS[nowDayIndex + moveDayIndex],
range: schedule.range.map((time) => time + moveTimeIndex),
}
: { ...schedule }
)
);
};
// SearchDialog에서 강의 추가
const addLecture = useCallback(
(lecture: Lecture) => {
if (!searchInfo) return;
const newSchedules: Schedule[] = parseSchedule(lecture.schedule).map((schedule) => ({
...schedule,
lecture,
}));
setSchedules((prev) => [...prev, ...newSchedules]);
setSearchInfo(null); // 모달 닫기
},
[searchInfo]
);
// 강의 삭제
const deleteLecture = useCallback((day: string, time: number) => {
setSchedules((prev) => prev.filter((schedule) => schedule.day !== day || !schedule.range.includes(time)));
}, []);
return (
<Stack key={tableId} width="600px">
<Flex justifyContent="space-between" alignItems="center">
<Heading as="h3" fontSize="lg">
시간표 {index + 1}
</Heading>
<ButtonGroup size="sm" isAttached>
<Button colorScheme="green" onClick={() => setSearchInfo({ tableId })}>
시간표 추가
</Button>
<Button colorScheme="green" mx="1px" onClick={() => onDuplicate(tableId, schedules)}>
복제
</Button>
<Button colorScheme="green" isDisabled={disabled} onClick={() => onRemove(tableId)}>
삭제
</Button>
</ButtonGroup>
</Flex>
{/* DnDContext 적용 */}
<ScheduleDndProvider onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<Box position="relative" outline={isActive ? '5px dashed' : undefined} outlineColor="blue.300">
<ScheduleTemplate tableId={tableId} onCellClick={setSearchInfo} />
{schedules.map((schedule, index) => (
<DraggableSchedule
key={`${schedule.lecture.title}-${index}`}
id={`${tableId}:${index}`}
data={schedule}
bg={getColor(schedule.lecture.id)}
onDeleteButtonClick={() => deleteLecture(schedule.day, schedule.range[0])}
/>
))}
{searchInfo?.tableId === tableId && (
<SearchDialog
searchInfo={searchInfo}
onClose={() => setSearchInfo(null)}
addLecture={addLecture} // 모달에서 강의 추가
/>
)}
</Box>
</ScheduleDndProvider>
</Stack>
);
});
export default ScheduleTable;
개별 시간표 내에서 강의 추가, 삭제, 수정 (dnd) 등의 상태 변화가 일어날 때 전체 시간표의 렌더링을 최적화하기 위해 useState로 개별 시간표 상태를 만들어 관리하도록 수정했다.
그리고 기존에 App.tsx 파일에 작성되어있던 ScheduleDndProvider를 각 테이블 파일 내로 이동시켜 전체 테이블 리렌더링을 최적화했다. 😊
이번 과제는 React Developer Tools를 사용하여 현재 페이지 내에서 컴포넌트 렌더링이 어떻게 진행되고 있는지 확인해가면서 직접 개선해나가는 의미있는 경험이었다. 아무래도 성능 최적화를 목표로 하여 개발한 경험이 없다보니 신기하게 느껴지기도 했고, 실제로 성능 개선이 눈에 보이는 과정을 겪으며 성취감도 느껴졌다.
위에서 말했듯 성능 최적화 경험이 없어서 그런지 과제를 시작할 때 감이 잘 잡히지 않았고, useMemo나 useCallback을 남발하게 됐던 것 같다. 어디까지 최적화를 적용해야 할지 판단하는 기준에 대해서도 확신이 서지 않는다. 코드를 작성할 때부터 성능을 의식하기보단 먼저 기능을 구현한 뒤에 성능을 측정하고 병목을 확인한 후에 최적화를 진행하는 습관을 들이면 감이 잡히지 않을까 하는 생각이 든다.
예전에 시간표 컴포넌트를 구현해본 경험이 있는데, 그 때 스타일을 하나하나 계산하여 강의 아이템을 배치했었기 때문에 이번 과제로 처음 알게된 dnd 라이브러리가 너무 신기했다...😳 라이브러리를 사용하여 아주 간단한 사이트를 만들면서 글로도 정리해보고 싶다는 생각이 들었당