hyunzsu 님의 상세페이지[8팀 현지수] Chapter 4-2 코드 관점의 성능 최적화

과제 체크포인트

배포 주소: https://hyunzsu.github.io/front_6th_chapter4-2/

과제 요구사항

  • 배포 후 url 제출

  • API 호출 최적화(Promise.all 이해)

  • SearchDialog 불필요한 연산 최적화

  • SearchDialog 불필요한 리렌더링 최적화

  • 시간표 블록 드래그시 렌더링 최적화

  • 시간표 블록 드롭시 렌더링 최적화

기술적 성장

1. 클로저 활용한 캐싱 시스템 구현

기존 코드에서 동일한 API를 중복 호출하는 문제를 해결하기 위해 클로저를 활용한 캐싱 패턴을 학습하고 적용했습니다.

Before

// 매번 새로운 API 호출 발생
const fetchAllLectures = async () => await Promise.all([
  await fetchMajors(),        // API Call 1
  await fetchLiberalArts(),   // API Call 2  
  await fetchMajors(),        // API Call 3
  await fetchLiberalArts(),   // API Call 4 
]);

After

// src/hooks/useLectureData.ts
const createCachedFetcher = (
  fetchFn: () => Promise<AxiosResponse<Lecture[]>>,
  cacheKey: string,
) => {
  let cache: Promise<AxiosResponse<Lecture[]>> | null = null; // 클로저로 캐시 보관

  return () => {
    if (cache) {
      console.log(`${cacheKey} 캐시에서 반환!`);
      return cache; // 캐시된 Promise 반환
    }
    console.log(`${cacheKey} 새로 API 호출`);
    cache = fetchFn(); // 첫 호출 시에만 실제 API 호출
    return cache;
  };
};

const fetchMajorsWithCache = createCachedFetcher(fetchMajors, "majors");
const fetchLiberalArtsWithCache = createCachedFetcher(fetchLiberalArts, "liberalArts");

학습 포인트

  • 클로저의 실용적 활용: 함수가 생성된 스코프의 변수(cache)에 지속적으로 접근 가능
  • Promise 캐싱: Promise 자체를 캐싱하여 동일한 비동기 작업의 결과를 재사용
  • 메모리 효율성: 한 번 호출된 결과를 메모리에 보관하여 후속 호출 시 네트워크 요청 생략

2. React 메모이제이션

컴포넌트별 렌더링 최적화를 위해 React.memo와 커스텀 비교함수를 활용한 메모이제이션 전략을 학습하고 적용했습니다.

폴더 구조

src/components/search-dialog/
├── SearchDialog.tsx          # 최상위 모달 컴포넌트
├── SearchForm.tsx           # 검색 폼 (memo 적용)
├── SearchResults.tsx        # 검색 결과 컨테이너 (memo 적용)
├── SearchItem.tsx          # 개별 검색 결과 아이템 (memo + 커스텀 비교함수)
└── filters/
    ├── GradeFilter.tsx     # 학년 필터 (memo 적용)
    ├── DayFilter.tsx       # 요일 필터 (memo 적용)
    ├── TimeFilter.tsx      # 시간 필터 (memo 적용)
    └── MajorFilter.tsx     # 전공 필터 (memo 적용)

SearchItem 컴포넌트 - arePropsEqual 함수를 활용한 렌더링 제어

  • arePropsEqual 함수 동작 원리
React.memo(Component, arePropsEqual?)
// areEqual: (prevProps, nextProps) => boolean
// true 반환 = props 동일 → 리렌더링 스킵
// false 반환 = props 변경 → 리렌더링 진행
  • 구현 코드
// src/components/search-dialog/SearchItem.tsx
export const SearchItem = memo(
  ({ addSchedule, ...lecture }: SearchItemProps) => {
    const { id, grade, title, credits, major, schedule } = lecture;

    return (
      <tr>
        <LectureCode id={id} />
        <LectureGrade grade={grade} />
        <LectureTitle title={title} />
        {/* ... 기타 컴포넌트들 */}
      </tr>
    );
  },
  // 커스텀 비교함수로 정확한 리렌더링 조건 설정
  (prevProps, nextProps) => {
    return (
      prevProps.id === nextProps.id &&
      prevProps.grade === nextProps.grade &&
      prevProps.title === nextProps.title &&
      prevProps.credits === nextProps.credits &&
      prevProps.major === nextProps.major &&
      prevProps.schedule === nextProps.schedule &&
      prevProps.addSchedule === nextProps.addSchedule
    );
  },
);
  • 왜 arePropsEqual 함수가 필요했는가?

    • 함수 props 문제 해결: addSchedule 콜백 함수가 매번 새로 생성되어도 다른 props가 동일하면 리렌더링 방지
    • 대용량 리스트 최적화: 3000+ 개의 검색 결과에서 각 아이템의 불필요한 리렌더링 방지
    • 인피니트 스크롤 성능: 새로운 아이템이 추가될 때 기존 아이템들의 리렌더링 차단
  • 기본 React.memo vs arePropsEqual 함수 비교

// 기본 React.memo (얕은 비교)
const BasicMemo = memo((props) => <div>{props.data}</div>);
// 문제점: 함수나 객체 props가 매번 새로 생성되면 항상 리렌더링

// arePropsEqual 함수 사용
const OptimizedMemo = memo(
  (props) => <div>{props.data}</div>,
  (prev, next) => prev.data === next.data // 필요한 필드만 비교
);
// 장점: 핵심 데이터만 변경되었을 때만 리렌더링

세부 컴포넌트 메모이제이션

// 각각의 셀을 개별 컴포넌트로 분리하여 메모이제이션
const LectureCode = memo(({ id }: { id: string }) => (
  <td width="100px">{id}</td>
));

const LectureGrade = memo(({ grade }: { grade: number }) => (
  <td width="50px">{grade}</td>
));

const LectureTitle = memo(({ title }: { title: string }) => (
  <td width="200px">{title}</td>
));

학습 포인트

  • 컴포넌트 단위 분리: 변경 빈도가 다른 UI 요소를 별도 컴포넌트로 분리
  • props 최소화: 필요한 props만 전달하여 불필요한 리렌더링 방지
  • 커스텀 비교함수: 복잡한 객체의 경우 정확한 비교 로직 구현

3. 커스텀 훅을 통한 로직 추상화

반복되는 성능 최적화 패턴을 재사용 가능한 커스텀 훅으로 추상화하여 코드의 일관성과 유지보수성을 향상시키는 방법을 학습하고 적용했습니다.

useAutoCallback 훅 - 콜백 안정화 패턴

useCallback의 의존성 배열 관리 복잡성을 해결하고자 개발

// src/hooks/useAutoCallback.ts
export const useAutoCallback = <T extends (...args: any[]) => any>(
  callback: T,
) => {
  const ref = useRef(callback); // 1. ref에 함수 저장
  ref.current = callback; // 2. 매 렌더링마다 최신 함수로 업데이트

  return useCallback((...args: Parameters<T>): ReturnType<T> => {
    return ref.current(...args); // 3. 호출 시 최신 함수 실행
  }, []); // 4. 의존성 배열은 빈 배열로 고정
};

활용 예시

// src/hooks/useSearchWithPagination.ts
const changeSearchOption = useAutoCallback(
  (field: keyof SearchOption, value: SearchOption[typeof field]) => {
    setPage(1);
    setSearchOptions((prev) => ({ ...prev, [field]: value }));
    loaderWrapperRef.current?.scrollTo(0, 0);
  }
);

const handleGradesChange = useAutoCallback((value: number[]) =>
  changeSearchOption("grades", value),
);

const handleDaysChange = useAutoCallback((value: string[]) => 
  changeSearchOption("days", value)
);

useScheduleDrag 훅 - DnD 상태 최적화

DnD Context 구독을 최소화하여 불필요한 리렌더링 방지

// src/hooks/useScheduleDrag.ts
export const useScheduleDrag = (tableId: string): boolean => {
  const dndContext = useDndContext();

  return useMemo(() => {
    const activeId = dndContext.active?.id;
    if (!activeId) return false;

    const activeTableId = String(activeId).split(':')[0];
    return activeTableId === tableId;
  }, [dndContext.active?.id, tableId]);
};

개선점

  • Context 구독을 특정 훅에 집중시켜 구독 범위 최소화
  • useMemo로 계산 결과 캐싱하여 불필요한 재계산 방지
  • 해당 테이블의 드래그 상태만 정확히 추적

4. useMemo 체이닝을 통한 필터링 최적화

복잡한 검색 필터링 로직에서 단계별 메모이제이션을 통해 변경되지 않은 필터 단계의 재계산을 방지하는 최적화 기법을 학습하고 적용했습니다.

Before

const getFilteredLectures = () => {
  const { query = '', credits, grades, days, times, majors } = searchOptions;
  return lectures
    .filter(lecture => lecture.title.toLowerCase().includes(query.toLowerCase()))
    .filter(lecture => grades.length === 0 || grades.includes(lecture.grade))
    .filter(lecture => majors.length === 0 || majors.includes(lecture.major))
    // ... 모든 필터를 매번 전체 데이터에 적용
};

After

// 1단계: 쿼리 필터링
const queryFilteredLectures = useMemo(() => {
  const { query = "" } = searchOptions;
  if (!query) return lectures;
  
  return lectures.filter(
    (lecture) =>
      lecture.title.toLowerCase().includes(query.toLowerCase()) ||
      lecture.id.toLowerCase().includes(query.toLowerCase()),
  );
}, [lectures, searchOptions.query]);

// 2단계: 학점 필터링 (이전 단계 결과 활용)
const creditsFilteredLectures = useMemo(() => {
  const { credits } = searchOptions;
  if (!credits) return queryFilteredLectures;
  
  return queryFilteredLectures.filter(
    (lecture) => lecture.credits.startsWith(String(credits)),
  );
}, [queryFilteredLectures, searchOptions.credits]);

// 3단계: 학년 필터링 (체이닝 계속)
const gradesFilteredLectures = useMemo(() => {
  const { grades } = searchOptions;
  if (grades.length === 0) return creditsFilteredLectures;
  
  return creditsFilteredLectures.filter(
    (lecture) => grades.includes(lecture.grade),
  );
}, [creditsFilteredLectures, searchOptions.grades]);

학습 포인트

  • 점진적 필터링: 변경되지 않은 필터 단계는 재계산하지 않음
  • 메모리 효율성: 각 단계별 중간 결과를 캐싱하여 재사용
  • 성능 향상: 대용량 데이터에서 필터 변경 시 응답 속도 개선 (검색 조건 변경 시 해당 단계부터만 재계산)

코드 품질

1. useReducer 패턴을 통한 상태관리 구조화

복잡한 시간표 상태 관리를 위해 useReducer 패턴을 도입하여 상태 변경 로직을 체계화하고 예측 가능한 상태 업데이트를 구현했습니다.

기존 문제점

기존 useState로 관리되던 복잡한 시간표 상태는 다음과 같은 문제점들이 있었습니다:

  • 여러 컴포넌트에서 상태 변경 로직이 분산되어 일관성 부족
  • 복잡한 드래그 앤 드롭 로직의 예측 불가능성
  • 상태 변경 과정 추적의 어려움
  • 테스트 작성의 복잡성

Before - 분산된 상태 관리

// 각 컴포넌트에서 개별적으로 상태 변경
const [schedulesMap, setSchedulesMap] = useState(initialState);

// 복잡한 드래그 로직이 컴포넌트에 직접 구현
const handleDragEnd = (event) => {
  setSchedulesMap(prev => {
    // 복잡한 상태 변경 로직이 여기저기 분산...
    const newMap = { ...prev };
    // 드래그 로직 처리...
    return newMap;
  });
};

After - useReducer 패턴 적용

// src/hooks/useScheduleActions.ts
export type ScheduleAction =
  | { type: 'UPDATE_SCHEDULE'; tableId: string; scheduleIndex: number; schedule: Schedule }
  | { type: 'MOVE_SCHEDULE'; tableId: string; scheduleIndex: number; moveDayIndex: number; moveTimeIndex: number }
  | { type: 'ADD_SCHEDULES'; tableId: string; schedules: Schedule[] }
  | { type: 'DELETE_SCHEDULE'; tableId: string; timeInfo: TimeInfo }
  | { type: 'DUPLICATE_TABLE'; sourceTableId: string; newTableId: string }
  | { type: 'DELETE_TABLE'; tableId: string }
  | { type: 'SET_SCHEDULES_MAP'; schedulesMap: Record<string, Schedule[]> };

export const useScheduleActions = (
  dispatch: React.Dispatch<ScheduleAction>
): ScheduleActionsReturn => {
  const moveSchedule = useCallback(
    (tableId: string, scheduleIndex: number, moveDayIndex: number, moveTimeIndex: number) => {
      dispatch({
        type: 'MOVE_SCHEDULE',
        tableId,
        scheduleIndex,
        moveDayIndex,
        moveTimeIndex,
      });
    },
    [dispatch]
  );

  // ... 기타 액션들

  return useMemo(
    () => ({
      updateSchedule,
      moveSchedule,
      addSchedules,
      deleteSchedule,
      duplicateTable,
      deleteTable,
      setSchedulesMap,
    }),
    [/* 모든 액션들 */]
  );
};
// src/reducers/scheduleReducer.ts
export const scheduleReducer = (
  state: Record<string, Schedule[]>,
  action: ScheduleAction
): Record<string, Schedule[]> => {
  switch (action.type) {
    case 'MOVE_SCHEDULE': {
      const { tableId, scheduleIndex, moveDayIndex, moveTimeIndex } = action;
      const currentSchedule = state[tableId]?.[scheduleIndex];
      
      if (!currentSchedule) return state;

      // 새로운 위치 계산 및 유효성 검사
      const newDayIndex = DAY_LABELS.indexOf(currentSchedule.day) + moveDayIndex;
      
      if (newDayIndex < 0 || newDayIndex >= DAY_LABELS.length) return state;

      const updatedSchedule = {
        ...currentSchedule,
        day: DAY_LABELS[newDayIndex],
        range: currentSchedule.range.map((time) => time + moveTimeIndex),
      };

      return {
        ...state,
        [tableId]: state[tableId].map((s, index) =>
          index === scheduleIndex ? updatedSchedule : s
        ),
      };
    }
    // ... 기타 액션 처리
  }
};

코드 품질 개선 효과

  • 관심사 분리: UI와 비즈니스 로직의 명확한 분리
  • 예측 가능성: 순수 함수 기반의 안정적인 상태 관리
  • 타입 안전성: Union Type과 TypeScript를 활용한 컴파일 타임 오류 방지

2. DnD 시스템 최적화 - 드롭 시 렌더링 문제 해결

드래그 앤 드롭 시 전체 컴포넌트가 리렌더링되는 문제를 해결하기 위해 상태 구독 범위를 최소화하고 정확한 드래그 상태 추적 시스템을 구축했습니다.

기존 문제점

드래그 앤 드롭 발생 시 Context 의존성으로 인해 모든 테이블 컴포넌트가 불필요하게 리렌더링되어 사용자 체감 성능이 저하되었습니다.

Before - Context 직접 구독

// 🔴 매번 Context에서 데이터를 가져와 전역 의존성 발생
export const ScheduleTableContainer = memo(({ tableId, onScheduleTimeClick }) => {
  const { schedulesMap, actions } = useScheduleContext();
  const schedules = useScheduleTable(schedulesMap, tableId);

  return (
    <ScheduleDndProvider tableId={tableId}>
      <ScheduleTable schedules={schedules} tableId={tableId} />
    </ScheduleDndProvider>
  );
});

After - Props를 통한 독립적 렌더링

// ✅ 필요한 데이터만 Props로 전달받아 독립적 렌더링
interface ScheduleTableContainerProps {
  tableId: string;
  schedules: Schedule[];           // Props로 직접 전달
  actions: ScheduleActionsReturn;  // Props로 직접 전달
  onScheduleTimeClick: (timeInfo: TimeInfo) => void;
}

export const ScheduleTableContainer = memo(
  ({ tableId, schedules, actions, onScheduleTimeClick }) => {
    return (
      <ScheduleDndProvider actions={actions}>
        <ScheduleTable
          schedules={schedules}
          tableId={tableId}
          onScheduleTimeClick={onScheduleTimeClick}
        />
      </ScheduleDndProvider>
    );
  },
  (prev, next) =>
    prev.tableId === next.tableId &&
    prev.schedules === next.schedules &&
    prev.actions === next.actions &&
    prev.onScheduleTimeClick === next.onScheduleTimeClick
);

개별 테이블 관리 컴포넌트 분리

// TableStackItem - 각 테이블을 독립적으로 관리
const TableStackItem = memo(
  ({ tableId, index, schedules, actions, setSearchInfo }) => {
    const handleOpenAdd = useCallback(
      () => setSearchInfo({ tableId }),
      [setSearchInfo, tableId]
    );

    const handleDeleteTable = useCallback(
      () => actions.deleteTable(tableId),
      [actions, tableId]
    );

    return (
      <Stack width='600px'>
        <Flex justifyContent='space-between' alignItems='center'>
          <Heading as='h3' fontSize='lg'>시간표 {index + 1}</Heading>
          <ButtonGroup size='sm' isAttached>
            <Button colorScheme='green' onClick={handleOpenAdd}>
              시간표 추가
            </Button>
            <Button colorScheme='green' onClick={handleDeleteTable}>
              삭제
            </Button>
          </ButtonGroup>
        </Flex>
        <ScheduleTableContainer
          tableId={tableId}
          schedules={schedules}
          actions={actions}
          onScheduleTimeClick={handleScheduleTimeClick}
        />
      </Stack>
    );
  },
  (prev, next) =>
    prev.tableId === next.tableId &&
    prev.schedules === next.schedules &&
    prev.actions === next.actions
);

코드 품질 개선 효과

  • 의존성 역전: Context 직접 구독에서 Props 주입 방식으로 변경하여 컴포넌트 독립성 확보
  • 단일 책임 원칙: 각 컴포넌트가 하나의 테이블 관리에만 집중하도록 책임 범위 명확화
  • 확장성 향상: Props 인터페이스를 통한 명확한 데이터 흐름으로 새로운 기능 추가 시 영향 범위 최소화

학습 효과 분석

가장 큰 배움이 있었던 부분

React 성능 최적화의 체계적 접근법을 학습할 수 있었습니다. 단순히 memo나 useMemo를 사용하는 것을 넘어서, 문제의 근본 원인을 파악하고 구조적으로 해결하는 방법을 익혔습니다. 특히 클로저 기반 캐싱, useMemo 체이닝, areEqual 함수를 활용한 정밀한 렌더링 제어 등 실무에서 바로 적용 가능한 기법들을 습득했습니다.

리뷰 받고 싶은 내용

  • 이번 과제에서 React.memo, useMemo, useCallback 등 다양한 메모이제이션 기법을 활용했습니다. areEqual 함수를 이용한 커스텀 비교를 사용했는데, 실무에서는 메모이제이션을 어느 정도 수준까지 적용하는 것이 적절한지 궁금합니다. 과도한 메모이제이션이 오히려 메모리 사용량 증가나 코드 복잡성을 높일 수 있다고 하는데, 실제 프로덕션 환경에서는 어떤 기준으로 메모이제이션 적용 여부를 판단하시는지, 그리고 성능 측정과 최적화의 우선순위를 어떻게 정하시는지 경험을 공유해주실 수 있을까요?

과제 피드백

지수님 10주간 고생 많이 하셨어요! 질문도 꼼꼼하게 해주시고 과제도 지금까지 잘 해주셨는데요. 아숩게도 마지막 과제 PR을 마무리 하는 단계에서 제가 봐버린것 같네요 그래도 최적화 관점에서 각각 시도할 수 있는 여러 내용을 잘 적용해주신것 같아서 많은 것들을 배운 주차이셨던것 같네요. 과제는 잘 진행해주셨고, 따로 질문은 없으셔서 여기서 마무리해보도록 하겠습니다.

고생하셨고, 앞으로 개발인생 화이팅 하세요! 그럼 내일봬요~