Skip to content

[1팀 한아름] Chapter 4-2 코드 관점의 성능 최적화 - #25

Open
areumH wants to merge 15 commits into
hanghae-plus:mainfrom
areumH:main
Open

[1팀 한아름] Chapter 4-2 코드 관점의 성능 최적화#25
areumH wants to merge 15 commits into
hanghae-plus:mainfrom
areumH:main

Conversation

@areumH

@areumH areumH commented Sep 10, 2025

Copy link
Copy Markdown

과제 체크포인트

배포 링크

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 등을 남발하게 됐던 것 같아요. 그렇다면 실무에서 실제로 최적화를 적용해야 할지 판단하는 기준이나, 최적화할 때 지양하는 패턴이 있는지도 궁금합니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant