TroubleShooting

[TroubleShooting] 대규모 리스트 렌더링 성능 개선

teddy bear 2026. 7. 13. 10:42
반응형

대규모 리스트 렌더링 성능 개선: 가상 스크롤(Virtual Scroll) 직접 구현하기

"리스트에 데이터 좀 늘었다고 스크롤이 이렇게 버벅여도 되나요?" — 운영팀 문의를 받고 콘솔을 열어보니, DOM 노드 수가 12,000개를 넘어가고 있었다.


🔍 문제 상황

담당하던 관리자 페이지에는 거래 내역을 테이블 형태로 보여주는 화면이 있었다. 초기 설계 당시엔 페이지네이션으로 20건씩 끊어서 보여줬지만, 기획 변경으로 "스크롤하면서 계속 이어서 보고 싶다" 는 요구사항이 추가되며 무한 스크롤 방식으로 바뀌었다.

문제는 데이터가 누적될수록 심각해졌다.

  • 데이터 500건: 큰 문제 없음
  • 데이터 3,000건: 스크롤 시 미세한 끊김
  • 데이터 8,000건 이상: 스크롤할 때마다 프레임이 뚝뚝 끊기고, 필터를 바꾸면 화면이 1~2초씩 멈춤

React 컴포넌트 개수가 늘어나는 만큼 브라우저가 힘들어하는 건 당연했다. 진짜 문제는 **"화면에 보이지도 않는 8,000개의 행을 전부 DOM에 그리고 있다"**는 것이었다.


1단계: 성능 병목부터 정확히 진단하기

감으로 "가상 스크롤 도입하면 되겠지"라고 바로 결정하지 않고, Chrome DevTools Performance 탭으로 먼저 원인을 숫자로 확인했다.

[리스트 8,000건 초기 렌더링 프로파일링 결과]
Scripting     : 2,840ms
Rendering     : 1,120ms
Painting      : 640ms
Total Blocking Time : 3,200ms  ← 🚨
FPS (스크롤 중) : 평균 11fps (목표: 60fps)

Total Blocking Time이 3.2초라는 건 사용자가 그 시간 동안 아무 인터랙션도 할 수 없다는 뜻이다. React DevTools의 Profiler로 렌더 트리를 열어보니, 리스트 아이템 8,000개가 각각 개별 컴포넌트로 마운트되어 있었다.

💡 왜 React.memo만으로는 부족했나? 이미 각 행에 React.memo를 적용해 불필요한 리렌더링은 막아둔 상태였다. 하지만 이건 "다시 그리는" 비용을 줄일 뿐, 애초에 8,000개의 DOM 노드가 존재하는 것 자체의 비용(레이아웃 계산, 페인팅, 메모리)은 해결하지 못했다. 근본 원인은 리렌더링이 아니라 DOM 노드 개수 그 자체였다.


2단계: 왜 가상 스크롤인가

결론은 명확했다. **"보이는 영역(viewport)의 행만 실제로 렌더링하고, 나머지는 화면에서 사라진 것처럼 처리"**하는 가상 스크롤(Virtual Scroll / Windowing)이 필요했다.

라이브러리(react-window, react-virtualized)를 붙이는 방법도 있었지만, 팀에서 사용하던 테이블에 가변 높이 행(row)행 내부 확장(expand) 기능이 이미 존재해서, 커스텀 요구사항을 맞추기 위해 핵심 로직을 직접 구현하기로 했다.

가상 스크롤의 핵심 원리

전체 컨테이너 높이 = 전체 아이템 개수 × 평균 아이템 높이
                     (스크롤바가 정상적으로 동작하도록 "가짜 높이"를 만든다)

현재 스크롤 위치(scrollTop)를 기준으로
→ 화면에 보여야 할 시작 인덱스(startIndex)와 끝 인덱스(endIndex)를 계산
→ 그 구간의 아이템만 실제로 렌더링
→ 나머지는 위/아래 padding(spacer)으로 공간만 차지하게 만든다

3단계: 구현 — 기본 버전

function VirtualList({ items, itemHeight, containerHeight }) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef(null);

  // 화면에 보여줄 범위 계산
  const startIndex = Math.floor(scrollTop / itemHeight);
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const endIndex = Math.min(items.length, startIndex + visibleCount);

  const visibleItems = items.slice(startIndex, endIndex);

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  return (
    <div
      ref={containerRef}
      onScroll={handleScroll}
      style={{ height: containerHeight, overflowY: 'auto', position: 'relative' }}
    >
      {/* 전체 스크롤 영역 확보용 spacer */}
      <div style={{ height: items.length * itemHeight }}>
        <div style={{ transform: `translateY(${startIndex * itemHeight}px)` }}>
          {visibleItems.map((item) => (
            <Row key={item.id} data={item} height={itemHeight} />
          ))}
        </div>
      </div>
    </div>
  );
}

기본 버전을 붙이자마자 프로파일러 상 렌더링 컴포넌트 수가 8,000개 → 30개 수준으로 줄었다. 하지만 실제 사용해보니 문제가 세 가지 더 남아 있었다.


4단계: 트러블슈팅 — 세 가지 추가 이슈

문제 1) 빠르게 스크롤하면 흰 화면이 번쩍임 (Overscan 부재)

startIndex와 endIndex에 딱 맞춰서만 렌더링하니, 빠르게 스크롤할 때 새 행이 그려지기 전에 빈 공간이 순간적으로 노출됐다.

해결: 위아래로 여분의 행을 미리 렌더링하는 overscan을 추가했다.

const OVERSCAN = 5;
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - OVERSCAN);
const endIndex = Math.min(items.length, startIndex + visibleCount + OVERSCAN * 2);

문제 2) 행마다 높이가 달라서 위치 계산이 틀어짐

일부 행은 내용이 길어 2줄로 렌더링되는 등, 고정 높이 가정이 깨지는 케이스가 있었다. 처음엔 모든 행 높이를 고정값으로 계산했더니, 실제 가변 높이 행 아래로 갈수록 스크롤 위치와 실제 화면이 어긋났다.

해결: ResizeObserver로 각 행의 실제 렌더링 높이를 측정해 캐싱하고, 누적 높이 배열을 유지하는 방식으로 변경했다.

const rowHeights = useRef(new Map()); // id → 실제 높이 캐시

function measureRow(id, node) {
  if (node) {
    const height = node.getBoundingClientRect().height;
    rowHeights.current.set(id, height);
  }
}

// 누적 offset 계산 (실측 높이 기반)
function getItemOffset(index) {
  let offset = 0;
  for (let i = 0; i < index; i++) {
    offset += rowHeights.current.get(items[i].id) ?? estimatedHeight;
  }
  return offset;
}

⚠️ 매 렌더링마다 누적 합을 처음부터 다시 계산하면 O(n)이 반복되어 또 다른 병목이 생긴다. 실제로는 prefix sum 배열을 별도로 캐싱하고, 특정 행의 높이가 바뀔 때만 그 이후 구간을 갱신하도록 최적화했다.

문제 3) 필터 변경 시 스크롤 위치가 이상하게 유지됨

필터를 바꿔 리스트 내용이 완전히 달라졌는데도, 이전 scrollTop 값이 그대로 남아 엉뚱한 위치가 보이는 문제가 있었다.

해결: 필터링 조건(items 배열의 identity)이 바뀌면 스크롤 위치와 높이 캐시를 초기화하도록 useEffect로 처리했다.

useEffect(() => {
  containerRef.current?.scrollTo(0, 0);
  rowHeights.current.clear();
}, [filterKey]); // 필터 조건이 바뀔 때만 리셋

5단계: 결과 검증

동일한 8,000건 데이터로 Before/After를 비교했다.

지표 적용 전 적용 후

초기 렌더링 DOM 노드 수 8,000+ 약 40개 (overscan 포함)
Total Blocking Time 3,200ms 180ms
스크롤 중 평균 FPS 11fps 58fps
초기 렌더링 소요 시간 2,840ms 90ms
메모리 사용량(힙) 210MB 68MB

Performance 탭의 프레임 그래프도 빨간 막대(Long Task)로 가득했던 화면에서, 스크롤 내내 초록색 막대가 유지되는 그래프로 바뀌었다.


정리: 이번 경험에서 얻은 체크리스트

  1. "느리다"는 감을 숫자로 바꾸는 게 먼저다. Performance 탭의 Total Blocking Time, FPS, DOM 노드 수를 먼저 측정하고 병목 지점을 특정한다.
  2. React.memo는 리렌더링 비용을 줄일 뿐, DOM 노드 개수 자체의 비용은 줄이지 못한다. 이 둘을 구분해서 접근해야 한다.
  3. 가상 스크롤은 "보이는 것만 그린다"는 단순한 아이디어지만, overscan, 가변 높이, 상태 리셋 세 가지를 처리하지 않으면 실사용에서 바로 문제가 드러난다.
  4. 라이브러리를 쓸지 직접 구현할지는 커스텀 요구사항의 복잡도로 판단한다. 요구사항이 단순하면 라이브러리가 정답일 수 있다.
  5. 최적화는 항상 Before/After 수치를 남겨야 팀에 설득력 있게 공유할 수 있다.

성능 개선 작업은 체감으로 끝내면 재현도, 회고도 어렵다. 이번에도 프로파일링 데이터를 꾸준히 남겨둔 덕분에, 이후 비슷한 리스트 화면에 가상 스크롤을 적용할 때 훨씬 빠르게 판단할 수 있었다.


읽어주셔서 감사합니다. 가상 스크롤 관련해서 더 궁금하신 케이스(무한 스크롤과의 조합, 접근성 이슈 등)가 있다면 댓글로 남겨주세요 🙌