과제 체크포인트
과제 요구사항
-
배포 후 url 제출
-
API 호출 최적화(
Promise.all이해) -
SearchDialog 불필요한 연산 최적화
-
SearchDialog 불필요한 리렌더링 최적화
-
시간표 블록 드래그시 렌더링 최적화
-
시간표 블록 드롭시 렌더링 최적화
배포
https://ldhldh07.github.io/front_6th_chapter4-2/
과제 셀프회고
리액트 최적화를 위한 과제를 수행했습니다. 시간표 프로젝트를 메모이제이션, 캐싱을 이용해 최적화하고 사용자 경험을 개선하는 과제입니다.
기술적 성장
최적화가 가능한 범위는 그 폭이 넓습니다. 그중에서 리액트의 특성상 문제를 일으키기 쉬운 지점은 두가지입니다.
- 메모이제이션이 안되어서 데이터의 변화가 없음에도 다시 렌더링이 되는 경우
- 컴포넌트의 생명주기마다 큰 데이터를 호출하는 경우
과제를 통해 이 문제들을 찾아내고 해결하는 경험을 했습니다.
React.useMemo, React.useCallback
그중 비교적 익숙한 최적화 방식은 useMemo, useCallback을 이용한 최적화였습니다.
<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 })}
/>
<DraggableSchedule
key={`${schedule.lecture.title}-${index}`}
id={`${tableId}:${index}`}
data={schedule}
bg={getColor(schedule.lecture.id)}
onDeleteButtonClick={() => onDeleteButtonClick?.({
day: schedule.day,
time: schedule.range[0],
})}
/>
const onScheduleTimeClick = useCallback(
(timeInfo: { day: string; time: number }) => openSearch(tableId, timeInfo.day, timeInfo.time),
[openSearch, tableId],
);
const onDeleteButtonClick = useCallback(
({ day, time }: { day: string; time: number }) =>
updateTable(tableId, (prev) =>
prev.filter((s) => s.day !== day || !s.range.includes(time)),
),
[updateTable, tableId],
);
<TableGrid onScheduleTimeClick={handleScheduleTimeClock} />
{schedules.map((schedule, index) => (
<DraggableSchedule
key={`${schedule.lecture.title}-${index}`}
id={`${tableId}:${index}`}
data={schedule}
bg={getColor(schedule.lecture.id)}
onDeleteButtonClick={(schedule) => handleDeleteSchedule(schedule)}
/>
))}
매번 다시 생성할 필요가 없는 함수들을 useCallback을 통해 메모이제이션 해줍니다.
React.memo
memo를 통한 컴포넌트 최적화는 사실 이전까지는 많이 간과했던 부분입니다. 과제를 통해 memo를 통한 최적화의 유용성을 체감했습니다.
<Tbody>
{visibleLectures.map((lecture, index) => (
<Tr key={`${lecture.id}-${index}`}>
<Td width="100px">{lecture.id}</Td>
<Td width="50px">{lecture.grade}</Td>
<Td width="200px">{lecture.title}</Td>
<Td width="50px">{lecture.credits}</Td>
<Td width="150px" dangerouslySetInnerHTML={{ __html: lecture.major }}/>
<Td width="150px" dangerouslySetInnerHTML={{ __html: lecture.schedule }}/>
<Td width="80px">
<Button size="sm" colorScheme="green" onClick={() => addSchedule(lecture)}>추가</Button>
</Td>
</Tr>
))}
</Tbody>
const SearchItem = memo(({ lecture, addSchedule }: SearchItemProps) => {
return (
<tr style={tableRowStyles}>
<td style={{ ...tableCellStyles, width: "100px" }}>{lecture.id}</td>
<td style={{ ...tableCellStyles, width: "50px" }}>{lecture.grade}</td>
<td style={{ ...tableCellStyles, width: "200px" }}>{lecture.title}</td>
<td style={{ ...tableCellStyles, width: "50px" }}>{lecture.credits}</td>
<td
style={{ ...tableCellStyles, width: "150px" }}
dangerouslySetInnerHTML={{ __html: lecture.major }}
/>
<td
style={{ ...tableCellStyles, width: "150px" }}
dangerouslySetInnerHTML={{ __html: lecture.schedule }}
/>
<td style={{ ...tableCellStyles, width: "80px" }}>
<Button size="sm" colorScheme="green" onClick={() => addSchedule(lecture)}>
추가
</Button>
</td>
</tr>
);
});
SearchItem.displayName = "SearchItem";
...
<Tbody>
{visibleLectures.map((lecture, index) => (
<SearchItem
key={`${lecture.id}-${index}`}
lecture={lecture}
addSchedule={addSchedule}
/>
))}
</Tbody>
memo는 props가 변화하지 않는다면 메모이제이션으로 리렌더링하지 않도록 합니다. 이 컴포넌트에 영향을 끼치지 않는 값의 변경으로 컴포넌트가 리렌더링되는 것을 방지합니다.
이때 props가 변화하지 않는다는 조건을 통해 해야하는 작업이 있습니다. prop에 들어가는 computed값이나 함수들의 경우 useMemo나 useCallback을 통해 메모이제이션을 해야합니다.
- memo할 컴포넌트를 판단
- 분리 후 메모이제이션
- props에 포함된 함수들에 대한 메모이제이션
이런 작업 흐름을 익힐 수 있었습니다.
네이티브
UI 라이브러리 컴포넌트 대신 브라우저 네이티브 태그를 사용했습니다. 필요한 최소한의 스타일만 직접 지정해 렌더링 오버헤드를 줄였습니다.
반복 렌더가 매우 많은 영역(테이블/그리드/셀)에 우선 적용했습니다. 적용 기준
- 반복 렌더 수가 많고 구조가 고정된 영역
- 스타일 계산이 단순하고 재사용 비용이 낮은 영역
- 접근성 요구가 단순하거나 직접 제어 가능한 영역
<Tr>
<Td>{lecture.id}</Td>
<Td>{lecture.title}</Td>
{/* ... */}
</Tr>
<tr style={tableRowStyles}>
<td style={{ ...tableCellStyles, width: "100px" }}>{lecture.id}</td>
<td style={{ ...tableCellStyles, width: "200px" }}>{lecture.title}</td>
{/* ... */}
</tr>
context 범위를 통한 리렌더링 방지 및 전파
단일 컨텍스트에 상태와 액션을 함께 넣으면, 액션만 쓰는 컴포넌트도 상태 변경마다 리렌더링됩니다. 이를 피하기 위해 상태와 액션 컨텍스트를 분리하고, Provider value를 useMemo로 고정해 전파 범위를 최소화했습니다.
- 상태 컨텍스트: 상태를 읽는 컴포넌트만 구독
- 액션 컨텍스트: 업데이트 함수만 제공, 상태 변경에 재구독되지 않음
- 테이블 단위 업데이트: 특정 tableId 배열만 새 참조로 교체, 나머지는 참조 보존
컨텍스트 분리와 Provider 구성
드래그/드롭 시 비활성 카드까지 리렌더되는 문제를 줄이기 위해 DnD 구독 범위를 카드 단위로 축소하고, 드롭 처리에서도 해당 테이블만 부분 업데이트하도록 변경했습니다.
<ChakraProvider>
<ScheduleProvider>
<ScheduleDndProvider>
<ScheduleTables/>
</ScheduleDndProvider>
</ScheduleProvider>
</ChakraProvider>
<ScheduleDndProvider>
<ScheduleTable
key={`schedule-table-${tableId}`}
schedules={schedules}
tableId={tableId}
onScheduleTimeClick={onScheduleTimeClick}
onDeleteButtonClick={onDeleteButtonClick}
/>
</ScheduleDndProvider>
테이블별로 각각의 context를 형성하게 합니다. 그럼으로써 독립적으로 상태에 의존하고 다른 컨텍스트의 상태 변화에 다른 컨텍스트를 가진 테이블이 리렌더링하는 동작을 방지해줍니다.
Provider 환경에서 state 컨텍스트와 action 컨텍스트를 분리하여 action 컨텍스트만 구독하는 방식으로 최적화를 이뤄낸 것도 새로운 발견이었습니다.
import React, { createContext, useContext, useMemo, useState } from "react";
import { Schedule } from "./types";
type SchedulesMap = Record<string, Schedule[]>;
interface ScheduleContextType {
schedulesMap: SchedulesMap;
setSchedulesMap: React.Dispatch<React.SetStateAction<SchedulesMap>>;
duplicate: (tableId: string) => void;
remove: (tableId: string) => void;
}
const ScheduleContext = createContext<ScheduleContextType | undefined>(undefined);
export const useScheduleContext = () => {
const ctx = useContext(ScheduleContext);
if (!ctx) throw new Error("useScheduleContext must be used within a ScheduleProvider");
return ctx;
};
export const ScheduleProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [schedulesMap, setSchedulesMap] = useState<SchedulesMap>({});
const duplicate = (tableId: string) => {
setSchedulesMap(prev => ({ ...prev, ["schedule-" + Date.now()]: [...(prev[tableId] ?? [])] }));
};
const remove = (tableId: string) => {
setSchedulesMap(prev => {
const copy = { ...prev };
delete copy[tableId];
return copy;
});
};
const value = useMemo(
() => ({ schedulesMap, setSchedulesMap, duplicate, remove }),
[schedulesMap, duplicate, remove],
);
return <ScheduleContext.Provider value={value}>{children}</ScheduleContext.Provider>;
};
import { useScheduleContext } from "./ScheduleContext";
export default function ScheduleDndProvider({ children }: { children: React.ReactNode }) {
const { setSchedulesMap } = useScheduleContext(); // 상태+액션 컨텍스트 전체 구독 → schedulesMap 변경마다 재렌더
// ...
return <div>{children}</div>;
}
변경후
const ScheduleStateContext = createContext<ScheduleStateContextType | undefined>(undefined);
const ScheduleActionsContext = createContext<ScheduleActionsContextType | undefined>(undefined);
export const useScheduleState = () => {
const context = useContext(ScheduleStateContext);
if (context === undefined) {
throw new Error("useScheduleState must be used within a ScheduleProvider");
}
return context;
};
export const useScheduleActions = () => {
const context = useContext(ScheduleActionsContext);
if (context === undefined) {
throw new Error("useScheduleActions must be used within a ScheduleProvider");
}
return context;
};
export default function ScheduleDndProvider({ children }: PropsWithChildren) {
const { setSchedulesMap } = useScheduleActions();
학습 효과 분석
최적화를 하는 워크플로우에 익숙해지고자 했습니다.
- 문제를 인식
- 성능 측정
- 도구를 이용해 문제가 되는 지점을 파악
- 해결 수 다시 성능 측정
- 장기적인 레퍼런스 및 지표로 사용
코딩을 하고 다음 이슈를 빨리 해결하는 과정에서 단순히 눈에 의존하고 위와 같은 절차를 지나치게 됩니다. 이런 과정을 하고자 하는 의지는 있었지만 실천을 하진 않았습니다.
콘솔, 프로파일러를 통한 성능 측정을 하는것을 학습했습니다. 그 과정에서 어느 부분에 문제가 생기는지 어떻게 측정하고 결과값을 어떻게 봐야 하는지 알았습니다.
과제 피드백
두현님 벌써 10주차가 지나갔습니다. 회고문서도 잘 작성해주셨고 과제도 잘 해주셨네요! 수고 많으셨습니다. 앞으로 항상 좋은 일만 가득하길 빌게요!