과제 체크포인트
배포 링크
과제 요구사항
- 배포 후 url 제출
- API 호출 최적화(
Promise.all이해) - SearchDialog 불필요한 연산 최적화
- SearchDialog 불필요한 리렌더링 최적화
- 시간표 블록 드래그시 렌더링 최적화
- 시간표 블록 드롭시 렌더링 최적화
과제 셀프회고
기술적 성장
Promise.all
과제를 진행하면서 Promise.all 사용법에서 잘못된 부분을 발견했습니다. 배열 안에서 await를 사용하면 순차 실행되는데, Promise 객체 자체를 넘겨야 병렬 처리가 된다는 점을 참고해서 수정했습니다.
// 잘못된 사용 - 순차 실행
await Promise.all([await fetchMajors(), await fetchLiberalArts()]);
// 올바른 사용 - 병렬 실행
await Promise.all([fetchMajors(), fetchLiberalArts()]);
React.memo
React.memo를 효과적으로 사용하는 방법도 배울 수 있었는데 부모 컴포넌트에서 객체나 함수를 직접 생성해서 props로 전달하면 memo로 감싸도 의미가 없다는 점이 제일 중요한 것 같습니다.
// Before
const SearchDialog = () => {
return (
<div>
<QueryInput
searchOptions={{query: '', grades: []}} // 매번 새 객체
onChange={(field, value) => {}} // 매번 새 함수
/>
</div>
);
};
// After
const SearchDialog = () => {
const [searchOptions, setSearchOptions] = useState({query: '', grades: []});
const handleChange = useCallback((field, value) => {
setSearchOptions(prev => ({...prev, [field]: value}));
}, []);
return (
<div>
<QueryInput
searchOptions={searchOptions} // 안정된 참조
onChange={handleChange} // 안정된 참조
/>
</div>
);
};
useMemo와 useCallback으로 참조를 안정화하거나, 복잡한 객체 대신 원시값들을 개별적으로 전달하는 방식으로 작업을 진행했습니다.
Chakra UI 컴포넌트 VS JSX Element
Chakra UI 컴포넌트와 HTML 요소 간의 성능 차이가 제일 의외의 최적화 작업이였던거 같아요.. 대량 데이터 렌더링에서 Chakra UI의 Tr, Td 컴포넌트를 순수 HTML tr, td로 바꾸는 것만으로도 80%? 정도의 성능 개선이 되었습니다.
// Before: Chakra UI 컴포넌트 사용 (220ms)
<Table size="sm" variant="striped">
<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="80px">
<Button size="sm" colorScheme="green" onClick={() => addSchedule(lecture)}>
추가
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
// After: HTML 요소 직접 사용 (40ms)
<Table size="sm" variant="striped">
<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="80px">
<button onClick={() => addSchedule(lecture)}>
추가
</button>
</td>
</tr>
))}
</Tbody>
</Table>
UI 라이브러리 컴포넌트들이 매번 theme 계산, props 처리, hooks 실행 등의 오버헤드를 가진다는 걸 알 수 있었습니다. 특히 100개 행에서 각 행마다 8개의 컴포넌트가 있으니 총 800번의 함수 호출이 HTML 요소로는 단순한 DOM 요소 생성으로 바뀌는 차이가 컸습니다.
Context Provider 범위 축소하기
제일 중요했던 DND에서는 Context Provider의 위치가 제일 중요한 부분이었습니다. ScheduleDndProvider를 App 레벨에서 각 테이블 레벨로 이동시키니 한 테이블의 DnD가 다른 테이블에 영향을 주지 않게 되었습니다.
// Before: App 레벨 Provider - 모든 테이블 영향받음
function App() {
return (
<ChakraProvider>
<ScheduleProvider>
<ScheduleDndProvider>
<ScheduleTables />
</ScheduleDndProvider>
</ScheduleProvider>
</ChakraProvider>
);
}
// After: ScheduleCard 컴포넌트로 범위 축소하기
function ScheduleCard() {
// ...
return (
// ...
<ScheduleDndProvider tableId={tableId} schedules={schedules} onSchedulesChange={handleSchedulesChange}>
<ScheduleTable
schedules={schedules}
tableId={tableId}
onScheduleTimeClick={handleScheduleTimeClick}
onDeleteButtonClick={handleDeleteButtonClick}
/>
</ScheduleDndProvider>
// ...
);
}
Context 범위를 축소하는 것만으로도 렌더링 범위를 크게 줄일 수 있다는 점을 배웠습니다. 기존에는 한 테이블을 드래그해도 모든 테이블의 모든 컴포넌트가 리렌더링되었는데, 이제는 드래그하는 테이블 내부의 컴포넌트들만 리렌더링됩니다.
API 캐싱 시스템을 구현할 때는 단순한 결과 캐싱이 아닌 Promise 자체를 캐싱하는 방식을 썼습니다. 동시에 같은 API가 여러 번 호출되어도 실제로는 한 번만 요청이 발생할 수 있도록 구현했습니다
코드 품질
LectureService 클래스로 API 로직을 UI에서 분리한 부분이 만족스럽습니다. 싱글톤 패턴을 적용해서 캐싱 시스템까지 통합할 수 있었고, 관심사 분리도 명확하게 할 수 있었습니다.
const http = axios.create({
baseURL: import.meta.env.BASE_URL,
});
export class LectureService {
private static instance: LectureService;
// 프로미스 캐시
private majorsCache: Promise<AxiosResponse<Lecture[]>> | null = null;
private liberalArtsCache: Promise<AxiosResponse<Lecture[]>> | null = null;
private constructor() {}
public static getInstance() {
if (!LectureService.instance) {
LectureService.instance = new LectureService();
}
return LectureService.instance;
}
public async getMajorLectures() {
if (!this.majorsCache) {
this.majorsCache = this.fetchMajorLectures();
}
return this.majorsCache;
}
public async getLiberalArtsLectures() {
if (!this.liberalArtsCache) {
this.liberalArtsCache = this.fetchLiberalArtsLectures();
}
return this.liberalArtsCache;
}
public async getAllLectures() {
const startTime = performance.now();
console.log("API 호출 시작: ", startTime);
// 올바른 Promise.all 사용법으로 수정
const results = await Promise.all([
(console.log("API Call 1", performance.now()), this.getMajorLectures()),
(console.log("API Call 2", performance.now()), this.getLiberalArtsLectures()),
(console.log("API Call 3", performance.now()), this.getMajorLectures()),
(console.log("API Call 4", performance.now()), this.getLiberalArtsLectures()),
(console.log("API Call 5", performance.now()), this.getMajorLectures()),
(console.log("API Call 6", performance.now()), this.getLiberalArtsLectures()),
]);
const endTime = performance.now();
console.log("모든 API 호출 완료: ", endTime);
console.log("API 호출에 걸린 시간(ms): ", endTime - startTime);
return results.flatMap((result) => result.data);
}
public clearCache() {
this.majorsCache = null;
this.liberalArtsCache = null;
}
private fetchMajorLectures() {
return http.get<Lecture[]>("/schedules-majors.json");
}
private fetchLiberalArtsLectures() {
return http.get<Lecture[]>("/schedules-liberal-arts.json");
}
}
컴포넌트 분리 전략에서는 각 검색 옵션을 독립적인 컴포넌트로 만들었습니다. QueryInput, CreditSelect, GradeSelect 등으로 나누니 하나의 옵션만 변경되어도 다른 컴포넌트들은 리렌더링되지 않았습니다.
의외로 key 값도 중요했는데 DnD에서 index를 key로 사용하니 순서가 바뀔 때마다 React가 컴포넌트들의 내용이 변경된 것으로 잘못 인식했습니다. 고유한 id를 key로 사용하도록 바꾸니 불필요한 리렌더링이 크게 줄었습니다.
아쉬운 부분은 Promise.all에서 하나라도 실패하면 전체가 실패하는 구조입니다. Promise.allSettled 같은 대안을 고려해볼 필요가 있습니다.
메모이제이션을 어느 정도까지 해야 하는지도 고민이었습니다. 모든 걸 메모이제이션하면 메모리 사용량이 늘어날 수 있어서, DevTools로 성능을 측정하면서 필요한 부분만 선별적으로 적용했습니다. (그래도 너무 메모이제이션을 남발한거 아닌가? 같은 생각을 하고있습니다..ㅠㅠㅠ)
학습 효과 분석
성능 최적화를 이론이 아니라 실제 애플리케이션에서 해보고 DevTools로 수치까지 확인한 게 가장 큰 학습이 되었던 거 같아요. 그냥 '느리다'에서 시작해서 원인 분석하고 해결책 찾아서 적용하는 전체 과정을 경험할 수 있었습니다.
과제 피드백
실제로 성능 문제를 체감할 수 있는 복잡한 애플리케이션을 제공한 점이 좋았습니다. API부터 렌더링까지 여러 영역을 한번에 다룰 수 있어서 종합적인 학습이 가능했습니다.
리뷰 받고 싶은 내용
이번 과제를 진행하면서 과도한 리렌더링을 줄여나가면서 "과연 어느 정도까지 리렌더링을 허용해줘야 할까?"라는 궁금증이 있습니다. 리렌더링이 발생한다고 해도 성능에 영향이 적을 수도 있고, 반대로 리렌더링을 모두 막으려다가 코드가 과도하게 복잡해지거나 시간이 많이 소모되는 등의 문제가 생길 수 있다고 생각합니다.
또한 메모리 관점에서도 useMemo, useCallback, React.memo 같은 최적화 기법은 내부적으로 값을 캐싱하기 때문에 남발할 경우 메모리 사용량이 늘어날 것이라고 생각이 되는데. 그렇다면 실제 서비스에서 리렌더링을 어느 정도까지 진행해야 하는지, 그 기준을 어떻게 잡는지 궁금합니다.
코치님은 실무에서 메모이제이션을 자주 하시는 편인가요? 만약 메모이제이션을 한다면? 전체적으로 다 메모이제이션하는 것을 선택하시는 편인지, 아니면 최소한의 부분에 대해서만 메모이제이션 하시는 편인지도 궁금합니다.
과제 피드백
찬규님 고생하셨습니다! 이번 과제도 잘 해주셨네요. 마지막임에도 회고도 잘 작성해주셨고, 과제도 훌륭하게 목적에 맞게 잘 구현해주셨습니다 :+1 각각 구현 관점에 대해 나름 생각해주셔서 작성해주신 부분도 좋았습니다.
질문주신 부분에 있어 저는 개인적으로 우리가 회사에서 만나는 성능 최적화는 거의 높은 확률로 말씀해주시는 케이스에 해당이 될 수 밖에 없었던 것 같아요. 코드가 과도하게 복잡해지고 시간이 많이 소모되어 개선했지만 그럼에도 성능을 10-20ms 줄이는게 중요했거든요. 지금은 이런 것들이 과하다(?) 느껴질 수 있지만 그럼에도 이런 부분들을 해결하는 경험들을 해보는게 추후에 저희들이 실제 상황을 만났을 때 큰 도움이 될 것이라 생각해요. 지금은 있는 그대로 많은 것들을 최적화하고 측정해보는게 좋았겠지만 실무에서는 초기 구현에서는 필요한 만큼만 하고, 추후에 문제가 발생했을 때 최적화하는게 필요한것 같아요!
메모이제이션에 대한 관점도 결국 같은 대답인것 같아요 ㅎㅎ 자명한 것들에 대해서는 우선적으로 하지만 그런 케이스들은 생각보다 많이 없고, 문제가 발생할 때 해결하는 편 입니다.
고생하셨고, 앞으로 개발인생 화이팅 하세요! 그럼 내일봬요~ 이번 과제도 잘 해주셨네요. 마지막임에도 회고도 잘 작성해주셨고, 과제도 훌륭하게 목적에 맞게 잘 구현해주셨습니다 :+1 각각 구현 관점에 대해 나름 생각해주셔서 작성해주신 부분도 좋았습니다.