대용량 무한 스크롤, 가상 스크롤로 성능 최적화하기 🚀

osohyun0224·2025년 4월 29일
95

성능 최적화

목록 보기
1/1
post-thumbnail

안녕하세요! 프론트엔드 개발자 Garden, 오소현입니다. :)

여러분은 혹시 무한 스크롤을 구현하면서
"대량 데이터를 끊김 없이 보여주려면 어떻게 해야 하지?" 고민해보신 적 있나요?

저는 최근 이커머스 페이지를 개발하면서 이 문제를 깊게 고민하게 되었는데요!

처음에는 단순한 무한 스크롤로 충분할 줄 알았지만,
막상 수천 개가 넘는 상품 데이터를 다루다 보니 생각보다 훨씬 많은 문제를 만났습니다 😅😅

오늘은 제가 이 문제를 어떻게 해결했는지,
가상 스크롤을 도입하면서 어떤 것들을 고민했는지 공유해보려고 해요! 🚀

❓ 무한 스크롤의 한계 😅

구현하고자 하는 페이지는 수천 개 이상의 상품 데이터를 유저에게 끊김 없이 보여줘야 했습니다.
제 생각에 이커머스 서비스 특성은, 사용자가 페이지를 넘기지 않고 스크롤로 계속 탐색할 수 있어야 했기 때문에, 자연스럽게 무한 스크롤 방식을 선택했습니다.

무한스크롤은 React Query의 useInfiniteQuery를 활용하여 상태 관리와 데이터 페칭 로직을 간소화했습니다.

따라서 아래와 같은 장점을 가질 수 있었습니다 !
1. 캐싱: React Query가 데이터 캐싱을 처리하여 불필요한 API 호출 감소
2. 상태 관리 간소화: 로딩, 에러, 페이지 상태 등을 쉽게 관리
3. 코드 가독성: 비즈니스 로직과 UI 로직의 분리가 명확해짐

export const useProducts = (time: TimeDealType = 'current') =>
  useInfiniteQuery({
    queryKey: [...QUERY_KEYS.PRODUCTS, time],
    queryFn: ({ pageParam = 1 }) => getProducts(time, pageParam),
    getNextPageParam: (lastPage, _, lastPageParam) =>
      lastPage.isLastPage ? null : (lastPageParam as number) + 1,
  });

Intersection Observer로 스크롤 하단 감지 ➡️ API로 다음 페이지 데이터 요청 ➡️ 기존 데이터에 append 하는 흐름으로 구현했어요! 구조도를 그려보면 아래와 같습니다:)

🤔 IntersectionObserver로 구현한 이유

여기서 잠깐, Intersection Observer로 무한 스크롤을 구현한 이유를 한번 정리해보겠습니다.

스크롤 하단을 감지하는 방법으로는 scroll 이벤트를 활용하거나, scrollHeight, scrollTop 같은 수치를 계산하는 방식도 있는데요, 왜 Intersection Observer을 사용했을까요 ?

해당 방법은 사용자가 스크롤할 때마다 이벤트가 아주 자주 발생하기 때문에, 매번 위치를 계산하고, 성능 문제를 막기 위해 쓰로틀링이나 디바운싱 같은 추가 작업도 필요합니다. 이런 방식은 부하가 크기도 하고, 코드도 꽤 복잡해지기 쉽다고 생각했어요!!

IntersectionObserver는 레이아웃 계산과 별도로 동작하는 비동기 방식으로, 브라우저가 최적화된 방법으로 요소의 가시성을 감지합니다. 덕분에 스크롤 성능이 더 부드럽고, 개발자 입장에서도 사용하기 더 편했어요!

게다가 이 방식은 감지의 정확도 면에서도 훨씬 좋다고 생각했습니다. 단순히 스크롤 위치를 계산하는 게 아니라 실제로 특정 요소(loadMoreRef)가 화면에 얼마나 보이고 있는지를 브라우저가 정확하게 판단해주기 때문입니다. 그래서 다음 페이지를 불러오는 타이밍도 더 정교하게 맞출 수 있었습니다.

무엇보다 마음에 들었던 건 코드가 훨씬 간결해진다는거죱!! scroll 이벤트로 구현하면 이벤트 등록, 해제, 위치 계산 등 여러 단계를 챙겨야 하지만, IntersectionObserver는 딱 한 번 observe()만 해주면 이후 처리가 깔끔하게 끝나더라고요! 유지보수하기도 편했고, 코드 가독성도 높아졌습니다!

실제로 저는 상품 목록 하단에 div를 하나 두고, 해당 요소가 화면에 보일 때 fetchNextPage()를 호출하는 방식으로 구현했습니다.

useEffect(() => {
  const observer = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting) {
      fetchNextPage();
    }
  });

  if (loadMoreRef.current) {
    observer.observe(loadMoreRef.current);
  }

return () => {
  if (loadMoreRef.current) {
    observer.unobserve(loadMoreRef.current);
  }
  observer.disconnect();
};
  
  
}, [fetchNextPage]);

무한 스크롤의 성능 문제 😅

하지만, 수많은 데이터를 무한스크롤로 불러와야할 때는 성능 문제가 먼저 고민이 되었는데요, 바로 무한 스크롤만으로 전체적인 성능 문제와, DOM 노드의 수가 증가하는 문제가 발생했습니다.

❗ 1. DOM 노드 수 증가

데이터가 추가될수록 화면에 렌더링되는 노드가 기하급수적으로 늘어났습니다. 각 상품 카드는 여러 DOM 요소로 구성되었기 때문에, 1,000개의 상품이면 실제로는 약 10,000개 이상의 DOM 노드가 생성되었습니다.

개발자 도구의 Performance 모니터링 결과, 1,000개를 넘어가자 모바일 디바이스에서 브라우저가 뚝뚝 끊기기 시작했고, 프레임 드롭이 발생했습니다.

❗ 2. 메모리 과다 사용

모든 상품 데이터와 이미지를 메모리에 계속 들고 있어야 했습니다. 특히 상품 이미지는 메모리 사용량이 크기 때문에, 중저가 모바일 기기에서는 렌더링 실패나 앱 크래시 현상이 발생했습니다.

성능 모니터에서 측정한 결과, 약 2,000개 상품 데이터를 로드했을 때 메모리 사용량이 500MB 이상까지 증가하는 것을 확인했습니다.

❗ 3. 스크롤 퍼포먼스 저하

가장 치명적인 문제는 스크롤 성능 저하였습니다. 스크롤이 매끄럽지 않고 중간중간 멈췄으며, JavaScript 계산 때문에 렌더링 블로킹이 발생했습니다. FPS(Frames Per Second)가 30 이하로 떨어지는 경우가 많았고, 이는 유저 경험을 종종 해치는 수준이었습니다.

🚀 가상 스크롤로 해결하기

가상 스크롤은 "화면에 보이는 요소만 렌더링" 하는 기법입니다.
이를 통해 항상 DOM에는 20~30개만 유지되도록 하여 렌더링 효율을 극대화했습니다.

📌 무한 스크롤 vs 가상 스크롤 비교

구분무한 스크롤가상 스크롤 (Virtual Scroll)
렌더링 방식전체 아이템 계속 추가현재 보이는 아이템만 렌더링
DOM 노드 수무한 증가일정 수준 유지
성능느려짐 (렌더링, 메모리 모두 부담)일정한 퍼포먼스 유지
구현 난이도쉬움복잡 (scroll 위치, offset 계산 필요)

제가 이 문제를 해결한 방법은 화면에 보이는 요소만 렌더링하고 나머지는 가상화하는 방식이 가장 효과적이라 판단했습니다.

🧠 구현 방식 요약

스크롤 컨테이너
└─ VirtualList
    └─ virtualItems (20~30개만 렌더)
        └─ 상품 카드 (with LazyImage)

위와 같이 가상스크롤을 구현해서 10,000개 이상의 상품도 항상 DOM에는 20~30개만 존재하도록 만들 수 있습니다.

덕분에 10,000개 넘는 데이터도 전혀 부담 없이 보여줄 수 있게 됐습니다ㅎㅎㅎㅎ

실제로 스크롤하는 도중의 DOM 구조를 보면 이렇게 필요한 부분만 존재해요 !!👇👇

1. 가상 스크롤로 렌더링 성능 최적화하기 💻

1) 가상 스크롤 구현을 위한 핵심 라이브러리와 커스텀 훅

가상 스크롤 구현을 위해 @tanstack/react-virtual 라이브러리를 선택했고, 이를 활용한 커스텀 훅을 만들었습니다:

export const useVirtualScroll = ({
  itemCount,
  itemHeight,
  overscan = 5,
  parentRef,
}: UseVirtualScrollOptions) => {
  const virtualizer = useVirtualizer({
    count: itemCount,
    getScrollElement: () => parentRef.current?.parentElement,
    estimateSize: () => itemHeight,
    overscan,
  });

  useEffect(() => {
    if (parentRef.current) {
      parentRef.current.style.setProperty('--virtual-height', `${virtualizer.getTotalSize()}px`);
    }
  }, [parentRef, virtualizer]);

  return {
    virtualItems: virtualizer.getVirtualItems(),
    totalSize: virtualizer.getTotalSize(),
  };
};

2) VirtualList 컴포넌트 구현

VirtualList는 현재 보이는 virtualItems만 렌더링하는 컴포넌트입니다.

export const VirtualList = forwardRef(({ items, renderItem, virtualItems }, ref) => (
  <div className={styles.virtualList}>
    <div ref={ref} className={styles.virtualContainer}>
      {virtualItems.map(({ index, start, size }) => (
        <div
          key={index}
          className={styles.virtualItem}
          style={{ transform: `translateY(${start}px)`, height: `${size}px` }}
        >
          {renderItem(items[index], index)}
        </div>
      ))}
    </div>
  </div>
));

스타일 구현도 CSS 변수화를 통해 중요한 부분이었습니다!!!

.virtualList {
  overflow: auto;
  .virtualContainer {
    height: var(--virtual-height);
    position: relative;
  }
  .virtualItem {
    position: absolute;
    width: 100%;
  }
}

2. 상품 목록 페이지에 적용하기 🛒

이제 무한 스크롤과 가상 스크롤을 통합했습니다. 특히 상품 목록 페이지에서는 상품을 2개씩 묶어 하나의 행(row)으로 표시하는 그리드 레이아웃을 사용했기 때문에, 이에 맞춰 가상 스크롤도 최적화했습니다 :)

// ProductList.tsx
export const ProductSection = () => {
  ...
  
  // 2개씩 묶어서 행(row) 단위로 가상화
  const { virtualItems } = useVirtualScroll({
    itemCount: Math.ceil(activeProducts.length / 2), // 2개씩 묶어서 계산
    itemHeight: 260, // 행 높이
    overscan: 5, 
    parentRef,
  });

  // 행(row) 렌더링 함수
  const renderRow = (rowIndex: number) => {
    const startIndex = rowIndex * 2;
    const endIndex = Math.min(startIndex + 2, activeProducts.length);
    const rowProducts = activeProducts.slice(startIndex, endIndex);

    return (
      <div key={rowIndex} className={styles.row}>
        {rowProducts.map((product) => (
          <div key={product.id} className={styles.item}>
            <TimeSaleProductCard
              product={product}
              isComingSoon={activeTab === "next"}
            />
          </div>
        ))}
      </div>
    );
  };
  
  return (
    <div className={styles.container}>
      <div className={styles.scrollContainer}>
        <VirtualList
          ref={parentRef}
          items={Array.from({ length: Math.ceil(activeProducts.length / 2) })}
          virtualItems={virtualItems}
          renderItem={(_, index) => renderRow(index)}
        />
      </div>
      
      <div ref={loadMoreRef} className={styles.loadMoreTrigger} />
    </div>
  );
};

3. 추가 최적화 포인트 🔥

가상 스크롤 구현 과정에서 다음과 같은 추가 최적화 작업도 수행했습니다!

1) 메모이제이션 활용

상품 카드 컴포넌트를 React.memo로 감싸고, 렌더링 함수에는 useCallback을 적용하여 불필요한 리렌더링을 방지했습니다:

// 상품 카드 컴포넌트 메모이제이션
export const ProductCard = memo(({ product, isComingSoon }: Props) => {
  // 카드 구현...
});

// 렌더링 함수 메모이제이션
const renderRow = useCallback((rowIndex: number) => {
  // 행 렌더링 로직...
}, [activeProducts, activeTab]);

2) 이미지 지연 로딩

보이는 영역의 이미지만 로드하기 위해 loading="lazy" 속성과 Intersection Observer를 활용한 커스텀 이미지 컴포넌트를 구현했습니다:

export const LazyImage = ({ src, alt }: { src: string; alt: string }) => {
  const [isInView, setIsInView] = useState(false);
  const ref = useRef<HTMLImageElement>(null);

useEffect(() => {
  const observer = new IntersectionObserver(([entry]) => {
    if (entry.isIntersecting) {
      setIsInView(true);
      if (ref.current) observer.unobserve(ref.current);
    }
  });

  if (ref.current) observer.observe(ref.current);
  return () => observer.disconnect();
}, []);


  return <img ref={ref} src={isInView ? src : 'placeholder.webp'} alt={alt} loading="lazy" />;
};

3) 리스트 아이템 배치 최적화

레이아웃 변경(layout shift)을 최소화하기 위해 상품 카드의 크기를 일정하게 유지하고, CSS Grid 또는 Flexbox를 통해 효율적으로 배치했습니다!

.row {
...
  
  .item {
    min-width: 0; // 오버플로우 방지
    
    // 고정 비율 유지
    aspect-ratio: 1 / 1.5;
  }
}

4. 성능 측정 결과 📊

이러한 최적화 작업 후 성능 개선 결과는 다음과 같았습니다!!

지표최적화 전최적화 후
DOM 노드 수10,000개 이상200개 이하
메모리 사용량500MB 이상120MB 이하
스크롤 FPS30 이하55~60 안정적 유지
Total Blocking Time (TBT)250ms 이상45ms 이하

모바일 기기에서도 끊김 없이 부드러운 스크롤이 가능해졌고, 메모리 제한이 있는 환경에서도 안정적으로 동작하게 되었습니다.

마치며,, 🧾

무한 스크롤은 좋은 UX 패턴이지만, 그 자체만으로는 대규모 데이터 렌더링에 한계가 있습니다. 가상 스크롤을 적용함으로써 DOM 노드 수를 크게 줄이고, 메모리 사용량을 최적화하며, 스크롤 성능을 대폭 개선할 수 있었습니다.

특히 이커머스 페이지와 같이 대량의 상품 데이터를 표시해야 하는 환경에서는 무한 스크롤과 가상 스크롤의 결합이 필수적이라고 생각합니다. 이러한 최적화를 통해 사용자에게 끊김 없는 쇼핑 경험을 제공할 수 있었습니다. :)

profile
Garden / Junior Frontend Developer

24개의 댓글

comment-user-thumbnail
2025년 4월 29일

무한스크롤은 처음에 말씀주신 옵저버만 사용했는데 가상스크롤에 대해 새롭게 알 수 있었습니다! 성능 개선이 많이 된걸 보니 한번 저도 사용이 필요할 때 검토해봐야겠어요 :)

예전에 저희 무한스크롤 개선 시에 react memo를 사용했던걸로 기억하는데, 실제 서비스에서는 이 방법으로는 부족했을지도 궁금합니다!

1개의 답글
comment-user-thumbnail
2025년 4월 30일

오 가상 스크롤을 직접 구현하시다니 역시 소현님..!! 👍

1개의 답글
comment-user-thumbnail
2025년 4월 30일

이렇게도 최적화 할 수 있는지 몰랐네요 ㅎㅎ 글 잘봤어요!

1개의 답글
comment-user-thumbnail
2025년 4월 30일

메모이제이션과a 이미지 지연 로딩 같은 추가 최적화 팁이네요 !! 이커머스처럼 대량 데이터를 다루는 서비스에서 성능과 UX를 동시에 개선하는 방법을 명확하게 보여주셔서 많이 배웠습니다. 정말 잘 읽었습니다 :)

1개의 답글
comment-user-thumbnail
2025년 4월 30일

렌더링 흐름을 시각 자료로 잘 가시화해주어서 이해하기 쉬웠습니다 ㅎㅎ 좋은 글 감사합니다~

1개의 답글
comment-user-thumbnail
2025년 4월 30일

성능 개선을 위해 깊게 고민하신 내용이 느껴져서 너무 좋았습니다! 좋은 내용 공유해주셔서 감사합니다:)

1개의 답글
comment-user-thumbnail
2025년 4월 30일

CSS 변수를 이렇게도 사용할 수 있군요.. 좋은 글 감사합니다!!

1개의 답글
comment-user-thumbnail
2025년 4월 30일

무한스크롤에 대한 문제를 이런 방법을 통해 해결할 수 있다는걸 알려주셔서 감사합니다!
저도 한번 테스트를 해봐야겠네요

1개의 답글
comment-user-thumbnail
2025년 5월 2일

무한스크롤의 성능부분에 대해서 고민하지 않았던 것 같은데, 소현님 글 읽고 많은 생각을 하게 됐습니다 :) 감사합니다!

1개의 답글
comment-user-thumbnail
2025년 5월 6일

가장 최상단의 돔은 없애버리는 방식인건가요?
어려운 로직을 보기 쉽게 구현한 점이 인상깊어요

1개의 답글
comment-user-thumbnail
2025년 5월 6일

sliding window같네요 잘 보고갑니다!!

1개의 답글
comment-user-thumbnail
1일 전

다시 거꾸로 올라갈 때도 동일한가요?

1개의 답글