과제 체크포인트
과제 요구사항
-
배포 후 url 제출 : https://j2h30728.github.io/front_6th_chapter4-2/
-
API 호출 최적화(
Promise.all이해) -
SearchDialog 불필요한 연산 최적화
-
SearchDialog 불필요한 리렌더링 최적화
-
시간표 블록 드래그시 렌더링 최적화
-
시간표 블록 드롭시 렌더링 최적화
과제 셀프회고
API 호출 최적화
1. Promise.all
문제 점
- 과제에서는 Promise.all 내부 배열에서 await을 검으로써, 배열을 순회할 때 해당 Promise가 완료될 떄 까지 기다리게 되었습니다.
- 동일한 API 중복 호출을 2~3번 연속해서 요청하고있다.
해결 방법
- Promise.all 내부에서 병렬적으로 순회돌게끔 await을 제거
- 동일한 API 중복호출을 방지하고자, Promise 객체를 변수에 담고, 그 변수를 Promise.all 배열에서 resolve 및 reject를 관리하게끔 함
const fetchAllLectures = async () => {
const majorsPromise = fetchMajors();
const liberalArtsPromise = fetchLiberalArts();
return Promise.all([majorsPromise, liberalArtsPromise, majorsPromise, liberalArtsPromise, majorsPromise, liberalArtsPromise]);
};
2. 캐싱
- 이미 호출한 API는 다시 호출 하지 않게 캐싱
- Map을 만들어 캐시키 값에 매칭되는 페칭함수 결과인 Promise을 저장
- 페처함수를 확창하여 생성할까 고민했지만, 빠른 과제 진행을 위하여 역할 단위로 관심사분리로 cache 맵을 생성했습니다.
const cache = new Map<string, Promise<unknown>>();
export const memoizedFetch = <T>(key: string, fn: () => Promise<T>) => {
if (cache.has(key)) {
return cache.get(key) as Promise<T>;
}
const promise = fn();
cache.set(key, promise);
return promise;
};
const fetchMajors = () =>
axios.get<Lecture[]>(`${BASE_URL}/schedules-majors.json`);
const fetchLiberalArts = () =>
axios.get<Lecture[]>(`${BASE_URL}/schedules-liberal-arts.json`);
const fetchAllLectures = async () =>
await Promise.all([
memoizedFetch("majors", fetchMajors),
memoizedFetch("liberalArts", fetchLiberalArts),
memoizedFetch("majors", fetchMajors),
memoizedFetch("liberalArts", fetchLiberalArts),
memoizedFetch("majors", fetchMajors),
memoizedFetch("liberalArts", fetchLiberalArts),
]);
첫 호출시와 두 번째 호출 사이의 시간이 줄어듬을 확인할 수 있습니다.
불필요한 연산 방지
- 하나의 배열에서 연속적으로 filter 메서드를 6번 순회하는 것이 시간복잡도를 고려하여, 이 계산이 불필요하다 판단되어 최적화를 진행했습니다.
- filter 메서드를 한 번 순회하되, 처음부터 모든 케이스에 대한 조건을 확인하여 언하는 배열을 생성하게끔 수정했습니다.
const filteredLectures = useMemo(() => {
const { query = "", credits, grades, days, times, majors } = searchOptions;
return lectures.filter((lecture) => {
if (query) {
const lowerQuery = query.toLowerCase();
if (
!(
lecture.title.toLowerCase().includes(lowerQuery) ||
lecture.id.toLowerCase().includes(lowerQuery)
)
)
return false;
}
if (grades.length > 0 && !grades.includes(lecture.grade)) {
return false;
}
if (majors.length > 0 && !majors.includes(lecture.major)) {
return false;
}
if (credits && !lecture.credits.startsWith(String(credits))) {
return false;
}
if (days.length > 0 || times.length > 0) {
const schedules = lecture.schedule
? parseSchedule(lecture.schedule)
: [];
if (
days.length > 0 &&
!schedules.some((schedule) => days.includes(schedule.day))
)
return false;
if (
times.length > 0 &&
!schedules.some((schedule) =>
schedule.range.some((time) => times.includes(time))
)
)
return false;
}
return true;
});
}, [lectures, searchOptions]);
이번 과제로 프로파일러를 사용하고 어떤것이 다른지 확인하게 되었는데, 필터 로직을 최적화하고 나서는 어떤 부분을 봐야할지 잘 모르겠더라구요. 그래서 최적화 전후의 프로파일러를 찍어서 확인 해보니 아래의 부분에서 시간이 줄어드는 것을 확인했습니다.
(이걸 측정하는 것이 맞는가는 아직도 잘 모르겠습니다 ㅋㅋ)
| 필터 로직 최적화 전 | 필터 로직 최적화 후 |
|---|---|
| render : 377.5ms | 263ms |
불필요한 렌더링 방지
이 부분은 발제코치인 준일 코치님이 QnA에서 잘 설명해주셔서 쉽게 진행했습니다!
그래도 과제를 하면서 직접 프로파일러로 메모이제이션이 유효한가? 에 대해서 확인했습니다.
많은 수의 강의 목록의 아이템들을 메모이제이션을 진행했기 때문에, 스크롤을 내려 다음페이지를 렌더링할때는 이전 페이지 요소들은 리렌더링이 되지않습니다. 해당 목록의 아이템 displayName을 SearchItem으로 설정했습니다.
SearchItem : Did not client render
Drop을 했을 때 렌더링 최적화
이 부분이 생각보다 오래걸려서 놀랬습니다. ㅋㅋ
그래도 전역상태라이브러리를 쓰지않고싶다는 생각하면서 진행했어요. 사실 문제를 해결하기위해서 어떤 도구를 쓰던 상관없지만, 학습하는 차원에서 이유를 분석하고 리액트의 네이티브 기능으로 해결하고 싶었습니다.
제가 생각하는 useCallback, useMemo, memo를 해도 drop 에서의 리렌더링최적화가 잘 안되더라구요.
과제 요구사항을 잘 읽었더라면 시간을 좀 더 아낄 수 있지않았을까 반성과 회고를 합니다. ㅠㅠ
모든 구간이 schedulesMap을 의존하고 있습니다. 그래서 schedulesMap이 업데이트 되면 모든 컴포넌트가 업데이트 되는 형태입니다. (실제로는 한 개의 Schedule만 업데이트 되고 있지만, 모든 Schedule Data가 업데이트 된것으로 인지하게 됩니다.) 전역상태를 업데이트하거나 가져오는 방식을 개선하고 메모이제이션을 적절하게 사용할 경우, Drop을 했을 때에 불필요한 렌더링을 방지할 수 있습니다.
코드 분석 내용
- 모든 구간이 schedulesMap 의존 : Object.entries(schedulesMap) 로 전체 테이블 렌더링
- schedulesMap 를 수정할 때 데이터 전체를 업데이트 하는 로직
- contextAPI + useState조합으로 state와 action가 혼재해서 사용됨
위와 같이 분석했었습니다.
contextAPI + useState조합으로 state와 setter가 혼재해서 사용됨
3번의 해결법이 제일 간단하기 때문에 ScheduleProvider 에서 state와 action를 분리하는하여 두개의 context프로바이더를 생성했습니다.
export const ScheduleProvider = ({ children }: PropsWithChildren) => {
const [schedulesMap, setSchedulesMap] =
useState<Record<string, Schedule[]>>(dummyScheduleMap);
const actionValue = { setSchedulesMap };
const stateValue = useMemo(
() => ({ schedulesMap, tableCount: Object.keys(schedulesMap).length }),
[schedulesMap]
);
return (
<ScheduleActionContext.Provider value={actionValue}> // action provider
<ScheduleStateContext.Provider value={stateValue}> // state provider
{children}
</ScheduleStateContext.Provider>
</ScheduleActionContext.Provider>
);
};
schedulesMap 를 수정할 때 데이터 전체를 업데이트 하는 로직
contextAPI에서 state와 action을 분리한다해도 리렌더링은 해결되지 않았습니다. 그이유는 action이 일어날 때, schedulesMap 전체가 변경되기 때문에 memo를 해두어도 각 필드의 데이터값은 다른 참조값을 가지고 있기 때문입니다.
최대한 현재 코드를 유지하면서 수정작업을 하기위해, update하는 로직에서 변경되는 필드만 변경하게끔 설정했습니다.
업데이트 할 경우에 파라메터로 들어오는 값과 이전의 값을 비교해서 동일할 경우에는 이전 상태값 (prev)을 반환하는 로직을 만들었습니다.
const updateTableSchedules = useCallback(
(tableId: string, updater: (prev: Schedule[]) => Schedule[]) => {
setSchedulesMap((prev) => {
const currentSchedules = prev[tableId] || [];
const newSchedules = updater(currentSchedules); // 어떻게 업데이트할 지는 외부에서 주입해 줌
// 참조 값 자체가 동일할 때
if (currentSchedules === newSchedules) {
return prev;
}
// 내부의 값이 동일할 때
if (
currentSchedules.length === newSchedules.length &&
currentSchedules.every((schedule, i) => schedule === newSchedules[i])
) {
return prev;
}
// 해당 tableId 필드값이 변경되었으면 전체 업데이트
return { ...prev, [tableId]: newSchedules };
});
},
[]
);
기술적 성장
프로파일러
프로파일러를 직접 녹화하고 측정된 값을 비교 분석해본 것은 처음이었습니다.
처음이었기에 어떤걸 봐야할지 잘 몰랐기도 하고 사실 검색도 많이 안하고 징징댔습니다.ㅋㅋㅋ
지금 보니 학습의 자세가 잘 안되어있네요. 시정하겠습니다ㅋㅋㅋㅋㅋ
친구에게 물어보기도 하고 준일코치님이 실습을 많이 진행해주셔서 프로파일러 사용법과 다양한 케이스를 학습하게 되어서 좋았습니다.
메모이제이션
프로파일러도 모르고 메모이제이션을 안하고 살고있었는데 발제와 과제덕분에 재밌는 경험했습니다. memo, useCallback과 useMemo에 대한 개념은 알지만 실제로 유효하게 사용하고 있지는 않았어요.
취업을 한다면 메모이제이션을 효용성있게 쓰는 곳에 가보고싶다고 했었기도하구요. 그런데 그냥 그런 쪽으로 취업을 했었더라도 이 프로세스를 거치지않았으면 몰랐을 것 같습니다.
- 컴포넌트는 함수이기 때문에 리렌더링이 일어날 경우, 해당 컴포넌트 코드는 재실행된다. 그렇기 때문에 참조값은 리렌더링될 때 다른 주소값을 가진다.
- 프롭에 내려줄 때에는 useMemo, useCallback으로 메모이제이션하여 내려준다.
- 만약, 리렌더링 촉발기준으로 컴포넌트 내부에서 처리가 가능하다면 메모이제이션 함수를 쓰는것보다 코드위치를 바꾸어 무분별한 메모이제이션을 줄인다.
알았던 내용이지만, 프로파일러를 통해 확인했던 경험이 큰것 같스빈다.
최적화 지점
그리고 저는 준일코치님이 배포된 페이지가 정말 도움이 돠었었어요. 시작이나 마무리에서도요.
코치님이 어떤 부분이 힌트가 되냐는 질문을 했었는데 나의 무지함이 조금 엿보여서 혼자서 막 이상한 소리를 했습니다.
사실....'힌트라고 해서 만든 배포페이지가 아닌데 힌트라고 하는거시냐' 라는 것인가라고 혼자 판단( 또 판단!! 또!또!)했습니다. 그러다보니 혼자 부끄러워서 더 그랬던 것 같아요. 준일코치님이 이 글을 보시지는 않겠지만 죄송합니다. 죄송할...일은 아닌가? 아무튼 제 마음은 그래요.
이번 과제를 하면서 제일 고민했던 건 어떤 부분에 최적화를 해야하는 가? 였어요. 추가기능? 그런거 생각도 못했어요. (사실 ,생각은 했는데 못했어요. 다시하기 스터디 때 해보려구요! 저번에 rxjs 말씀해주신건 잊지않고 있습니다.)
그래서 배포된 페이지를 엄청 뜯어보기도하고, 제가 작업한뒤에도 그것을 비교 많이 했었습니다.
아참, 지금 분리된 컴포넌트에서 FormInput과 FormSelect 컴포넌트는 메모이제이션이 과한 것이라 판단하고있습니다. 수정해야지 수정해야지하면서 출근해버렸네요. 언제 고치지? 히히
코드 품질
위 회고글 포함!
학습 효과 분석
가장 큰 배움이 있었던 부분
- 어느 곳에 메모이제이션을 하여 최적화 할까? 라는 고민에 답변을 좀 더 합리적으로 도출해낼 수 있는 힘을 기를 수 있었어요.
- 최적화라고 하면 부담감이 좀 느껴졌는데 , 그런 부담감과 거부감이 사라져서 좋았습니다.
추가 학습이 필요한 영역
- 가상스크롤은 해본적이 있어서 사전에 말씀해주신 RxJs를 써보고 싶었는데, 일정분배를 잘 못해서 일정내에 진행하질 못했네요.
- 다음 주 부터 진행할 다시하기 스터디에서 RxJs를 적용해보는게 목표입니다! 호호
실무 적용 가능성
- 입사한 후에 많은 코드를 작성하면서, 기능구현에만 집중했기 때문에 최적화를 해야겠다는 생각은 별로 안했던 것 같습니다.
- 이제는 기능 구현과 함께 최적화를 고려할 수 있는 개발자가 된 것 같아요! 저번에 QnA시간에 코치님께 최적화 습관화에 대해서 질문드렸었는데요. 이 한 주를 보내면서 습과화 시키지는 못했어도. 고민하고 생각할 수 있게 된것 같습니다. 이를 계속 의식적으로 진행하여 체득하고 습과화로 만들겠습니다~
과제 피드백
아쉬운 점이 있었다해도 발제 코치님이 다 커버 해주셨어요 ㅋㅋㅋ 정말 감사합니다.
리뷰 받고 싶은 내용
schedulesMap 최적화하는 방법은 되게 많다고 생각합니다. 코치님은 schedulesMap 최적화를 진행한다고 하면 어떤 방식으로 진행하실 것 같나요? 사실 제 방식은 모든 필드의 값들을 조회하고 있기 때문에 정말 좋은가? 라고 생각하면, 잘 모르겠습니다. 어쩌면 외부라이브러리를 설치해서 최적화가 잘되어있고 검증된 로직을 가져다 쓴다면 더 좋은 최적화가 되었을 수도 있겠죠.
이런 생각이 들면서, 경험이 많고 최적화 작업을 많이 해봤을 코치님은 이러한 작업에서 어떤 방법을 채택할까 궁금했습니다.
과제 피드백
수고했어요 지현!! 이번 과제는 React 애플리케이션에서 실제 성능 병목 지점을 찾고 최적화하는 것이 목표였습니다.
API 호출 최적화에서 memoizedFetch 함수로 캐싱 시스템을 직접 구현한 부분 잘했어요. Promise 객체를 Map에 저장해서 중복 호출을 방지하는 방식 좋았습니다. 다시 한번 TanstackQuery를 떠올려보면 QueryKey가 이런식으로 활용되어 최적화를 제공하고 있다는걸 생각해볼 수 있겠네요.
"그래도 전역상태라이브러리를 쓰지않고싶다는 생각하면서 진행했어요. ... 사실 문제를 해결하기위해서 어떤 도구를 쓰던 상관없지만, 학습하는 차원에서 이유를 분석하고 리액트의 네이티브 기능으로 해결하고 싶었습니다." 라고 생각했군요. ㅎㅎ 쉽지 않았을텐데 잘 했습니다. 전역상태관리의 경우도 업데이트가 되지 않으면 기존의 캐싱값을 반환하는 식으로 만들곤 하죠.
"어떤 부분에 최적화를 해야하는가?"라는 고민을 많이 했다고 하셨는데, 맞아요! 이게 핵심입니다. 성능 최적화라는 것에 목매여 억지로 했다가는 나중에 코드 수정이 어렵고 그냥 작성하게 되면 성능이 문제가 생기는 병목을 맞이하게 되죠. 이렇게 짜면 성능 문제가 생기는 구나 등을 알고 나면 처음부터 그렇지 않도록 혹은 성능최적화를 하기에 유리하도록 리렌더링이 중복되지 않게 컴포넌트들을 분리하는게 중요하다는 것을 느껴볼 수 있는 시간이 되었기를 바래요.
Q) schedulesMap 최적화하는 방법은 되게 많다고 생각합니다. 코치님은 schedulesMap 최적화를 진행한다고 하면 어떤 방식으로 진행하실 것 같나요?
=> 이렇게 객체와 배열을 가지는 경우 세부내용이 바뀌었는데 배열을 다시 만들어내면서 전체 리렌더링이 발생하거나 혹은 계산 로직이 되는 것이 주로 문제가 되죠. 저는 컴포넌트에서 리렌더링의 의존성을 분리하는 방법을 선호합니다. 데이터 컴포넌트는 도메인 props만 가지고 React.memo를 합니다. 값이 변경이 생기지 않으면 리렌더링이 되지 않겠죠.
const ScheduleItem = memo(({ schedule }) => { // 내용이 바뀌어야먄 리렌더링 })
=> 아래처럼 객체를 세부내용을 수정하기만 하는데 배열에서 하는 방식을 쓰게 되면 세부내용 수정이 배열에 영향을 미치는 구조가 되는데 이를 분리하는 정규화라는 방식을 사용합니다.
// 문제 있는 방식 - 배열에서 객체 수정 const updateSchedule = (scheduleId, newTitle) => { setSchedules(prev => prev.map(schedule => schedule.id === scheduleId ? { ...schedule, title: newTitle } // 배열 순회하며 객체 수정 : schedule )) // 전체 배열이 새로 생성됨 }
=> 그래서 객체 수정과 배열을 다음과 같이 분리를 하면 불필요한 계산과 리렌더링을 최소화 할 수 있습니다.
// 개선된 방식 - 객체는 ObjectTable로, 배열 순서는 ID만으로.. const [schedules, setSchedules] = useState({}) const [sortedIds, setSortedIds] = useState([])
const updateScheduleContent = (id, updates) => { setSchedules(prev => ({ ...prev, [id]: { ...prev[id], ...updates } })) // 배열은 건드리지 않음 }
const updateScheduleOrder = (newOrder) => {
setSortedIds(newOrder)
// 객체 내용은 건드리지 않음
}
const addSchedule = (schedule) => { // 1. 객체 추가 setSchedules(prev => ({ ...prev, [schedule.id]: schedule })) // 2. 배열에 ID만 추가하고 재정렬 setSortedIds(prev => [...prev, schedule.id].sort(byDateComparator)) }
작업량이 많아져서 자주 하는 방식은 아니지만 성능향상이 필요하다면 정규화라는 방식은 배워둘만하다 생각해요.
지난 10주만 너무 너무 수고 많았어요. 지금까지의 노력과 경험들이 앞으로 성장하는데 있어 큰 도움이 되고 추억이 되는 시간이 되기를 바래요. 화이팅입니다!