순차적으로 개선하는 무한 스크롤

bluejoy·2024년 3월 18일
3

React

목록 보기
18/19

개요

무한 스크롤은 매우 좋지만, 여러 개선점이 있다. 오늘은 이 무한 스크롤을 순차적으로 개선해 볼 예정이다. 최종 목표는 가상화 및 sentry 기반 로드를 적용시켜 퍼포먼스와 UX를 개선하는 것이다.

시작

데이터 로드 훅

// useLoadInfiniteData.ts
import {
  useInfiniteQuery,
  InfiniteData,
  QueryKey,
} from "@tanstack/react-query";

const sleep = async (ms: number): Promise<void> => {
  return await new Promise((resolve) => {
    setTimeout(() => resolve(), ms);
  });
};

export interface DummyData {
  id: number;
  title: string;
  src: string;
}

const getSomeData = async (
  startIdx: number,
  dataSize: number,
): Promise<DummyData[]> => {
  await sleep(100);
  return new Array(dataSize).fill(0).map((_, offset) => ({
    title: `${startIdx * dataSize + offset + 1}번째 데이터`,
    id: startIdx * dataSize + offset,
	  src: `https://picsum.photos/seed/${id}/1000`, // 1000*1000 사이즈의 이미지
  }));
};

export const useLoadInfiniteData = (pageSize: number) => {
  return useInfiniteQuery<
    DummyData[],
    Error,
    InfiniteData<DummyData[], number>,
    QueryKey,
    number
  >({
    queryKey: ["data"],
    queryFn: async ({ pageParam: idx }) => {
      return await getSomeData(idx, pageSize);
    },
    initialPageParam: 0,
    getNextPageParam: (_, __, lastPageParam) => {
      return lastPageParam + 1;
    },
    getPreviousPageParam: (_, __, firstPageParam) => {
      if (firstPageParam == 0) {
        return null;
      }
      return firstPageParam - 1;
    },
  });
};

@tanstack/react-query을 사용하는 일반적인 무한 스크롤 데이터 로드 훅이다. 컨텐츠의 정보와 이미지 URL을 가져오는데, 이후 렌더 성능 측정을 위해 항상 정해진 이미지를 가져온다.

페이지

// Test.tsx
import { ReactElement } from "react";
import { useLoadInfiniteData } from "./useLoadInfiniteData";

const PAGE_SIZE = 10;

export const Test = (): ReactElement => {
  const { data: remoteData } = useLoadInfiniteData(PAGE_SIZE);

  const datas = remoteData?.pages.flatMap((page) => page) ?? [];

  return (
    <div
      style={{
        width: "50vw",
        display: "flex",
        flexDirection: "column",
      }}
    >
      {datas.map((data) => (
        <Content key={data.id} data={data} />
      ))}
    </div>
  );
};

일반적인 페이지이다. 아직 로드 관련 코드는 없다.

컨텐츠 컴포넌트

// Content.tsx
import { DummyData } from "./useLoadInfiniteData";

export interface ContentProps {
  data: DummyData;
}

export const Content = ({ data }: ContentProps) => {
  return (
    <div
      style={{
        width: "100%",
        display: "flex",
        flexDirection: "column",
        border: "1px solid black",
        padding: "10px",
      }}
    >
      <img
        src={data.src}
        alt={data.title}
        style={{ width: "100%", height: "auto" }}
      />
      <span>{data.title}</span>
    </div>
  );
};

일반적인 컨텐츠이다.

데이터 로드 기능 추가

sentry 사용하기

특정 요소를 sentry로 정하고, 해당 요소가 뷰포트에 보이면 더 많은 데이터를 로드해오는 방식으로 만들어보자.

초기 버전

// Test.tsx
// sentry 요소를 위한 state(동적이기에 state로 만듬)
const [sentryElement, setSentryElement] = useState<HTMLDivElement | null>(null);
// sentry 요소에 부착될 ref
const sentryRefCallback = (node: HTMLDivElement | null) => {
  setSentryElement(node);
};

우선 sentry 요소를 위한 state와 sentry 요소에 부착되어 state를 갱신해줄 ref 함수 2가지를 만들었다.

// Test.tsx
useEffect(() => {
  if (sentryElement == null) {
    return;
  }
  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) {
        fetchNextPage();
      }
    },
    {
      threshold: 0.5,
    },
  );
  observer.observe(sentryRef);
  return () => {
    observer.disconnect();
  };
}, [sentryElement, fetchNextPage]);

그리고 useEffect를 이용해서 해당 요소가 변경될 때 마다 IntersectionObserver를 생성하고 해당 entry를 감지하도록 했다. IntersectionObserver는 감지된 요소가 뷰포트 내부에 50% 이상 존재하면 fetchNextPage를 호출한다.

<div
  style={{
    width: "50vw",
    display: "flex",
    flexDirection: "column",
  }}
>
  // ... 생략
  {hasNextPage && !isFetchingNextPage && (
    <div
      ref={sentryRefCallback}
      style={{
        width: "100%",
        backgroundColor: "red",
      }}
    >
      ...loading
    </div>
  )}
</div>

목록 제일 하단에는 sentry 요소를 추가한다. 해당 요소에는 로딩중이라는 문구를 넣어 사용자가 인지할 수 있게 한다.

// Test.tsx
import { ReactElement, useEffect, useState } from "react";
import { useLoadInfiniteData } from "./useLoadInfiniteData";

const PAGE_SIZE = 10;

export const Test = (): ReactElement => {
  const {
    data: remoteData,
    hasNextPage,
    isFetchingNextPage,
    fetchNextPage,
  } = useLoadInfiniteData(PAGE_SIZE);
  const datas = remoteData?.pages.flatMap((page) => page) ?? [];
  const [sentryElement, setSentryElement] = useState<HTMLDivElement | null>(null);
  const sentryRefCallback = (node: HTMLDivElement | null) => {
    setSentryElement(node);
  };

  useEffect(() => {
    if (sentryElement == null) {
      return;
    }
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          fetchNextPage();
        }
      },
      {
        threshold: 0.5,
      },
    );
    observer.observe(sentryElement);
    return () => {
      observer.disconnect();
    };
  }, [sentryElement, fetchNextPage]);
  return (
    <div
      style={{
        width: "50vw",
        display: "flex",
        flexDirection: "column",
      }}
    >
      // ... 생략
      {hasNextPage && !isFetchingNextPage && (
        <div
          ref={sentryRefCallback}
          style={{
            width: "100%",
            backgroundColor: "red",
          }}
        >
          ...loading
        </div>
      )}
    </div>
  );
};

전체적으로는 이렇게 바뀌었다.

IntersectionObserver를 통해 sentry 요소를 감지하면 추가 데이터를 로드하는 간단한 구조이다.

IntersectionObserver의 재생성 막기 & 훅으로 분리

퍼포먼스를 더 개선하자면 IntersectionObserver는 매번 재생성될 필요가 없으므로 ref로 만들어준다. 그리고 재사용성 및 코드 분리를 위해 sentry 감지 로직을 별도의 훅으로 분리한다.

https://github.com/onderonur/react-intersection-observer-hook/blob/master/src/useIntersectionObserver.ts#L87
을 참조했습니다.

// useSentryFetch.ts
interface UseSentryFetchParams {
  fetchNextPage: () => void;
}
const useSentryFetch = ({ fetchNextPage }: UseSentryFetchParams) => {
  const observerRef = useRef<IntersectionObserver>(
    new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          fetchNextPage();
        }
      },
      {
        threshold: 0.5,
      },
    ),
  );
  const [sentryElement, setSentryElement] = useState<HTMLDivElement | null>(null);
  const sentryRefCallback = (node: HTMLDivElement | null) => {
    unobserve(sentryElement);
    setSentryElement(node);
    observe(node);
  };

  const observe = (node: HTMLDivElement | null) => {
    if (node == null) {
      return;
    }
    observerRef.current.observe(node);
  };

  const unobserve = (node: HTMLDivElement | null) => {
    if (node == null) {
      return;
    }
    observerRef.current.unobserve(node);
  };

  return { sentryRefCallback };
};

ref 기반으로 바뀐 것 말고는 이전의 코드에서 크게 달라진 것이 없다.

단점

현재는 유저가 스크롤 마지막의 sentry 요소를 뷰포트 내부로 가져오기 전에는 로드를 시작하지 않기 때문에 사용자 경험이 좋지 않다.

N번째 이전 요소를 감지해서 로드하기

sentryRefCallback의 요소를 변경해서 개선해보자. 이번에는 N번째 이전 요소를 sentry로 삼을 것이다. 이를 위해서 기존의 Content 컴포넌트에 forwardRef를 추가해준다. forwardRef를 사용하면 컴포넌트에서 ref를 통해 부모 요소에게 DOM 노드를 전달할 수 있다.

// Content.tsx
import { ForwardedRef, forwardRef } from "react";
import { DummyData } from "./useLoadInfiniteData";

export interface ContentProps {
  data: DummyData;
}

const ContentComponent = (
  { data }: ContentProps,
  ref: ForwardedRef<HTMLDivElement>,
) => {
  return (
    <div
      style={{
        width: "100%",
        display: "flex",
        flexDirection: "column",
        border: "1px solid black",
        padding: "10px",
      }}
      ref={ref}
    >
      <img
        src={data.src}
        alt={data.title}
        style={{ width: "100%", height: "auto" }}
      />
      <span>{data.title}</span>
    </div>
  );
};

export const Content = forwardRef<HTMLDivElement, ContentProps>(
  ContentComponent,
);

변경된 컴포넌트에다가 ref callback을 추가해주자.

// Test.tsx
// 생략

const PAGE_SIZE = 10;
export const Test = (): ReactElement => {
  // 생략
  const isLoadable = hasNextPage && !isFetchingNextPage;
  const isRef = (idx: number) => idx == datas.length - PAGE_SIZE && isLoadable;
  return (
    <div
      style={{
        width: "50vw",
        display: "flex",
        flexDirection: "column",
      }}
    >
      {datas.map((data, idx) => (
	      <Content
		      key={data.id}
          ref={isRef(idx) ? sentryRefCallback : null}
          data={data}
	      />
      ))}
      // ref 제거
      {isLoadable && (
        <div
          style={{
            width: "100%",
            backgroundColor: "red",
          }}
        >
          ...loading
        </div>
      )}
    </div>
  );
};

마지막 요소에서 10번째 전의 요소를 감지하면 로드하도록 로직을 변경해보았다. 로드가 오래 걸릴 경우 유저에게 로드 중이라는 것을 보여주기 위해 로딩 컴포넌트는 유지한다. 유저가 스크롤을 순차적으로 너무 빠르지 않게 내리는 경우에는 잘 동작한다. 그러나 유저가 빠른 스크롤을 통해 sentry 요소를 보지 않고 이동할 경우 로드할 수 없다.

마지막 페이지를 감지해서 로드하기

그렇다면 sentry 영역을 넓혀서 대응하자. 마지막 페이지를 통째로 감지한다면 더 효율적으로 감지가 가능하다.

// Test.tsx
// 생략
export const Test = (): ReactElement => {
  const {
    data: remoteData,
    hasNextPage,
    isFetchingNextPage,
    fetchNextPage,
  } = useLoadInfiniteData(PAGE_SIZE);
  // flatmap으로 평탄화하는 대신 각 페이지 별로 관리
  const pages = remoteData?.pages ?? [];
  const { sentryRefCallback } = useSentryFetch({ fetchNextPage });

  const isLoadable = hasNextPage && !isFetchingNextPage;
  const isRef = (idx: number) => idx == pages.length - 1 && isLoadable;
  return (
    <div
      style={{
        width: "50vw",
        display: "flex",
        flexDirection: "column",
      }}
    >
      {pages.map((page, idx) => (
        <div ref={isRef(idx) ? sentryRefCallback : null} key={`page-${idx}`}>
          {page.map((data) => (
            <Content key={data.id} data={data} />
          ))}
        </div>
      ))}
      // 로딩 컴포넌트 생략
    </div>
  );
};

이렇게 할 경우 useSentryFetchthreshold를 조정해줘야 한다. 그렇지 않으면 감지 영역의 높이 50%보다 뷰포트 높이가 낮은 경우가 빈번하게 발생해 데이터를 로드해오지 못한다.

// useSentryFetch.ts

([entry]) => {
  if (entry.isIntersecting) {
    fetchNextPage();
  }
},
{
  threshold: 0.1,
},

이렇게 바꾼다면 이전에 발생한 예외 케이스에 대응 가능하다. page 기반으로 관리하기에 queryClient의 캐시도 관리가 쉬워지는 부가적인 장점이 생긴다.

모든 page를 순회하며 해당 요소를 찾아 캐시를 갱신할 필요가 없음.

스크롤 이벤트 기반으로 로드하기

스크롤 이벤트 기반으로 로드하는 것도 가능하다. 한번 만들어보자.

스크롤 기반 훅 만들기

import { useEffect } from "react";

interface UseScrollFetchParams {
  fetchNextPage: () => void;
  isLoadable: boolean;
  fetchLimitY?: number;
  scrollElement?: HTMLElement | null;
}
export const useScrollFetch = ({
  fetchNextPage,
  isLoadable,
  fetchLimitY = 500,
  scrollElement = document.documentElement,
}: UseScrollFetchParams) => {
  useEffect(() => {
    if (scrollElement == null) {
      return;
    }
    const handleScroll = () => {
      const { scrollTop, scrollHeight, clientHeight } = scrollElement;
      if (!isLoadable) {
        return;
      }
      if (scrollHeight - (scrollTop + clientHeight) >= fetchLimitY) {
        return;
      }
      fetchNextPage();
    };

    if (scrollElement === document.documentElement) {
      window.addEventListener("scroll", handleScroll);
      return () => {
        window.removeEventListener("scroll", handleScroll);
      };
    }
    scrollElement.addEventListener("scroll", handleScroll);
    return () => {
      scrollElement.removeEventListener("scroll", handleScroll);
    };
  }, [isLoadable, fetchNextPage, fetchLimitY, scrollElement]);
};

scrollElement로부터 스크롤 높이(scrollHeight), 스크롤 바닥의 위치(scrollTop + clientHeight)를 비교해 스크롤 가능한 높이를 구하고 그 높이가 임계치(fetchLimitY) 미만이고 로드가 가능할 경우 fetchNextPage를 실행하는 간단한 훅이다.

기본 값으로는 document를 대상으로 수행되며 남은 스크롤 가능한 높이가 500px일 경우 데이터를 로드해온다.

// Test.tsx
// 생략
export const Test = (): ReactElement => {
  // 생략
  const scrollElement = useRef<HTMLDivElement | null>(null);
  useScrollFetch({
    fetchNextPage,
    isLoadable,
    scrollElement: scrollElement.current,
  });
  return (
    <div
      ref={scrollElement}
      style={{
        width: "50vw",
        display: "flex",
        flexDirection: "column",
        height: "50vh",
        overflow: "scroll",
      }}
    >
	    // 생략
    </div>
  );
};

이렇게 스크롤 요소(컨테이너)를 정해 사용하거나

// Test.tsx
// 생략
export const Test = (): ReactElement => {
  // 생략

  const isLoadable = hasNextPage && !isFetchingNextPage;

  useScrollFetch({
    fetchNextPage,
    isLoadable,
  });
  return (
    <div
      style={{
        width: "50vw",
        display: "flex",
        flexDirection: "column",
      }}
    >
      // 생략
    </div>
  );
};

문서 전체를 대상으로 감지 가능하다.

조금 더 개선해보자

매 스크롤 시 handleScroll 함수가 호출되는 것은 비효율적이므로 설정한 기간 동안 최대 한번만 호출되도록 throttle을 추가해주자.

// useScrollFetch.ts
const throttle = (callback: () => void, ms: number) => {
  let lastTime = 0;
  return () => {
    const now = new Date().getTime();
    if (now - lastTime < ms) {
      return;
    }
    lastTime = now;
    callback();
  };
};
useEffect(() => {
    if (scrollElement == null) {
      return;
    }
    const handleScroll = throttle(() => {
      const { scrollTop, scrollHeight, clientHeight } = scrollElement;
      if (!isLoadable) {
        return;
      }
      if (scrollHeight - (scrollTop + clientHeight) >= fetchLimitY) {
        return;
      }
      fetchNextPage();
    }, 160);

    if (scrollElement === document.documentElement) {
      window.addEventListener("scroll", handleScroll);
      return () => {
        window.removeEventListener("scroll", handleScroll);
      };
    }
    scrollElement.addEventListener("scroll", handleScroll);
    return () => {
      scrollElement.removeEventListener("scroll", handleScroll);
    };
  }, [isLoadable, fetchNextPage, fetchLimitY, scrollElement]);

최대 160ms에 한번 실행되도록 최적화를 적용하였다.

둘 중 어느게 좋을까?

무한 스크롤 구현 방법으로는 크게 sentry 요소 기반스크롤 이벤트 기반 두 가지가 있습니다. 각 방법은 장단점을 가지고 있으며, 어떤 방식을 선택할지는 구현하려는 애플리케이션의 요구 사항과 성능 목표에 따라 달라집니다.

Sentry 요소 기반 (Intersection Observer API)

장점:

  • 성능: 스크롤 이벤트를 지속적으로 감지하는 대신, 특정 요소가 뷰포트에 들어오는지를 감지하기 때문에 성능이 더 우수합니다.
  • 간결함: 스크롤 이벤트 리스너를 직접 관리할 필요가 없어 코드가 더 간결해질 수 있습니다.
  • 호환성: 대부분의 현대 브라우저에서 지원됩니다.

단점:

  • 브라우저 호환성: 구형 브라우저에서는 지원되지 않을 수 있습니다. (폴리필을 사용할 수 있으나 추가적인 고려가 필요)
  • 동적 콘텐츠: 페이지 내용이 동적으로 변할 때, 관찰 대상 요소를 적절히 관리해야 합니다.

스크롤 이벤트 기반

장점:

  • 유연성: 스크롤 위치에 기반해 다양한 동작을 정밀하게 제어할 수 있습니다.
  • 호환성: 모든 브라우저에서 널리 지원됩니다.

단점:

  • 성능 문제: 스크롤 이벤트는 매우 빈번하게 발생할 수 있으며, 이로 인해 성능 저하가 발생할 수 있습니다. 이를 완화하기 위해 스로틀링(throttling) 또는 디바운싱(debouncing) 기법을 적용해야 합니다.
  • 복잡성: 스크롤 이벤트를 직접 관리해야 하며, 성능 최적화를 위한 추가적인 로직이 필요할 수 있습니다.

결론

  • 일반적으로 성능과 간결함을 중시한다면, sentry 요소 기반의 접근 방식(Intersection Observer API 사용)을 추천합니다. 이 방법은 브라우저가 자체적으로 최적화된 방식으로 요소의 가시성을 체크하기 때문에, 스크롤 이벤트를 수동으로 관리하는 것보다 더 효율적입니다.
  • 구형 브라우저 지원이 중요하거나 더 세밀한 스크롤 제어가 필요한 경우, 스크롤 이벤트 기반의 접근 방식을 선택할 수 있습니다. 이 경우, 성능을 위해 스로틀링이나 디바운싱 기법을 적절히 활용해야 합니다.

by chatgpt

이 부분에 대해서는 gpt의 답변을 인용하겠다.

렌더링 성능

문제

테스트 환경

  • 시크릿 탭
  • 요소 개수: 1000개
  • lighthouse에서 Device는 Mobile을 선택, Catergories에는 Performance만 체크해주었다.

퍼포먼스

퍼포먼스를 좀 더 극적으로 보기 위해 위의 테스트 환경에서 측정해보았다. 현재는 LCP까지 10.9초가 걸렸다. 개발 모드이기에 정확한 수치는 의미가 없지만 상대적으로 비교가 가능할 것으로 기대된다.

다시 sentry 기반으로 복구하고 개선해보겠다.

lazy loading

이미지에 lazy loading 적용

가장 간단하게 최적화하기 위해 이미지에게 지연 로딩을 적용 시켜보자

지연 로딩(lazy loading)은 리소스를 논 블로킹(중요하지 않음)으로 식별하여 필요할 때만 로드하는 전략입니다. 이는 중요 렌더링 경로의 길이를 단축하는 방법으로, 페이지 로드 시간을 감소시킬 수 있습니다.

지연 로딩은 애플리케이션의 여러 순간에 발생할 수 있지만, 일반적으로 스크롤 및 내비게이션과 같은 일부 사용자의 상호 작용에서 발생합니다.
https://developer.mozilla.org/ko/docs/Web/Performance/Lazy_loading

이미지에는 지연 속성을 설정함으로써 지연 로딩이 가능하다.

// Content.tsx
<div
  style={{
    width: "100%",
    display: "flex",
    flexDirection: "column",
    border: "1px solid black",
    padding: "10px",
  }}
  ref={ref}
>
  <img
    src={data.src}
    alt={data.title}
    style={{ width: "100%", height: "auto" }}
    loading="lazy" // 추가
  />
  <span>{data.title}</span>
</div>

**loading: 브라우저가 이미지를 불러올 때 사용할 방식을 지정합니다.**
eager: 뷰포트 안에 위치하는지 여부에 상관하지 않고 이미지를 즉시 불러옵니다. (기본값)
lazy: 이미지가 뷰포트의 일정 거리 안으로 들어와야 불러옵니다. 거리는 브라우저가 정의합니다. 이미지를 보게 될 것으로 충분히 예상 가능한 상황에만 불러옴으로써, 불필요하게 네트워크와 저장소 대역폭을 낭비하지 않을 수 있습니다. 또한 일반적인 사용처에서는 대개 성능을 향상할 수 있습니다.
https://developer.mozilla.org/ko/docs/Web/HTML/Element/img#loading

이것만으로도 로드 성능이 크게 개선될 것으로 기대했지만

LCP가 개선되지 않았다. 대신 Total Blocking Time이 감소해 점수가 증가했는데,

위가 기존, 아래가 lazy loading 버전

아마 이미지 로드 및 렌더링에 관한 스크립트 시간이 줄어서 그런 듯 하다. 그러나 이미지를 지연 로드해오기에 Layout Shift가 체감되게 발생하며 Reflow(이후의 Repainting)가 발생하는 것은 변함이 없다.

CLS(레이아웃 변경 횟수)가 증가하면 사용자가 예기치 않은 레이아웃 변경을 경험하기에 코어 웹 바이탈 지표가 하락한다.

예기치 않은 레이아웃 변경으로 인해 텍스트가 갑자기 움직이면 읽는 동안 원래 있던 위치를 잃거나 잘못된 링크나 버튼을 클릭하게 되는 등 여러 가지 방식으로 사용자 환경에 지장을 줄 수 있습니다. 경우에 따라, 이렇게 하면 심각한 손상이 발생할 수 있습니다.
https://web.dev/articles/cls?utm_source=devtools&hl=ko

이미지 영역 미리 잡아두기

이미지 영역을 미리 정해줌으로써 Layout Shift를 방지할 수 있다.

이미지 및 동영상 요소에 항상 width 및 height 크기 속성을 포함하거나 CSS aspect-ratio를 사용하여 필요한 공간을 예약합니다. 이렇게 하면 이미지가 로드되는 동안 브라우저가 문서에 올바른 공간을 할당할 수 있습니다.
https://web.dev/articles/optimize-cls?hl=ko#images-without-dimensions

여기서는 반응형(컨테이너의 50vw에 맞춰짐)으로 이미지가 구성되어 있기에 aspect-ratio를 사용해서 비율을 정해줄 것이다.

// Content.tsx
<div
  style={{
    width: "100%",
    display: "flex",
    flexDirection: "column",
    border: "1px solid black",
    padding: "10px",
  }}
  ref={ref}
>
  <img
    src={data.src}
    alt={data.title}
    style={{ width: "100%", height: "auto", aspectRatio: "1" }} // 비율 추가
    loading="lazy"
  />
  <span>{data.title}</span>
</div>

1:1 비율의 이미지만 존재하기에aspect-ratio만 설정해주었다. 만약 비율이 다양하다면 반드시 목록 api에서 이미지의 사이즈에 대한 정보를 제공해주어야 한다.

이미지의 영역이 미리 정해져 있기에 로드 전과 로드 후의 영역이 같아 Layout Shift가 없어졌다. 그러나 Lazy Loading은 좋은 방법이지만 결국 요소가 계속 늘어나면 렌더 성능은 지연될 것이다.

요소가 10000개로 늘어났을 때의 퍼포먼스. 무척 끔찍한 점수를 보여준다.

가상화

소개

많은 요소에 대해 보이는 만큼만 렌더링함으로써 렌더 성능을 개선 가능하다. 이를 흔히 가상화(virtualization)라고 부른다.

spend time generating the visuals only for the content we see and care about.
https://www.kirupa.com/hodgepodge/ui_virtualization.htm

무한 스크롤에 가상화를 적용시켜 더 성능을 증가시켜 보자.

가상 높이 설정

우선 총 요소의 개수만큼 스크롤을 발생시키기 위해 가상으로 높이를 설정해주어야 한다. 지금은 각 요소가 부모 컨테이너의 너비인 50vw에 맞춰진 높이를 가지고 있다.

각 요소가 50vw + 19.5px의 높이를 가지고 있다. 그럼 총 높이는 calc(calc(50vw + 19.5px) * ${contentLength})로 나타낼 수 있다. 이 컨테이너에 relative position을 주고 각 요소를 absolute position으로 잡아주며 위치에 맞게 설정 해주면 된다!! 무척 쉽다.

// Test.tsx
// 생략

export const Test = (): ReactElement => {
  const {
    data: remoteData,
    hasNextPage,
    isFetchingNextPage,
  } = useLoadInfiniteData(PAGE_SIZE);

  const contents = (remoteData?.pages ?? []).flat();
  const contentLength = contents.length;
  const isLoadable = hasNextPage && !isFetchingNextPage;
	const contentHeight = `calc(50vw + 19.5px)`;
  return (
    <div
      style={{
        width: "50vw",
        display: "flex",
        flexDirection: "column",
        height: `calc(${contentHeight} * ${contentLength})`,
        position: "relative",
      }}
    >
      {contents.map((data,index) => (
        <Content
          key={data.id}
          data={data}
          top={`calc(${contentHeight} * ${index})`}
        />
      ))}
      // 로딩 생략
    </div>
  );
};

page를 다시 평탄화 시켰다. 이는 개발의 편의성을 위한 것이다. 컨테이너에 고정 높이 및 relative 속성을 부여하고 각 컨텐츠의 상단 위치를 contentHeight * index로 본다.

// Content.tsx
<div
  key={data.id}
  style={{
    width: "100%",
    display: "flex",
    flexDirection: "column",
    border: "1px solid black",
    padding: "10px",
    position: "absolute",
    top: top,
  }}
  ref={ref}
>
  <img
    src={data.src}
    alt={data.title}
    style={{ width: "100%", height: "auto", aspectRatio: "1" }}
    loading="lazy"
  />
  <span>{data.title}</span>
</div>

컨텐츠에 absolute 속성을 부여하고 전달받은 top 값으로 위치를 정해주면 가상화를 적용하기 위한 준비는 끝났다. 이제 간단하게 가상화 목록을 모의로 추가해 테스트해보겠다.

const virtualContents = contents.slice(0, PAGE_SIZE).map((data, index) => ({
  virtualIndex: index,
  data,
}));

return (
	{virtualContents.map(({ data, virtualIndex }) => (
	  <Content
	    key={data.id}
	    data={data}
	    top={`calc(${contentHeight} * ${virtualIndex})`}
	  />
	))}
)

이렇게 하면 10개의 요소만 위치에 렌더링 가능하다. 이제 우리가 할 일은 스크롤 이벤트가 발생하면 virtualContents가 바라보는 contents의 위치를 바꿔주면 된다.

Intersection Observer 추가

IntersectionObserver를 사용하는 방법을 먼저 시도해 보았다.

// Test.tsx
const virtualSize = 30; // 가상화된 요소의 갯수
const [virtualPos, setVirtualPos] = useState({ // 가상화 위치
  start: 0,
  end: virtualSize,
});

const observer = useRef<IntersectionObserver>(
  new IntersectionObserver((entries) => {
    const entry = entries[0];
    if (entry.isIntersecting) {
      return;
    }
		// 감지된 요소의 현재 위치를 통해 스크롤 방향을 알아낸다.
    const isScrollDown = entry.boundingClientRect.top < 0;
    unobserve(entry.target);
    if (isScrollDown) {
	    // 스크롤이 아래 방향이라면 해당 요소 + 1로 가상 요소의 위치를 옮긴다.
      const target = entry.target as HTMLDivElement;
      const virtualIndex = Number(target.dataset.virtualIndex);
      setVirtualPos({
        start: virtualIndex + 1,
        end: virtualIndex + virtualSize + 1,
      });
    } else {
	    // 스크롤이 아래 방향이라면 해당 요소 - 1로 가상 요소의 위치를 옮긴다.
      const target = entries.slice(-1)[0].target as HTMLDivElement;
      const virtualIndex = Number(target.dataset.virtualIndex);
      setVirtualPos({
        start: Math.max(0, virtualIndex - virtualSize - 1),
        end: Math.max(virtualSize, virtualIndex - 1),
      });
    }
  }),
);

const observe = (node: Element) => {
  observer.current.observe(node);
};
const unobserve = (node: Element) => {
  observer.current.unobserve(node);
};
const unobserveAll = () => {
  observer.current.disconnect();
};

const virtualContents = contents
  .slice(virtualPos.start, virtualPos.end)
  .map((data, index) => ({
    virtualIndex: index + virtualPos.start,
    data,
    ref: (node: HTMLDivElement | null) => {
      if (node == null) {
        return;
      }
      // ref를 통해 렌더 후 관찰한다.
      observe(node);
    },
  }));
// 메모리 낭비를 방지하기 위해 렌더 전 모든 관찰을 해제한다.
unobserveAll();
return (
    <div
      style={{
        width: "50vw",
        display: "flex",
        flexDirection: "column",
        height: `calc(${contentHeight} * ${contentLength})`,
        position: "relative",
      }}
    >
      {virtualContents.map(({ data, virtualIndex, ref }) => (
        <Content
          key={data.id}
          top={`calc(${contentHeight} * ${virtualIndex})`}
          ref={ref}
          virtualIndex={virtualIndex}
        />
      ))}
      // 생략
    </div>
  );

가상 요소들의 ref로 해당 요소를 감지에 등록하고, 감지된다면 data-virtual-index를 통해 가상화 위치를 얼마나 옮겨야 될지 알아내고, 옮긴다. 만약 스크롤이 위 방향이라면 사라진 가상화 요소의 상대적인 y값은 0보다 클 것이다. 아래 방향이라면 반대이다. 방향에 따라 해당 요소를 마지막으로 갱신하거나, 처음으로 갱신해주면 된다.

import { ForwardedRef, forwardRef } from "react";
import { DummyData } from "./useLoadInfiniteData";

export interface ContentProps {
  data: DummyData;
  virtualIndex: number;
}

const ContentComponent = (
  { data, virtualIndex }: ContentProps,
  ref: ForwardedRef<HTMLDivElement>,
) => {
  return (
    <div
      style={{
        width: "100%",
        display: "flex",
        flexDirection: "column",
        border: "1px solid black",
        padding: "10px",
      }}
      data-virtual-index={virtualIndex}
      ref={ref}
    >
      <img
        src={data.src}
        alt={data.title}
        style={{ width: "100%", height: "auto" }}
        loading="lazy"
      />
      <span>{data.title}</span>
    </div>
  );
};

export const Content = forwardRef<HTMLDivElement, ContentProps>(
  ContentComponent,
);

컨텐츠에는 이렇게 data-virtual-index를 부여해준다.

개발자 도구를 통해 요소 개수를 세어보면 가상화가 적용된 것을 확인 가능하다.

이렇게 하면 점수가 큰 폭으로 개선된다(10000개 기준).

그러나 스크롤을 빠르게 하거나, 스크롤 바를 통해 감지 이벤트 전에 가상화 목록 아래로 내려가버리면 현재의 구현에서는 문제가 발생한다. 매 요소가 사라짐을 순차적으로 감지하고 최대 가상화 요소의 개수만큼 이동하기에 몇천개의 요소를 건너뛰면 로드되기까지 몇초가 걸린다.

해결할 방법을 못찾아서 스크롤 이벤트 기반으로 바꾸기로 결정했다.

scroll 기반으로 변경

// Test.tsx
const virtualSize = 30;
const [virtualPos, setVirtualPos] = useState({
  start: 0,
  end: virtualSize,
});
const scrollRef = useRef<HTMLDivElement>(null);

const computeHeight = useCallback((height: string): number => {
  const _elem = document.createElement("div");
  _elem.style.height = height;
  _elem.style.visibility = "hidden";
  document.body.appendChild(_elem);
  const realContentHeight = _elem.clientHeight;
  document.body.removeChild(_elem);
  return realContentHeight;
}, []);
const scrollListener = useCallback(() => {
  if (!scrollRef.current) {
    return;
  }
  const scrollTop = -scrollRef.current.getBoundingClientRect().top;
  const virtualIndex = Math.floor(scrollTop / computeHeight(contentHeight));
  const start = Math.max(0, virtualIndex - virtualSize);
  const end = Math.min(contentLength, virtualIndex + virtualSize);
  setVirtualPos({ start, end });
}, [contentHeight, contentLength, virtualSize, scrollRef]);
useEffect(() => {
  window.addEventListener("scroll", scrollListener);
  return () => {
    window.removeEventListener("scroll", scrollListener);
  };
}, [scrollListener]);

scrollRef를 컨테이너에 부착시켜주면 된다. 스크롤 이벤트 기반으로 virtualSize만큼의 가상화 요소를 렌더링한다. 퍼포먼스는 이전과 비슷하다.

현재 아쉬운 점은 2가지이다.

  • 클라이언트 높이에 맞춰진 가상화 아이템 개수.
  • 공통 훅으로 분리 안함

클라이언트 높이에 맞춰진 가상화 아이템 개수

// Test.tsx
const {
  data: remoteData,
  hasNextPage,
  isFetchingNextPage,
} = useLoadInfiniteData(PAGE_SIZE);

const contents = (remoteData?.pages ?? []).flat();
const contentLength = contents.length;
const isLoadable = hasNextPage && !isFetchingNextPage;
const contentHeight = `calc(50vw + 19.5px)`;

/**
 * @description string 높이를 계산하여 pixel로 반환
 */
const computePixelHeight = useCallback((): number => {
  const _elem = document.createElement("div");
  _elem.style.height = contentHeight;
  _elem.style.visibility = "hidden";
  document.body.appendChild(_elem);
  const realContentHeight = _elem.clientHeight;
  document.body.removeChild(_elem);
  return realContentHeight;
}, [contentHeight]);

/**
 * @description 화면에 들어가는 virtual size를 계산하여 반환
 */
const computeVirtualSize = useCallback(() => {
  return Math.ceil(window.innerHeight / computePixelHeight()) + 2;
}, [computePixelHeight]);
const virtualSize = useRef(computeVirtualSize());
const [virtualPos, setVirtualPos] = useState({
  start: 0,
  end: virtualSize.current,
});

const scrollRef = useRef<HTMLDivElement>(null);

const handleScroll = useCallback(() => {
  if (scrollRef.current == null) {
    return;
  }
  const scrollTop = -scrollRef.current.getBoundingClientRect().top;

  const virtualIndex = Math.floor(scrollTop / computePixelHeight());
  const start = Math.max(0, virtualIndex);
  const end = Math.min(contentLength, virtualIndex + virtualSize.current);
  setVirtualPos({ start, end });
}, [contentLength, scrollRef, computePixelHeight]);
const handleResize = useCallback(() => {
  const newVirtualSize = computeVirtualSize();
  virtualSize.current = newVirtualSize;
  handleScroll();
}, [handleScroll, computeVirtualSize]);
useEffect(() => {
  window.addEventListener("resize", handleResize);
  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, [handleResize]);
useEffect(() => {
  window.addEventListener("scroll", handleScroll);
  return () => {
    window.removeEventListener("scroll", handleScroll);
  };
}, [handleScroll]);

const virtualContents = contents
  .slice(virtualPos.start, virtualPos.end)
  .map((data, index) => ({
    virtualIndex: index + virtualPos.start,
    data,
  }));

스트링으로 주어진 높이 값을 계산하기 위해 실제 돔 요소를 가지고 측정한다(computePixelHeight). 그렇게 계산된 픽셀 높이를 가지고 화면의 내부 크기를 나눠 요소가 들어갈 개수를 측정한다. 이 측정은 페이지의 크기가 변경될 때마다 다시 수행한다(resize).

스크롤 시에는 현재 스크롤 상단의 위치를 요소 높이로 나눠 현재 위치를 알아낸다. 알아낸 위치를 통해 가상화 위치를 적절하게 조정한다.

이로써, 사용자의 화면 크기에 맞게 가상화 아이템의 개수가 동적으로 조정된다. 또한, 화면 크기가 변경될 경우에도 적절하게 반응하여 가상화 아이템의 개수를 재조정한다. 이렇게 함으로써 사용자의 화면 크기에 관계없이 최적의 성능을 유지할 수 있다. 다만, 아직 공통 훅으로 분리되지 않았다.

훅으로 분리

// Test.tsx
// 생략
export const Test = (): ReactElement => {
  const {
    data: remoteData,
    hasNextPage,
    isFetchingNextPage,
  } = useLoadInfiniteData(PAGE_SIZE);
	// 현재 다음 페이지를 가져오는 코드는 임시로 제거
  const contents = (remoteData?.pages ?? []).flat();
  const isLoadable = hasNextPage && !isFetchingNextPage;
  const contentHeight = `calc(50vw + 19.5px)`;
  const { virtualContents, containerHeight } = useScrollVirtualizer({
    contents,
    contentHeight,
  });
  return (
    <div
      style={{
        width: "50vw",
        display: "flex",
        flexDirection: "column",
        height: containerHeight,
        position: "relative",
      }}
    >
      {virtualContents.map(({ data, top }) => (
	      <Content
          key={data.id}
          top={top}
          ref={ref}
          virtualIndex={virtualIndex}
        />
      ))}
      {isLoadable && (
        <div
          style={{
            width: "100%",
            backgroundColor: "red",
            position: "absolute",
            bottom: 0,
          }}
        >
          ...loading
        </div>
      )}
    </div>
  );
};
// useScrollVirtualizer.ts
import { useCallback, useEffect, useRef, useState } from "react";

interface VirtualItem<T> {
  virtualIndex: number;
  data: T;
  top: string;
}
interface ScrollVirtualizerParams<T> {
  contents: T[];
  overscan?: number;
  contentHeight: string;
  scrollElement?: HTMLElement | null;
}

interface ScrollVirtualizerReturns<T> {
  virtualContents: VirtualItem<T>[];
  containerHeight: string;
}
export const useScrollVirtualizer = <T>({
  contents,
  overscan = 10,
  contentHeight,
  scrollElement = window.document.documentElement,
}: ScrollVirtualizerParams<T>): ScrollVirtualizerReturns<T> => {
  const contentLength = contents.length;
  /**
   * @description string 높이를 계산하여 pixel로 반환
   */
  const computePixelHeight = useCallback((): number => {
    const _elem = document.createElement("div");
    _elem.style.height = contentHeight;
    _elem.style.visibility = "hidden";
    document.body.appendChild(_elem);
    const realContentHeight = _elem.clientHeight;
    document.body.removeChild(_elem);
    return realContentHeight;
  }, [contentHeight]);

  /**
   * @description 화면에 들어가는 virtual size를 계산하여 반환
   */
  const computeVirtualSize = useCallback(() => {
    if (scrollElement == null) {
      return 0;
    }
    return (
      Math.ceil(scrollElement.clientHeight / computePixelHeight()) + overscan
    );
  }, [computePixelHeight, overscan, scrollElement]);
  const virtualSize = useRef(computeVirtualSize());
  const [virtualPos, setVirtualPos] = useState({
    start: 0,
    end: virtualSize.current,
  });

  const handleScroll = useCallback(() => {
    if (scrollElement == null) {
      return;
    }
    const scrollTop = -scrollElement.getBoundingClientRect().top;

    const virtualIndex = Math.floor(scrollTop / computePixelHeight());
    const start = Math.max(0, virtualIndex);
    const end = Math.min(contentLength, virtualIndex + virtualSize.current);
    setVirtualPos({ start, end });
  }, [contentLength, scrollElement, computePixelHeight]);

  const handleResize = useCallback(() => {
    const newVirtualSize = computeVirtualSize();
    virtualSize.current = newVirtualSize;

    handleScroll();
  }, [handleScroll, computeVirtualSize]);
  useEffect(() => {
    if (scrollElement == null) {
      return;
    }
    if (scrollElement === window.document.documentElement) {
      window.addEventListener("resize", handleResize);
      return () => {
        window.removeEventListener("resize", handleResize);
      };
    }
    scrollElement.addEventListener("resize", handleResize);
    return () => {
      scrollElement.removeEventListener("resize", handleResize);
    };
  }, [handleResize, scrollElement]);
  useEffect(() => {
    if (scrollElement == null) {
      return;
    }
    if (scrollElement === window.document.documentElement) {
      window.addEventListener("scroll", handleScroll);
      return () => {
        window.removeEventListener("scroll", handleScroll);
      };
    }
    scrollElement.addEventListener("scroll", handleScroll);
    return () => {
      scrollElement.removeEventListener("scroll", handleScroll);
    };
  }, [handleScroll, scrollElement]);

  const pixelHeight = computePixelHeight();
  const virtualContents = contents
    .slice(virtualPos.start, virtualPos.end)
    .map((data, index) => {
      const virtualIndex = index + virtualPos.start;
      return {
        virtualIndex: index + virtualPos.start,
        data,
        top: `calc(${pixelHeight}px * ${virtualIndex})`,
      };
    });

  return {
    virtualContents,
    containerHeight: `calc(${pixelHeight}px * ${contentLength})`,
  };
};

이렇게 분리했다. scrollElement가 없을 경우는 문서 전체를 대상으로, scrollElement가 주어질 경우 해당 엘리먼트를 대상으로 가상화를 실행한다.

그러나 여기서도 치명적인 문제가 있다. 주어진 contentHeight가 요소마다 다르다면? 해당 상황을 가정해보기 위해 이미지 api를 변경하고 aspect-ratio 속성을 제거한다. 그 결과는 아래와 같다.

컨텐츠의 예상 높이와 실제 높이가 다르기에 레이아웃이 어긋나는 문제가 발생한다.

동적인 경우에 대응하기

// useScrollVirtualizer.ts
import { useCallback, useEffect, useRef, useState } from "react";

interface VirtualItem<T> {
  virtualIndex: number;
  data: T;
  ref: (instance: HTMLElement | null) => void; // 추가
  minHeight?: number; // 추가
}
interface ScrollVirtualizerParams<T> {
  contents: T[];
  overscan?: number;
  contentHeight: string;
  scrollElement?: HTMLElement | null;
}

interface ScrollVirtualizerReturns<T> {
  virtualContents: VirtualItem<T>[];
  containerHeight: string;
  top: string;
}
export const useScrollVirtualizer = <T>({
  contents,
  overscan = 10,
  contentHeight,
  scrollElement = window.document.documentElement,
}: ScrollVirtualizerParams<T>): ScrollVirtualizerReturns<T> => {
  const contentLength = contents.length;
  const realContentHeightMap = useRef(new Map<number, number>()); // 추가
  /**
   * @description string 높이를 계산하여 pixel로 반환
   */
  const computePixelHeight = useCallback((): number => {
    const _elem = document.createElement("div");
    _elem.style.height = contentHeight;
    _elem.style.visibility = "hidden";
    document.body.appendChild(_elem);
    const realContentHeight = _elem.clientHeight;
    document.body.removeChild(_elem);
    return realContentHeight;
  }, [contentHeight]);

  /**
   * @description 화면에 들어가는 virtual size를 계산하여 반환
   */
  const computeVirtualSize = useCallback(() => {
    if (scrollElement == null) {
      return 0;
    }
    return Math.ceil(scrollElement.clientHeight / computePixelHeight());
  }, [computePixelHeight, scrollElement]);
  const virtualSize = useRef(computeVirtualSize() + overscan);
  const [virtualPos, setVirtualPos] = useState({
    start: 0,
    end: virtualSize.current,
  });

  const handleScroll = useCallback(() => {
    if (scrollElement == null) {
      return;
    }
    let scrollTop = -scrollElement.getBoundingClientRect().top;
    const pixelHeight = computePixelHeight();
    let virtualIndex = 0;
    while (scrollTop > 0) {
      scrollTop -=
        realContentHeightMap.current.get(virtualIndex) ?? pixelHeight;
      virtualIndex += 1;
    }
    const start = Math.max(0, virtualIndex - overscan);
    const end = Math.min(
      contentLength,
      virtualIndex + virtualSize.current + overscan,
    );
    setVirtualPos({ start, end });
  }, [contentLength, scrollElement, computePixelHeight, overscan]);

  const handleResize = useCallback(() => {
    const newVirtualSize = computeVirtualSize();
    virtualSize.current = newVirtualSize;

    handleScroll();
  }, [handleScroll, computeVirtualSize]);
  useEffect(() => {
    if (scrollElement == null) {
      return;
    }
    if (scrollElement === window.document.documentElement) {
      window.addEventListener("resize", handleResize);
      return () => {
        window.removeEventListener("resize", handleResize);
      };
    }
    scrollElement.addEventListener("resize", handleResize);
    return () => {
      scrollElement.removeEventListener("resize", handleResize);
    };
  }, [handleResize, scrollElement]);
  useEffect(() => {
    if (scrollElement == null) {
      return;
    }
    if (scrollElement === window.document.documentElement) {
      window.addEventListener("scroll", handleScroll);
      return () => {
        window.removeEventListener("scroll", handleScroll);
      };
    }
    scrollElement.addEventListener("scroll", handleScroll);
    return () => {
      scrollElement.removeEventListener("scroll", handleScroll);
    };
  }, [handleScroll, scrollElement]);

  const pixelHeight = computePixelHeight();
  const virtualContents = contents
    .slice(virtualPos.start, virtualPos.end)
    .map((data, index) => {
      const virtualIndex = index + virtualPos.start;
      return {
        virtualIndex,
        data,
        minHeight: realContentHeightMap.current.get(virtualIndex),
        ref: (instance: HTMLElement | null) => {
          if (instance == null) {
            return;
          }
          // ref를 통해 실제 높이를 기록
          realContentHeightMap.current.set(virtualIndex, instance.clientHeight);
        },
      };
    });

	// 실제 높이를 아는 곳은 그 높이 사용, 모르면 주어진 높이 사용
  let top = 0;
  let containerHeight = 0;
  for (let i = 0; i < contentLength; i++) {
    containerHeight += realContentHeightMap.current.get(i) ?? pixelHeight;
  }
  for (let i = 0; i < virtualPos.start; i++) {
    top += realContentHeightMap.current.get(i) ?? pixelHeight;
  }

  return {
    virtualContents,
    containerHeight: `${containerHeight}px`,
    top: `${top}px`,
  };
};

여기서 다시 추가된 top은 가상화 요소들의 박스 상단 위치이다. 각각의 요소의 위치를 조정하는 방식으로는 높이가 동적인 케이스에 대응할 수 없기에 박스의 위치를 조정하고, 나머지 요소는 일렬로 나열한다.

realContentHeightMapref를 가지고 실제 렌더링 된 요소의 높이를 기록하고, containerHeighttop에 반영한다.

// Test.tsx
<div
  style={{
    width: "50vw",
    display: "flex",
    flexDirection: "column",
    height: containerHeight,
    position: "relative",
  }}
>
	<div
	  style={{
	    position: "absolute",
	    top: top,
	  }}
	>
	  {virtualContents.map(({ data, ref, minHeight }) => (
	    <Content
	      key={data.id}
		    data={data}
		    minHeight={minHeight}
	      ref={ref}
	    />
	  ))}
	</div>
</div>

이렇게 컨테이너 아래에 box 요소를 추가하고 가상화 요소를 묶어줘서 사용한다.

다시 sentry 기반 로드 추가하기

이제 가상화 적용이 완료되었으니 다시 로드 기능을 복구해보자.

// Test.tsx
<div
  style={{
    position: "absolute",
    top: top,
  }}
>
  {virtualContents.slice(0, -10).map((props) => (
    <Content key={props.data.id} {...props} />
  ))}
  <div ref={isLoadable ? sentryRefCallback : undefined}>
    {virtualContents.slice(-10).map((props) => (
      <Content key={props.data.id} {...props} />
    ))}
  </div>
</div>

마지막 10개를 div 안에 넣고 해당 div를 감지 요소로 삼아 적용했다.

더 개선하고 싶은 점.

  • computePixelHeight를 좀 더 간단하게 구하는 방법은?(문자열 파싱 등으로)
  • 물결치는 스크롤 높이는 스크롤이 멈출 시에만 갱신

결과

영상

퍼포먼스 제한을 건 영상

  • Layout-shift는 어쩔 수 없다. 이미지의 영역을 미리 잡아주면 되지만, 동적인 요소에 대한 테스트 때문에 일부러 제거했다.
  • 실제로 동적인 요소를 가상화를 적용시킨다면, 어느 정도의 Layout-shift는 감수해야할듯?
  • 대신 Lazy Loading은 끄는게 맞아보인다(로드된 이미지들의 Layout-shift를 최소화 하기 위해)

소스 코드

소스 코드는 https://github.com/bluejoyq/react-examples/tree/master/infinite-scroll-virtualize 에서 확인하실 수 있습니다.

참고 자료

profile
개발자 지망생입니다.

0개의 댓글