본문 바로가기
항해 플러스

항해 플러스 프론트엔드 10주차 회고(불필요한 연산, 리렌더링 방지하기)

by 이의찬 2025. 9. 12.

이번 주의 과제

이번 주의 과제는 시간표의 성능을 최적화하는 것이였다. 정확히는 다음의 문제점들이 있었는데

  • 수업 검색 모달에서 페이지네이션(무한 스크롤)이 느림
  • 똑같은 API를 계속 호출
  • 드래그/드롭 시 모든 컴포넌트가 리렌더링
  • 시간표가 많아질수록 렌더링이 기하급수적으로 느려짐

과제 진행 과정

1. 캐시를 구현해보기

캐시로 api 호출 최적화하기

const createCachedFetch = () => {
  const cache = new Map<string, Promise<AxiosResponse<Lecture[]>>>();

  const fetchMajors = () => {
    if (!cache.has('majors')) { // 캐시가 없다면 새로 axios 요청 호출 후 저장
      cache.set('majors', axios.get<Lecture[]>('./schedules-majors.json'));
    }
    return cache.get('majors')!; // 동일한 Promise 객체 반환
  };
 //...
// 6번 호출하지만 실제로는 2번의 네트워크 요청만 발생
const fetchAllLectures = async () =>
  await Promise.all([
    fetchMajors(),     // 새로운 요청
    fetchLiberalArts(), // 새로운 요청
    fetchMajors(),     // 캐시에서 반환
    fetchLiberalArts(), // 캐시에서 반환
    fetchMajors(),     // 캐시에서 반환
    fetchLiberalArts()  // 캐시에서 반환
  ]);

2. props에는 가능한 원시값을 넘겨주기

컴포넌트는 랜더링 될 때 마다 내부에서 새로운 선언이 이뤄진다. 근데 리액트는 Object.is()를 사용해서 원시값은 값만 비교하는데, 객체는 참조를 비교한다.

고로 부모에서 매번 리랜더링이 일어나는 상태에서 참조값을 자식 컴포넌트에 props로 넘겨주면? memo해봤자 props가 계속 바뀌니까 자식 컴포넌트도 계속 리랜더링된다!

그렇기에, props에는 가능한 원시값을 넘겨주는 것이 권장된다.

3. 메모이제이션을 적절하게 잘 사용하는 방법

그럼에도 불구하고 참조값을 props로 넘기고 싶다면 두 가지 방법이 있다.

1. 일단 받은 다음 react.memo의 propsAreEqual 함수 활용

react.memo의 propsAreEqual 함수 활용 예시

// LectureTableRow.tsx
const LectureTableRow = React.memo<LectureTableRowProps>(
  ({ lecture, index, onAddSchedule }) => {
    return (
      <Tr key={`${lecture.id}-${index}`}>
 //...
      </Tr>
    );
  },

  // 커스텀 비교 함수: 각 필드를 하나씩 개별적으로 비교, 값이 true면 그대로 유지하고, false일 때에만 리렌더링 진행
  (prevProps, nextProps) => {
    return (
      prevProps.lecture.id === nextProps.lecture.id &&
      prevProps.lecture.grade === nextProps.lecture.grade &&
    //...
    );
  }
);

2. useCallback으로 메모이제이션 한 후 props로 넘겨주기

useCallback의 경우, 컴포넌트가 리랜더링 되는 경우에도 의존값만 바뀌지 않으면 매번 재생성되는 것을 막을 수 있기에 props로 넘겨줘도 안전하다.

메모이제이션 기법 중 나머지 하나인 useMemo의 경우, 비용이 큰 계산을 최적화 할 때 사용된다.
feat: 필터링된 강의 목록과 전공 목록 메모이제이션 참조

Context의 Provider를 사용해서 상태를 격리하기

<ScheduleDndProvider>의 경우 모든 table이 같은 provider 내부에 있어서 하나의 table의 값만 변경되어도 모두 리랜더링이 일어나고 있었다. 이를 provider를 분리해서 상태를 격리하고, 리랜더링이 각 테이블만 일어나도록 수정했다.

이후 Drop 시 전체 리렌더링이 일어나는 ScheduleContext도 비슷한 문제가 있었는데, 이는 아래와 같은 방법으로 해결했다.

가장 기억에 남는 부분

Drop 시 렌더링 최적화

ScheduleContext는 모든 테이블의 스케줄 데이터를 하나의 schedulesMap으로 관리한다. 고로 Drop 시 schedulesMap가 다시 업데이트되고, 이를 구독하는 모든 컴포넌트들에서 리랜더링이 일어나고 있었다.

생각한 해결방안은 다음과 같았다.

  1. Context 분리 - 모두 동일한 Context를 사용하는게 문제였기에, 가장 먼저 떠올린 방법. Context를 분리함으로써 구독하던 상태도 분리하면 문제가 해결된다.
  2. zustand 사용
  3. 메모이제이션만으로 해결
  4. State에 주입 ✅ - 사실 재밌는 방법이라고 생각해서 선택했다. 전역으로 있는 상태를 TableWrapper로 각 Table마다 state를 만들어 준 다음 상태를 주입하고, 그 다음부터는 각 테이블별로 상태를 관리하도록 구현했다.
// ScheduleContext.tsx
interface ScheduleContextType {
  initialSchedulesMap: Record<string, Schedule[]>; // 초기값만 제공
  //...
}
// TableWrapper.tsx - 각 테이블별 독립 상태
const TableWrapper = ({ tableId, index }: TableWrapperProps) => {
  const { initialSchedulesMap, duplicateTable, removeTable } = useScheduleContext();
  
  // 각 테이블마다 독립적인 로컬 state
  const [schedules, setSchedules] = useState<Schedule[]>(
    initialSchedulesMap[tableId] || []
  );

  // 로컬 상태 변경 함수들
  const handleScheduleAdd = useCallback((newSchedules: Schedule[]) => {
    setSchedules(prev => [...prev, ...newSchedules]); // 자신의 state만 변경
  }, []);

  return (
    <ScheduleDndProvider 
      schedules={schedules} 
      onSchedulesChange={setSchedules}
    >
      {/* 테이블 UI */}
    </ScheduleDndProvider>
  );
};

다만 메모리측면에서 같은 상태를 context와 state 양쪽에서 가지고 있어야 한다는 점, 그리고 전역 상태가 아닌 로컬 상태로 만들어서 관리하는 방법인데 전역상태가 필요하다면 골치아파지기에 실제 프로덕트였다면 zustand를 사용했을 것 같다.

느낀 점

가장 아쉬운 점은 지난 회사에서 대용량 데이터로 차트를 그릴 일이 많았는데, 그때는 이런 성능 최적화 기법들을 알고 있지 못했다는 점이다. 그 당시에는 메모이제이션 기법 조차도 명확하게 이해하지 못해서 사용을 꺼려했었다.

이제는 어떤 최적화 기법들이 있고, 왜 사용해야 하는지와 언제 사용해야하는지를 익혔기에 조금씩이나마 사용할 수 있을 것 같다.

as-is / to-be

as-is

  • useCallback, useMemo, React.memo 등의 메모이제이션 기법을 정확히 알고 사용하지 못함
  • 컴포넌트를 적절하게 나누는 것이 성능 최적화에도 영향을 미치는 것을 알지 못함

to-be

  • 메모이제이션 기법을 언제, 왜 사용해야하는지 이해함
  • 성능 최적화를 위해 컴포넌트를 적절하게 나눌 수 있음