yunwoo-yu 님의 상세페이지[2팀 유윤우] Chapter 4-2 코드 관점의 성능 최적화

과제 체크포인트

배포 링크

https://yunwoo-yu.github.io/front_6th_chapter4-2/

과제 요구사항

  • 배포 후 url 제출

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

  • SearchDialog 불필요한 연산 최적화

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

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

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

과제 셀프회고

마지막 과제를 진행하며

마지막 과제를 진행하며 "끝이 다가온다"는 아쉬움을 달래고, 끝까지 완주하자는 마음으로 임했습니다.

처음 항해에 합류했을 때 예상했던 것보다 훨씬 높은 난이도에 당황했습니다. BP도 받고 모든 과제를 올패스하며 많은 것을 배우겠다던 당초 목표와 달리, 거의 매일 밤을 새가며 과제 통과에만 매달린 제 모습이 아직도 생생합니다. 🥹

제가 느낀 BP의 핵심 기준은 단순한 코드 품질을 넘어서, 과제의 본질을 얼마나 깊이 이해하고 이를 글로 명확히 표현했는가, 그리고 학습 과정을 PR에서 얼마나 체계적으로 정리했는가에 있다고 생각합니다.

평소 글쓰기에 자신이 없었던 터라, 과제 완료 후 PR을 작성할 때면 이미 체력이 바닥나 있어 겪었던 경험들이 제대로 정리되지 않아 생략하는 경우가 많았습니다. 동료들로부터 "윤우님은 개발 잘 하시는데 아쉽네요"라는 말을 여러 번 들으면서, 항해 10주 동안 가장 성장하지 못한 부분이 바로 이 지점이었다는 아쉬움이 남습니다.

하지만 글을 거의 쓰지 않던 저였음에도 매주 PR 회고와 WIL 작성을 통해 예전보다 훨씬 자연스럽게 글을 쓸 수 있게 되었습니다. 회고의 중요성, 학습 내용을 체계적으로 정리하는 방법, 그리고 읽는 이를 배려하는 글쓰기 등 10주간 배운 소중한 경험들을 바탕으로 앞으로도 꾸준히 글을 써나가며 성장해나갈 계획입니다.

취업 준비 시절, 아낌없이 지식을 나눠주신 분을 만나 "저분처럼 지식을 공유하는 개발자가 되자"고 다짐했었는데, 다행히 팀원분들께 작은 도움이라도 드릴 수 있는 순간들이 있어서 그 다짐을 조금이나마 실천할 수 있었다고 생각합니다. 앞으로는 더 많은 분들께 실질적인 도움을 드릴 수 있도록 더욱 성장하겠습니다.

10주간 정말 많은 것을 배우고 경험할 수 있었습니다. 코치님들께 진심으로 감사드립니다. 😄🙇‍♂️

기술적 성장

Promise 란

Promise는 JavaScript에서 비동기 연산의 "최종 상태"와 "결과 값"을 표현하는 객체입니다. 가장 중요한 특징은 한 번 정해진 상태(성공 또는 실패)는 절대 변하지 않는다는 점입니다. 이는 Promise가 제공하는 신뢰성의 핵심이기도 합니다.

then, catch, finally

Promise의 힘은 체이닝에서 나옵니다. then, catch, finally 메서드는 각각 새로운 Promise를 반환하기 때문에 연쇄적으로 연결할 수 있습니다.

then(onFulfilled, onRejected)는 성공과 실패 모두를 처리할 수 있지만, 가독성을 위해 보통 성공 케이스만 처리하고 catch를 별도로 사용합니다. catch(onRejected)는 실패 케이스만 분리해서 처리할 때 사용하고, finally(onFinally)는 성공과 실패에 관계없이 정리 작업을 수행할 때 유용합니다.

finally는 결과 값을 변경하지 않습니다! 이는 정리 작업의 본질을 잘 표현한 설계라고 생각합니다.

Promise.resolve와 Promise.reject

Promise.resolve(value)는 값을 Promise로 감싸는 유틸리티입니다. 만약 value가 thenable(then 메서드를 가진) 객체라면 동화(assimilate) 과정을 거쳐 해당 객체의 상태를 따라갑니다. 그렇지 않다면 즉시 성공 상태로 감쌉니다.

Promise.reject(error)는 항상 즉시 실패 상태의 Promise를 만듭니다. 이 차이점을 이해하는 것이 중요합니다.

Promise 메서드들

Promise.all

Promise.all은 모든 입력이 성공해야만 성공하는 fail-fast 전략을 취합니다. 모든 Promise가 성공하면 입력 순서 그대로 결과 배열을 반환하고, 하나라도 실패하면 즉시 실패합니다.

과제를 진행하면서 잘못된 부분이 여기 있었습니다. 병렬 실행을 원한다면 "이미 시작된 Promise"들을 배열로 만들어 넘겨야 하는데, 배열 요소 내부에서 await를 사용하면 직렬 실행이 되어버립니다.

// 병렬(권장) - Promise를 먼저 만들어서 동시에 시작
const [a, b] = await Promise.all([fetchA(), fetchB()]);

// 직렬(지양) - 배열 요소 안에서 await 사용으로 순차 실행
const [a, b] = await Promise.all([await fetchA(), await fetchB()]);

Promise.allSettled

Promise.allSettled는 모든 입력이 완료될 때까지 기다린 후, 각 항목의 상태와 값을 모두 돌려줍니다. 일부 실패를 허용하면서 최대한의 결과를 수집하고 싶을 때 매우 유용합니다.

Promise.race와 Promise.any

Promise.race는 가장 먼저 완료된 하나만 반환합니다(성공/실패 구분 없음). 타임아웃 제어나 "최초 응답 우선" 전략에 사용할 수 있습니다.

Promise.any는 첫 번째 "성공"만 반환합니다. 모두 실패하면 AggregateError가 발생하는데, 여러 백업 엔드포인트 중 하나의 성공만 필요할 때 유용합니다.

고려하면 좋을 포인트

실제 프로덕션 환경에서는 네트워크 요청의 취소가 중요합니다. fetch는 AbortController를 통해 취소할 수 있습니다.

const ac = new AbortController();
try {
  const [a, b] = await Promise.all([
    fetch(urlA, { signal: ac.signal }),
    fetch(urlB, { signal: ac.signal }),
  ]);
} catch (e) {
  ac.abort(); // 하나 실패 시 나머지 요청도 중단
}

클로저를 이용한 캐시처리

export const createCachedFetcher = <T>(
  fetchFn: () => Promise<AxiosResponse<T>>
) => {
  let cache: Promise<AxiosResponse<T>> | null = null;

  return (): Promise<AxiosResponse<T>> => {
    if (cache) {
      console.log("캐시 사용");
      return cache;
    }

    console.log("새로운 Promise 생성");
    cache = fetchFn();

    return cache;
  };
};

Promise.all을 개선하는 방향으로 저는 다양한 곳에서 재사용 가능하도록 createCachedFetcher 유틸 함수를 만들어 진행했습니다!

함수를 반환하는 클로저 형태이며 캐시를 체크하고 있다면 return. 없다면 콜백 함수의 호출 결과를 cache에 넣고 return 합니다

const fetchMajors = createCachedFetcher(() =>
  axios.get<Lecture[]>(`${base}schedules-majors.json`)
);
const fetchLiberalArts = createCachedFetcher(() =>
  axios.get<Lecture[]>(`${base}schedules-liberal-arts.json`)
);

// TODO: 이 코드를 개선해서 API 호출을 최소화 해보세요 + Promise.all이 현재 잘못 사용되고 있습니다. 같이 개선해주세요.
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()),
  ]);
image

이 부분에서 모든 API 호출 완료가 굉장히 늦은 시간으로 뜨는 이슈가 있었는데 이 이슈는 SearchDialog가 open 상태값이 falsy한 값임에도 먼저 마운트 되어 발생하는 문제였습니다.

해당 이슈는 SearchDialog를 조건부 렌더링 처리하여 searchInfo가 있을때만 마운트 되도록 변경하여 해결했습니다 👍

불필요한 연산 개선

검색 필터링은 “필요할 때, 필요한 만큼만” 수행되어야 합니다. 기존에는 매 타이핑과 페이지 증가마다 모든 강의를 전부 다시 거르고, 각 강의의 스케줄 문자열을 매번 파싱해 불필요한 비용이 컸습니다. 이를 다음과 같이 정리했습니다.

import { useEffect, useState } from "react";

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
}

const SearchDialog = ({ searchInfo, onClose }: Props) => {
  const { setSchedulesMap } = useScheduleActions();

  const loaderWrapperRef = useRef<HTMLDivElement>(null);
  const loaderRef = useRef<HTMLDivElement>(null);
  const [lectures, setLectures] = useState<Lecture[]>([]);
  const [page, setPage] = useState(1);
  const [searchOptions, setSearchOptions] = useState<SearchOption>({
    query: "",
    grades: [],
    days: [],
    times: [],
    majors: [],
  });
  const debouncedQuery = useDebounce(searchOptions.query, 300); // query에 debounce 적용

// ...etc
  • 입력 안정화(useDebounce): 검색어는 useDebounce로 300ms 지연시켜 타이핑 중간중간의 불필요한 필터링을 막았습니다. “언제 필터링을 할지”를 제어해 연산 빈도를 낮춥니다.
  // 검색 옵션 + 캐시를 캡처한 매칭 함수
  const filterFn = useMemo(() => {
    const { credits, grades, days, times, majors } = searchOptions;
    const queryText = debouncedQuery?.trim().toLowerCase();
    const gradesSet = grades.length ? new Set(grades) : null;
    const majorsSet = majors.length ? new Set(majors) : null;
    const daysSet = days.length ? new Set(days) : null;
    const timesSet = times.length ? new Set(times) : null;
    const creditsPrefix = credits ? String(credits) : null;

    return (lecture: Lecture) => {
      if (queryText) {
        const title = lecture.title.toLowerCase();
        const idText = lecture.id.toLowerCase();

        if (!title.includes(queryText) && !idText.includes(queryText)) {
          return false;
        }
      }
      if (gradesSet && !gradesSet.has(lecture.grade)) {
        return false;
      }

      if (majorsSet && !majorsSet.has(lecture.major)) {
        return false;
      }
      if (creditsPrefix && !lecture.credits.startsWith(creditsPrefix)) {
        return false;
      }

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

        if (daysSet && !schedules.some((s) => daysSet.has(s.day))) {
          return false;
        }

        if (
          timesSet &&
          !schedules.some((s) => s.range.some((t) => timesSet.has(t)))
        ) {
          return false;
        }
      }
      return true;
    };
  }, [
    debouncedQuery,
    searchOptions.credits,
    searchOptions.grades,
    searchOptions.days,
    searchOptions.times,
    searchOptions.majors,
  ]);
  • 필터 함수 분리(useMemo): searchOptions와 디바운스된 쿼리를 캡처한 “필터 함수”만 useMemo로 생성하고, 실제 필터링은 나중에 수행합니다. 이렇게 하면 옵션이 바뀌지 않는 한 동일한 함수가 재사용되어 불필요한 재생성을 줄입니다. (클로저 이용) 이후 totalCount, visibleLectures은 useMemo로 메모이제이션 해주었습니다.

지연 평가(Lazy evaluation)

컴퓨터 프로그래밍에서 느긋한 계산법(Lazy evaluation)은 결과가 “필요해지는 순간”까지 계산을 미루는 기법입니다.

lazy load와 달리, “데이터 로드”가 아니라 “연산 자체”를 미루는 데 초점을 둡니다. 반대 개념은 엄격한 평가(strict evaluation)로, 호출 즉시 전부 계산합니다. 저는 이번에 지연 평가에 대해 처음 알게 되어 아래와 같이 공부하고, 문제를 해결해봤습니다.

제너레이터로 구현하는 지연 평가

제네레이터란?

function*로 선언하는 “중단 가능한 함수”. 호출 시 즉시 실행되지 않고, 이터레이터를 반환합니다. 이터레이터의 next()가 호출될 때마다 내부 실행이 진행되며, yield에서 값을 내보내고 “일시 정지”합니다. 다음 next()가 오면 정지했던 지점부터 다시 이어서 실행합니다.

function* counter() {
  let i = 0;
  while (true) {
    const step = yield i; // 값을 내보내고 일시 정지
    i += step ?? 1;       // next(step)으로 외부 값 수신 가능
  }
}

const it = counter();
it.next();      // { value: 0, done: false }
it.next(2);     // { value: 2, done: false }
it.next(2);     // { value: 4, done: false }

JavaScript 제너레이터는 이터러블을 “한 개씩” 흘려보내며 필요할 때만 다음 값을 계산합니다. 이를 이용하면 filter → map → take 같은 파이프라인을 전부 지연된 상태로 구성할 수 있습니다.

// 지연 map
function* lazyMap<T, U>(iter: Iterable<T>, fn: (v: T, i: number) => U): Iterable<U> {
  let i = 0;
  for (const v of iter) yield fn(v, i++);
}

// 지연 filter
function* lazyFilter<T>(iter: Iterable<T>, pred: (v: T, i: number) => boolean): Iterable<T> {
  let i = 0;
  for (const v of iter) if (pred(v, i++)) yield v;
}

// 앞에서부터 최대 N개만 수집 (지연)
function* lazyTake<T>(iter: Iterable<T>, n: number): Iterable<T> {
  if (n <= 0) return;
  let i = 0;
  for (const v of iter) {
    yield v;
    if (++i >= n) return;
  }
}

// Array로 변환(최종 단계에서만 수행)
const toArray = <T>(iter: Iterable<T>) => Array.from(iter);
// 사용 예시
const src = [1, 2, 3, 4, 5, 6];

const pipeline =
  lazyTake(
    lazyMap(
      lazyFilter(src, (x) => x % 2 === 0), // 2, 4, 6
      (x) => x * 10,                      // 20, 40, 60
    ),
    2                                       // 앞에서 2개만
  );

const out = toArray(pipeline); // [20, 40]

이렇게 제너레이터를 이용해 함수를 구성하면 필터/맵이 “당장” 전체를 돌지 않습니다. take(2)가 만족되면 나머지는 아예 계산하지 않습니다.

하지만 과제에서는 totalCount가 필요하기에 전체 데이터를 가져와야해서 제너레이터 함수는 사용하지 않았습니다.

처음 채택한 방법은 es-lodash 의 chain 메서드를 이용해서 지연평가를 적용하려 했습니다. 하지만 production 환경에서 chain 기반 API에 문제가 생겼습니다. 이슈를 발견했고 내용은 아래와 같습니다.

체인 가능한 메서드들이 래퍼에 동적으로 결합되는 구조라, 번들러의 정적 분석과 상충합니다. 그 결과 사용하지 않는 메서드가 번들에 포함되거나, 프로덕션 번들에서 메서드가 잘려 나가 “wrapper.filter is not a function” 같은 런타임 에러가 발생한다는 내용이였습니다.

import _ from 'lodash-es 형태로 사용하거나 tree shaking이 되지않도록 제외하는 방법도 있었지만, 거대한 lodash-es 라이브러리를 tree shaking 없이 사용하는건.. 마이너스가 크다고 생각했습니다.

  import { filter, take } from 'lodash-es';
  const visible = take(filter(lectures, pred), n); // 트리 셰이킹 우호적

조금 더 서칭해봤을 때 chain 메서드 없이 해당 코드로 사용할 경우 tree shaking도 가능했습니다만,, 이미 함수를 통해 구현했기에 고려하지 않았습니다.

export function collectMatchingUpTo<T>(
  items: T[],
  matches: (item: T) => boolean,
  maxCount: number
): T[] {
  const result: T[] = [];

  for (let i = 0; i < items.length && result.length < maxCount; i++) {
    if (matches(items[i])) result.push(items[i]);
  }

  return result;
}

filter된 lecture 리스트, 메모이제이션된 매칭 함수, 필요한 count를 받아 반복문을 통해 필요한 개수만큼 반환해 렌더링을 진행했습니다. 아쉬운 점은 totalCount가 필요해 필터링 된 리스트를 한번 계산하긴 해야한다는 점이 아쉬웠습니다.

왜 Chakra , 가 느릴 수 있는가

준일 코치님이 Q&A 시간에 왜 느린지에 대해 설명해주셨지만 정확히 이해가 가지 않아 한번 코드를 찾아보았습니다. 저는 Tr 컴포넌트를 기준으로 어떻게 구성되어있는지를 한번 파헤쳐보았습니다!

const {
  StylesProvider,
  ClassNamesProvider,
  useRecipeResult,
  withContext,
  useStyles: useTableStyles,
  PropsProvider,
} = createSlotRecipeContext({ key: "table" })

export interface TableRowProps extends HTMLChakraProps<"tr">, UnstyledProp {}

export const TableRow = withContext<HTMLTableRowElement, TableRowProps>(
  "tr",
  "row",
)

TableRow는 createSlotRecipeContext({ key: "table" })가 만들어준 컨텍스트 유틸을 써서 생성됩니다.

  const withContext = <T, P>(
    Component: React.ElementType<any>,
    slot?: R extends keyof ConfigRecipeSlots ? ConfigRecipeSlots[R] : string,
    options?: WithContextOptions<P>,
  ): React.ForwardRefExoticComponent<
    React.PropsWithoutRef<P> & React.RefAttributes<T>
  > => {
    const SuperComponent = chakra(Component, {}, options as any)
    const StyledComponent = forwardRef<any, any>((props, ref) => {
      const { unstyled, ...restProps } = props
      const styles = useStyles()
      const classNames = useClassNames()
      const className = classNames?.[slot as keyof typeof classNames]

      return (
        <SuperComponent
          {...restProps}
          css={[!unstyled && slot ? styles[slot] : undefined, props.css]}
          ref={ref}
          className={cx(props.className, className)}
        />
      )
    })

    // @ts-expect-error
    StyledComponent.displayName = Component.displayName || Component.name
    return StyledComponent as any
  }

withContext("tr", "row")가 내부적으로 chakra(Component)로 한 번 감싸고(SuperComponent), 다시 한 번 forwardRef한 StyledComponent를 반환합니다. StyledComponent는 매 렌더마다

  • useStyles()로 테이블의 멀티-슬롯 스타일 객체(variant/size/theme 토큰 반영)를 컨텍스트에서 읽고
  • useClassNames()로 슬롯별 클래스 네임을 얻고
  • css prop에 슬롯 스타일과 사용자 props.css를 배열로 병합해 넘기며
  • className도 cx()로 병합합니다.
  • 최종적으로 chakra('tr') 래퍼(SuperComponent)에 전달합니다.

이 형태만 봐도 왜 느려지는지 알 것 같지만.. 추측을 해보자면 느려지는 이유는

  • 두 겹 래핑 비용: chakra(Component) + forwardRef 1개 렌더에 “훅 실행 + 스타일 파싱 + 프롭 필터링 + 병합”이 누적됩니다.
  • 테이블은 멀티-슬롯 레시피(행/셀/헤더 등)를 사용합니다 상위 Table의 variant/size/colorMode가 바뀌면 레시피 결과가 바뀌고, useStyles()를 구독하는 모든 슬롯(Tr, Td)이 리렌더됩니다.
  • 컨텍스트 기반 리렌더 전파가 됩니다. useStyles()/useClassNames()는 컨텍스트 값 변화에 반응합니다. 테이블 레벨의 작은 변화(예: 사이즈/variant/색상 모드)가 전체 슬롯 트리를 다시 그리게 만듭니다.

이걸 개선 하려면 바디 영역은 /로, 스타일은 정적 className(사전 CSS)로 처리하고 동적 스타일 최소화하며 가능한 클래스 분기로 처리하고, 스타일 계산은 상위에서 한 번만하는 방법들이 있습니다.

결론은 Tr/Td는 “컨텍스트 기반 스타일 레시피 + 스타일 프롭 파싱 + Emotion 직렬화”를 매 셀마다 수행하는 구조라, 대량 렌더/자주 리렌더 환경에서 네이티브 엘리먼트보다 느려집니다.

리스트 가상화 기법

리스트 가상화(Windowing)는 "사용자가 보고 있는 부분만 실제로 렌더링하자"는 이상적인 기법입니다.

화면 높이가 500px이고 각 항목이 50px라면, 동시에 볼 수 있는 항목은 최대 10개입니다. 그렇다면 1만 개 데이터가 있어도 실제 DOM에는 10~15개 정도만 존재하면 충분합니다. 사용자가 스크롤하면 보이지 않게 된 항목은 DOM에서 제거하고, 새로 보이게 될 항목을 동적으로 생성합니다.

대부분의 이 가상화 기법을 쓸 수 있는 라이브러리들은 아래와 같은 방식으로 설계되었습니다.

  1. 가상 높이 계산: 전체 항목 수 × 항목 높이로 스크롤바 영역 결정
  2. 뷰포트 추적: 현재 스크롤 위치에서 보이는 항목 범위 계산
  3. 동적 렌더링: 보이는 범위의 항목 + 1~2만 실제 DOM으로 렌더링
  4. 위치 조정: CSS transform으로 항목들을 정확한 위치에 배치
  5. 스크롤 연동: 스크롤 이벤트에 따라 렌더링 범위 업데이트
{visibleLectures.map((lecture, index) => (
  <SearchItem key={lecture.id} {...lecture} />
))}
// 1000개 결과 → 1000개 DOM 노드

원래 성능 이슈가 있던 이 SearchItem을

<TableVirtuoso
  data={visibleLectures}
  itemContent={(index, lecture) => (
    <SearchItem {...lecture} />
  )}
/>
// 1000개 결과 → ~20개 DOM 노드 (화면에 보이는 만큼만)

리스트 가상화는 "보이는 것만 렌더링한다"는 단순한 아이디어에서 출발하지만, 대량 데이터 처리에서 혁신적인 성능 개선을 가져다줍니다. 특히 사용자 경험이 중요한 현대 웹 애플리케이션에서는 필수적인 기술 중 하나라고 생각합니다.

다만 모든 리스트에 가상화를 적용할 필요는 없습니다. 데이터 규모와 사용 패턴을 고려해 적절한 시점에 도입하는 것이 중요합니다. 작은 리스트에서는 오히려 복잡도만 증가시킬 수 있으니까요.

마지막으로 react-virtuoso 라이브러리를 통해 리스트 가상화를 진행(아래 GIF 참고)했지만 프로파일러로 확인 시 과제의 불합 여부 체크가 어려울 것 같아 현재 코드에서는 제거해서 올려두었습니다.🙇‍♂️

리뷰 받고 싶은 내용

화면-기록-2025-09-11-오후-6 52 22

gif를 보시면 현재 리스트 windowing (가상화)를 적용해봤습니다. 과제 제출은 제거한 버전으로 올려놨는데 현재 100개씩 데이터를 가져오는데 모바일 환경에서는 DOM의 갯수가 많아질 수록 영향이 있을 것 같아 한번 적용해보았습니다!

궁금한 점이 현재 상황에서 이 리스트 가상화를 적용해 DOM의 갯수를 줄인것과 리렌더링 시 메모이제이션을 통해 이전 데이터는 리렌더링 하지 않는 구조 중 어떤게 더 성능적으로 이득일까요?

DOM의 갯수는 10개지만 스크롤마다 동적으로 index가 바뀌어야 하는 방식과, DOM의 갯수는 1000개 지만 한번 불러오고 더 이상 영향을 주지 않는 현재 메모이제이션 방식 중 이 리스트의 경우 어떤게 올바른 선택일 지 궁금합니다!

과제 피드백

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


gif를 보시면 현재 리스트 windowing (가상화)를 적용해봤습니다. 과제 제출은 제거한 버전으로 올려놨는데 현재 100개씩 데이터를 가져오는데 모바일 환경에서는 DOM의 갯수가 많아질 수록 영향이 있을 것 같아 한번 적용해보았습니다! 궁금한 점이 현재 상황에서 이 리스트 가상화를 적용해 DOM의 갯수를 줄인것과 리렌더링 시 메모이제이션을 통해 이전 데이터는 리렌더링 하지 않는 구조 중 어떤게 더 성능적으로 이득일까요? DOM의 갯수는 10개지만 스크롤마다 동적으로 index가 바뀌어야 하는 방식과, DOM의 갯수는 1000개 지만 한번 불러오고 더 이상 영향을 주지 않는 현재 메모이제이션 방식 중 이 리스트의 경우 어떤게 올바른 선택일 지 궁금합니다!

지금은 react 렌더링 성능을 토대로 측정하고 있는데요, 아예 개발자 도구에 있는 성능 측정 도구를 이용해서 DOM 렌더링 시간을 측정해본 다음에 비교해보시면 좋답니다 ㅎㅎ 사실 대부분의 경우에 가상화 스크롤을 적용하는게 좋을 수 있다고 생각해요 ㅋㅋ 다만 성능에 대한 격차가 크지 않으면, 가령 1회 렌더링에 200ms 이상 걸리는게 아니라면 굳이 적용할필요는 없다고 생각해요! 어차피 체감이 크진 않을테니까요 ㅎㅎ