본문 바로가기

React

React에서 Virtualized List 구현하기

스크롤 목록이 수천 개의 아이템을 그릴 때 렌더링과 페인팅 비용이 급격히 증가합니다. Virtualized List는 화면에 보이는 영역만 렌더링해 메모리 사용과 렌더링 비용을 크게 줄이는 기법입니다. 실무에서는 기본 원리를 이해한 뒤 라이브러리를 쓰는 것이 가장 빠르지만, 직접 구현해 두면 커스텀 요구사항 대응에 유리합니다.

1. 핵심 아이디어

원리는 간단합니다.

- 스크롤 컨테이너 높이는 고정합니다.
- 전체 항목 높이만큼 빈 공간을 만들고, 보이는 인덱스 구간만 실제 DOM으로 렌더링합니다.
- 렌더링된 블록을 translateY로 적절한 위치에 이동합니다.
- 약간의 overscan을 더 렌더링해 빠른 스크롤에도 깜박임을 줄입니다.

2. 최소 구현: 고정 높이 아이템 VirtualList

아이템 높이가 일정할 때 가장 구현이 쉽습니다.

import React, { useRef, useState, useCallback } from 'react';

function VirtualList({
  itemCount,
  itemHeight,
  height,
  renderItem, // (index) => JSX
  overscan = 2,
  className,
  style,
}) {
  const [scrollTop, setScrollTop] = useState(0);
  const rafRef = useRef(null);

  const onScroll = useCallback((e) => {
    const st = e.currentTarget.scrollTop;
    if (rafRef.current) cancelAnimationFrame(rafRef.current);
    rafRef.current = requestAnimationFrame(() => setScrollTop(st));
  }, []);

  const totalHeight = itemCount * itemHeight;
  const visibleCount = Math.ceil(height / itemHeight);
  const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const end = Math.min(
    itemCount - 1,
    start + visibleCount + overscan * 2 - 1
  );

  const offsetY = start * itemHeight;
  const items = [];
  for (let i = start; i <= end; i += 1) {
    items.push(
      <div key={i} style={{ height: itemHeight }}>
        {renderItem(i)}
      </div>
    );
  }

  return (
    <div
      className={className}
      onScroll={onScroll}
      style={{
        ...style,
        height,
        overflowY: 'auto',
        position: 'relative',
        contain: 'strict', // 리페인트 범위 한정
      }}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            transform: `translateY(${offsetY}px)`,
            willChange: 'transform',
          }}
        >
          {items}
        </div>
      </div>
    </div>
  );
}

export default VirtualList;

3. 사용 예시

데이터 10만 개를 부드럽게 스크롤합니다.

import React from 'react';
import VirtualList from './VirtualList';

const data = Array.from({ length: 100000 }, (_, i) => ({ id: i, title: `Row ${i}` }));

const Row = React.memo(function Row({ item }) {
  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        padding: '0 12px',
        height: '100%',
        borderBottom: '1px solid #eee',
        boxSizing: 'border-box',
        background: item.id % 2 ? '#fff' : '#fafafa',
      }}
    >
      <strong style={{ width: 80 }}>#{item.id}</strong>
      <span>{item.title}</span>
    </div>
  );
});

export default function App() {
  return (
    <VirtualList
      itemCount={data.length}
      itemHeight={48}
      height={400}
      overscan={4}
      renderItem={(index) => <Row item={data[index]} />}
    />
  );
}

4. 스크롤 성능 팁

- overscan: 2~6 사이로 시작해 깜박임과 메모리를 균형 있게 조정합니다.
- requestAnimationFrame: 스크롤 이벤트를 매 프레임으로 묶어 setState 빈도를 줄입니다.
- contain: CSS contain: strict로 페인팅 범위를 제한합니다.
- 컴포넌트 메모이제이션: Row를 React.memo로 감싸 re-render를 줄입니다.
- 키 안정성: key는 index로 충분하지만, 동적 삽입이 잦다면 고유 id를 권장합니다.
- 스타일: box-shadow, filter 등 페인트 비용이 큰 스타일은 지양합니다.

5. 가변 높이 아이템은 어떻게?

아이템 높이가 제각각이면 시작 인덱스 계산과 오프셋 관리가 복잡해집니다. 실무에서는 react-virtuoso, react-window의 VariableSizeList를 권장합니다. 직접 구현이 필요하면 ResizeObserver로 각 아이템 높이를 측정해 맵에 저장하고, prefix sum으로 오프셋을 계산합니다.

import React, { useLayoutEffect, useRef } from 'react';

function MeasuredRow({ index, onSize, children }) {
  const ref = useRef(null);
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const report = () => onSize(index, el.offsetHeight);
    report();
    const ro = new ResizeObserver(report);
    ro.observe(el);
    return () => ro.disconnect();
  }, [index, onSize]);
  return <div ref={ref}>{children}</div>;
}

// 부모에서는 heights Map과 prefix 배열로 이진 탐색을 구현해 startIndex를 찾습니다.
// 스크롤 도중 높이가 변하면 오프셋을 업데이트하고 다시 그립니다.
// 구현 복잡도가 높으므로 라이브러리 사용을 우선 고려하세요.

6. 언제 라이브러리를 쓸까?

- react-window: 경량, 고정/가변 높이 모두 지원, API 단순합니다.
- react-virtualized: 기능 풍부하지만 비교적 무겁습니다.
- react-virtuoso: 가변 높이와 동적 데이터에 강합니다.
라이브러리는 측정, 스크롤 앵커링, 리사이즈 대응 등 엣지 케이스를 잘 처리합니다.

7. 체크리스트와 흔한 문제

- 컨테이너 스타일: height 고정, overflowY: auto, position: relative를 잊지 않습니다.
- 부모 레이아웃: flex 부모일 때 높이가 0이 되지 않도록 min-height 또는 명시적 height를 지정합니다.
- 스크롤 점프: 데이터 추가/삭제 시 start 오프셋을 유지하도록 주의합니다.
- SSR: 최초 렌더 높이 의존 로직은 브라우저에서만 실행합니다.
- 접근성: 키보드 포커스 이동 시 비가시 아이템 포커스가 필요하면 스크롤을 보정합니다.

위의 최소 구현만으로도 대부분의 무한 스크롤 목록에서 큰 성능 향상을 얻을 수 있습니다. 고급 요구사항이나 가변 높이가 필요하다면 검증된 라이브러리를 도입하고, 커스텀 로직이 필요한 좁은 부분만 직접 구현하는 전략을 추천합니다.