areumH 님의 상세페이지[1팀 한아름] Chapter 4-2 코드 관점의 성능 최적화

과제 체크포인트

배포 링크

https://areumh.github.io/front_6th_chapter4-2/

과제 요구사항

  • 배포 후 url 제출

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

  • SearchDialog 불필요한 연산 최적화

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

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

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

과제 셀프회고

api 호출 최적화

// (이미 호출한 api는 다시 호출하지 않도록 - 클로저를 이용하여 캐시 구성)
const createLectureFetcher = () => {
  let lectureCache: Lecture[] | null = null; // 클로저에서만 유지되는 캐시

  const fetchAllLectures = async () => {
    if (lectureCache) return lectureCache; // 캐시가 있으면 api 호출 생략

    const res = 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()),
    ]);

    lectureCache = res.flatMap((r) => r.data); // 전공과 교양을 평탄화하여 하나의 배열로 처리
    return lectureCache;
  };

  return { fetchAllLectures };
};

// fetcher 생성
const lectureFetcher = createLectureFetcher();

lectureCache는 createLectureFetcher 내부에만 존재하는 클로저 캐시이며, api를 호출한 후 데이터를 저장한다. 최초 호출 이후에 또 호출하면 이를 즉시 반환하며 외부에서는 캐시에 접근할 수 없다. SearchDialog 컴포넌트 외부에 한 번만 생성하여 모달의 리렌더링에 관계 없이 캐시 유지가 가능하다.

스크린샷 2025-09-11 23 35 03

모달을 열어보면 최초 한 번 열었을 때만 api를 호출하고, 그 이후엔 캐싱된 값을 사용하여 api를 호출하지 않는 걸 확인할 수 있다!

✅ SearchDialog 최적화

// src/components/lecture/LectureHeadItem.tsx
const LectureHeadItem = () => {
  return (
    <Table>
      <Thead>
        <Tr>
          <Th width="100px">과목코드</Th>
          <Th width="50px">학년</Th>
          <Th width="200px">과목명</Th>
          <Th width="50px">학점</Th>
          <Th width="150px">전공</Th>
          <Th width="150px">시간</Th>
          <Th width="80px"></Th>
        </Tr>
      </Thead>
    </Table>
  );
};

export default React.memo(LectureHeadItem);

// src/components/lecture/LectureItem.tsx
interface LectureItemProps {
  index: number;
  lecture: Lecture;
  addLecture: (lecture: Lecture) => void;
}

const LectureItem = ({ lecture, addLecture }: LectureItemProps) => {
  return (
    <Tr>
      <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="150px" dangerouslySetInnerHTML={{ __html: lecture.major }} />
      <Td width="150px" dangerouslySetInnerHTML={{ __html: lecture.schedule }} />
      <Td width="80px">
        <Button size="sm" colorScheme="green" onClick={() => addLecture(lecture)}>
          추가
        </Button>
      </Td>
    </Tr>
  );
};

export default React.memo(LectureItem);
  • 강의 헤더 아이템과 강의 단일 아이템을 컴포넌트로 분리한 뒤 memo를 사용하여 리렌더링을 방지했다.
  • 이 외에도 filteredLectures, visibleLectures, allMajor, lastPage, changeSearchOption 등에 useMemo와 useCallback을 사용하여 최적화했다.

✅ 시간표 블록 드래그, 드롭 최적화


const ScheduleTable = React.memo(({ index, disabled, tableId, initialSchedule, onDuplicate, onRemove }: Props) => {
  // 시간표 개별 상태 관리
  const [schedules, setSchedules] = useState<Schedule[]>(initialSchedule);

  // 드래그 시작 시 active 업데이트
  const handleDragStart = ({ active }: { active: Active }) => {
    setIsActive(active);
  };

  // 드래그 종료 시 호출
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const handleDragEnd = (event: any) => {
    // active - 드래그한 아이템
    // delta - 이동 거리
    const { active, delta } = event;
    // 드래그 종료 시 active 제거
    setIsActive(null);

    const { x, y } = delta;
    const [, index] = active.id.split(':');
    const schedule = schedules[index];
    const nowDayIndex = DAY_LABELS.indexOf(schedule.day as (typeof DAY_LABELS)[number]);

    // 이동한 그리드 계산
    const moveDayIndex = Math.floor(x / 80);
    const moveTimeIndex = Math.floor(y / 30);

    setSchedules((prev) =>
      prev.map((schedule, idx) =>
        idx === Number(index)
          ? {
              ...schedule,
              day: DAY_LABELS[nowDayIndex + moveDayIndex],
              range: schedule.range.map((time) => time + moveTimeIndex),
            }
          : { ...schedule }
      )
    );
  };

  // SearchDialog에서 강의 추가
  const addLecture = useCallback(
    (lecture: Lecture) => {
      if (!searchInfo) return;

      const newSchedules: Schedule[] = parseSchedule(lecture.schedule).map((schedule) => ({
        ...schedule,
        lecture,
      }));

      setSchedules((prev) => [...prev, ...newSchedules]);
      setSearchInfo(null); // 모달 닫기
    },
    [searchInfo]
  );

  // 강의 삭제
  const deleteLecture = useCallback((day: string, time: number) => {
    setSchedules((prev) => prev.filter((schedule) => schedule.day !== day || !schedule.range.includes(time)));
  }, []);

  return (
    // ~
  );
});

export default ScheduleTable;

개별 시간표 내에서 강의 추가, 삭제, 수정(dnd) 등의 상태 변화가 일어날 때 전체 시간표 (scheduleMap)의 렌더링을 줄이기 위해선 개별 스케줄 상태를 만들어야 한다는 생각 밖에 떠오르지 않아 ScheduleTable 컴포넌트 내에서 각 상태를 useState로 관리하도록 해주었다..

interface ScheduleDndProviderProps {
  children: React.ReactNode;
  onDragStart?: (event: any) => void;
  onDragEnd?: (event: any) => void;
}

export default function ScheduleDndProvider({ children, onDragStart, onDragEnd }: ScheduleDndProviderProps) {
  const sensors = useSensors(
    // PointerSensor - 마우스나 터치 입력을 기준으로 드래그 감지
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8, // 8px 이상 움직여야 드래그 시작
      },
    })
  );

  return (
    <DndContext sensors={sensors} modifiers={modifiers} onDragStart={onDragStart} onDragEnd={onDragEnd}>
      {children}
    </DndContext>
  );
}

기존에 setScheduleMap을 사용하던 handleDragEnd 함수는 ScheduleTable 컴포넌트 내에서 강의 수정에 사용되어야 하므로 ScheduleDndProvider 내부가 아닌, 인자로 받아서 연결하도록 수정했다.

기술적 성장

  • React Dnd를 활용한 드래그 앤 드롭 구현 경험
  • useMemo, useCallback, React.memo 등을 사용한 렌더링 최적화
  • 상태 범위를 고려한 Context와 컴포넌트 로컬 상태 설계 경험

코드 품질

개별 시간표 상태 관리를 useState로 구현했는데, 상태 라이브러리 (jotai, zustand, store) 등을 사용하여 구현해볼걸 하는 생각이 든다. 과제를 진행했을 당시엔 과제로 제공된 ScheduleProvider와 개별 시간표 상태 관리를 함께 구현할 방법이 useState 밖에 떠오르지 않았다.. 현재는 setScheduleMap 함수가 시간표 복제, 삭제 기능에만 사용되고 있기 때문에 개선이 필요하다고 생각된다.

학습 효과 분석

  • 렌더링 최적화의 중요성과 React 내부 동작 이해
  • Context와 Props 전달 구조에 따라 성능이 달라지는 경섬

과제 피드백

리뷰 받고 싶은 내용

  • 이번 과제를 통해 @ dnd-kit 라이브러리에 대해 처음 알게 되었는데요!! 예전에 시간표 강의 컴포넌트를 하나하나 스타일로 계산하여 구현했던 경험이 있어서 그런지 너무나도 신기했습니다..ㅎㅎ 그래서 더욱 관심이 가고 더 자세히 공부해보고 싶은 마음이 들어요. 혹시 시간표같은 형태에서 사용할 수 있는 다른 DnD 라이브러리나 방법이 또 있는지 궁금합니다! 성능이나 코드 측면에서 더 나은 방식이 있는지 궁금해요.
  • 성능 최적화 경험이 없어서 그런지 과제를 처음 시작할 때 감이 잘 잡히지 않았고, 과제를 진행했다 하더라도 useMemo, useCallback 등을 남발하게 됐던 것 같아요. 그렇다면 실무에서 실제로 최적화를 적용해야 할지 판단하는 기준이나, 최적화할 때 지양하는 패턴이 있는지도 궁금합니다!

과제 피드백

안녕하세요 아름님! 마지막 과제 너무 잘 진행해주셨네요 ㅎㅎ 그동안 고생많으셨어요!!


이번 과제를 통해 @ dnd-kit 라이브러리에 대해 처음 알게 되었는데요!! 예전에 시간표 강의 컴포넌트를 하나하나 스타일로 계산하여 구현했던 경험이 있어서 그런지 너무나도 신기했습니다..ㅎㅎ 그래서 더욱 관심이 가고 더 자세히 공부해보고 싶은 마음이 들어요. 혹시 시간표같은 형태에서 사용할 수 있는 다른 DnD 라이브러리나 방법이 또 있는지 궁금합니다! 성능이나 코드 측면에서 더 나은 방식이 있는지 궁금해요.

Drag&Drop 이라는 키워드로 라이브러리를 검색해서 찾아보시면 많이 나올텐데요, 사실 제가 팀에서 사용하고 있는 라이브러리가 dnd kit 이라서 추가해봤어요 ㅋㅋ 저도 이거 말고 다른건 적용해본적이 없어서.... 잘 모르겠네요 ㅎㅎ ㅠㅠ

직접 Drag&Drop 기능을 만들어보는 것도 추천드려요!

성능 최적화 경험이 없어서 그런지 과제를 처음 시작할 때 감이 잘 잡히지 않았고, 과제를 진행했다 하더라도 useMemo, useCallback 등을 남발하게 됐던 것 같아요. 그렇다면 실무에서 실제로 최적화를 적용해야 할지 판단하는 기준이나, 최적화할 때 지양하는 패턴이 있는지도 궁금합니다!

실무에서는 "사용자가 불편함을 느낄 때" 최적화를 진행하는 경우가 대부분인 것 같아요! 그리고 대체로 이렇게 문의가 들어올 때 문제에 대해 더 깊이 있게 들여다보기도 해요 ㅎㅎ 그 이전에는 문제인식이 없기 때문에 "굳이?" 라는 생각이 들기도 한답니다 ㅋㅋ

최적화할 때 지양하는 패턴은... 사실 최적화가 목적이라기보단 코드 구성을 잘 하면, 그니까 클린하게 구성하면 자연스럽게 최적화 하기 좋은 구조가 된답니다. 가령 단일 책임 원칙을 토대로 코드를 작성한다거나!?

하나의 코드가 하나의 일만 하도록 만들 때 최적화를 하기가 무척 편해서요 ㅎㅎ Provider를 전체에 깜싸서 역할의 범위를 넓혔을 때 문제가 발생했는데, 역할의 범위를 좁히니까 문제가 해결된 것 처럼요!