티스토리 뷰

회사 내부 모니터링 대시보드에서 수천 건 이상의 카드(Card) 컴포넌트를 한 화면에 표시해야 하는 요구사항이 있었습니다.

처음에는 단순히 Array.map을 통해 모든 데이터를 렌더링했으나, 아래와 같은 문제가 발생했습니다.

  1. 렌더링 지연 : 한 번에 모든 컴포넌트를 그리다 보니, 브라우저 메인 스레드가 과부하되어 화면이 뜨기까지 20~30초 걸림
  2. 스크롤 성능 저하 : DOM 요소가 늘어나면서 스크롤 시 FPS가 크게 떨어지는 문제 발생
  3. 사용자 경험(UX) 악화 API는 이미 빠르게 응답했는데, 렌더링이 끝나지 않아 로딩 스피너가 바로 사라지고도 빈 화면이 한참 동안 노출

문제를 해결하기 위한 방법으로 리스트 가상화를 진행해보기로 했습니다.

 

리스트 가상화란 ?

화면에 보이는 아이템만 실제로 렌더링하고, 나머지는 미리 렌더링하지 않는 기법

ex) 데이터가 1000개 있을 때, 화면에 보이는 20개만 실제 DOM에 그리고 나머지는 가상으로 관리하다가 스크롤 위치에 따라 교체한다.

 

  1. 초기 렌더링 비용 절감 : 렌더링해야 할 요소 개수를 화면에 보이는 범위로 제한하며 초기 렌더링 시간을 줄여준다.
  2. 메모리 사용량 감소  : DOM 노드가 많아질수록 브라우저 메모리 소비가 커지는데, 실제 DOM에 존재하는 노드를 최소화하여 메모리 사용량을 줄인다.
  3. 스크롤 성능 개선 : 수천 개의 DOM 요소가 이미 렌더링 되었을 때 스크롤을 하면, 브라우저는 모든 요소의 위치를 다시 계산해야 하므로 FPS가 떨어집니다. 가상화로 현재 스크롤 영역에 있는 DOM만 유지하므로 스크롤이 부드러워진다.

대표 라이브러리

  1. react-window 
    - 가볍고 사용이 간단하며, 고정 크기(FixedSizeList) 또는 가변 크기(VariableSizeList) 리스트, 그리드 등을 지원합니다.
    - 많은 기능이 필요 없는 “단순 리스트”에 가장 알맞습니다.
  2. react-virtualized
    기능이 풍부하고 다양한 레이아웃(그리드, 테이블, 정렬된 리스트, 무한 스크롤 등)을 지원하지만, 설정이 복잡할 수 있습니다.
    대규모 프로젝트나 여러 가지 가상화 시나리오가 필요한 경우에 적합합니다.
  3. react-virtuoso
    동적 높이반응형 그리드무한 스크롤위로 스크롤시 로딩 등을 편리하게 지원합니다.
    내부에서 사이즈 측정을 자동으로 처리해 주어, 특별한 설정 없이도 동적인 아이템 높이를 잘 다룰 수 있습니다.

저는 대시보드의 그리드 형태를 유지하고, 동적 높이 및 반응형 레이아웃 처리가 편리하다는 react-virtuoso를 선택했습니다.

VirtuosoGrid 사용 주의점 & 팁

  1. 부모 컨테이너 높이 보장
    가상화 라이브러리가 “현재 뷰포트 크기가 얼마인지” 알아야 아이템 수를 계산할 수 있습니다.
    상위 요소가 height: 100%를 가지도록 CSS 설정, 또는 height: 600px처럼 구체적인 픽셀 값을 부여해야 합니다.
  2. 아이템 높이가 일정하면 더 쉽다
    아이템 높이가 고정이면, 스크롤 위치에 따른 “몇 번째 아이템을 보여줄지” 계산이 간단해 성능이 좋습니다
    동적 높이도 가상화할 수 있지만, 라이브러리 내부에서 별도의 측정 단계가 필요합니다.
  3. 불필요한 리렌더링 방지
    아이템 렌더 함수가 매번 새로 정의되면, VirtualizedList도 매번 재계산을 유발할 수 있습니다.
    React.memo, useCallback, useMemo를 적절히 활용해 최적화해야합니다.
  4. 스크롤 이벤트 활용 (Infinite Scrolling, Prev Data Loading 등)
    예: startReached, endReached 같은 콜백으로 상단/하단 도달 시 추가 로딩(무한 스크롤) 구현 가능.
    필요하다면 로딩 스피너를 Header나 Footer 컴포넌트로 표시할 수 있습니다.

적용하기 ( Mui + VirtuosoGrid )

import React from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import Box from '@mui/material/Box';
import Grid2 from '@mui/material/Unstable_Grid2';
import CircularProgress from '@mui/material/CircularProgress';
import ProcessCard from './ProcessCard';

const CardListContainer({ data, isPending, openInfoModal }) {
  // 로딩 상태 표시
  if (isPending) {
    return <CircularProgress />;
  }

  return (
    <Boxsx={{
        height: '100%',      // 상위 컨테이너도 100% 높이를 갖도록 설정 필요
        width: '100%',
      }}
    >
      <VirtuosoGrid
        totalCount={data.length}
        itemContent={(index) => {
          const item = data[index];
          return (
            <Grid2 item xs={12} sm={6} md={4} lg={3}>
              <ProcessCard data={item} openInfoModal={openInfoModal} />
            </Grid2>
          );
        }}
        components={{
          List: React.forwardRef(({ style, children }, listRef) => (
            <Grid2container
              spacing={2}
              ref={listRef}
              style={style}
            >
              {children}
            </Grid2>
          )),
          Item: ({ children, ...props }) => (
            <div {...props}>{children}</div>
          ),
        }}
        style={{ height: '100%' }} // 내부에서 스크롤
      />
    </Box>
  );
}
Virtuoso : 주로 세로(Vertical) 목록에 최적화되어 있습니다. 아이템을 한 줄씩 리스트 형태로 렌더링할 때 주로 사용합니다.
VirtuosoGrid : 그리드(2차원) 형태로 여러 열(columns)을 구성하는 경우에 사용합니다. 예를 들어, MUI의 Grid처럼 카드 레이아웃을 여러 열로 배치해야 할 때 적합합니다.

 

  1. totalCount : 렌더링 할 아이템의 총 개수
  2. itemContnet : 반복되는 아이템을 어떻게 렌더링할지 정의하는 콜백 함수
    - 인자로 index가 넘어오며, data[index]를 통해 실제 반복 할 아이템을 가져와 JSX로 반환한다. 
  3. components : VirtuosoGrid가 내부적으로 사용하는 컴포넌트를 커스터마이징한다.
    1. List : 전체 그리드를 감싸는 컴포넌트
    2. Item : 각 아이템을 감싸는 래퍼 컴포넌트
  4. style : VirtuosoGrid의 기본 컨테이너에 적용할 스타일
    - height 100%로 설정해서 부모와 동일한 높이를 차지하게 지정해야 내부 스크롤이 제대로 동작하고 가상화 계산이 정확히 된다.

components 옵션 추가 설명

1. List에서 forwardRef 사용 ?

VirtuosoGrid는 내부에서 리스트 컨테이너의 크기(높이/너비)나 스크롤 위치를 직접 측정해야한다.

이를 위해 가상화 라이브러리가 DOM노드의 ref를 필요로한다.

List는 전체 그리드 래퍼이므로, 라이브러리가 이 래퍼의 참조(ref)를 가져다가 스크롤 계산, 높이 측정을 한다.

 

👉 Virtuoso는 리스트 컨테이너의 DOM노드에 접근하여 스크롤 계산과 높이 측정을 해야하므로, forwardRef를 사용해 Grid2 컴포넌트 내부의 실제 DOM 노드에 Virtuoso가 전달해준 ref를 연결(attach)해줍니다.

 

2. Item에서 props ?

VirtuosoGrid에서 한 개의 itemContent가 렌더될 때 그 내용을 item 컴포넌트로 감쌉니다.

따라서 각 아이템의 위치,스타일 등 가상화 라이브러리가 필요한 속성들이 props로 넘어올 수 있습니다.

즉, 라이브러리가 Item 컴포넌트에게 넘기는 다양한 속성(e.g. style, data-속성, onClick 등)을 그대로 <div>에 전파하기 위해 ...props를 사용합니다. Virtuoso가 아이템 배치를 위해 stylepositiontransform 등 여러 속성을 동적으로 부여할 수 있는데, ...props가 없다면 그것들이 DOM에 적용되지 않을 수 있습니다. 

 

👉 Virtuoso가 부여한 속성이 DOM에 그대로 반영되어 정확한 위치/크기로 아이템이 배치

 

성능 측정 & 비교

1. 가상화 전  ( Array.map )

 

  1. JS 실행이 길다 (노란색 블록이 많음)
    • 수천 개의 Card 컴포넌트를 한 번에 렌더링하기 때문에 JS 실행이 오래 걸려서 렌더링을 블로킹함
    • React가 모든 컴포넌트를 한꺼번에 렌더링하려고 하면서 CPU 점유율이 높아지고, UI 반응 속도가 떨어짐
  2. 레이아웃 & 스타일 계산 (보라색 블록이 많음)
    • 보라색이 많이 보이는 이유는 많은 DOM 요소를 브라우저가 한꺼번에 계산해야 하기 때문
    • 많은 카드 컴포넌트를 배치하면서 레이아웃이 무거워지고, 스타일 계산이 반복됨
  3. 메인 스레드 과부하
    • 메인 스레드가 JS 연산과 DOM 업데이트로 인해 꽉 차 있음.
    • 사용자 입력(스크롤, 클릭) 처리가 늦어지고 스크롤 성능이 저하되며 FPS가 크게 떨어짐

2. 가상화 후 

  1. JS 실행 시간이 급격히 줄어듦 (노란색 거의 없음)
    • 불필요한 모든 카드 렌더링을 제거하고, 화면에 보이는 것만 렌더링하도록 최적화됨
    • React가 Virtualized List를 활용하여, 화면에 필요한 컴포넌트만 렌더링하면서 실행 시간이 급감함
  2. 레이아웃 & 스타일 계산 감소 (보라색 줄어듦)
    • DOM 업데이트 수가 줄었기 때문에, 브라우저의 스타일 재계산(Layout Recalculation) 부담이 줄어듦
    • CSS 적용, Flex/Grid 등의 배치 연산이 줄면서 렌더링이 최적화되면서 FPS 50-60 유지
  3. 메인 스레드 여유 증가 → 부드러운 UI 가능
    • 메인 스레드가 가벼워지고, 스크롤이 부드러워짐
    • 이전에는 수천 개의 카드를 한꺼번에 계산했지만, 이제는 보이는 것만 처리해서 렌더링 지연이 사라짐
  4. 로딩 속도 급격히 개선 (총 시간 31.2초 → 2.33초)
    • 첫 번째 캡처에서 JS 실행과 렌더링이 오래 걸렸던 것이 가상화 후 사라짐
    • API 응답은 동일하지만, 렌더링 최적화 덕분에 사용자 경험(UX) 개선

가상화의 효과

대량의 데이터를 한 화면에서 효율적으로 보여주기 위해, 단순히 map으로 모든 컴포넌트를 렌더링하기보다는 가상화를 도입하여 필요한 부분만 그리는 방식이 필수적임을 깨달았습니다.

특히 react-virtuoso는 그리드 형태의 반응형 레이아웃을 적용하기 쉬우며, 내부적으로 사이즈 측정을 자동 처리해주어 동적 높이에도 유연하게 대응할 수 있었습니다.

 

결과적으로,

  • JS 실행 시간 감소 (노란색 줄어듦) → 불필요한 렌더링 제거
  • 스타일 계산 & 레이아웃 부담 감소 (보라색 줄어듦) → DOM 업데이트 최적화
  • 메인 스레드 여유 증가 → 스크롤 & UI 성능 개선
  • 전체 로딩 시간 31.2초 → 2.33초 → UX 대폭 향상
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함