react-virtualized를 적용하며 성능 개선한 내용을 정리해보려고 한다.

단순히 가상화 리스트를 도입한 것에서 그치지 않고, Chrome 개발자 도구를 통해 메모리 구조와 GC 작동 방식까지 추적하며 성능 최적화가 실제로 어떤 효과를 가져오는지를 직접 비교해봤습니다.


대용량 리스트 렌더링, 문제는 성능과 메모리였다

프로젝트에서 최대 10만개에 이르는 오디오 스크립트 데이터를 한 번에 보여줘야 했습니다.. 레거시 프로젝트에선 단순히 .map()을 통해 리스트를 출력하는 방식으로 개발 되었었지만, 예상대로 다음과 같은 문제가 있었다.

  • 스크롤 시 끊김 현상 발생
  • DevTools의 JavasScript Heap 메모리 지속 증가
  • 사용자 경험(UX) 저하

이런 상황이 존재하여 성능 최적화 및 리스트 가상화 도입을 하게 되었다.


무엇땜에 react-virtualized를 선택했는가?

당시 고민했던 가상화 라이브러리는 두 가지였다:

react-window는 더 가볍고 최신 라이브러리이며,
실제로 다운로드 수나 커뮤니티 트렌드를 보면 아래처럼 요즘 더 많이 사용되고 있다. 사용 할 당시는 react-virtualized 의 다운로드 수가 더 많았습니다.

react-virtualized vs window

다운로드 수를 보고 라이브러리를 선택하지는 않지만, 그 당시 react-virtualized를 선택했다.

이유는 다음과 같다:

  • react-window는 스타일이 단순한 리스트에 최적화되어 있었고,
  • 테이블 형태의 커스터마이징이 까다로웠다
  • 반면 react-virtualized는 기본적으로 AutoSizer, Table, ScrollSync 등 다양한 컴포넌트가 준비되어 있어서
  • 복잡한 레이아웃에서도 바로 적용하기 수월했다

내가 다루는 UI는 단순 리스트가 아니라 스크립트, 체크박스, 오디오 버튼, 텍스트 수정 기능까지 포함된 테이블 구조였고, 이런 상황에서는 react-virtualized가 훨씬 빠르게 결과를 낼 수 있었다.


react-virtualized 내부적으로 어떤 방식으로 동작하는가

react-virtualized는 기본적으로 scrollTopcontainerSize를 기준으로
현재 화면에서 보여야 하는 row의 시작 인덱스와 끝 인덱스를 계산한다.

아래는 react-virtualized/source/Grid/utils/CellSizeAndPositionManager.js 에 존재하는 내부 로직 일부이다:

getVisibleCellRange({ containerSize, offset }) {
  const totalCells = this._cellCount;
  let startIndex = this._findNearestCell(offset);
  const maxOffset = offset + containerSize - 1;
  let stopIndex = startIndex;

  while (
    stopIndex < totalCells - 1 &&
    this.getSizeAndPositionOfCell(stopIndex).offset + this.getSizeAndPositionOfCell(stopIndex).size < maxOffset
  ) {
    stopIndex++;
  }

  return {
    start: startIndex,
    stop: stopIndex,
  };
}

이 함수는 다음과 같은 흐름으로 작동한다:

  • 현재 스크롤 위치(offset)에 가장 가까운 셀 인덱스를 계산한다.
  • 해당 셀부터 시작해 화면 끝까지 보이는 셀을 stopIndex로 지정한다.
  • 이 범위에 해당하는 셀만 DOM에 mount하고, 나머지는 렌더링하지 않는다.

실제로 적용한 방식

내가 적용한 구조는 아래와 같다. 핵심은 AutoSizer와 List를 조합해 가시 영역만 렌더링하도록 구성하는 것이다.

const Table = (displayedList: IData) => {
  return (
    <>
      {displayedList.length ? (
        <AutoSizer>
          {({ height, width }) => {
            return (
              <List
                key={displayedList.map((item) => item.id).join(",")}
                height={height}
                width={width}
                rowCount={displayedList.length}
                rowRenderer={rowRenderer}
                rowHeight={calculateRowHeight}
              />
            );
          }}
        </AutoSizer>
      ) : (
        <EmptyContainer>요청된 리스트가 없습니다.</EmptyContainer>
      )}
    </>
  );
};
  • rowHeight는 텍스트 길이에 따라 동적으로 계산되도록 구성했고
  • rowRenderer는 각각의 스크립트, 체크박스, 오디오 버튼, 수정 가능한 textarea 등을 렌더링하도록 커스터마이징했다

기존 .map() 기반 리스트와 비교해보면, 렌더링 구조를 바꾼 것만으로도 스크롤 성능과 메모리 사용량이 확연히 달라졌다.


메모리 사용량 변화

Chrome DevTools의 Performance 탭을 이용해 스크롤 중 JS Heap 메모리를 측정해보았다.

map() 렌더링 기반 사용 시

Heap Before

  • JS Heap: 46.8MB → 47.5MB
  • GC(Garbage Collection) 발생 없음
  • 메모리가 계속 유지되며 누적 (우 상향 그래프)
  • 프리징, 렉 발생 가능성 존재

react-virtualized 적용 시

Heap After

  • JS Heap: 16.9MB → 최대 70.8MB까지 증가 후 회수
  • GC (Garbage Collection) 주기적으로 발생
  • 메모리가 안정적으로 회수되며 일정하게 유지 (계단 식 그래프)

적용 후 스크롤 변화

스크롤 성능 비교

가상화 전에는 몇천 건 이상의 데이터에서 스크롤이 누적될수록 브라우저가 버벅였지만, 적용 후에는 View Section만 렌더링 되어 수만 건도 부드럽게 스크롤 되는걸 볼 수 있습니다.

위의 테이터는 실 데이터가 아닌 dummy 데이터 입니다.


메모리를 아낄 수 있었던 진짜 이유는?

단순히 DOM 수를 줄인 것만으로도 이렇게 큰 차이가 났을까?

그렇다면 단순히 렌더링된 DOM 수를 줄였다는 이유만으로 왜 JS Heap 메모리 사용량과 GC 작동이 개선되었을까?

이 질문에 답하려면 JavaScript의 메모리 구조와 GC 동작 방식을 함께 살펴봐야 한다.

JavaScript 엔진은 메모리를 크게 Stack과 Heap으로 나눈다.

  • Stack: 고정된 크기의 원시 값 (number, string 등)
  • Heap: 동적으로 할당되는 객체, 배열, DOM 등

대부분의 UI 상태나 데이터, 렌더링되는 컴포넌트들은 Heap에 저장된다.

GC(Garbage Collector)는 언제 발생하는가?

let data = { value: "스크립트" };
data = null;

위처럼 더 이상 참조되지 않는 객체는 GC(Garbage Collector)가 Mark-and-Sweep 알고리즘으로 판단해 해당 메모리를 제거하게 된다.

하지만, .map()으로 생성된 수만 개의 DOM이 계속 참조되고 있다면? GC(Garbage Collector)는 작동하지 않는다. 이로 인해 메모리 누수가 발생하게 됩니다.

react-virtualized는 필요한 DOM만 메모리에 유지하고, 나머지는 unmount되기 때문에 GC(Garbage Collector)가 잘 작동할 수 있는 구조가 된다.


마무리하며

이번 작업을 통해 성능 병목을 해결하는 과정은 단순히 라이브러리 하나를 도입하는 것이 아니라,
왜 이 기술을 선택해야 했는지, 실제로 어떤 효과를 가져오는지,
그리고 어떻게 작동하는지를 이해하는 과정이 동반되어야 한다는 사실을 다시금 느꼈다.

앞으로도 성능 문제가 생긴다면 단순히 새로운 기술을 시도하기보다,
문제를 측정하고, 원인을 파악하고, 내부 구조를 이해한 뒤 설계하는 습관을 유지하고 싶다.
그 과정 속에서 나의 기준도 함께 성장할 수 있기를 바란다.