Skip to content

Conversation

@seunghoonKang
Copy link

@seunghoonKang seunghoonKang commented Dec 25, 2025

과제 체크포인트

과제 요구사항

배포 주소

  • 배포 후 url 제출
  • API 호출 최적화(Promise.all 이해)
  • SearchDialog 불필요한 연산 최적화
  • SearchDialog 불필요한 리렌더링 최적화
  • 시간표 블록 드래그시 렌더링 최적화
  • 시간표 블록 드롭시 렌더링 최적화

과제 셀프회고

1) API 호출 부분을 최적화주세요

- Promise.all은 Promise 배열을 받아 병렬 실행한다.
- 배열 항목에 await를 쓰면 순차 실행된다.
  • Promise 내부에 동작하는 fetch 함수의 await이 병렬로 실행돼야할 Promise를 직렬로 실행되게 한다!
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()),
  ]);

2) 이미 호출한 API는 다시 호출하지 않도록 시도해보세요. (힌트: 클로저를 이용해서 캐시를 구성하면 됩니다.)

  • API를 다시 호출하지 않게 하는 방법은 어떻게 하면 될까 흠
  • https://apidog.com/kr/blog/caching-api-responses-in-react-2/
    • 인터넷을 보다 api 캐싱 글을 보게 됐다.
    • 캐시된 데이터가 있으면 그걸 쓰고, 아니면 새로운 결과를 담는 형태다.
  • 유틸 함수로 fetch하는 함수를 그대로 받아와, 캐시데이터가 있으면 캐시 데이터를, 없다면 fetch를 진행하게 구현했다.
// utils.ts
export const withCache = <T>(fetchFn: () => Promise<T>) => {
  let cachedData: T | null = null;

  return async (): Promise<T> => {
    if (cachedData) {
      return cachedData;
    }

    cachedData = await fetchFn();
    return cachedData;
  };
};
const fetchMajors = () => axios.get<Lecture[]>("/schedules-majors.json");
const fetchLiberalArts = () =>
  axios.get<Lecture[]>("/schedules-liberal-arts.json");

const fetchMajorsWithCache = withCache(fetchMajors);
const fetchLiberalArtsWithCache = withCache(fetchLiberalArts);

3) 불필요한 연산이 발생하지 않도록 해주세요

를 하기전에, 먼저 해당 SearchDialog가 무한스크롤로 동작해야 할 부분이 제대로 동작하지 않음을 발견했다.

console을 찍어보니 무한스크롤에 필요한

      const $loader = loaderRef.current;
      const $loaderWrapper = loaderWrapperRef.current;

위 두개가 처음 모달이 열렸을 때 null값으로 잡히기 때문이었다.

이를 해결하기 위해, requestAnimationFrame을 사용하여 해결해주었다.

requestAnimationFrame은 브라우저의 다음 리페인트 전에 실행되므로 DOM 렌더링 후 실행을 보장하기에 setTimeout같은 처리보다 더 정확한 시점을 보장할 수 있다.

 useEffect(() => {
    if (!searchInfo) {
      return;
    }

    let observer: IntersectionObserver | null = null;

    const rafId = requestAnimationFrame(() => {
      const $loader = loaderRef.current;
      const $loaderWrapper = loaderWrapperRef.current;

      if (!$loader || !$loaderWrapper) {
        return;
      }

      observer = new IntersectionObserver(
        (entries) => {
          if (entries[0].isIntersecting) {
            setPage((prevPage) => Math.min(lastPage, prevPage + 1));
          }
        },
        { threshold: 0, root: $loaderWrapper }
      );

      observer.observe($loader);
    });

    return () => {
      cancelAnimationFrame(rafId);
      if (observer) {
        observer.disconnect();
      }
    };
  }, [lastPage, searchInfo]);

그럼 이제 다시

3) 불필요한 연산이 발생하지 않도록 해주세요

를 해보자.

  const getFilteredLectures = () => {
    const { query = "", credits, grades, days, times, majors } = searchOptions;
    return lectures
      .filter(
        (lecture) =>
          lecture.title.toLowerCase().includes(query.toLowerCase()) ||
          lecture.id.toLowerCase().includes(query.toLowerCase())
      )
      .filter(
        (lecture) => grades.length === 0 || grades.includes(lecture.grade)
      )
      .filter(
        (lecture) => majors.length === 0 || majors.includes(lecture.major)
      )
      .filter(
        (lecture) => !credits || lecture.credits.startsWith(String(credits))
      )
      .filter((lecture) => {
        if (days.length === 0) {
          return true;
        }
        const schedules = lecture.schedule
          ? parseSchedule(lecture.schedule)
          : [];
        return schedules.some((s) => days.includes(s.day));
      })
      .filter((lecture) => {
        if (times.length === 0) {
          return true;
        }
        const schedules = lecture.schedule
          ? parseSchedule(lecture.schedule)
          : [];
        return schedules.some((s) =>
          s.range.some((time) => times.includes(time))
        );
      });
  };
  • 딱봐도 뭔가 문제있어보임
  const filteredLectures = useMemo(() => {
    const { query = "", credits, grades, days, times, majors } = searchOptions;
    const lowerQuery = query.toLowerCase();

    return lectures.filter((lecture) => {
      if (grades.length > 0 && !grades.includes(lecture.grade)) return false;
      if (majors.length > 0 && !majors.includes(lecture.major)) return false;
      if (credits && !lecture.credits.startsWith(String(credits))) return false;

      if (
        query &&
        !lecture.title.toLowerCase().includes(lowerQuery) &&
        !lecture.id.toLowerCase().includes(lowerQuery)
      ) {
        return false;
      }

      const hasDayFilter = days.length > 0;
      const hasTimeFilter = times.length > 0;

      if (hasDayFilter || hasTimeFilter) {
        const schedules = lecture.schedule
          ? parseSchedule(lecture.schedule)
          : [];

        // 요일 조건 확인
        if (hasDayFilter && !schedules.some((s) => days.includes(s.day))) {
          return false;
        }

        // 시간 조건 확인
        if (
          hasTimeFilter &&
          !schedules.some((s) => s.range.some((t) => times.includes(t)))
        ) {
          return false;
        }
      }

      return true;
    });
  }, [lectures, searchOptions]);

  const visibleLectures = useMemo(() => {
    return filteredLectures.slice(0, page * PAGE_SIZE);
  }, [filteredLectures, page]);

불필요한 연산 차단: 인피니트 스크롤이 발생하여 page가 바뀔 때, 리액트는 의존성 배열을 확인한다.

  • filteredLectures의 의존성인 [lectures, searchOptions]는 변하지 않았으므로, 필터링 함수(filter)는 실행되지 않고 메모리에 저장된 값을 즉시 재사용.
  • 오직 visibleLectures만 실행되어 이미 계산된 결과에서 100개, 200개씩 **잘라내기(slice)**만 수행

4) 불필요한 렌더링 방지

const allMajors = useMemo(
    () => [...new Set(lectures.map((lecture) => lecture.major))],
    [lectures]
  );
const MajorCheckbox = memo(({ major }: { major: string }) => {
  return (
    <Box>
      <Checkbox size="sm" value={major}>
        {major.replace(/<p>/gi, " ")}
      </Checkbox>
    </Box>
  );
});
  const addSchedule = useCallback(
    (lecture: Lecture) => {
      if (!searchInfo) return;
      const { tableId } = searchInfo;
      const schedules = parseSchedule(lecture.schedule).map((schedule) => ({
        ...schedule,
        lecture,
      }));
      setSchedulesMap((prev) => ({
        ...prev,
        [tableId]: [...prev[tableId], ...schedules],
      }));
      onClose();
    },
    [searchInfo, setSchedulesMap, onClose]
  );
const LectureRow = memo(
  ({ lecture, onAdd }: { lecture: Lecture; onAdd: (l: Lecture) => void }) => {
    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={() => onAdd(lecture)}>
            추가
          </Button>
        </Td>
      </Tr>
    );
  }
);

5) “지연평가” 라는 키워드를 토대로 찾아보면 알 수 있답니다!

  • 내용을 보고 지연평가에 대해 찾아보니, 계산이 필요한 시점까지 계산을 미루는 것이라고 한다.
  • 보통 제너레이터, lodash를 사용해 만드는 것 같은데, ai에게 물어보니 for 문으로도 구현이 가능하다고 한다.
  const { totalCount, visibleLectures } = useMemo(() => {
    const { query = "", credits, grades, days, times, majors } = searchOptions;
    const lowerQuery = query.toLowerCase();

    const visible = [];
    let count = 0;
    const targetCount = page * PAGE_SIZE;

    for (const lecture of lectures) {
      // 필터링 조건 (비용 낮은 순서대로 배치)
      if (grades.length > 0 && !grades.includes(lecture.grade)) continue;
      if (majors.length > 0 && !majors.includes(lecture.major)) continue;
      if (credits && !lecture.credits.startsWith(String(credits))) continue;

      if (
        query &&
        !lecture.title.toLowerCase().includes(lowerQuery) &&
        !lecture.id.toLowerCase().includes(lowerQuery)
      )
        continue;

      const hasDayFilter = days.length > 0;
      const hasTimeFilter = times.length > 0;

      if (hasDayFilter || hasTimeFilter) {
        const schedules = lecture.schedule
          ? parseSchedule(lecture.schedule)
          : [];
        if (hasDayFilter && !schedules.some((s) => days.includes(s.day)))
          continue;
        if (
          hasTimeFilter &&
          !schedules.some((s) => s.range.some((t) => times.includes(t)))
        )
          continue;
      }

      // 여기까지 오면 필터를 통과한 데이터임
      if (visible.length < targetCount) {
        visible.push(lecture);
      }

      count++; // 전체 검색 결과 개수는 계속 카운트

      // [핵심] 만약 전체 개수를 보여줄 필요가 없다면 여기서 바로 break 할 수 있음.
      // 하지만 지금은 "검색 결과: N개" 표시가 필요하므로 끝까지 돌되,
      // 무거운 연산(스케줄 파싱 등)을 통과한 녀석들만 카운트함.
    }

    return { totalCount: count, visibleLectures: visible };
  }, [lectures, searchOptions, page]);
  • 이걸 구현하고 테스트하다가 리스트가 제대로 렌더링 되지 않는걸 발견해서 확인해보니 Lecture의 키값에서 중복이 발생해서였다.
  • 더불어 이걸 테스트하며 console을 찍었는데, console이 모달이 열리기 전에도 100개가 찍혀있는 걸 보게 됐다.
  • 이에 대해 상위 컴포넌트인 테이블에서 조건에 맞을 때 모달이 그려지도록 수정했다.
//ScheduleTables.tsx
// searchInfo가 있을 때만 동작하도록
{searchInfo && (
  <SearchDialog
    searchInfo={searchInfo}
    onClose={() => setSearchInfo(null)}
  />
)}

6) DnD 최적화

  • ScheduleTable 컴포넌트와 DraggableSchedule 컴포넌트를 memo로 감싸, 불필요한 렌더링을 최소화했다.
  • 드래그 앤 드롭 시 활성화된 테이블의 아웃라인을 표시하는 ActiveTableOutline 컴포넌트를 만들고 memo로 래핑, 조건부 렌더링으로 불필요한 렌더링을 방지했다.
const ActiveTableOutline = memo(({ tableId }: { tableId: string }) => {
  const { active } = useDndContext();
  const activeTableId = active ? String(active.id).split(":")[0] : null;
  const isTargetTable = activeTableId === tableId;

  if (!isTargetTable) return null;
  // ...
});
  • schedules 메모이제이션
const schedules = useMemo(
  () => schedulesMap[tableId] || [],
  [schedulesMap, tableId]
);
  • schedulesMaptableId가 변경되지 않으면 이전 값 재사용한다.
  • 매 렌더링마다 새로운 배열을 생성하는 것을 방지했다.
  • 비슷하게 lectures를 메모이제이션했고, getColor 함수 또한 useCallback으로 메모이제이션 해 주었다.
  • Context API를 통한 상태 관리 개선

변경 전

  • schedules를 props로 전달
  • 각 테이블마다 schedules를 개별적으로 관리

변경 후

  • useScheduleContext를 통해 전역 상태에서 직접 가져옴
  • Props drilling을 제거했다!
  • 상태 관리를 중앙화 시켰다.
const { schedulesMap } = useScheduleContext();
const schedules = useMemo(
  () => schedulesMap[tableId] || [],
  [schedulesMap, tableId]
);

후기

  • 다른 과제들보다 조금 더 명확한 과제라고 느껴졌습니다.

    • 그렇게 느낀건 어쩌면, 다른 과제는 어디서부터 손봐야할 지 막막했었거나,
    • 잡히지 않은 개념을 처음부터 이해해야해서라 생각합니다.
  • 지연평가라는걸 새롭게 배웠습니다! 멋진 기술(?) 방법(?) 이라고 생각이 들었습니다. 정말 최적화라는 것에는 다양한 방법이 있구나~!

  • 실제 프로젝트에서는 이만큼 최적화 할 내용이 없어서(?) 일수도 있지만, 이렇게 memo와 useMemo, useCallback을 많은 곳에 쓰는 게 맞나? 생각이 들었습니다.

  • 리액트의 profiler가 여전히 낯설지만, 이를 잘 활용하여 실제 프로젝트에서도 더 나은 최적화를 가져가고 싶습니다.

@seunghoonKang seunghoonKang changed the title 과제 제출을 위한 빈 커밋 날리기 [6팀 강승훈] Chapter 4-2. 코드 관점의 성능 최적화 Dec 25, 2025
@seunghoonKang seunghoonKang changed the title [6팀 강승훈] Chapter 4-2. 코드 관점의 성능 최적화 [1팀 강승훈] Chapter 4-2. 코드 관점의 성능 최적화 Dec 26, 2025
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