과제 체크포인트
배포
https://hwirin-kim.github.io/front_6th_chapter4-2/
과제 요구사항
-
배포 후 url 제출
-
API 호출 최적화(
Promise.all이해) -
SearchDialog 불필요한 연산 최적화
-
SearchDialog 불필요한 리렌더링 최적화
-
시간표 블록 드래그시 렌더링 최적화
-
시간표 블록 드롭시 렌더링 최적화
과제 셀프회고
이번 과제는 성능 최적화에 관한 부분으로 현업에서 일하며 흔하게 접할만한 상황들이 주어졌다. 특히 과제를 진행하며 어떻게 해야 좀 더 리렌더링을 유발하지 않는 습관이 생기는지 알듯한 느낌이였다.
이번 과제를 진행하며 사용한 방법은 콘솔로그활용 및 React Dev Tools의 하이라이팅을 주로 사용하였다. 하이라이팅을 쓰며 리렌더링 되는 부분을 시각적으로 확인하고, 좀 애매한 부분은 콘솔로 해당 컴포넌트가 정말 리렌더링 되는지 체크하는 방식을 사용했다.
첫 번째 api 호출 최적화
여기에는 크게 두 가지 문제가 있었다. 첫 번째는 Promise.all 안에서 promise를 넘기지 않고 await를 하여 대기하는 문제. 두 번째는 동일 api의 중복 호출이였다. (물론 일부러 그렇게 코드가 짜여져있었지만..)
첫 번째 케이스는 Promise.all내부에 await를 삭제함으로써 쉽게 해결했다. 두 번째 케이스는 호출을 캐싱하는 방법을 선택했다.
import axios, { AxiosResponse } from "axios";
import { baseUrl } from "../baseUrl";
type CacheEntry = {
promise: Promise<AxiosResponse<unknown>>;
time: number;
};
const cache = new Map<string, CacheEntry>();
const TTL = 5 * 60 * 1000; //5분
export const fetchWithCache = async <T>(
url: string,
options?: { force?: boolean }
): Promise<AxiosResponse<T>> => {
const now = Date.now();
const cached = cache.get(url);
// force 요청이 아니고, 캐시가 존재하며 TTL 이내라면 캐시 반환
if (!options?.force && cached && now - cached.time < TTL) {
console.log("캐시 히트 : ", url);
return cached.promise as Promise<AxiosResponse<T>>;
}
// 그 외 새 요청 실행
console.log("API 호출 : ", url);
const request = axios.get<T>(baseUrl + url);
// 새 요청 캐시 저장
cache.set(url, { promise: request, time: now });
// 새 요청 반환
return request;
};
위 코드와 같이, 어떤 url로 api 요청을 받으면 해당 요청이 캐시에 존재하는지 먼저 확인하고, 없다면 새 요청을 실행 및 url을 키로 하여 캐시에 저장하는 방식을 취했다.
그때 드는 생각이 "그렇다면 이 캐시는 언제까지 유효하지..?" 라는 생각이 들었다. 그래서 TTL값을 설정하여 해당 시간이 지나면 다시 같은 url의 새로운 요청을 받도록 하였다.
또한 만약 강제로 요청을 보내고 싶은 경우가 있을 수 있으므로 force option을 추가하여 force인 경우 무조건 새 요청을 실행하도록 했다.
// 실 사용 예시
const fetchMajors = () => fetchWithCache<Lecture[]>("/schedules-majors.json");
const fetchLiberalArts = () =>
fetchWithCache<Lecture[]>("/schedules-liberal-arts.json");
const fetchAllLectures = async () =>
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()),
]);
위 코드와 같이 사용하면 이제 캐싱이 잘 된다.
SearchDialog 최적화
이곳은 여러 컴포넌트가 한 곳에서 작성되어있고, 또한 입력폼의 경우 하나의 상태를 조작하여 여러 컴포넌트에 데이터를 뿌려주고있었다. 그래서 일단 시도한 방법은 다음과 같다.
- 컴포넌트를 분리하고 memo로 감싸기
- 분리한 컴포넌트에 searchOptions 객체를 다 보내지 말고 실제 필요한 값만 보내기
- changeSearchOption함수는 모든 입력 컴포넌트에서 필요한데 리렌더링되면서 다시 정의되지 않도록 useCallback사용하기 이 정도를 통해 한 입력컴포넌트를 동작해도 다른 입력컴포넌트가 리렌더링되는 문제를 막았다.
또한 이 다이얼로그에는 무한스크롤 자체에 버그가 존재했다. 바로 useRef에 element를 넣어서 해당 element의 위치에 도달하면 감지되도록 intersectionObserver를 사용하는 것인데, 모달이 열리면서 useRef가 초기화 될때 jsx는 없으므로 null이 들어간다. 그래서 감지할 element가 없으니 제대로 동작하지 않는 것이다. 이 때, 아무 입력컴포넌트를 건드려 리렌더링을 유발하면 이때부터는 element가 useRef에 할당되어 무한스크롤이 잘 동작한다.
그래서 나는 이 문제를 해결하기위해 다음과 같은 방법을 사용했다.
// 무한스크롤 관련 참조
const loaderWrapperRef = useRef<HTMLDivElement>(null);
const loaderRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const setLoaderWrapperRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node) return; // unmount 시 null 들어옴
const $loader = loaderRef.current;
if (!$loader) return;
// 기존 옵저버 제거
observerRef.current?.unobserve($loader);
// 새 옵저버 등록
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setPage((prev) => Math.min(lastPage, prev + 1));
}
},
{ root: node }
);
observer.observe($loader);
observerRef.current = observer;
},
[lastPage]
);
return (
<Box overflowY="auto" maxH="500px" ref={setLoaderWrapperRef}>
<Table size="sm" variant="striped">
<Tbody>
{visibleLectures.map((lecture, index) => (
<LectureRow
key={`${lecture.id}-${index}`}
lecture={lecture}
addSchedule={addSchedule}
/>
))}
</Tbody>
</Table>
<Box ref={loaderRef} h="20px" />
</Box>
)
Wrapper Box에 Ref를 바로 넘기지 않고 setLoaderWrapperRef라는 콜백함수를 넣어 jsx가 읽혀질때 함수가 실행되어 element가 할당되도록 만든 것이다. 위 방식은 현업에서 자주 사용했던 방법이라서 여기에서 곧바로 적용해볼 수 있었다.
마지막은 무한스크롤 되는 아이템들의 리렌더링문제이다. 해결 방법은 Tbody내부에서 새로운 컴포넌트로 빼낸 후 메모이제이션 되도록 하였다.
시간표 드래그 앤 드롭
시간표 드래그 앤 드롭 시 상당히 많은 리렌더링을 유발하고있었다. 원인은 다양하게 존재했지만 가장 큰 문제는 dndContext의 값 변화가 전체 시간표들을 리렌더링 시키는 것이였다. 일단 가장 쉬운 방법으로 프로바이더가 공유되어있으므로 각 시간표별로 프로바이더를 따로 보내줬다. 이렇게 한 이유는, 프로바이더를 공유해서 사용하면 activeId값을 계산하게 되면서 모든 컴포넌트가 한 번은 같이 리렌더링 되는 문제가 존재한다 라고 생각했기 때문이다. 또한 우리 프로젝트에서 시간표끼리 드래그를 넘겨줄것도 아니기 때문에 하나의 프로바이더가 아니여도 괜찮다고 판단했다.
이제 그다음 문제를 해결하기 위해 일단 컴포넌트들을 작게 분리하고 memo로 감쌌다. 그렇게 감싸다보니 결국 시간표가 업데이트되면 같은 컨텍스트를 공유하는 모든 곳이 리렌더링이 된다는 것도 발견하게 되었다.
그래서 이전에 배웠던 useSyncExternalStore를 활용해 외부 스토어를 하나 만든 뒤 마치 redux의 selector와 유사하게 특정 조각만 구독할 수 있고, 그리고 상태 업데이트 또한 키값을 통해 각 시간표마다 따로 진행되도록 만들었다.
// 스토어
import dummyScheduleMap from "../dummyScheduleMap";
import { Schedule } from "../types";
type ScheduleMap = Record<string, Schedule[]>;
// emit할 때 어떤 테이블이 바뀌었는지 알려주기
type Listener = (changedId: string) => void;
/**
* ScheduleStore
* 시간표 데이터를 관리하는 스토어
*/
class ScheduleStore {
private schedulesMap: ScheduleMap = dummyScheduleMap;
private listeners = new Set<Listener>();
/**
* subscribe
* 시간표 데이터 변경 시 호출되는 리스너를 등록
* @param listener 리스너
* @returns 해제 함수
*/
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener); // unsubscribe
};
/**
* getScheduleMap
* 시간표 데이터를 반환
* @returns 시간표 데이터
*/
getScheduleMap = () => this.schedulesMap;
/**
* setScheduleMap
* 시간표 데이터를 업데이트
* @param next 업데이트 함수
*/
setScheduleMap = (
next: ScheduleMap | ((prev: ScheduleMap) => ScheduleMap) // ✅ 두 가지 다 허용
) => {
if (typeof next === "function") {
this.schedulesMap = (next as (prev: ScheduleMap) => ScheduleMap)(
this.schedulesMap
);
} else {
this.schedulesMap = next;
}
this.emit("*");
};
/**
* setTable
* 특정 tableId만 업데이트
*/
setTable = (
tableId: string,
next: Schedule[] | ((prev: Schedule[]) => Schedule[])
) => {
const prevTable = this.schedulesMap[tableId] ?? [];
const newTable = typeof next === "function" ? next(prevTable) : next;
// 전체 객체 새로 만들지 말고, 해당 테이블만 교체
this.schedulesMap[tableId] = newTable;
this.emit(tableId);
};
/**
* deleteTable
* 특정 tableId만 삭제
*/
deleteTable = (tableId: string) => {
const copy = { ...this.schedulesMap };
delete copy[tableId];
this.schedulesMap = copy;
this.emit(tableId);
};
/**
* duplicateTable
* 특정 tableId만 복제
*/
duplicateTable = (tableId: string) => {
const copy = { ...this.schedulesMap };
copy[`${tableId}-${Date.now()}`] = [...copy[tableId]];
this.schedulesMap = copy;
this.emit(tableId);
};
/**
* deleteScheduleItem
*/
deleteScheduleItem = (tableId: string, data: Schedule) => {
this.schedulesMap[tableId] = this.schedulesMap[tableId].filter(
(schedule) =>
schedule.day !== data.day || !schedule.range.includes(data.range[0])
);
this.emit(tableId);
};
/**
* emit
* 시간표 데이터 변경 시 호출되는 리스너 실행
*/
private emit = (changedId: string) => {
this.listeners.forEach((listener) => listener(changedId));
};
}
// 싱글톤 인스턴스 생성
export const scheduleStore = new ScheduleStore();
위 처럼 하나의 스토어를 만들었고, 그 안에 필요한 메서드들을 만들었다.
import { useSyncExternalStore } from "react";
import { Schedule } from "../types";
import { scheduleStore } from "./schedule.store";
import dummyScheduleMap from "../dummyScheduleMap";
const EMPTY: Schedule[] = [];
// 특정 tableId에 해당하는 schedule만 구독
export function useSchedule(tableId: string): Schedule[] {
return useSyncExternalStore(
(onStoreChange) =>
scheduleStore.subscribe((changedId) => {
if (changedId === "*" || changedId === tableId) {
onStoreChange();
}
}),
() => scheduleStore.getScheduleMap()[tableId] ?? EMPTY
);
}
type ScheduleMap = Record<string, Schedule[]>;
// 시간표 데이터 전체를 구독
export function useSchedulesMap(): ScheduleMap {
return useSyncExternalStore(
scheduleStore.subscribe,
scheduleStore.getScheduleMap,
() => dummyScheduleMap
);
}
이제 위 훅을 사용하여 구독할 데이터를 선택해서 사용할 수 있게 되었다.
기술적 성장
리렌더링은 크게 부모컴포넌트의 리렌더링, 상태의 변화, 프롭스의 변화에 의해 발생한다. 따라서 부모컴포넌트의 리렌더링으로부터 자식이 리렌더링 되지 않으려면 자식 컴포넌트를 메모이제이션 해둬야한다. 하지만 자식컴포넌트에 넘겨지는 props에 객체/배열/함수를 새로 만들어서 넘겨주는 형태가 된다면 자식컴포넌트는 여전히 리렌더링 된다. 따라서 최소한 필요한 값 자체만 넘겨지도록 하고, 함수의 경우엔 useCallback을 통해 메모이제이션하여 새로 만들지 않고 기존 함수로 넘길 수 있도록 하는게 중요하다.
그래서 평소 습관 처럼 다음과 같은 생각을 하면서 코드를 짜면 좋다고 생각이 들었다.
- props최소화
- 자식 컴포넌트에 진짜 필요한 것만 넘기기
- 객체를 통째로 넘기지 말고 자식이 쓰는 값만 뽑아서 전달하기
- props로 넘기는 함수는 useCallback쓰기
- 큰 컴포넌트는 분리하고, 자주 바뀌지 않는 컴포넌트들은 memo로 감싸기
위 세 가지만 생각하면서 만들어도 리렌더링이 효과적으로 줄 것이라고 생각이 들었다. 이번 과제 내내 위 3가지를 생각하며 최적화를 진행했기 때문에 더욱 와닿았다.
코드 품질
이번 코드에서 마음에 들었던 부분은 스토어관련 부분이다. 이전에 공부했던 내용을 다시 사용했다는 부분에서 만족감이 컸다.
물론 만들면서 AI의 도움을 받다보니 내가 원하는 메서드를 안만들어준다거나 하여 필요없는데 포함되어있는 부분들도 존재한다. 이제 그런 부분들을 제거하고, 더 깔끔하고 실용적인 방법이 있는지 고민해봐야겠다.
학습 효과 분석
처음과 비교하여 눈에 띄게 리렌더링을 최소화 시키면서 어떤 코드들이 리렌더링을 많이 유발시키는지 이해하게 되었다. 하지만 남아있는 고민은 "과연 어디까지 리렌더링을 막는데 시간을 쏟아야 하나?" 라는 생각이 들었다. 지금 상태보다 더 줄이려면 줄일수도 있을것 같다는 생각이 든다. 아직 완벽하지 않기 때문이다. 하지만 성능최적화가 되었다 라는 기준을 어디에 잡고 진행해야 하는지는 아직 감이 없는것 같다.
과제 피드백
현업에서 업무를 보며 성능최적화에 실패했던 프로젝트가 있었다. 당시 과도한 리렌더링으로 화면이 느려지면서 버그가 생기곤 했는데, 그때 당시 결국 성능 최적화에 실패하고 다른 방식으로 기능을 만들었다. 당시엔 완전 초보자 시절이라 지금처럼 React dev tools도 몰랐고, 렌더링을 줄이기 위해 뭐부터 시작해야할지 감도 없었다. 하지만 지금 이 지식을 가지고 다시 도전한다면 왠지 성공해낼 수 있을것 같다는 생각이 든다.
리뷰 받고 싶은 내용
리뷰 받고 싶은 내용보다 질문이 있습니다. 이번 과제를 진행하면서 과도한 리렌더링을 줄여나가면서 "과연 어느정도까지 리렌더링을 허용해줘야할까?" 라는 의문이 남았습니다. 리렌더링이 발생한다고 해도 성능에 영향이 적을수도 있고, 반대로 리렌더링을 모두 막으려다가 코드가 과도하게 복잡해지거나 시간이 많이 소모되는 등의 문제가 생길 수 있다고 생각합니다. 또한 메모리 관점에서도 useMemo, useCallback, React.memo 와 같은 최적화기법은 내부적으로 값을 캐싱하기 때문에 남발할 경우 메모리 사용량이 늘어날 것이라고 예측됩니다. 그렇다면 실제 서비스에서 리렌더링을 어느정도 까지 진행해야하는지 기준을 어떻게 잡는지 궁금합니다. (그 기준에 객관적인 지표가 있는것인지 궁금합니다.)
과제 피드백
안녕하세요 휘린님! 마지막 과제 너무 잘 진행해주셨네요 ㅎㅎ 그동안 너무 고생하셨습니다!!
리뷰 받고 싶은 내용보다 질문이 있습니다. 이번 과제를 진행하면서 과도한 리렌더링을 줄여나가면서 "과연 어느정도까지 리렌더링을 허용해줘야할까?" 라는 의문이 남았습니다. 리렌더링이 발생한다고 해도 성능에 영향이 적을수도 있고, 반대로 리렌더링을 모두 막으려다가 코드가 과도하게 복잡해지거나 시간이 많이 소모되는 등의 문제가 생길 수 있다고 생각합니다. 또한 메모리 관점에서도 useMemo, useCallback, React.memo 와 같은 최적화기법은 내부적으로 값을 캐싱하기 때문에 남발할 경우 메모리 사용량이 늘어날 것이라고 예측됩니다. 그렇다면 실제 서비스에서 리렌더링을 어느정도 까지 진행해야하는지 기준을 어떻게 잡는지 궁금합니다. (그 기준에 객관적인 지표가 있는것인지 궁금합니다.)
별도의 객관적인 지표가 있다기보단... 저의 경우 최적화가 꼭 필요한 시점은 "사용자가 불편함을 느낄 때" 라고 생각해요. 그 이전까지는 굳이? 라는 생각입니다 ㅎㅎ
해보면 알겠지만, 원리만 제대로 알고 있으면 최적화를 하는게 어렵진 않아요. 그래서 일단 기능을 빠르게 구현하고, 시간적 여유가 있을 때 혹은 정말 필요하다고 느끼는 시점에 진행하면 된다고 생각합니다.
제가 주로 최적화를 했던 시점은 위에서도 언급했지만, 사용자 문의가 들어왔을때였어요! 그렇게만 해도 충분했었답니다..!
다만 기술부채가 너무 쌓이면 나중에 해결하기가 어려워서.. 주기적으로 점검하면좋아요 ㅋㅋ
현재 프로젝트의 문제를 미리미리 파악하고, 이 문제를 해결하는데 어느 정도의 시간이 소요되는지 측정해보는거죠.