과제 체크포인트
https://eveneul.github.io/front_6th_chapter4-2/
과제 요구사항
-
배포 후 url 제출
-
API 호출 최적화(
Promise.all이해) -
SearchDialog 불필요한 연산 최적화
-
SearchDialog 불필요한 리렌더링 최적화
-
시간표 블록 드래그시 렌더링 최적화
-
시간표 블록 드롭시 렌더링 최적화
과제 셀프회고
1. fetchAllLectures 함수 개선
🚨 개선 전
🌟 개선 후
좋은 컴퓨터는 더 빨라져요
기존 코드
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())
])
🚨 문제점
await을 사용해서 병렬이 아닌fetchMajors→fetchLiberalArts함수가 순차적으로 실행되어Promise.all의 목적인 병렬 실행이 무의미해집니다.fetchMajors()함수와fetchLiberalArts()함수가 중복으로 실행됩니다. (대체 왜?)- 캐싱을 하지 않아 컴포넌트가 재마운트될 때마다 API를 호출해서 이미 로드한 데이터를 재사용하지 못합니다.
개선된 코드
const fetchAllLectures = (() => {
let cache: Promise<Lecture[]> | null = null
return async () => {
if (cache) {
return cache
}
cache = Promise.all([fetchMajors(), fetchLiberalArts()]).then((results) => {
console.log('API 호출 완료', performance.now())
return results.flatMap((result) => result.data)
})
return cache
}
})()
🌟 개선점
- API 호출에 걸린 시간을 131ms → 46ms으로 약 85ms 단축했습니다.
- 클로저를 이용한 캐시 변수로 이미 호출된 데이터가 있으면 재사용(리턴)하고, 없으면 새로 호출합니다.
fetchMajors,fetchLiberalArts함수가 처음 한 번만 2번 호출합니다.- Promise.all에서 await을 제거하여 병렬 처리로 변경했습니다.
2. SearchDialog 컴포넌트 개선
🚨 문제점
getFilteredLectures함수가 컴포넌트가 마운트될 때마다 불필요하게 재계산되었습니다.filteredLectures,allMajors역시 마운트 시마다 매번 새로 할당되어 성능 낭비가 있었습니다.- 인피니티 스크롤로 강의 데이터를 불러올 때, 실제로는 리렌더링이 필요 없는
Popover컴포넌트까지 함께 리렌더링되는 문제가 있었습니다.
🌟 개선점
- 함수 및 값 메모이제이션
useMemo,useCallback을 적용하여getFilteredLectures,filteredLectures,allMajors가 불필요하게 재생성되지 않도록 최적화했습니다.
- 비즈니스 로직 분리
useSearchDialog커스텀 훅을 만들어, 컴포넌트 내부에 길게 선언되던 로직과 함수들을 분리했습니다.- 이로써
SearchDialog는 UI 중심으로만 유지되고, 상태/로직 관리가 명확해졌습니다.
- 테이블 구조 최적화
Table관련 코드를 별도 컴포넌트로 분리했습니다.- 특히
Tr(행) 단위를React.memo로 감싸, 개별 행이 변경되지 않는 이상 리렌더링이 발생하지 않도록 했습니다.
- Popover 렌더링 최소화
- 인피니티 스크롤 시에도
Popover컴포넌트가 리렌더링되지 않도록 의존성을 정리하고, 불필요한 상태 전파를 차단했습니다.
- 인피니티 스크롤 시에도
3. ScheduleTable 개선
🚨 문제점
- 강의(버튼)를 dragging할 때 테이블의 각 셀(
GridItem포함)이 모두 리렌더링되었습니다.- 이 과정에서 불필요하게 함수들이 재생성되고, 셀 단위 컴포넌트들이 매번 다시 계산되는 문제가 있었습니다.
DraggableSchedule을 드래그하는 동안, 강의 삭제 여부를 묻는PopoverContent까지 리렌더링이 발생했습니다.
🌟 개선점
- 함수 메모이제이션
useCallback을 사용해 이벤트 핸들러 함수들의 불필요한 재생성을 방지했습니다.
- 컴포넌트 분리
ScheduleTable내부에 있던 작은 단위 컴포넌트들을 별도로 분리했습니다.- 예:
TableCell,TimeCell,DayHeaderCell,TableOutline
- 예:
- 이로써 각 셀이 독립적으로 리렌더링 범위를 가지도록 최적화했습니다.
- 드래그 시 Popover 최적화
-
드래그 중에는
PopoverContent가 다시 렌더링되지 않도록const shouldRenderPopover = !isDragging조건을 추가했습니다. -
DraggableSchedule을memo로 감싸 불필요한 렌더링을 더 줄였습니다.
-
// 개선 전
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];
};
// 개선 후
const getColor = useCallback(
(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]
},
[schedules]
)
// 개선 전
<GridItem
key={`${day}-${timeIndex + 2}`}
borderWidth="1px 0 0 1px"
borderColor="gray.300"
bg={timeIndex > 17 ? 'gray.100' : 'white'}
cursor="pointer"
_hover={{ bg: 'yellow.100' }}
onClick={() => onScheduleTimeClick?.({ day, time: timeIndex + 1 })} // 이벤트 콜백 함수
/>
// 개선 후
const memoizedOnScheduleTimeClick = useCallback(
(timeInfo: {day: string; time: number}) => {
onScheduleTimeClick?.(timeInfo)
},
[onScheduleTimeClick]
)
// 개선 전
<PopoverContent onClick={(event) => event.stopPropagation()}>
<PopoverArrow />
<PopoverCloseButton />
<PopoverBody>
<Text>강의를 삭제하시겠습니까?</Text>
<Button colorScheme='red' size='xs' onClick={onDeleteButtonClick}>
삭제
</Button>
</PopoverBody>
</PopoverContent>
// 개선 후
const shouldRenderPopover = !isDragging
...
{shouldRenderPopover && (
<PopoverContent onClick={(event) => event.stopPropagation()}>
<PopoverArrow />
<PopoverCloseButton />
<PopoverBody>
<Text>강의를 삭제하시겠습니까?</Text>
<Button colorScheme='red' size='xs' onClick={onDeleteButtonClick}>
삭제
</Button>
</PopoverBody>
</PopoverContent>
)}
4. ScheduleTables 개선
🚨 문제점
- 강의를 드래그 & 드롭하면 관련 없는 다른 Table도 불필요하게 리렌더링되는 문제가 있었습니다.
- 기존에는 Context로
schedulesMap,setSchedulesMap을 공유하고 있어서, 한 테이블의 변경이 모든 하위 컴포넌트에 전파되었습니다.
🤨 시도했던 방법들
- Context를 Getter/Setter로 분리해보려 했으나, 여전히 Context 자체는 전역으로 공유되기 때문에 리렌더링 범위를 줄이기 어려웠습니다.
- 하지만 Context는 Context일 뿐.. 하위 컴포넌트들이 다 영향을 받아서 이 방법은 오랜 고민 끝에 포기했습니다.
🌟 개선점
- Zustand 전역 상태 관리를 도입했습니다.
store/scheduleStore.ts를 생성하여schedulesMap과setSchedulesMap뿐 아니라 update, add, remove, duplicate 등 CRUD 액션을 정의했습니다.- 각 컴포넌트에서는 Context 대신 커스텀 훅(
useSchedulesByTableId)을 사용하여 특정tableId에 해당하는 데이터만 구독하도록 변경했습니다. handleDragEnd에서setSchedulesMap대신updateSchedule을 호출하도록 수정하여, 드래그 & 드롭 시 해당 테이블만 업데이트되고 다른 테이블은 리렌더링 되지 않도록 개선했습니다.
// store/scheduleStore.ts
export const useScheduleStore = create<ScheduleStore>((set) => ({
// 스케줄 데이터
schedulesMap: dummyScheduleMap,
// 스케줄 데이터 setter
setSchedulesMap: (schedulesMap) => set({ schedulesMap }),
// 스케줄 데이터 update
updateSchedule: (tableId, index, updatedSchedule) => {
set((state) => ({
schedulesMap: {
...state.schedulesMap,
[tableId]: state.schedulesMap[tableId].map((schedule, i) =>
i === index ? updatedSchedule : schedule
),
},
}));
},
...
export const useSchedulesByTableId = (tableId: string) => {
return useScheduleStore(
useShallow((state) => state.schedulesMap[tableId] || [])
);
};
// ScheduleEndProvider.tsx
export default function ScheduleDndProvider({
children,
tableId,
}: PropsWithChildren & { tableId: string }) {
// Context에서 가지고 오는 대신 전역 상태 관리에서 선언한 훅으로 대신하여 가지고 옴
const schedules = useSchedulesByTableId(tableId);
const updateSchedule = useScheduleStore((state) => state.updateSchedule);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleDragEnd = (event: any) => {
const { active, delta } = event;
const { x, y } = delta;
const [tableId, 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);
// setSchedulesMap으로 상태를 변경하는 것이 아닌 DND를 한 데이터의 테이블을 update로 진행
const updatedSchedule = {
...schedule,
day: DAY_LABELS[nowDayIndex + moveDayIndex],
range: schedule.range.map((time) => time + moveTimeIndex),
};
updateSchedule(tableId, Number(index), updatedSchedule);
};
리뷰 받고 싶은 내용
리뷰 받기보다는 질문이 있는데요, 원래 이렇게 모든 걸 다 메모이제이션 하는 걸까요? 성능 최적화를 많이 해 보지를 못해서, 이렇게 모든 컴포넌트와 함수 하나하나를 다 memo, callback을 써야 하는 게 맞는 건지 궁금합니다!
과제 피드백
수고했어요 하늘!! 이번 과제는 React 애플리케이션에서 실제 성능 병목 지점을 찾고 최적화하는 것이 목표였습니다.
API 호출 최적화에서 문제점을 정확히 파악하고 개선한 부분이 잘했어요. await을 잘못 사용해서 병렬 처리가 무의미했던 점과 중복 호출 문제를 클로저 캐싱으로 잘 해결했네요! 이 사실은 기억하면서 TanstackQuery를 한번 더 떠올려보세요. TanstackQuery가 해주는 최적화 편의가 이러한 방식으로 만들어져 있답니다.
useMemo, useCallback으로 불필요한 재계산을 방지하고, 커스텀 훅으로 비즈니스 로직을 분리하고, React.memo로 행 단위 리렌더링을 최적화한 부분들이 좋았습니다. 내가 수정한 데이터는 분명 행의 일부 데이터인데 리렌더링이 호출되는 범위가 그보다 훨씬 더 커지게 되었다면 memo을 걸 수 있으면 좋겠지요.
ScheduleTable에서 드래그 시 전체 리렌더링 문제를 해결한 과정도 잘 봤습니다. 실시간 타이머를 이용한 에니메이션이나 실시간 수치나 UI등을 포함하고 있다거나 지금처럼 드래그와 같이 이벤트가 짧은 시간 내 엄청 많은 경우에도 리렌더링이 성능을 방해하는 경우가 있으니 지금의 방식처럼 기억해뒀다가 잘 해결해나가길 바래요!
Context에서 Zustand로 전환한 결정도 좋습니다. Context의 경우 최적화를 스스로 하지는 않기에 리렌더링이이 모든 영역에서 발생하는 문제가 발생하죠. 반면 Zustand의 경우 selector를 이용해 리렌더의 범위를 조정할 수 있으니 좋습니다.
현대에 와서는 이렇게 이미 최적화에 대한 방법들이 포함된 라이브러리들이 많아 최적화를 해야하는 작업이 자주 발생하지는 않지만 어째서 이런게 가능한지를 이해하면 나중에 꼭 필요한 경우 큰 두려움이나 헤매는 과정없이 잘 해낼거라 생각합니다.
A) 리뷰 받기보다는 질문이 있는데요, 원래 이렇게 모든 걸 다 메모이제이션 하는 걸까요? 성능 최적화를 많이 해 보지를 못해서, 이렇게 모든 컴포넌트와 함수 하나하나를 다 memo, callback을 써야 하는 게 맞는 건지 궁금합니다!
=> 모든 걸 다 메모이제이션할 필요는 없어요. 메모이제이션은 동작을 skip하는 대신 메모리 사용량을 방식입니다. 게다가 코드도 복잡해지요. 다 트레이드 오프가 있으므로 언제나 메모이제이션은 실제 성능 문제가 있을 때만 적용하는 게 좋다라고 합니다. (그렇지만 메모제이션이 그정도 까지 문제는 아니기에 언제는 하고 언제는 안하고 이거 고민을 할바야 전부 다 습관적으로 거는게 낫다 하는 의견도 있지요. 어떤 게 맞을 것 같은지는 하늘이 한번 잘 생각해보세요)
=> 언제 사용하는지 기준을 잡아보면 우선 리렌더링이 중요한 기준인데 값이 변화는 국소적이나 리렌더링이 되는 범위는 넒고 내부 계산 로직이 있고 그 계산이 배열의 합처럼 큰 데이터로 부터 오는 경우입니다. : 1) 복잡한 계산이 있는 함수, 2) 자주 리렌더링되는 컴포넌트, 3) props가 자주 변경되지 않는 컴포넌트, 4) 하위 컴포넌트가 많은 부모 컴포넌트 정도에만 적용하시면 돼요.
=> 하나의 컴포넌트에서 리렌더링이 되어야 하는 이유가 여러개라면 useMemo보다는 컴포넌트를 분리하고 React.memo를 쓰는 방법도 좋습니다.
이번 과제에서 성능 측정도 하고 실제 개선 효과도 확인하면서 최적화의 전 과정을 체험해보셨으니, 앞으로 비슷한 상황에서 어디부터 접근할지 감이 생겼을 거예요. 수고하셨습니다. 제가 하늘이는 꼭 꼭 기억할게요. 지난 10주간 너무너무 수고 많았습니다. 이 경험들이 실력이 성장하고 스스로도 이제 나는 잘 하는 프론트엔드 개발자라는 자부심을 만드는 계기가 되었기를 바래요!